Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from src.modules.account.account_router import account_router
from src.modules.location.location_router import location_router
from src.modules.party.party_router import party_router
from src.modules.police.police_router import police_router
from src.modules.student.student_router import student_router

app = FastAPI()
Expand Down Expand Up @@ -42,3 +43,4 @@ def read_root():
app.include_router(party_router)
app.include_router(student_router)
app.include_router(location_router)
app.include_router(police_router)
18 changes: 18 additions & 0 deletions backend/src/modules/location/location_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,24 @@ async def delete_location(self, location_id: int) -> Location:
await self.session.commit()
return location

async def increment_warnings(self, location_id: int) -> Location:
"""Increment the warning count for a location by 1."""
location_entity = await self._get_location_entity_by_id(location_id)
location_entity.warning_count += 1
self.session.add(location_entity)
await self.session.commit()
await self.session.refresh(location_entity)
return location_entity.to_model()

async def increment_citations(self, location_id: int) -> Location:
"""Increment the citation count for a location by 1."""
location_entity = await self._get_location_entity_by_id(location_id)
location_entity.citation_count += 1
self.session.add(location_entity)
await self.session.commit()
await self.session.refresh(location_entity)
return location_entity.to_model()

async def autocomplete_address(self, input_text: str) -> list[AutocompleteResult]:
# Autocomplete an address using Google Maps Places API. Biased towards Chapel Hill, NC area
try:
Expand Down
44 changes: 44 additions & 0 deletions backend/src/modules/police/police_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, status
from src.core.authentication import authenticate_police_or_admin
from src.modules.account.account_model import Account
from src.modules.location.location_model import Location
from src.modules.location.location_service import LocationService
from src.modules.police.police_model import PoliceAccount

police_router = APIRouter(prefix="/api/police", tags=["police"])


@police_router.post(
"/locations/{location_id}/warnings",
response_model=Location,
status_code=status.HTTP_200_OK,
summary="Increment location warning count",
description="Increments the warning count for a location. Requires police or admin authentication.",
)
async def increment_warnings(
location_id: int,
location_service: LocationService = Depends(),
_: Account | PoliceAccount = Depends(authenticate_police_or_admin),
) -> Location:
"""
Increment the warning count for a location by 1.
"""
return await location_service.increment_warnings(location_id)


@police_router.post(
"/locations/{location_id}/citations",
response_model=Location,
status_code=status.HTTP_200_OK,
summary="Increment location citation count",
description="Increments the citation count for a location. Requires police or admin authentication.",
)
async def increment_citations(
location_id: int,
location_service: LocationService = Depends(),
_: Account | PoliceAccount = Depends(authenticate_police_or_admin),
) -> Location:
"""
Increment the citation count for a location by 1.
"""
return await location_service.increment_citations(location_id)
184 changes: 184 additions & 0 deletions backend/test/modules/police/police_router_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from typing import Any
from unittest.mock import AsyncMock

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from src.core.authentication import authenticate_police_or_admin
from src.main import app
from src.modules.account.account_model import Account, AccountRole
from src.modules.location.location_model import Location
from src.modules.location.location_service import (
LocationNotFoundException,
LocationService,
)
from src.modules.police.police_model import PoliceAccount


@pytest_asyncio.fixture
async def mock_location_service():
"""Create a mock LocationService for testing"""
return AsyncMock(spec=LocationService)


@pytest_asyncio.fixture
async def override_dependencies_police(mock_location_service: AsyncMock):
"""Override dependencies with police authentication"""

async def _fake_police():
return PoliceAccount(email="[email protected]")

def _get_mock_location_service():
return mock_location_service

app.dependency_overrides[authenticate_police_or_admin] = _fake_police
app.dependency_overrides[LocationService] = _get_mock_location_service
yield
app.dependency_overrides.clear()


@pytest_asyncio.fixture
async def override_dependencies_admin(mock_location_service: AsyncMock):
"""Override dependencies with admin authentication"""

async def _fake_admin():
return Account(
id=1,
email="[email protected]",
first_name="Admin",
last_name="User",
pid="999999999",
role=AccountRole.ADMIN,
)

def _get_mock_location_service():
return mock_location_service

app.dependency_overrides[authenticate_police_or_admin] = _fake_admin
app.dependency_overrides[LocationService] = _get_mock_location_service
yield
app.dependency_overrides.clear()


@pytest_asyncio.fixture
async def override_dependencies_no_auth(mock_location_service: AsyncMock):
"""Override dependencies without authentication"""

def _get_mock_location_service():
return mock_location_service

app.dependency_overrides[LocationService] = _get_mock_location_service
yield
app.dependency_overrides.clear()


@pytest.mark.asyncio
async def test_increment_warnings_as_police(
override_dependencies_police: Any, mock_location_service: AsyncMock
):
"""Test incrementing warnings as police returns updated location"""
mock_location = Location(
id=1,
google_place_id="ChIJTest123",
formatted_address="123 Test St, Chapel Hill, NC 27514, USA",
latitude=35.9132,
longitude=-79.0558,
street_number="123",
street_name="Test St",
unit=None,
city="Chapel Hill",
county="Orange County",
state="NC",
country="US",
zip_code="27514",
warning_count=1,
citation_count=0,
hold_expiration=None,
)
mock_location_service.increment_warnings.return_value = mock_location

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/api/police/locations/1/warnings")

assert response.status_code == 200
data = response.json()
assert data["id"] == 1
assert data["warning_count"] == 1
mock_location_service.increment_warnings.assert_called_once_with(1)


@pytest.mark.asyncio
async def test_increment_citations_as_admin(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you test to ensure staff/student can't increment?

override_dependencies_admin: Any, mock_location_service: AsyncMock
):
"""Test incrementing citations as admin returns updated location"""
mock_location = Location(
id=2,
google_place_id="ChIJTest456",
formatted_address="456 Test Ave, Durham, NC 27701, USA",
latitude=35.9940,
longitude=-78.8986,
street_number="456",
street_name="Test Ave",
unit=None,
city="Durham",
county="Durham County",
state="NC",
country="US",
zip_code="27701",
warning_count=0,
citation_count=1,
hold_expiration=None,
)
mock_location_service.increment_citations.return_value = mock_location

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/api/police/locations/2/citations")

assert response.status_code == 200
data = response.json()
assert data["id"] == 2
assert data["citation_count"] == 1
mock_location_service.increment_citations.assert_called_once_with(2)


@pytest.mark.asyncio
async def test_increment_warnings_location_not_found(
Copy link
Collaborator

@manyuagashe manyuagashe Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see a testing location not found for warnings but I do not see one for citations

override_dependencies_police: Any, mock_location_service: AsyncMock
):
"""Test incrementing warnings for non-existent location returns 404"""
mock_location_service.increment_warnings.side_effect = LocationNotFoundException(
location_id=999
)

async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/api/police/locations/999/warnings")

assert response.status_code == 404
mock_location_service.increment_warnings.assert_called_once_with(999)


@pytest.mark.asyncio
async def test_increment_warnings_unauthenticated(override_dependencies_no_auth: Any):
"""Test incrementing warnings without authentication returns 401"""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/api/police/locations/1/warnings")
assert response.status_code == 401


@pytest.mark.asyncio
async def test_increment_citations_unauthenticated(override_dependencies_no_auth: Any):
"""Test incrementing citations without authentication returns 401"""
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.post("/api/police/locations/1/citations")
assert response.status_code == 401