From c4b061af21e6ca1bcc38360a91654ecea3f6b4a4 Mon Sep 17 00:00:00 2001 From: aLEGEND21 Date: Tue, 27 Jan 2026 14:21:50 -0500 Subject: [PATCH 1/2] refactor: Store warnings/complaints as a separate Incident table --- backend/script/reset_dev.py | 2 - backend/src/main.py | 6 +- backend/src/modules/__init__.py | 4 +- .../src/modules/complaint/complaint_model.py | 17 -- .../src/modules/complaint/complaint_router.py | 76 ------ .../modules/complaint/complaint_service.py | 92 ------- .../{complaint => incident}/__init__.py | 0 .../incident_entity.py} | 30 ++- .../src/modules/incident/incident_model.py | 24 ++ .../src/modules/incident/incident_router.py | 76 ++++++ .../src/modules/incident/incident_service.py | 94 ++++++++ .../src/modules/location/location_entity.py | 24 +- .../src/modules/location/location_model.py | 17 +- .../src/modules/location/location_router.py | 8 +- .../src/modules/location/location_service.py | 32 +-- backend/src/modules/police/police_router.py | 48 ---- backend/test/conftest.py | 12 +- .../complaint/complaint_router_test.py | 189 --------------- .../complaint/complaint_service_test.py | 166 ------------- .../modules/incident/incident_router_test.py | 224 ++++++++++++++++++ .../modules/incident/incident_service_test.py | 194 +++++++++++++++ .../incident_utils.py} | 42 ++-- .../modules/location/location_router_test.py | 33 +-- .../modules/location/location_service_test.py | 102 ++++---- .../test/modules/location/location_utils.py | 4 - .../test/modules/police/police_router_test.py | 125 ---------- 26 files changed, 733 insertions(+), 908 deletions(-) delete mode 100644 backend/src/modules/complaint/complaint_model.py delete mode 100644 backend/src/modules/complaint/complaint_router.py delete mode 100644 backend/src/modules/complaint/complaint_service.py rename backend/src/modules/{complaint => incident}/__init__.py (100%) rename backend/src/modules/{complaint/complaint_entity.py => incident/incident_entity.py} (50%) create mode 100644 backend/src/modules/incident/incident_model.py create mode 100644 backend/src/modules/incident/incident_router.py create mode 100644 backend/src/modules/incident/incident_service.py delete mode 100644 backend/src/modules/police/police_router.py delete mode 100644 backend/test/modules/complaint/complaint_router_test.py delete mode 100644 backend/test/modules/complaint/complaint_service_test.py create mode 100644 backend/test/modules/incident/incident_router_test.py create mode 100644 backend/test/modules/incident/incident_service_test.py rename backend/test/modules/{complaint/complaint_utils.py => incident/incident_utils.py} (55%) delete mode 100644 backend/test/modules/police/police_router_test.py diff --git a/backend/script/reset_dev.py b/backend/script/reset_dev.py index 4fa80ee..5fc7705 100644 --- a/backend/script/reset_dev.py +++ b/backend/script/reset_dev.py @@ -124,8 +124,6 @@ async def reset_dev(): for location_data in data["locations"]: location = LocationEntity( - citation_count=location_data["citation_count"], - warning_count=location_data["warning_count"], hold_expiration=parse_date(location_data.get("hold_expiration")), formatted_address=location_data["formatted_address"], google_place_id=location_data["google_place_id"], diff --git a/backend/src/main.py b/backend/src/main.py index 7bfddda..1ff641e 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -2,10 +2,9 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from src.modules.account.account_router import account_router -from src.modules.complaint.complaint_router import complaint_router +from src.modules.incident.incident_router import incident_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() @@ -45,5 +44,4 @@ def read_root(): app.include_router(party_router) app.include_router(student_router) app.include_router(location_router) -app.include_router(police_router) -app.include_router(complaint_router) +app.include_router(incident_router) diff --git a/backend/src/modules/__init__.py b/backend/src/modules/__init__.py index 34688ed..37b0b49 100644 --- a/backend/src/modules/__init__.py +++ b/backend/src/modules/__init__.py @@ -18,7 +18,7 @@ """ from .account.account_entity import AccountEntity -from .complaint.complaint_entity import ComplaintEntity +from .incident.incident_entity import IncidentEntity from .location.location_entity import LocationEntity from .party.party_entity import PartyEntity from .police.police_entity import PoliceEntity @@ -26,7 +26,7 @@ __all__ = [ "AccountEntity", - "ComplaintEntity", + "IncidentEntity", "LocationEntity", "PartyEntity", "PoliceEntity", diff --git a/backend/src/modules/complaint/complaint_model.py b/backend/src/modules/complaint/complaint_model.py deleted file mode 100644 index c240fde..0000000 --- a/backend/src/modules/complaint/complaint_model.py +++ /dev/null @@ -1,17 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel - - -class ComplaintData(BaseModel): - """Data DTO for a complaint without id.""" - - location_id: int - complaint_datetime: datetime - description: str = "" - - -class ComplaintDto(ComplaintData): - """Output DTO for a complaint.""" - - id: int diff --git a/backend/src/modules/complaint/complaint_router.py b/backend/src/modules/complaint/complaint_router.py deleted file mode 100644 index 5ab560c..0000000 --- a/backend/src/modules/complaint/complaint_router.py +++ /dev/null @@ -1,76 +0,0 @@ -from fastapi import APIRouter, Depends, status -from src.core.authentication import authenticate_admin, authenticate_staff_or_admin -from src.modules.account.account_model import AccountDto - -from .complaint_model import ComplaintData, ComplaintDto -from .complaint_service import ComplaintService - -complaint_router = APIRouter(prefix="/api/locations", tags=["complaints"]) - - -@complaint_router.get( - "/{location_id}/complaints", - response_model=list[ComplaintDto], - status_code=status.HTTP_200_OK, - summary="Get all complaints for a location", - description="Returns all complaints associated with a given location. Staff or admin only.", -) -async def get_complaints_by_location( - location_id: int, - complaint_service: ComplaintService = Depends(), - _: AccountDto = Depends(authenticate_staff_or_admin), -) -> list[ComplaintDto]: - """Get all complaints for a location.""" - return await complaint_service.get_complaints_by_location(location_id) - - -@complaint_router.post( - "/{location_id}/complaints", - response_model=ComplaintDto, - status_code=status.HTTP_201_CREATED, - summary="Create a complaint for a location", - description="Creates a new complaint associated with a location. Admin only.", -) -async def create_complaint( - location_id: int, - complaint_data: ComplaintData, - complaint_service: ComplaintService = Depends(), - _: AccountDto = Depends(authenticate_admin), -) -> ComplaintDto: - """Create a complaint for a location.""" - return await complaint_service.create_complaint(location_id, complaint_data) - - -@complaint_router.put( - "/{location_id}/complaints/{complaint_id}", - response_model=ComplaintDto, - status_code=status.HTTP_200_OK, - summary="Update a complaint", - description="Updates an existing complaint. Admin only.", -) -async def update_complaint( - location_id: int, - complaint_id: int, - complaint_data: ComplaintData, - complaint_service: ComplaintService = Depends(), - _: AccountDto = Depends(authenticate_admin), -) -> ComplaintDto: - """Update a complaint.""" - return await complaint_service.update_complaint(complaint_id, location_id, complaint_data) - - -@complaint_router.delete( - "/{location_id}/complaints/{complaint_id}", - response_model=ComplaintDto, - status_code=status.HTTP_200_OK, - summary="Delete a complaint", - description="Deletes a complaint. Admin only.", -) -async def delete_complaint( - location_id: int, - complaint_id: int, - complaint_service: ComplaintService = Depends(), - _: AccountDto = Depends(authenticate_admin), -) -> ComplaintDto: - """Delete a complaint.""" - return await complaint_service.delete_complaint(complaint_id) diff --git a/backend/src/modules/complaint/complaint_service.py b/backend/src/modules/complaint/complaint_service.py deleted file mode 100644 index 1969b48..0000000 --- a/backend/src/modules/complaint/complaint_service.py +++ /dev/null @@ -1,92 +0,0 @@ -from fastapi import Depends -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession -from src.core.database import get_session -from src.core.exceptions import NotFoundException -from src.modules.location.location_service import LocationNotFoundException - -from .complaint_entity import ComplaintEntity -from .complaint_model import ComplaintData, ComplaintDto - - -class ComplaintNotFoundException(NotFoundException): - def __init__(self, complaint_id: int): - super().__init__(f"Complaint with ID {complaint_id} not found") - - -class ComplaintService: - def __init__( - self, - session: AsyncSession = Depends(get_session), - ): - self.session = session - - async def _get_complaint_entity_by_id(self, complaint_id: int) -> ComplaintEntity: - result = await self.session.execute( - select(ComplaintEntity).where(ComplaintEntity.id == complaint_id) - ) - complaint_entity = result.scalar_one_or_none() - if complaint_entity is None: - raise ComplaintNotFoundException(complaint_id) - return complaint_entity - - async def get_complaints_by_location(self, location_id: int) -> list[ComplaintDto]: - """Get all complaints for a given location.""" - result = await self.session.execute( - select(ComplaintEntity).where(ComplaintEntity.location_id == location_id) - ) - complaints = result.scalars().all() - return [complaint.to_dto() for complaint in complaints] - - async def get_complaint_by_id(self, complaint_id: int) -> ComplaintDto: - """Get a single complaint by ID.""" - complaint_entity = await self._get_complaint_entity_by_id(complaint_id) - return complaint_entity.to_dto() - - async def create_complaint(self, location_id: int, data: ComplaintData) -> ComplaintDto: - """Create a new complaint.""" - new_complaint = ComplaintEntity( - location_id=location_id, - complaint_datetime=data.complaint_datetime, - description=data.description, - ) - try: - self.session.add(new_complaint) - await self.session.commit() - except IntegrityError as e: - # Foreign key constraint violation indicates location doesn't exist - if "locations" in str(e).lower() or "foreign key" in str(e).lower(): - raise LocationNotFoundException(location_id) from e - raise - await self.session.refresh(new_complaint) - return new_complaint.to_dto() - - async def update_complaint( - self, complaint_id: int, location_id: int, data: ComplaintData - ) -> ComplaintDto: - """Update an existing complaint.""" - complaint_entity = await self._get_complaint_entity_by_id(complaint_id) - - complaint_entity.location_id = location_id - complaint_entity.complaint_datetime = data.complaint_datetime - complaint_entity.description = data.description - - try: - self.session.add(complaint_entity) - await self.session.commit() - except IntegrityError as e: - # Foreign key constraint violation indicates location doesn't exist - if "locations" in str(e).lower() or "foreign key" in str(e).lower(): - raise LocationNotFoundException(location_id) from e - raise - await self.session.refresh(complaint_entity) - return complaint_entity.to_dto() - - async def delete_complaint(self, complaint_id: int) -> ComplaintDto: - """Delete a complaint.""" - complaint_entity = await self._get_complaint_entity_by_id(complaint_id) - complaint = complaint_entity.to_dto() - await self.session.delete(complaint_entity) - await self.session.commit() - return complaint diff --git a/backend/src/modules/complaint/__init__.py b/backend/src/modules/incident/__init__.py similarity index 100% rename from backend/src/modules/complaint/__init__.py rename to backend/src/modules/incident/__init__.py diff --git a/backend/src/modules/complaint/complaint_entity.py b/backend/src/modules/incident/incident_entity.py similarity index 50% rename from backend/src/modules/complaint/complaint_entity.py rename to backend/src/modules/incident/incident_entity.py index e0d8fe7..3a37a6a 100644 --- a/backend/src/modules/complaint/complaint_entity.py +++ b/backend/src/modules/incident/incident_entity.py @@ -1,23 +1,24 @@ -from datetime import datetime +from datetime import UTC from typing import TYPE_CHECKING, Self -from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship from src.core.database import EntityBase -from src.modules.complaint.complaint_model import ComplaintData, ComplaintDto +from src.modules.incident.incident_model import IncidentData, IncidentDto, IncidentSeverity if TYPE_CHECKING: from src.modules.location.location_entity import LocationEntity -class ComplaintEntity(MappedAsDataclass, EntityBase): - __tablename__ = "complaints" +class IncidentEntity(MappedAsDataclass, EntityBase): + __tablename__ = "incidents" id: Mapped[int] = mapped_column(Integer, primary_key=True, init=False) location_id: Mapped[int] = mapped_column( Integer, ForeignKey("locations.id", ondelete="CASCADE"), nullable=False ) - complaint_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + incident_datetime: Mapped[str] = mapped_column(DateTime(timezone=True), nullable=False) + severity: Mapped[IncidentSeverity] = mapped_column(Enum(IncidentSeverity), nullable=False) description: Mapped[str] = mapped_column(String, nullable=False, default="") # Relationships @@ -26,18 +27,25 @@ class ComplaintEntity(MappedAsDataclass, EntityBase): ) @classmethod - def from_data(cls, data: ComplaintData) -> Self: + def from_data(cls, data: IncidentData) -> Self: return cls( location_id=data.location_id, - complaint_datetime=data.complaint_datetime, + incident_datetime=data.incident_datetime, + severity=data.severity, description=data.description, ) - def to_dto(self) -> ComplaintDto: + def to_dto(self) -> IncidentDto: """Convert entity to model.""" - return ComplaintDto( + # Ensure incident_datetime is timezone-aware + incident_dt = self.incident_datetime + if incident_dt.tzinfo is None: + incident_dt = incident_dt.replace(tzinfo=UTC) + + return IncidentDto( id=self.id, location_id=self.location_id, - complaint_datetime=self.complaint_datetime, + incident_datetime=incident_dt, description=self.description, + severity=self.severity, ) diff --git a/backend/src/modules/incident/incident_model.py b/backend/src/modules/incident/incident_model.py new file mode 100644 index 0000000..854e744 --- /dev/null +++ b/backend/src/modules/incident/incident_model.py @@ -0,0 +1,24 @@ +from enum import Enum + +from pydantic import AwareDatetime, BaseModel + + +class IncidentSeverity(Enum): + COMPLAINT = "complaint" + WARNING = "warning" + CITATION = "citation" + + +class IncidentData(BaseModel): + """Data DTO for an incident without id.""" + + location_id: int + incident_datetime: AwareDatetime + description: str = "" + severity: IncidentSeverity + + +class IncidentDto(IncidentData): + """Output DTO for an incident.""" + + id: int diff --git a/backend/src/modules/incident/incident_router.py b/backend/src/modules/incident/incident_router.py new file mode 100644 index 0000000..3e7d3dc --- /dev/null +++ b/backend/src/modules/incident/incident_router.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, status +from src.core.authentication import authenticate_admin, authenticate_staff_or_admin +from src.modules.account.account_model import AccountDto + +from .incident_model import IncidentData, IncidentDto +from .incident_service import IncidentService + +incident_router = APIRouter(prefix="/api/locations", tags=["incidents"]) + + +@incident_router.get( + "/{location_id}/incidents", + response_model=list[IncidentDto], + status_code=status.HTTP_200_OK, + summary="Get all incidents for a location", + description="Returns all incidents associated with a given location. Staff or admin only.", +) +async def get_incidents_by_location( + location_id: int, + incident_service: IncidentService = Depends(), + _: AccountDto = Depends(authenticate_staff_or_admin), +) -> list[IncidentDto]: + """Get all incidents for a location.""" + return await incident_service.get_incidents_by_location(location_id) + + +@incident_router.post( + "/{location_id}/incidents", + response_model=IncidentDto, + status_code=status.HTTP_201_CREATED, + summary="Create an incident for a location", + description="Creates a new incident associated with a location. Admin only.", +) +async def create_incident( + location_id: int, + incident_data: IncidentData, + incident_service: IncidentService = Depends(), + _: AccountDto = Depends(authenticate_admin), +) -> IncidentDto: + """Create an incident for a location.""" + return await incident_service.create_incident(location_id, incident_data) + + +@incident_router.put( + "/{location_id}/incidents/{incident_id}", + response_model=IncidentDto, + status_code=status.HTTP_200_OK, + summary="Update an incident", + description="Updates an existing incident. Admin only.", +) +async def update_incident( + location_id: int, + incident_id: int, + incident_data: IncidentData, + incident_service: IncidentService = Depends(), + _: AccountDto = Depends(authenticate_admin), +) -> IncidentDto: + """Update an incident.""" + return await incident_service.update_incident(incident_id, location_id, incident_data) + + +@incident_router.delete( + "/{location_id}/incidents/{incident_id}", + response_model=IncidentDto, + status_code=status.HTTP_200_OK, + summary="Delete an incident", + description="Deletes an incident. Admin only.", +) +async def delete_incident( + location_id: int, + incident_id: int, + incident_service: IncidentService = Depends(), + _: AccountDto = Depends(authenticate_admin), +) -> IncidentDto: + """Delete an incident.""" + return await incident_service.delete_incident(incident_id) diff --git a/backend/src/modules/incident/incident_service.py b/backend/src/modules/incident/incident_service.py new file mode 100644 index 0000000..7e8377d --- /dev/null +++ b/backend/src/modules/incident/incident_service.py @@ -0,0 +1,94 @@ +from fastapi import Depends +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from src.core.database import get_session +from src.core.exceptions import NotFoundException +from src.modules.location.location_service import LocationNotFoundException + +from .incident_entity import IncidentEntity +from .incident_model import IncidentData, IncidentDto + + +class IncidentNotFoundException(NotFoundException): + def __init__(self, incident_id: int): + super().__init__(f"Incident with ID {incident_id} not found") + + +class IncidentService: + def __init__( + self, + session: AsyncSession = Depends(get_session), + ): + self.session = session + + async def _get_incident_entity_by_id(self, incident_id: int) -> IncidentEntity: + result = await self.session.execute( + select(IncidentEntity).where(IncidentEntity.id == incident_id) + ) + incident_entity = result.scalar_one_or_none() + if incident_entity is None: + raise IncidentNotFoundException(incident_id) + return incident_entity + + async def get_incidents_by_location(self, location_id: int) -> list[IncidentDto]: + """Get all incidents for a given location.""" + result = await self.session.execute( + select(IncidentEntity).where(IncidentEntity.location_id == location_id) + ) + incidents = result.scalars().all() + return [incident.to_dto() for incident in incidents] + + async def get_incident_by_id(self, incident_id: int) -> IncidentDto: + """Get a single incident by ID.""" + incident_entity = await self._get_incident_entity_by_id(incident_id) + return incident_entity.to_dto() + + async def create_incident(self, location_id: int, data: IncidentData) -> IncidentDto: + """Create a new incident.""" + new_incident = IncidentEntity( + location_id=location_id, + incident_datetime=data.incident_datetime, + description=data.description, + severity=data.severity, + ) + try: + self.session.add(new_incident) + await self.session.commit() + except IntegrityError as e: + # Foreign key constraint violation indicates location doesn't exist + if "locations" in str(e).lower() or "foreign key" in str(e).lower(): + raise LocationNotFoundException(location_id) from e + raise + await self.session.refresh(new_incident) + return new_incident.to_dto() + + async def update_incident( + self, incident_id: int, location_id: int, data: IncidentData + ) -> IncidentDto: + """Update an existing incident.""" + incident_entity = await self._get_incident_entity_by_id(incident_id) + + incident_entity.location_id = location_id + incident_entity.incident_datetime = data.incident_datetime + incident_entity.description = data.description + incident_entity.severity = data.severity + + try: + self.session.add(incident_entity) + await self.session.commit() + except IntegrityError as e: + # Foreign key constraint violation indicates location doesn't exist + if "locations" in str(e).lower() or "foreign key" in str(e).lower(): + raise LocationNotFoundException(location_id) from e + raise + await self.session.refresh(incident_entity) + return incident_entity.to_dto() + + async def delete_incident(self, incident_id: int) -> IncidentDto: + """Delete an incident.""" + incident_entity = await self._get_incident_entity_by_id(incident_id) + incident = incident_entity.to_dto() + await self.session.delete(incident_entity) + await self.session.commit() + return incident diff --git a/backend/src/modules/location/location_entity.py b/backend/src/modules/location/location_entity.py index f2cc497..ad9d819 100644 --- a/backend/src/modules/location/location_entity.py +++ b/backend/src/modules/location/location_entity.py @@ -1,14 +1,16 @@ from datetime import UTC, datetime -from typing import Self +from typing import TYPE_CHECKING, Self from sqlalchemy import DECIMAL, DateTime, Index, Integer, String from sqlalchemy.inspection import inspect from sqlalchemy.orm import Mapped, MappedAsDataclass, mapped_column, relationship from src.core.database import EntityBase -from src.modules.complaint.complaint_entity import ComplaintEntity from .location_model import LocationData, LocationDto +if TYPE_CHECKING: + from src.modules.incident.incident_entity import IncidentEntity + class LocationEntity(MappedAsDataclass, EntityBase): __tablename__ = "locations" @@ -26,8 +28,6 @@ class LocationEntity(MappedAsDataclass, EntityBase): longitude: Mapped[float] = mapped_column(DECIMAL(11, 8), nullable=False) # OCSL Data (fields with defaults) - warning_count: Mapped[int] = mapped_column(Integer, default=0) - citation_count: Mapped[int] = mapped_column(Integer, default=0) hold_expiration: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) @@ -43,8 +43,8 @@ class LocationEntity(MappedAsDataclass, EntityBase): zip_code: Mapped[str | None] = mapped_column(String(10), default=None) # e.g. "27514" # Relationships - complaints: Mapped[list["ComplaintEntity"]] = relationship( - "ComplaintEntity", + incidents: Mapped[list["IncidentEntity"]] = relationship( + "IncidentEntity", back_populates="location", cascade="all, delete-orphan", lazy="selectin", # Use selectin loading to avoid N+1 queries @@ -54,10 +54,10 @@ class LocationEntity(MappedAsDataclass, EntityBase): __table_args__ = (Index("idx_lat_lng", "latitude", "longitude"),) def to_dto(self) -> LocationDto: - # Check if complaints relationship is loaded to avoid lazy loading in tests + # Check if incidents relationship is loaded to avoid lazy loading in tests # This prevents issues when LocationEntity is created without loading relationships insp = inspect(self) - complaints_loaded = "complaints" not in insp.unloaded + incidents_loaded = "incidents" not in insp.unloaded hold_exp = self.hold_expiration if hold_exp is not None and hold_exp.tzinfo is None: @@ -77,11 +77,9 @@ def to_dto(self) -> LocationDto: state=self.state, country=self.country, zip_code=self.zip_code, - warning_count=self.warning_count, - citation_count=self.citation_count, hold_expiration=hold_exp, - complaints=[complaint.to_dto() for complaint in self.complaints] - if complaints_loaded + incidents=[incident.to_dto() for incident in self.incidents] + if incidents_loaded else [], ) @@ -100,7 +98,5 @@ def from_data(cls, data: LocationData) -> Self: state=data.state, country=data.country, zip_code=data.zip_code, - warning_count=data.warning_count, - citation_count=data.citation_count, hold_expiration=data.hold_expiration, ) diff --git a/backend/src/modules/location/location_model.py b/backend/src/modules/location/location_model.py index 915eb46..68bb7a0 100644 --- a/backend/src/modules/location/location_model.py +++ b/backend/src/modules/location/location_model.py @@ -1,11 +1,8 @@ from typing import Self -from pydantic import AwareDatetime, BaseModel, Field +from pydantic import AwareDatetime, BaseModel from src.core.models import PaginatedResponse -from src.modules.complaint.complaint_model import ComplaintDto - -# Maximum allowed value for warning/citation counts to prevent overflow -MAX_COUNT = 999999 +from src.modules.incident.incident_model import IncidentDto class AutocompleteInput(BaseModel): @@ -36,16 +33,12 @@ class AddressData(BaseModel): class LocationData(AddressData): - warning_count: int = Field(default=0, ge=0) - citation_count: int = Field(default=0, ge=0) hold_expiration: AwareDatetime | None = None @classmethod def from_address( cls, address: AddressData, - warning_count: int = 0, - citation_count: int = 0, hold_expiration: AwareDatetime | None = None, ) -> Self: return cls( @@ -61,15 +54,13 @@ def from_address( state=address.state, country=address.country, zip_code=address.zip_code, - warning_count=warning_count, - citation_count=citation_count, hold_expiration=hold_expiration, ) class LocationDto(LocationData): id: int - complaints: list[ComplaintDto] = [] + incidents: list[IncidentDto] = [] PaginatedLocationResponse = PaginatedResponse[LocationDto] @@ -77,6 +68,4 @@ class LocationDto(LocationData): class LocationCreate(BaseModel): google_place_id: str - warning_count: int = Field(default=0, ge=0) - citation_count: int = Field(default=0, ge=0) hold_expiration: AwareDatetime | None = None diff --git a/backend/src/modules/location/location_router.py b/backend/src/modules/location/location_router.py index 3391f86..771c09f 100644 --- a/backend/src/modules/location/location_router.py +++ b/backend/src/modules/location/location_router.py @@ -120,8 +120,6 @@ async def create_location( return await location_service.create_location( LocationData.from_address( address_data, - warning_count=data.warning_count, - citation_count=data.citation_count, hold_expiration=data.hold_expiration, ) ) @@ -141,15 +139,11 @@ async def update_location( address_data = await location_service.get_place_details(data.google_place_id) location_data = LocationData.from_address( address_data, - warning_count=data.warning_count, - citation_count=data.citation_count, hold_expiration=data.hold_expiration, ) else: location_data = LocationData( - **location.model_dump(exclude={"warning_count", "citation_count", "hold_expiration"}), - warning_count=data.warning_count, - citation_count=data.citation_count, + **location.model_dump(exclude={"hold_expiration", "id", "incidents"}), hold_expiration=data.hold_expiration, ) diff --git a/backend/src/modules/location/location_service.py b/backend/src/modules/location/location_service.py index 88b58f2..ed736e4 100644 --- a/backend/src/modules/location/location_service.py +++ b/backend/src/modules/location/location_service.py @@ -17,7 +17,7 @@ ) from .location_entity import LocationEntity -from .location_model import MAX_COUNT, AddressData, AutocompleteResult, LocationData, LocationDto +from .location_model import AddressData, AutocompleteResult, LocationData, LocationDto class GoogleMapsAPIException(InternalServerException): @@ -57,14 +57,6 @@ def __init__(self, location_id: int, hold_expiration: datetime): ) -class CountLimitExceededException(BadRequestException): - def __init__(self, location_id: int, count_type: str): - super().__init__( - f"Cannot increment {count_type} for location {location_id}: " - f"maximum count of {MAX_COUNT} reached" - ) - - def get_gmaps_client() -> googlemaps.Client: # Dependency injection function for Google Maps client. return googlemaps.Client(key=env.GOOGLE_MAPS_API_KEY) @@ -179,28 +171,6 @@ async def delete_location(self, location_id: int) -> LocationDto: await self.session.commit() return location - async def increment_warnings(self, location_id: int) -> LocationDto: - """Increment the warning count for a location by 1.""" - location_entity = await self._get_location_entity_by_id(location_id) - if location_entity.warning_count >= MAX_COUNT: - raise CountLimitExceededException(location_id, "warning_count") - location_entity.warning_count += 1 - self.session.add(location_entity) - await self.session.commit() - await self.session.refresh(location_entity) - return location_entity.to_dto() - - async def increment_citations(self, location_id: int) -> LocationDto: - """Increment the citation count for a location by 1.""" - location_entity = await self._get_location_entity_by_id(location_id) - if location_entity.citation_count >= MAX_COUNT: - raise CountLimitExceededException(location_id, "citation_count") - location_entity.citation_count += 1 - self.session.add(location_entity) - await self.session.commit() - await self.session.refresh(location_entity) - return location_entity.to_dto() - 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: diff --git a/backend/src/modules/police/police_router.py b/backend/src/modules/police/police_router.py deleted file mode 100644 index e77562d..0000000 --- a/backend/src/modules/police/police_router.py +++ /dev/null @@ -1,48 +0,0 @@ -from fastapi import APIRouter, Depends, status -from src.core.authentication import authenticate_police_or_admin -from src.modules.account.account_model import AccountDto -from src.modules.location.location_model import LocationDto -from src.modules.location.location_service import LocationService -from src.modules.police.police_model import PoliceAccountDto - -police_router = APIRouter(prefix="/api/police", tags=["police"]) - - -@police_router.post( - "/locations/{location_id}/warnings", - response_model=LocationDto, - 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(), - _: AccountDto | PoliceAccountDto = Depends(authenticate_police_or_admin), -) -> LocationDto: - """ - 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=LocationDto, - 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(), - _: AccountDto | PoliceAccountDto = Depends(authenticate_police_or_admin), -) -> LocationDto: - """ - Increment the citation count for a location by 1. - """ - return await location_service.increment_citations(location_id) diff --git a/backend/test/conftest.py b/backend/test/conftest.py index c72a021..e05c4af 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -20,14 +20,14 @@ from src.core.database import EntityBase, database_url, get_session from src.main import app from src.modules.account.account_service import AccountService -from src.modules.complaint.complaint_service import ComplaintService +from src.modules.incident.incident_service import IncidentService from src.modules.location.location_service import LocationService from src.modules.party.party_service import PartyService from src.modules.police.police_service import PoliceService from src.modules.student.student_service import StudentService from test.modules.account.account_utils import AccountTestUtils -from test.modules.complaint.complaint_utils import ComplaintTestUtils +from test.modules.incident.incident_utils import IncidentTestUtils from test.modules.location.location_utils import GmapsMockUtils, LocationTestUtils from test.modules.party.party_utils import PartyTestUtils from test.modules.police.police_utils import PoliceTestUtils @@ -183,8 +183,8 @@ def location_service(test_session: AsyncSession, mock_gmaps: MagicMock): @pytest.fixture() -def complaint_service(test_session: AsyncSession): - return ComplaintService(session=test_session) +def incident_service(test_session: AsyncSession): + return IncidentService(session=test_session) @pytest.fixture() @@ -235,8 +235,8 @@ def gmaps_utils( @pytest.fixture() -def complaint_utils(test_session: AsyncSession, location_utils: LocationTestUtils): - return ComplaintTestUtils(session=test_session, location_utils=location_utils) +def incident_utils(test_session: AsyncSession, location_utils: LocationTestUtils): + return IncidentTestUtils(session=test_session, location_utils=location_utils) @pytest.fixture() diff --git a/backend/test/modules/complaint/complaint_router_test.py b/backend/test/modules/complaint/complaint_router_test.py deleted file mode 100644 index c551871..0000000 --- a/backend/test/modules/complaint/complaint_router_test.py +++ /dev/null @@ -1,189 +0,0 @@ -from datetime import UTC, datetime - -import pytest -from httpx import AsyncClient -from src.modules.complaint.complaint_model import ComplaintDto -from src.modules.complaint.complaint_service import ComplaintNotFoundException -from test.modules.complaint.complaint_utils import ComplaintTestUtils -from test.modules.location.location_utils import LocationTestUtils -from test.utils.http.assertions import ( - assert_res_failure, - assert_res_success, - assert_res_validation_error, -) -from test.utils.http.test_templates import generate_auth_required_tests - -test_complaint_authentication = generate_auth_required_tests( - ({"admin", "staff"}, "GET", "/api/locations/1/complaints", None), - ( - {"admin"}, - "POST", - "/api/locations/1/complaints", - ComplaintTestUtils.get_sample_data(), - ), - ( - {"admin"}, - "PUT", - "/api/locations/1/complaints/1", - ComplaintTestUtils.get_sample_data(), - ), - ({"admin"}, "DELETE", "/api/locations/1/complaints/1", None), -) - - -class TestComplaintRouter: - """Tests for complaint CRUD operations.""" - - admin_client: AsyncClient - complaint_utils: ComplaintTestUtils - location_utils: LocationTestUtils - - @pytest.fixture(autouse=True) - def _setup( - self, - complaint_utils: ComplaintTestUtils, - location_utils: LocationTestUtils, - admin_client: AsyncClient, - ): - self.complaint_utils = complaint_utils - self.location_utils = location_utils - self.admin_client = admin_client - - @pytest.mark.asyncio - async def test_get_complaints_by_location_success(self) -> None: - """Test successfully getting all complaints for a location.""" - location = await self.location_utils.create_one() - complaints = await self.complaint_utils.create_many(i=2, location_id=location.id) - - response = await self.admin_client.get(f"/api/locations/{location.id}/complaints") - data = assert_res_success(response, list[ComplaintDto]) - - assert len(data) == 2 - data_by_id = {complaint.id: complaint for complaint in data} - for complaint in complaints: - assert complaint.id in data_by_id - self.complaint_utils.assert_matches(complaint, data_by_id[complaint.id]) - - @pytest.mark.asyncio - async def test_get_complaints_by_location_empty(self) -> None: - """Test getting complaints for a location with no complaints.""" - location = await self.location_utils.create_one() - - response = await self.admin_client.get(f"/api/locations/{location.id}/complaints") - data = assert_res_success(response, list[ComplaintDto]) - - assert data == [] - - @pytest.mark.asyncio - async def test_create_complaint_success(self) -> None: - """Test successfully creating a complaint.""" - location = await self.location_utils.create_one() - complaint_data = await self.complaint_utils.next_data(location_id=location.id) - - response = await self.admin_client.post( - f"/api/locations/{location.id}/complaints", - json=complaint_data.model_dump(mode="json"), - ) - data = assert_res_success(response, ComplaintDto, status=201) - - self.complaint_utils.assert_matches(complaint_data, data) - - @pytest.mark.asyncio - async def test_create_complaint_with_empty_description(self) -> None: - """Test creating a complaint with empty description.""" - location = await self.location_utils.create_one() - complaint_data = await self.complaint_utils.next_data( - location_id=location.id, description="" - ) - - response = await self.admin_client.post( - f"/api/locations/{location.id}/complaints", - json=complaint_data.model_dump(mode="json"), - ) - data = assert_res_success(response, ComplaintDto, status=201) - - assert data.description == "" - - @pytest.mark.asyncio - async def test_create_complaint_location_id_required(self) -> None: - """Test creating a complaint without location_id fails validation.""" - location = await self.location_utils.create_one() - complaint_data = { - "complaint_datetime": "2025-11-18T20:30:00", - "description": "Noise complaint", - } - - response = await self.admin_client.post( - f"/api/locations/{location.id}/complaints", json=complaint_data - ) - - assert_res_validation_error(response, expected_fields=["location_id"]) - - @pytest.mark.asyncio - async def test_update_complaint_success(self) -> None: - """Test successfully updating a complaint.""" - complaint = await self.complaint_utils.create_one() - update_data = await self.complaint_utils.next_data( - location_id=complaint.location_id, - complaint_datetime=datetime(2025, 11, 20, 23, 0, 0, tzinfo=UTC), - description="Updated description", - ) - - response = await self.admin_client.put( - f"/api/locations/{complaint.location_id}/complaints/{complaint.id}", - json=update_data.model_dump(mode="json"), - ) - data = assert_res_success(response, ComplaintDto) - - assert data.id == complaint.id - self.complaint_utils.assert_matches(update_data, data) - - @pytest.mark.asyncio - async def test_update_complaint_not_found(self) -> None: - """Test updating a non-existent complaint.""" - location = await self.location_utils.create_one() - update_data = await self.complaint_utils.next_data(location_id=location.id) - - response = await self.admin_client.put( - f"/api/locations/{location.id}/complaints/999", - json=update_data.model_dump(mode="json"), - ) - - assert_res_failure(response, ComplaintNotFoundException(999)) - - @pytest.mark.asyncio - async def test_update_complaint_location_id_required(self) -> None: - """Test updating a complaint without location_id fails validation.""" - complaint = await self.complaint_utils.create_one() - update_data = { - "complaint_datetime": "2025-11-20T23:00:00", - "description": "Updated description", - } - - response = await self.admin_client.put( - f"/api/locations/{complaint.location_id}/complaints/{complaint.id}", - json=update_data, - ) - - assert_res_validation_error(response, expected_fields=["location_id"]) - - @pytest.mark.asyncio - async def test_delete_complaint_success(self) -> None: - """Test successfully deleting a complaint.""" - complaint = await self.complaint_utils.create_one() - - response = await self.admin_client.delete( - f"/api/locations/{complaint.location_id}/complaints/{complaint.id}" - ) - data = assert_res_success(response, ComplaintDto) - - self.complaint_utils.assert_matches(complaint, data) - - @pytest.mark.asyncio - async def test_delete_complaint_not_found(self) -> None: - """Test deleting a non-existent complaint.""" - location = await self.location_utils.create_one() - - response = await self.admin_client.delete(f"/api/locations/{location.id}/complaints/999") - - assert_res_failure(response, ComplaintNotFoundException(999)) diff --git a/backend/test/modules/complaint/complaint_service_test.py b/backend/test/modules/complaint/complaint_service_test.py deleted file mode 100644 index a82977d..0000000 --- a/backend/test/modules/complaint/complaint_service_test.py +++ /dev/null @@ -1,166 +0,0 @@ -from datetime import UTC, datetime - -import pytest -from src.modules.complaint.complaint_service import ComplaintNotFoundException, ComplaintService -from test.modules.complaint.complaint_utils import ComplaintTestUtils -from test.modules.location.location_utils import LocationTestUtils - - -class TestComplaintService: - complaint_utils: ComplaintTestUtils - location_utils: LocationTestUtils - complaint_service: ComplaintService - - @pytest.fixture(autouse=True) - def _setup( - self, - complaint_utils: ComplaintTestUtils, - location_utils: LocationTestUtils, - complaint_service: ComplaintService, - ): - self.complaint_utils = complaint_utils - self.location_utils = location_utils - self.complaint_service = complaint_service - - @pytest.mark.asyncio - async def test_create_complaint(self) -> None: - """Test creating a new complaint.""" - location = await self.location_utils.create_one() - data = await self.complaint_utils.next_data(location_id=location.id) - - complaint = await self.complaint_service.create_complaint(location.id, data) - - self.complaint_utils.assert_matches(complaint, data) - - @pytest.mark.asyncio - async def test_create_complaint_with_empty_description(self) -> None: - """Test creating a complaint with empty description (default).""" - location = await self.location_utils.create_one() - data = await self.complaint_utils.next_data(location_id=location.id, description="") - - complaint = await self.complaint_service.create_complaint(location.id, data) - - assert complaint.description == "" - - @pytest.mark.asyncio - async def test_get_complaints_by_location_empty(self) -> None: - """Test getting complaints for a location with no complaints.""" - location = await self.location_utils.create_one() - - complaints = await self.complaint_service.get_complaints_by_location(location.id) - - assert complaints == [] - - @pytest.mark.asyncio - async def test_get_complaints_by_location(self) -> None: - """Test getting all complaints for a location.""" - location = await self.location_utils.create_one() - complaints = await self.complaint_utils.create_many(i=2, location_id=location.id) - - fetched = await self.complaint_service.get_complaints_by_location(location.id) - - assert len(fetched) == 2 - for complaint, expected in zip(fetched, complaints, strict=False): - self.complaint_utils.assert_matches(complaint, expected) - - @pytest.mark.asyncio - async def test_get_complaint_by_id(self) -> None: - """Test getting a complaint by its ID.""" - complaint_entity = await self.complaint_utils.create_one() - - fetched = await self.complaint_service.get_complaint_by_id(complaint_entity.id) - - self.complaint_utils.assert_matches(fetched, complaint_entity) - - @pytest.mark.asyncio - async def test_get_complaint_by_id_not_found(self) -> None: - """Test getting a complaint by non-existent ID raises not found exception.""" - with pytest.raises(ComplaintNotFoundException, match="Complaint with ID 999 not found"): - await self.complaint_service.get_complaint_by_id(999) - - @pytest.mark.asyncio - async def test_update_complaint(self) -> None: - """Test updating a complaint.""" - complaint_entity = await self.complaint_utils.create_one() - - update_data = await self.complaint_utils.next_data( - location_id=complaint_entity.location_id, - ) - - updated = await self.complaint_service.update_complaint( - complaint_entity.id, complaint_entity.location_id, update_data - ) - - assert updated.id == complaint_entity.id - self.complaint_utils.assert_matches(updated, update_data) - - @pytest.mark.asyncio - async def test_update_complaint_not_found(self) -> None: - """Test updating a non-existent complaint raises not found exception.""" - data = await self.complaint_utils.next_data() - - with pytest.raises(ComplaintNotFoundException, match="Complaint with ID 999 not found"): - await self.complaint_service.update_complaint(999, data.location_id, data) - - @pytest.mark.asyncio - async def test_delete_complaint(self) -> None: - """Test deleting a complaint.""" - complaint_entity = await self.complaint_utils.create_one() - - deleted = await self.complaint_service.delete_complaint(complaint_entity.id) - - self.complaint_utils.assert_matches(deleted, complaint_entity) - - # Verify it's actually deleted - with pytest.raises(ComplaintNotFoundException): - await self.complaint_service.get_complaint_by_id(complaint_entity.id) - - @pytest.mark.asyncio - async def test_delete_complaint_not_found(self) -> None: - """Test deleting a non-existent complaint raises not found exception.""" - with pytest.raises(ComplaintNotFoundException, match="Complaint with ID 999 not found"): - await self.complaint_service.delete_complaint(999) - - @pytest.mark.asyncio - async def test_delete_complaint_verify_others_remain(self) -> None: - """Test that deleting one complaint doesn't affect others.""" - location = await self.location_utils.create_one() - complaints = await self.complaint_utils.create_many(i=2, location_id=location.id) - - await self.complaint_service.delete_complaint(complaints[0].id) - - # complaint 2 should still exist - fetched = await self.complaint_service.get_complaint_by_id(complaints[1].id) - self.complaint_utils.assert_matches(fetched, complaints[1]) - - # Only one complaint should remain for this location - all_complaints = await self.complaint_service.get_complaints_by_location(location.id) - assert len(all_complaints) == 1 - self.complaint_utils.assert_matches(all_complaints[0], complaints[1]) - - @pytest.mark.asyncio - async def test_create_complaint_from_location_dto(self) -> None: - """Test creating a complaint with location data (ComplaintData).""" - location = await self.location_utils.create_one() - data = await self.complaint_utils.next_data( - location_id=location.id, description="Location noise complaint" - ) - - complaint = await self.complaint_service.create_complaint(location.id, data) - - self.complaint_utils.assert_matches(complaint, data) - - @pytest.mark.asyncio - async def test_complaint_data_persistence(self) -> None: - """Test that all complaint data fields are properly persisted.""" - location = await self.location_utils.create_one() - data = await self.complaint_utils.next_data( - location_id=location.id, - complaint_datetime=datetime(2025, 12, 25, 14, 30, 45, tzinfo=UTC), - description="Detailed description of the complaint issue", - ) - - created = await self.complaint_service.create_complaint(location.id, data) - fetched = await self.complaint_service.get_complaint_by_id(created.id) - - self.complaint_utils.assert_matches(fetched, data) diff --git a/backend/test/modules/incident/incident_router_test.py b/backend/test/modules/incident/incident_router_test.py new file mode 100644 index 0000000..9b55c17 --- /dev/null +++ b/backend/test/modules/incident/incident_router_test.py @@ -0,0 +1,224 @@ +from datetime import UTC, datetime + +import pytest +from httpx import AsyncClient +from src.modules.incident.incident_model import IncidentDto, IncidentSeverity +from src.modules.incident.incident_service import IncidentNotFoundException +from test.modules.incident.incident_utils import IncidentTestUtils +from test.modules.location.location_utils import LocationTestUtils +from test.utils.http.assertions import ( + assert_res_failure, + assert_res_success, + assert_res_validation_error, +) +from test.utils.http.test_templates import generate_auth_required_tests + +test_incident_authentication = generate_auth_required_tests( + ({"admin", "staff"}, "GET", "/api/locations/1/incidents", None), + ( + {"admin"}, + "POST", + "/api/locations/1/incidents", + IncidentTestUtils.get_sample_data(), + ), + ( + {"admin"}, + "PUT", + "/api/locations/1/incidents/1", + IncidentTestUtils.get_sample_data(), + ), + ({"admin"}, "DELETE", "/api/locations/1/incidents/1", None), +) + + +class TestIncidentRouter: + """Tests for incident CRUD operations.""" + + admin_client: AsyncClient + incident_utils: IncidentTestUtils + location_utils: LocationTestUtils + + @pytest.fixture(autouse=True) + def _setup( + self, + incident_utils: IncidentTestUtils, + location_utils: LocationTestUtils, + admin_client: AsyncClient, + ): + self.incident_utils = incident_utils + self.location_utils = location_utils + self.admin_client = admin_client + + @pytest.mark.asyncio + async def test_get_incidents_by_location_success(self) -> None: + """Test successfully getting all incidents for a location.""" + location = await self.location_utils.create_one() + incidents = await self.incident_utils.create_many(i=2, location_id=location.id) + + response = await self.admin_client.get(f"/api/locations/{location.id}/incidents") + data = assert_res_success(response, list[IncidentDto]) + + assert len(data) == 2 + data_by_id = {incident.id: incident for incident in data} + for incident in incidents: + assert incident.id in data_by_id + self.incident_utils.assert_matches(incident, data_by_id[incident.id]) + + @pytest.mark.asyncio + async def test_get_incidents_by_location_empty(self) -> None: + """Test getting incidents for a location with no incidents.""" + location = await self.location_utils.create_one() + + response = await self.admin_client.get(f"/api/locations/{location.id}/incidents") + data = assert_res_success(response, list[IncidentDto]) + + assert data == [] + + @pytest.mark.asyncio + async def test_create_incident_success(self) -> None: + """Test successfully creating an incident.""" + location = await self.location_utils.create_one() + incident_data = await self.incident_utils.next_data(location_id=location.id) + + response = await self.admin_client.post( + f"/api/locations/{location.id}/incidents", + json=incident_data.model_dump(mode="json"), + ) + data = assert_res_success(response, IncidentDto, status=201) + + self.incident_utils.assert_matches(incident_data, data) + + @pytest.mark.asyncio + async def test_create_incident_with_severity(self) -> None: + """Test creating incidents with different severity levels.""" + location = await self.location_utils.create_one() + + for severity in IncidentSeverity: + incident_data = await self.incident_utils.next_data( + location_id=location.id, severity=severity + ) + + response = await self.admin_client.post( + f"/api/locations/{location.id}/incidents", + json=incident_data.model_dump(mode="json"), + ) + data = assert_res_success(response, IncidentDto, status=201) + + assert data.severity == severity + + @pytest.mark.asyncio + async def test_create_incident_with_empty_description(self) -> None: + """Test creating an incident with empty description.""" + location = await self.location_utils.create_one() + incident_data = await self.incident_utils.next_data(location_id=location.id, description="") + + response = await self.admin_client.post( + f"/api/locations/{location.id}/incidents", + json=incident_data.model_dump(mode="json"), + ) + data = assert_res_success(response, IncidentDto, status=201) + + assert data.description == "" + + @pytest.mark.asyncio + async def test_create_incident_location_id_required(self) -> None: + """Test creating an incident without location_id fails validation.""" + location = await self.location_utils.create_one() + incident_data = { + "incident_datetime": "2025-11-18T20:30:00Z", + "description": "Noise incident", + "severity": "complaint", + } + + response = await self.admin_client.post( + f"/api/locations/{location.id}/incidents", json=incident_data + ) + + assert_res_validation_error(response, expected_fields=["location_id"]) + + @pytest.mark.asyncio + async def test_create_incident_severity_required(self) -> None: + """Test creating an incident without severity fails validation.""" + location = await self.location_utils.create_one() + incident_data = { + "location_id": location.id, + "incident_datetime": "2025-11-18T20:30:00Z", + "description": "Noise incident", + } + + response = await self.admin_client.post( + f"/api/locations/{location.id}/incidents", json=incident_data + ) + + assert_res_validation_error(response, expected_fields=["severity"]) + + @pytest.mark.asyncio + async def test_update_incident_success(self) -> None: + """Test successfully updating an incident.""" + incident = await self.incident_utils.create_one() + update_data = await self.incident_utils.next_data( + location_id=incident.location_id, + incident_datetime=datetime(2025, 11, 20, 23, 0, 0, tzinfo=UTC), + description="Updated description", + severity=IncidentSeverity.WARNING, + ) + + response = await self.admin_client.put( + f"/api/locations/{incident.location_id}/incidents/{incident.id}", + json=update_data.model_dump(mode="json"), + ) + data = assert_res_success(response, IncidentDto) + + assert data.id == incident.id + self.incident_utils.assert_matches(update_data, data) + + @pytest.mark.asyncio + async def test_update_incident_not_found(self) -> None: + """Test updating a non-existent incident.""" + location = await self.location_utils.create_one() + update_data = await self.incident_utils.next_data(location_id=location.id) + + response = await self.admin_client.put( + f"/api/locations/{location.id}/incidents/999", + json=update_data.model_dump(mode="json"), + ) + + assert_res_failure(response, IncidentNotFoundException(999)) + + @pytest.mark.asyncio + async def test_update_incident_location_id_required(self) -> None: + """Test updating an incident without location_id fails validation.""" + incident = await self.incident_utils.create_one() + update_data = { + "incident_datetime": "2025-11-20T23:00:00Z", + "description": "Updated description", + "severity": "warning", + } + + response = await self.admin_client.put( + f"/api/locations/{incident.location_id}/incidents/{incident.id}", + json=update_data, + ) + + assert_res_validation_error(response, expected_fields=["location_id"]) + + @pytest.mark.asyncio + async def test_delete_incident_success(self) -> None: + """Test successfully deleting an incident.""" + incident = await self.incident_utils.create_one() + + response = await self.admin_client.delete( + f"/api/locations/{incident.location_id}/incidents/{incident.id}" + ) + data = assert_res_success(response, IncidentDto) + + self.incident_utils.assert_matches(incident, data) + + @pytest.mark.asyncio + async def test_delete_incident_not_found(self) -> None: + """Test deleting a non-existent incident.""" + location = await self.location_utils.create_one() + + response = await self.admin_client.delete(f"/api/locations/{location.id}/incidents/999") + + assert_res_failure(response, IncidentNotFoundException(999)) diff --git a/backend/test/modules/incident/incident_service_test.py b/backend/test/modules/incident/incident_service_test.py new file mode 100644 index 0000000..63af574 --- /dev/null +++ b/backend/test/modules/incident/incident_service_test.py @@ -0,0 +1,194 @@ +from datetime import UTC, datetime + +import pytest +from src.modules.incident.incident_model import IncidentSeverity +from src.modules.incident.incident_service import IncidentNotFoundException, IncidentService +from test.modules.incident.incident_utils import IncidentTestUtils +from test.modules.location.location_utils import LocationTestUtils + + +class TestIncidentService: + incident_utils: IncidentTestUtils + location_utils: LocationTestUtils + incident_service: IncidentService + + @pytest.fixture(autouse=True) + def _setup( + self, + incident_utils: IncidentTestUtils, + location_utils: LocationTestUtils, + incident_service: IncidentService, + ): + self.incident_utils = incident_utils + self.location_utils = location_utils + self.incident_service = incident_service + + @pytest.mark.asyncio + async def test_create_incident(self) -> None: + """Test creating a new incident.""" + location = await self.location_utils.create_one() + data = await self.incident_utils.next_data(location_id=location.id) + + incident = await self.incident_service.create_incident(location.id, data) + + self.incident_utils.assert_matches(incident, data) + + @pytest.mark.asyncio + async def test_create_incident_with_empty_description(self) -> None: + """Test creating an incident with empty description (default).""" + location = await self.location_utils.create_one() + data = await self.incident_utils.next_data(location_id=location.id, description="") + + incident = await self.incident_service.create_incident(location.id, data) + + assert incident.description == "" + + @pytest.mark.asyncio + async def test_create_incident_with_severity(self) -> None: + """Test creating incidents with different severity levels.""" + location = await self.location_utils.create_one() + + for severity in IncidentSeverity: + data = await self.incident_utils.next_data(location_id=location.id, severity=severity) + incident = await self.incident_service.create_incident(location.id, data) + assert incident.severity == severity + + @pytest.mark.asyncio + async def test_get_incidents_by_location_empty(self) -> None: + """Test getting incidents for a location with no incidents.""" + location = await self.location_utils.create_one() + + incidents = await self.incident_service.get_incidents_by_location(location.id) + + assert incidents == [] + + @pytest.mark.asyncio + async def test_get_incidents_by_location(self) -> None: + """Test getting all incidents for a location.""" + location = await self.location_utils.create_one() + incidents = await self.incident_utils.create_many(i=2, location_id=location.id) + + fetched = await self.incident_service.get_incidents_by_location(location.id) + + assert len(fetched) == 2 + for incident, expected in zip(fetched, incidents, strict=False): + self.incident_utils.assert_matches(incident, expected) + + @pytest.mark.asyncio + async def test_get_incident_by_id(self) -> None: + """Test getting an incident by its ID.""" + incident_entity = await self.incident_utils.create_one() + + fetched = await self.incident_service.get_incident_by_id(incident_entity.id) + + self.incident_utils.assert_matches(fetched, incident_entity) + + @pytest.mark.asyncio + async def test_get_incident_by_id_not_found(self) -> None: + """Test getting an incident by non-existent ID raises not found exception.""" + with pytest.raises(IncidentNotFoundException, match="Incident with ID 999 not found"): + await self.incident_service.get_incident_by_id(999) + + @pytest.mark.asyncio + async def test_update_incident(self) -> None: + """Test updating an incident.""" + incident_entity = await self.incident_utils.create_one() + + update_data = await self.incident_utils.next_data( + location_id=incident_entity.location_id, + ) + + updated = await self.incident_service.update_incident( + incident_entity.id, incident_entity.location_id, update_data + ) + + assert updated.id == incident_entity.id + self.incident_utils.assert_matches(updated, update_data) + + @pytest.mark.asyncio + async def test_update_incident_severity(self) -> None: + """Test updating an incident's severity.""" + incident_entity = await self.incident_utils.create_one(severity=IncidentSeverity.COMPLAINT) + + update_data = await self.incident_utils.next_data( + location_id=incident_entity.location_id, + severity=IncidentSeverity.CITATION, + ) + + updated = await self.incident_service.update_incident( + incident_entity.id, incident_entity.location_id, update_data + ) + + assert updated.severity == IncidentSeverity.CITATION + + @pytest.mark.asyncio + async def test_update_incident_not_found(self) -> None: + """Test updating a non-existent incident raises not found exception.""" + data = await self.incident_utils.next_data() + + with pytest.raises(IncidentNotFoundException, match="Incident with ID 999 not found"): + await self.incident_service.update_incident(999, data.location_id, data) + + @pytest.mark.asyncio + async def test_delete_incident(self) -> None: + """Test deleting an incident.""" + incident_entity = await self.incident_utils.create_one() + + deleted = await self.incident_service.delete_incident(incident_entity.id) + + self.incident_utils.assert_matches(deleted, incident_entity) + + # Verify it's actually deleted + with pytest.raises(IncidentNotFoundException): + await self.incident_service.get_incident_by_id(incident_entity.id) + + @pytest.mark.asyncio + async def test_delete_incident_not_found(self) -> None: + """Test deleting a non-existent incident raises not found exception.""" + with pytest.raises(IncidentNotFoundException, match="Incident with ID 999 not found"): + await self.incident_service.delete_incident(999) + + @pytest.mark.asyncio + async def test_delete_incident_verify_others_remain(self) -> None: + """Test that deleting one incident doesn't affect others.""" + location = await self.location_utils.create_one() + incidents = await self.incident_utils.create_many(i=2, location_id=location.id) + + await self.incident_service.delete_incident(incidents[0].id) + + # incident 2 should still exist + fetched = await self.incident_service.get_incident_by_id(incidents[1].id) + self.incident_utils.assert_matches(fetched, incidents[1]) + + # Only one incident should remain for this location + all_incidents = await self.incident_service.get_incidents_by_location(location.id) + assert len(all_incidents) == 1 + self.incident_utils.assert_matches(all_incidents[0], incidents[1]) + + @pytest.mark.asyncio + async def test_create_incident_from_location_dto(self) -> None: + """Test creating an incident with location data (IncidentData).""" + location = await self.location_utils.create_one() + data = await self.incident_utils.next_data( + location_id=location.id, description="Location noise incident" + ) + + incident = await self.incident_service.create_incident(location.id, data) + + self.incident_utils.assert_matches(incident, data) + + @pytest.mark.asyncio + async def test_incident_data_persistence(self) -> None: + """Test that all incident data fields are properly persisted.""" + location = await self.location_utils.create_one() + data = await self.incident_utils.next_data( + location_id=location.id, + incident_datetime=datetime(2025, 12, 25, 14, 30, 45, tzinfo=UTC), + description="Detailed description of the incident issue", + severity=IncidentSeverity.WARNING, + ) + + created = await self.incident_service.create_incident(location.id, data) + fetched = await self.incident_service.get_incident_by_id(created.id) + + self.incident_utils.assert_matches(fetched, data) diff --git a/backend/test/modules/complaint/complaint_utils.py b/backend/test/modules/incident/incident_utils.py similarity index 55% rename from backend/test/modules/complaint/complaint_utils.py rename to backend/test/modules/incident/incident_utils.py index 88e623f..a135d90 100644 --- a/backend/test/modules/complaint/complaint_utils.py +++ b/backend/test/modules/incident/incident_utils.py @@ -2,30 +2,31 @@ from typing import Any, TypedDict, Unpack, override from sqlalchemy.ext.asyncio import AsyncSession -from src.modules.complaint.complaint_entity import ComplaintEntity -from src.modules.complaint.complaint_model import ComplaintData, ComplaintDto +from src.modules.incident.incident_entity import IncidentEntity +from src.modules.incident.incident_model import IncidentData, IncidentDto, IncidentSeverity from test.modules.location.location_utils import LocationTestUtils from test.utils.resource_test_utils import ResourceTestUtils -class ComplaintOverrides(TypedDict, total=False): +class IncidentOverrides(TypedDict, total=False): location_id: int - complaint_datetime: datetime + incident_datetime: datetime description: str + severity: IncidentSeverity -class ComplaintTestUtils( +class IncidentTestUtils( ResourceTestUtils[ - ComplaintEntity, - ComplaintData, - ComplaintDto, + IncidentEntity, + IncidentData, + IncidentDto, ] ): def __init__(self, session: AsyncSession, location_utils: LocationTestUtils): super().__init__( session, - entity_class=ComplaintEntity, - data_class=ComplaintData, + entity_class=IncidentEntity, + data_class=IncidentData, ) self.location_utils = location_utils @@ -34,14 +35,15 @@ def __init__(self, session: AsyncSession, location_utils: LocationTestUtils): def generate_defaults(count: int) -> dict[str, Any]: return { "location_id": 1, - "complaint_datetime": ( - datetime(2025, 11, 18, 20, 30, 0, tzinfo=UTC) + timedelta(days=count) + "incident_datetime": ( + datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) + timedelta(days=count) ).isoformat(), - "description": f"Complaint {count}", + "description": f"Incident {count}", + "severity": IncidentSeverity.COMPLAINT.value, } @override - async def next_dict(self, **overrides: Unpack[ComplaintOverrides]) -> dict: + async def next_dict(self, **overrides: Unpack[IncidentOverrides]) -> dict: # If location_id not provided, create a location if "location_id" not in overrides: location = await self.location_utils.create_one() @@ -53,24 +55,24 @@ async def next_dict(self, **overrides: Unpack[ComplaintOverrides]) -> dict: @override def get_or_default( - self, overrides: ComplaintOverrides | None = None, fields: set[str] | None = None + self, overrides: IncidentOverrides | None = None, fields: set[str] | None = None ) -> dict: return super().get_or_default(overrides, fields) @override - async def next_data(self, **overrides: Unpack[ComplaintOverrides]) -> ComplaintData: + async def next_data(self, **overrides: Unpack[IncidentOverrides]) -> IncidentData: return await super().next_data(**overrides) @override - async def next_entity(self, **overrides: Unpack[ComplaintOverrides]) -> ComplaintEntity: + async def next_entity(self, **overrides: Unpack[IncidentOverrides]) -> IncidentEntity: return await super().next_entity(**overrides) @override async def create_many( - self, *, i: int, **overrides: Unpack[ComplaintOverrides] - ) -> list[ComplaintEntity]: + self, *, i: int, **overrides: Unpack[IncidentOverrides] + ) -> list[IncidentEntity]: return await super().create_many(i=i, **overrides) @override - async def create_one(self, **overrides: Unpack[ComplaintOverrides]) -> ComplaintEntity: + async def create_one(self, **overrides: Unpack[IncidentOverrides]) -> IncidentEntity: return await super().create_one(**overrides) diff --git a/backend/test/modules/location/location_router_test.py b/backend/test/modules/location/location_router_test.py index 911c9f0..f8124ed 100644 --- a/backend/test/modules/location/location_router_test.py +++ b/backend/test/modules/location/location_router_test.py @@ -124,24 +124,7 @@ async def test_create_location_success(self): request_data = location_data.model_dump( mode="json", - include={"google_place_id", "warning_count", "citation_count", "hold_expiration"}, - ) - - response = await self.admin_client.post("/api/locations/", json=request_data) - data = assert_res_success(response, LocationDto, status=201) - - self.location_utils.assert_matches(location_data, data) - - @pytest.mark.asyncio - async def test_create_location_with_warnings_and_citations(self): - """Test creating location with warnings and citations.""" - location_data = await self.location_utils.next_data(warning_count=3, citation_count=2) - - self.gmaps_utils.mock_place_details(**location_data.model_dump()) - - request_data = location_data.model_dump( - mode="json", - include={"google_place_id", "warning_count", "citation_count", "hold_expiration"}, + include={"google_place_id", "hold_expiration"}, ) response = await self.admin_client.post("/api/locations/", json=request_data) @@ -161,7 +144,7 @@ async def test_create_location_duplicate_place_id(self): request_data = location_data.model_dump( mode="json", - include={"google_place_id", "warning_count", "citation_count", "hold_expiration"}, + include={"google_place_id", "hold_expiration"}, ) response = await self.admin_client.post("/api/locations/", json=request_data) @@ -173,23 +156,19 @@ async def test_update_location_success(self): location = await self.location_utils.create_one() update_data = await self.location_utils.next_data( google_place_id=location.google_place_id, # Keep same place_id - warning_count=5, - citation_count=3, ) request_data = update_data.model_dump( mode="json", - include={"google_place_id", "warning_count", "citation_count", "hold_expiration"}, + include={"google_place_id", "hold_expiration"}, ) response = await self.admin_client.put(f"/api/locations/{location.id}", json=request_data) data = assert_res_success(response, LocationDto) assert data.id == location.id - # When place_id unchanged, address data stays the same, only counts/expiration update + # When place_id unchanged, address data stays the same, only expiration can update assert data.google_place_id == location.google_place_id - assert data.warning_count == 5 - assert data.citation_count == 3 assert data.hold_expiration is None @pytest.mark.asyncio @@ -199,7 +178,7 @@ async def test_update_location_not_found(self): request_data = update_data.model_dump( mode="json", - include={"google_place_id", "warning_count", "citation_count", "hold_expiration"}, + include={"google_place_id", "hold_expiration"}, ) response = await self.admin_client.put("/api/locations/999", json=request_data) @@ -217,7 +196,7 @@ async def test_update_location_duplicate_place_id(self): request_data = update_data.model_dump( mode="json", - include={"google_place_id", "warning_count", "citation_count", "hold_expiration"}, + include={"google_place_id", "hold_expiration"}, ) response = await self.admin_client.put( diff --git a/backend/test/modules/location/location_service_test.py b/backend/test/modules/location/location_service_test.py index 381955c..2dc06ac 100644 --- a/backend/test/modules/location/location_service_test.py +++ b/backend/test/modules/location/location_service_test.py @@ -11,7 +11,7 @@ LocationService, PlaceNotFoundException, ) -from test.modules.complaint.complaint_utils import ComplaintTestUtils +from test.modules.incident.incident_utils import IncidentTestUtils from test.modules.location.location_utils import GmapsMockUtils, LocationTestUtils @@ -50,8 +50,6 @@ async def test_create_location_with_full_data(self): """Test creating a location with all optional fields populated""" data = await self.location_utils.next_data( unit="Apt 2B", - warning_count=1, - citation_count=2, hold_expiration=datetime(2025, 12, 31, 23, 59, 59, tzinfo=UTC), ) @@ -201,75 +199,73 @@ async def test_location_data_persistence(self): self.location_utils.assert_matches(all_locations[0], data) @pytest.mark.asyncio - async def test_location_complaints_field_defaults_to_empty_list(self): - """Test that Location DTO complaints field defaults to empty list.""" + async def test_location_incidents_field_defaults_to_empty_list(self): + """Test that Location DTO incidents field defaults to empty list.""" data = await self.location_utils.next_data() created = await self.location_service.create_location(data) - # Verify complaints field exists and is empty list - assert hasattr(created, "complaints") - assert created.complaints == [] - assert isinstance(created.complaints, list) + # Verify incidents field exists and is empty list + assert hasattr(created, "incidents") + assert created.incidents == [] + assert isinstance(created.incidents, list) @pytest.mark.asyncio - async def test_location_serialization_includes_complaints(self): - """Test that Location DTO properly serializes with complaints field.""" + async def test_location_serialization_includes_incidents(self): + """Test that Location DTO properly serializes with incidents field.""" data = await self.location_utils.next_data() created = await self.location_service.create_location(data) - # Test model_dump includes complaints + # Test model_dump includes incidents serialized = created.model_dump() - assert "complaints" in serialized - assert serialized["complaints"] == [] + assert "incidents" in serialized + assert serialized["incidents"] == [] # Test JSON serialization json_str = created.model_dump_json() - assert "complaints" in json_str + assert "incidents" in json_str -class TestLocationServiceWithComplaints: - """Tests for Location service with complaints relationship""" +class TestLocationServiceWithIncidents: + """Tests for Location service with incidents relationship""" location_utils: LocationTestUtils - complaint_utils: ComplaintTestUtils + incident_utils: IncidentTestUtils location_service: LocationService @pytest.fixture(autouse=True) def _setup( self, location_utils: LocationTestUtils, - complaint_utils: ComplaintTestUtils, + incident_utils: IncidentTestUtils, location_service: LocationService, ): self.location_utils = location_utils - self.complaint_utils = complaint_utils + self.incident_utils = incident_utils self.location_service = location_service @pytest.mark.asyncio - async def test_get_location_with_complaints(self): - """Test getting a location that has complaints includes the complaints.""" + async def test_get_location_with_incidents(self): + """Test getting a location that has incidents includes the incidents.""" location = await self.location_utils.create_one() - complaint1, complaint2 = await self.complaint_utils.create_many( - i=2, location_id=location.id - ) + incident1, incident2 = await self.incident_utils.create_many(i=2, location_id=location.id) # Fetch the location fetched = await self.location_service.get_location_by_id(location.id) - # Verify complaints are included - assert len(fetched.complaints) == 2 - self.complaint_utils.assert_matches(fetched.complaints[0], complaint1) - self.complaint_utils.assert_matches(fetched.complaints[1], complaint2) + # Verify incidents are included + assert len(fetched.incidents) == 2 + self.incident_utils.assert_matches(fetched.incidents[0], incident1) + self.incident_utils.assert_matches(fetched.incidents[1], incident2) @pytest.mark.asyncio - async def test_delete_location_with_complaints_cascades(self): - """Test that deleting a location also deletes its complaints (cascade delete).""" + async def test_delete_location_with_incidents_cascades(self): + """Test that deleting a location also deletes its incidents (cascade delete).""" location = await self.location_utils.create_one() - # Add complaints to the location - await self.complaint_utils.create_many(i=2, location_id=location.id) + # Add incidents to the location + await self.incident_utils.create_many(i=2, location_id=location.id) # Delete the location deleted = await self.location_service.delete_location(location.id) @@ -280,17 +276,17 @@ async def test_delete_location_with_complaints_cascades(self): all_locations = await self.location_utils.get_all() assert len(all_locations) == 0 - # Verify the complaints are also deleted (cascade delete) - all_complaints = await self.complaint_utils.get_all() - assert len(all_complaints) == 0 + # Verify the incidents are also deleted (cascade delete) + all_incidents = await self.incident_utils.get_all() + assert len(all_incidents) == 0 @pytest.mark.asyncio - async def test_update_location_retains_complaints(self): - """Test that updating a location retains its complaints.""" + async def test_update_location_retains_incidents(self): + """Test that updating a location retains its incidents.""" location = await self.location_utils.create_one() - # Add complaints to the location - complaint = await self.complaint_utils.create_one( + # Add incidents to the location + incident = await self.incident_utils.create_one( location_id=location.id, ) @@ -304,18 +300,18 @@ async def test_update_location_retains_complaints(self): # Verify location was updated self.location_utils.assert_matches(updated, update_data) - # Verify complaints are retained - assert len(updated.complaints) == 1 - self.complaint_utils.assert_matches(updated.complaints[0], complaint) + # Verify incidents are retained + assert len(updated.incidents) == 1 + self.incident_utils.assert_matches(updated.incidents[0], incident) @pytest.mark.asyncio - async def test_get_locations_includes_complaints(self): - """Test that getting all locations includes their complaints.""" + async def test_get_locations_includes_incidents(self): + """Test that getting all locations includes their incidents.""" location1, location2 = await self.location_utils.create_many(i=2) - # Add complaints to both locations - complaint1 = await self.complaint_utils.create_one(location_id=location1.id) - complaint2 = await self.complaint_utils.create_one(location_id=location2.id) + # Add incidents to both locations + incident1 = await self.incident_utils.create_one(location_id=location1.id) + incident2 = await self.incident_utils.create_one(location_id=location2.id) # Fetch all locations fetched_locations = await self.location_service.get_locations() @@ -327,12 +323,12 @@ async def test_get_locations_includes_complaints(self): assert loc1 is not None assert loc2 is not None - # Verify complaints are included - assert len(loc1.complaints) == 1 - self.complaint_utils.assert_matches(loc1.complaints[0], complaint1) + # Verify incidents are included + assert len(loc1.incidents) == 1 + self.incident_utils.assert_matches(loc1.incidents[0], incident1) - assert len(loc2.complaints) == 1 - self.complaint_utils.assert_matches(loc2.complaints[0], complaint2) + assert len(loc2.incidents) == 1 + self.incident_utils.assert_matches(loc2.incidents[0], incident2) class TestLocationServiceGoogleMapsAutocomplete: diff --git a/backend/test/modules/location/location_utils.py b/backend/test/modules/location/location_utils.py index 73cae77..d61809e 100644 --- a/backend/test/modules/location/location_utils.py +++ b/backend/test/modules/location/location_utils.py @@ -28,8 +28,6 @@ class LocationOverrides(TypedDict, total=False): state: str | None country: str | None zip_code: str | None - warning_count: int - citation_count: int hold_expiration: datetime | None @@ -62,8 +60,6 @@ def generate_defaults(count: int) -> dict[str, Any]: "state": ["NC", "VA", "SC", "GA"][count % 4], "country": "US", "zip_code": f"275{14 + count % 10}", - "warning_count": count % 5, - "citation_count": count % 3, "hold_expiration": None, } defaults["formatted_address"] = ( diff --git a/backend/test/modules/police/police_router_test.py b/backend/test/modules/police/police_router_test.py deleted file mode 100644 index a7ba67f..0000000 --- a/backend/test/modules/police/police_router_test.py +++ /dev/null @@ -1,125 +0,0 @@ -from unittest.mock import MagicMock - -import pytest -from httpx import AsyncClient -from src.modules.location.location_model import MAX_COUNT, LocationDto -from src.modules.location.location_service import ( - CountLimitExceededException, - LocationNotFoundException, -) -from test.modules.location.location_utils import LocationTestUtils -from test.utils.http.assertions import assert_res_failure, assert_res_success -from test.utils.http.test_templates import generate_auth_required_tests - -test_police_authentication = generate_auth_required_tests( - ({"admin", "police"}, "POST", "/api/police/locations/1/warnings", None), - ({"admin", "police"}, "POST", "/api/police/locations/1/citations", None), -) - - -class TestPoliceRouter: - """Tests for police router endpoints.""" - - police_client: AsyncClient - admin_client: AsyncClient - location_utils: LocationTestUtils - mock_place: MagicMock - - @pytest.fixture(autouse=True) - def _setup( - self, - location_utils: LocationTestUtils, - police_client: AsyncClient, - admin_client: AsyncClient, - mock_place: MagicMock, - ): - self.location_utils = location_utils - self.police_client = police_client - self.admin_client = admin_client - self.mock_place = mock_place - - @pytest.mark.asyncio - async def test_increment_warnings_success(self): - """Test incrementing warnings returns updated location.""" - location = await self.location_utils.create_one(warning_count=0) - - response = await self.police_client.post(f"/api/police/locations/{location.id}/warnings") - data = assert_res_success(response, LocationDto) - - assert data.id == location.id - assert data.warning_count == 1 - assert data.citation_count == location.citation_count - - @pytest.mark.asyncio - async def test_increment_warnings_location_not_found(self): - """Test incrementing warnings for non-existent location returns 404.""" - response = await self.police_client.post("/api/police/locations/999/warnings") - - assert_res_failure(response, LocationNotFoundException(999)) - - @pytest.mark.asyncio - async def test_increment_warnings_at_max_count(self): - """Test incrementing warnings when at max count returns 400.""" - location = await self.location_utils.create_one(warning_count=MAX_COUNT) - - response = await self.police_client.post(f"/api/police/locations/{location.id}/warnings") - - assert_res_failure(response, CountLimitExceededException(location.id, "warning_count")) - - @pytest.mark.asyncio - async def test_increment_warnings_multiple_times(self): - """Test incrementing warnings multiple times on same location.""" - location = await self.location_utils.create_one(warning_count=0) - - # First increment - response = await self.police_client.post(f"/api/police/locations/{location.id}/warnings") - data = assert_res_success(response, LocationDto) - assert data.warning_count == 1 - - # Second increment - response = await self.police_client.post(f"/api/police/locations/{location.id}/warnings") - data = assert_res_success(response, LocationDto) - assert data.warning_count == 2 - - @pytest.mark.asyncio - async def test_increment_citations_success(self): - """Test incrementing citations returns updated location.""" - location = await self.location_utils.create_one(citation_count=0) - - response = await self.police_client.post(f"/api/police/locations/{location.id}/citations") - data = assert_res_success(response, LocationDto) - - assert data.id == location.id - assert data.citation_count == 1 - assert data.warning_count == location.warning_count - - @pytest.mark.asyncio - async def test_increment_citations_location_not_found(self): - """Test incrementing citations for non-existent location returns 404.""" - response = await self.police_client.post("/api/police/locations/999/citations") - - assert_res_failure(response, LocationNotFoundException(999)) - - @pytest.mark.asyncio - async def test_increment_citations_at_max_count(self): - """Test incrementing citations when at max count returns 400.""" - location = await self.location_utils.create_one(citation_count=MAX_COUNT) - - response = await self.police_client.post(f"/api/police/locations/{location.id}/citations") - - assert_res_failure(response, CountLimitExceededException(location.id, "citation_count")) - - @pytest.mark.asyncio - async def test_increment_citations_multiple_times(self): - """Test incrementing citations multiple times on same location.""" - location = await self.location_utils.create_one(citation_count=0) - - # First increment - response = await self.police_client.post(f"/api/police/locations/{location.id}/citations") - data = assert_res_success(response, LocationDto) - assert data.citation_count == 1 - - # Second increment - response = await self.police_client.post(f"/api/police/locations/{location.id}/citations") - data = assert_res_success(response, LocationDto) - assert data.citation_count == 2 From 33cd7dcfb8547f6515898f3211f1d6aa64842cea Mon Sep 17 00:00:00 2001 From: aLEGEND21 Date: Sat, 31 Jan 2026 00:32:48 -0500 Subject: [PATCH 2/2] refactor: Couple of minor changes that were requested --- backend/src/core/authentication.py | 8 +++ .../src/modules/incident/incident_entity.py | 4 +- .../src/modules/incident/incident_model.py | 11 ++- .../src/modules/incident/incident_router.py | 25 +++---- .../src/modules/incident/incident_service.py | 12 ++-- backend/test/conftest.py | 3 + .../modules/incident/incident_router_test.py | 72 +++++++------------ .../test/modules/incident/incident_utils.py | 7 ++ 8 files changed, 75 insertions(+), 67 deletions(-) diff --git a/backend/src/core/authentication.py b/backend/src/core/authentication.py index 13162f4..e515db7 100644 --- a/backend/src/core/authentication.py +++ b/backend/src/core/authentication.py @@ -137,3 +137,11 @@ async def authenticate_police_or_admin( account: AccountDto | PoliceAccountDto = Depends(authenticate_by_role("police", "admin")), ) -> PoliceAccountDto | AccountDto: return account + + +async def authenticate_police_staff_or_admin( + account: AccountDto | PoliceAccountDto = Depends( + authenticate_by_role("police", "staff", "admin") + ), +) -> PoliceAccountDto | AccountDto: + return account diff --git a/backend/src/modules/incident/incident_entity.py b/backend/src/modules/incident/incident_entity.py index 3a37a6a..6663881 100644 --- a/backend/src/modules/incident/incident_entity.py +++ b/backend/src/modules/incident/incident_entity.py @@ -1,4 +1,4 @@ -from datetime import UTC +from datetime import UTC, datetime from typing import TYPE_CHECKING, Self from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String @@ -17,7 +17,7 @@ class IncidentEntity(MappedAsDataclass, EntityBase): location_id: Mapped[int] = mapped_column( Integer, ForeignKey("locations.id", ondelete="CASCADE"), nullable=False ) - incident_datetime: Mapped[str] = mapped_column(DateTime(timezone=True), nullable=False) + incident_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) severity: Mapped[IncidentSeverity] = mapped_column(Enum(IncidentSeverity), nullable=False) description: Mapped[str] = mapped_column(String, nullable=False, default="") diff --git a/backend/src/modules/incident/incident_model.py b/backend/src/modules/incident/incident_model.py index 854e744..75467c6 100644 --- a/backend/src/modules/incident/incident_model.py +++ b/backend/src/modules/incident/incident_model.py @@ -9,15 +9,20 @@ class IncidentSeverity(Enum): CITATION = "citation" -class IncidentData(BaseModel): - """Data DTO for an incident without id.""" +class IncidentCreateDto(BaseModel): + """Request body for creating/updating an incident (location_id comes from path).""" - location_id: int incident_datetime: AwareDatetime description: str = "" severity: IncidentSeverity +class IncidentData(IncidentCreateDto): + """Full incident data including location_id (for internal use).""" + + location_id: int + + class IncidentDto(IncidentData): """Output DTO for an incident.""" diff --git a/backend/src/modules/incident/incident_router.py b/backend/src/modules/incident/incident_router.py index 3e7d3dc..27889ca 100644 --- a/backend/src/modules/incident/incident_router.py +++ b/backend/src/modules/incident/incident_router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter, Depends, status -from src.core.authentication import authenticate_admin, authenticate_staff_or_admin +from src.core.authentication import authenticate_police_or_admin, authenticate_police_staff_or_admin from src.modules.account.account_model import AccountDto +from src.modules.police.police_model import PoliceAccountDto -from .incident_model import IncidentData, IncidentDto +from .incident_model import IncidentCreateDto, IncidentDto from .incident_service import IncidentService incident_router = APIRouter(prefix="/api/locations", tags=["incidents"]) @@ -13,12 +14,12 @@ response_model=list[IncidentDto], status_code=status.HTTP_200_OK, summary="Get all incidents for a location", - description="Returns all incidents associated with a given location. Staff or admin only.", + description="Returns all incidents for a location. Police, staff, or admin only.", ) async def get_incidents_by_location( location_id: int, incident_service: IncidentService = Depends(), - _: AccountDto = Depends(authenticate_staff_or_admin), + _: AccountDto | PoliceAccountDto = Depends(authenticate_police_staff_or_admin), ) -> list[IncidentDto]: """Get all incidents for a location.""" return await incident_service.get_incidents_by_location(location_id) @@ -29,13 +30,13 @@ async def get_incidents_by_location( response_model=IncidentDto, status_code=status.HTTP_201_CREATED, summary="Create an incident for a location", - description="Creates a new incident associated with a location. Admin only.", + description="Creates a new incident associated with a location. Police or admin only.", ) async def create_incident( location_id: int, - incident_data: IncidentData, + incident_data: IncidentCreateDto, incident_service: IncidentService = Depends(), - _: AccountDto = Depends(authenticate_admin), + _: AccountDto | PoliceAccountDto = Depends(authenticate_police_or_admin), ) -> IncidentDto: """Create an incident for a location.""" return await incident_service.create_incident(location_id, incident_data) @@ -46,14 +47,14 @@ async def create_incident( response_model=IncidentDto, status_code=status.HTTP_200_OK, summary="Update an incident", - description="Updates an existing incident. Admin only.", + description="Updates an existing incident. Police or admin only.", ) async def update_incident( location_id: int, incident_id: int, - incident_data: IncidentData, + incident_data: IncidentCreateDto, incident_service: IncidentService = Depends(), - _: AccountDto = Depends(authenticate_admin), + _: AccountDto | PoliceAccountDto = Depends(authenticate_police_or_admin), ) -> IncidentDto: """Update an incident.""" return await incident_service.update_incident(incident_id, location_id, incident_data) @@ -64,13 +65,13 @@ async def update_incident( response_model=IncidentDto, status_code=status.HTTP_200_OK, summary="Delete an incident", - description="Deletes an incident. Admin only.", + description="Deletes an incident. Police or admin only.", ) async def delete_incident( location_id: int, incident_id: int, incident_service: IncidentService = Depends(), - _: AccountDto = Depends(authenticate_admin), + _: AccountDto | PoliceAccountDto = Depends(authenticate_police_or_admin), ) -> IncidentDto: """Delete an incident.""" return await incident_service.delete_incident(incident_id) diff --git a/backend/src/modules/incident/incident_service.py b/backend/src/modules/incident/incident_service.py index 7e8377d..c0c962a 100644 --- a/backend/src/modules/incident/incident_service.py +++ b/backend/src/modules/incident/incident_service.py @@ -7,7 +7,7 @@ from src.modules.location.location_service import LocationNotFoundException from .incident_entity import IncidentEntity -from .incident_model import IncidentData, IncidentDto +from .incident_model import IncidentCreateDto, IncidentDto class IncidentNotFoundException(NotFoundException): @@ -32,9 +32,11 @@ async def _get_incident_entity_by_id(self, incident_id: int) -> IncidentEntity: return incident_entity async def get_incidents_by_location(self, location_id: int) -> list[IncidentDto]: - """Get all incidents for a given location.""" + """Get all incidents for a given location, ordered by incident datetime.""" result = await self.session.execute( - select(IncidentEntity).where(IncidentEntity.location_id == location_id) + select(IncidentEntity) + .where(IncidentEntity.location_id == location_id) + .order_by(IncidentEntity.incident_datetime) ) incidents = result.scalars().all() return [incident.to_dto() for incident in incidents] @@ -44,7 +46,7 @@ async def get_incident_by_id(self, incident_id: int) -> IncidentDto: incident_entity = await self._get_incident_entity_by_id(incident_id) return incident_entity.to_dto() - async def create_incident(self, location_id: int, data: IncidentData) -> IncidentDto: + async def create_incident(self, location_id: int, data: IncidentCreateDto) -> IncidentDto: """Create a new incident.""" new_incident = IncidentEntity( location_id=location_id, @@ -64,7 +66,7 @@ async def create_incident(self, location_id: int, data: IncidentData) -> Inciden return new_incident.to_dto() async def update_incident( - self, incident_id: int, location_id: int, data: IncidentData + self, incident_id: int, location_id: int, data: IncidentCreateDto ) -> IncidentDto: """Update an existing incident.""" incident_entity = await self._get_incident_entity_by_id(incident_id) diff --git a/backend/test/conftest.py b/backend/test/conftest.py index e05c4af..89843e0 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -87,6 +87,9 @@ async def create_test_client( async def _create_test_client(role: StringRole | None): async def override_get_session(): + # Rollback any pending transaction from previous failed requests + if test_session.in_transaction() and not test_session.is_active: + await test_session.rollback() yield test_session app.dependency_overrides[get_session] = override_get_session diff --git a/backend/test/modules/incident/incident_router_test.py b/backend/test/modules/incident/incident_router_test.py index 9b55c17..ece022e 100644 --- a/backend/test/modules/incident/incident_router_test.py +++ b/backend/test/modules/incident/incident_router_test.py @@ -14,20 +14,20 @@ from test.utils.http.test_templates import generate_auth_required_tests test_incident_authentication = generate_auth_required_tests( - ({"admin", "staff"}, "GET", "/api/locations/1/incidents", None), + ({"admin", "staff", "police"}, "GET", "/api/locations/1/incidents", None), ( - {"admin"}, + {"admin", "police"}, "POST", "/api/locations/1/incidents", - IncidentTestUtils.get_sample_data(), + IncidentTestUtils.get_sample_create_data(), ), ( - {"admin"}, + {"admin", "police"}, "PUT", "/api/locations/1/incidents/1", - IncidentTestUtils.get_sample_data(), + IncidentTestUtils.get_sample_create_data(), ), - ({"admin"}, "DELETE", "/api/locations/1/incidents/1", None), + ({"admin", "police"}, "DELETE", "/api/locations/1/incidents/1", None), ) @@ -79,10 +79,14 @@ async def test_create_incident_success(self) -> None: """Test successfully creating an incident.""" location = await self.location_utils.create_one() incident_data = await self.incident_utils.next_data(location_id=location.id) + # Exclude location_id from request body (it comes from path) + request_body = { + k: v for k, v in incident_data.model_dump(mode="json").items() if k != "location_id" + } response = await self.admin_client.post( f"/api/locations/{location.id}/incidents", - json=incident_data.model_dump(mode="json"), + json=request_body, ) data = assert_res_success(response, IncidentDto, status=201) @@ -97,10 +101,13 @@ async def test_create_incident_with_severity(self) -> None: incident_data = await self.incident_utils.next_data( location_id=location.id, severity=severity ) + request_body = { + k: v for k, v in incident_data.model_dump(mode="json").items() if k != "location_id" + } response = await self.admin_client.post( f"/api/locations/{location.id}/incidents", - json=incident_data.model_dump(mode="json"), + json=request_body, ) data = assert_res_success(response, IncidentDto, status=201) @@ -111,37 +118,23 @@ async def test_create_incident_with_empty_description(self) -> None: """Test creating an incident with empty description.""" location = await self.location_utils.create_one() incident_data = await self.incident_utils.next_data(location_id=location.id, description="") + request_body = { + k: v for k, v in incident_data.model_dump(mode="json").items() if k != "location_id" + } response = await self.admin_client.post( f"/api/locations/{location.id}/incidents", - json=incident_data.model_dump(mode="json"), + json=request_body, ) data = assert_res_success(response, IncidentDto, status=201) assert data.description == "" - @pytest.mark.asyncio - async def test_create_incident_location_id_required(self) -> None: - """Test creating an incident without location_id fails validation.""" - location = await self.location_utils.create_one() - incident_data = { - "incident_datetime": "2025-11-18T20:30:00Z", - "description": "Noise incident", - "severity": "complaint", - } - - response = await self.admin_client.post( - f"/api/locations/{location.id}/incidents", json=incident_data - ) - - assert_res_validation_error(response, expected_fields=["location_id"]) - @pytest.mark.asyncio async def test_create_incident_severity_required(self) -> None: """Test creating an incident without severity fails validation.""" location = await self.location_utils.create_one() incident_data = { - "location_id": location.id, "incident_datetime": "2025-11-18T20:30:00Z", "description": "Noise incident", } @@ -162,10 +155,13 @@ async def test_update_incident_success(self) -> None: description="Updated description", severity=IncidentSeverity.WARNING, ) + request_body = { + k: v for k, v in update_data.model_dump(mode="json").items() if k != "location_id" + } response = await self.admin_client.put( f"/api/locations/{incident.location_id}/incidents/{incident.id}", - json=update_data.model_dump(mode="json"), + json=request_body, ) data = assert_res_success(response, IncidentDto) @@ -177,31 +173,17 @@ async def test_update_incident_not_found(self) -> None: """Test updating a non-existent incident.""" location = await self.location_utils.create_one() update_data = await self.incident_utils.next_data(location_id=location.id) + request_body = { + k: v for k, v in update_data.model_dump(mode="json").items() if k != "location_id" + } response = await self.admin_client.put( f"/api/locations/{location.id}/incidents/999", - json=update_data.model_dump(mode="json"), + json=request_body, ) assert_res_failure(response, IncidentNotFoundException(999)) - @pytest.mark.asyncio - async def test_update_incident_location_id_required(self) -> None: - """Test updating an incident without location_id fails validation.""" - incident = await self.incident_utils.create_one() - update_data = { - "incident_datetime": "2025-11-20T23:00:00Z", - "description": "Updated description", - "severity": "warning", - } - - response = await self.admin_client.put( - f"/api/locations/{incident.location_id}/incidents/{incident.id}", - json=update_data, - ) - - assert_res_validation_error(response, expected_fields=["location_id"]) - @pytest.mark.asyncio async def test_delete_incident_success(self) -> None: """Test successfully deleting an incident.""" diff --git a/backend/test/modules/incident/incident_utils.py b/backend/test/modules/incident/incident_utils.py index a135d90..bdbf4af 100644 --- a/backend/test/modules/incident/incident_utils.py +++ b/backend/test/modules/incident/incident_utils.py @@ -42,6 +42,13 @@ def generate_defaults(count: int) -> dict[str, Any]: "severity": IncidentSeverity.COMPLAINT.value, } + @classmethod + def get_sample_create_data(cls) -> dict: + """Get sample data for IncidentCreate (without location_id, since it comes from path).""" + data = cls.generate_defaults(0) + del data["location_id"] + return data + @override async def next_dict(self, **overrides: Unpack[IncidentOverrides]) -> dict: # If location_id not provided, create a location