Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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.location.location_router import location_router
from src.modules.party.party_router import party_router
from src.modules.student.student_router import student_router
Expand Down Expand Up @@ -42,3 +43,4 @@ def read_root():
app.include_router(party_router)
app.include_router(student_router)
app.include_router(location_router)
app.include_router(complaint_router)
2 changes: 2 additions & 0 deletions backend/src/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""

from .account.account_entity import AccountEntity
from .complaint.complaint_entity import ComplaintEntity
from .location.location_entity import LocationEntity
from .party.party_entity import PartyEntity
from .police.police_entity import PoliceEntity
Expand All @@ -29,4 +30,5 @@
"PoliceEntity",
"PartyEntity",
"LocationEntity",
"ComplaintEntity",
]
Empty file.
44 changes: 44 additions & 0 deletions backend/src/modules/complaint/complaint_entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from datetime import datetime
from typing import TYPE_CHECKING, Self

from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.core.database import EntityBase

from .complaint_model import Complaint

if TYPE_CHECKING:
from ..location.location_entity import LocationEntity


class ComplaintEntity(EntityBase):
__tablename__ = "complaints"

id: Mapped[int] = mapped_column(Integer, primary_key=True)
location_id: Mapped[int] = mapped_column(
Integer, ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
)
complaint_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
description: Mapped[str] = mapped_column(String, nullable=False, default="")

# Relationships
location: Mapped["LocationEntity"] = relationship(
"LocationEntity", passive_deletes=True
)

@classmethod
def from_model(cls, data: Complaint) -> Self:
return cls(
location_id=data.location_id,
complaint_datetime=data.complaint_datetime,
description=data.description,
)

def to_model(self) -> Complaint:
"""Convert entity to model."""
return Complaint(
id=self.id,
location_id=self.location_id,
complaint_datetime=self.complaint_datetime,
description=self.description,
)
17 changes: 17 additions & 0 deletions backend/src/modules/complaint/complaint_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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 Complaint(ComplaintData):
"""Output DTO for a complaint."""

id: int
78 changes: 78 additions & 0 deletions backend/src/modules/complaint/complaint_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, status
from src.core.authentication import authenticate_admin, authenticate_staff_or_admin
from src.modules.account.account_model import Account

from .complaint_model import Complaint, ComplaintData
from .complaint_service import ComplaintService

complaint_router = APIRouter(prefix="/api/locations", tags=["complaints"])


@complaint_router.get(
"/{location_id}/complaints",
response_model=list[Complaint],
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(),
_: Account = Depends(authenticate_staff_or_admin),
) -> list[Complaint]:
"""Get all complaints for a location."""
return await complaint_service.get_complaints_by_location(location_id)


@complaint_router.post(
"/{location_id}/complaints",
response_model=Complaint,
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(),
_: Account = Depends(authenticate_admin),
) -> Complaint:
"""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=Complaint,
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(),
_: Account = Depends(authenticate_admin),
) -> Complaint:
"""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=Complaint,
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(),
_: Account = Depends(authenticate_admin),
) -> Complaint:
"""Delete a complaint."""
return await complaint_service.delete_complaint(complaint_id)
94 changes: 94 additions & 0 deletions backend/src/modules/complaint/complaint_service.py
Original file line number Diff line number Diff line change
@@ -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 .complaint_entity import ComplaintEntity
from .complaint_model import Complaint, ComplaintData


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[Complaint]:
"""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_model() for complaint in complaints]

async def get_complaint_by_id(self, complaint_id: int) -> Complaint:
"""Get a single complaint by ID."""
complaint_entity = await self._get_complaint_entity_by_id(complaint_id)
return complaint_entity.to_model()

async def create_complaint(
self, location_id: int, data: ComplaintData
) -> Complaint:
"""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)
raise
await self.session.refresh(new_complaint)
return new_complaint.to_model()

async def update_complaint(
self, complaint_id: int, location_id: int, data: ComplaintData
) -> Complaint:
"""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)
raise
await self.session.refresh(complaint_entity)
return complaint_entity.to_model()

async def delete_complaint(self, complaint_id: int) -> Complaint:
"""Delete a complaint."""
complaint_entity = await self._get_complaint_entity_by_id(complaint_id)
complaint = complaint_entity.to_model()
await self.session.delete(complaint_entity)
await self.session.commit()
return complaint
20 changes: 19 additions & 1 deletion backend/src/modules/location/location_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
from typing import Self

from sqlalchemy import DECIMAL, DateTime, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.core.database import EntityBase
from src.modules.complaint.complaint_entity import ComplaintEntity

from .location_model import Location, LocationData

Expand Down Expand Up @@ -40,9 +42,22 @@ class LocationEntity(EntityBase):
country: Mapped[str | None] = mapped_column(String(2)) # e.g. "US"
zip_code: Mapped[str | None] = mapped_column(String(10)) # e.g. "27514"

# Relationships
complaints: Mapped[list["ComplaintEntity"]] = relationship(
"ComplaintEntity",
back_populates="location",
cascade="all, delete-orphan",
lazy="selectin", # Use selectin loading to avoid N+1 queries
)

__table_args__ = (Index("idx_lat_lng", "latitude", "longitude"),)

def to_model(self) -> Location:
# Check if complaints 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

hold_exp = self.hold_expiration
if hold_exp is not None and hold_exp.tzinfo is None:
hold_exp = hold_exp.replace(tzinfo=timezone.utc)
Expand All @@ -64,6 +79,9 @@ def to_model(self) -> Location:
warning_count=self.warning_count,
citation_count=self.citation_count,
hold_expiration=hold_exp,
complaints=[complaint.to_model() for complaint in self.complaints]
if complaints_loaded
else [],
)

@classmethod
Expand Down
2 changes: 2 additions & 0 deletions backend/src/modules/location/location_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pydantic import AwareDatetime, BaseModel
from src.core.models import PaginatedResponse
from src.modules.complaint.complaint_model import Complaint


class AutocompleteInput(BaseModel):
Expand Down Expand Up @@ -65,6 +66,7 @@ def from_address(

class Location(LocationData):
id: int
complaints: list[Complaint] = []


PaginatedLocationResponse = PaginatedResponse[Location]
Expand Down
3 changes: 0 additions & 3 deletions backend/src/modules/location/location_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import asyncio
import json
from datetime import datetime

import googlemaps
Expand Down Expand Up @@ -239,8 +238,6 @@ async def get_place_details(self, place_id: str) -> AddressData:

place = place_result["result"]

print(json.dumps(place, indent=2, ensure_ascii=False))

street_number = None
street_name = None
city = None
Expand Down
4 changes: 4 additions & 0 deletions backend/test/modules/complaint/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""
Tests for the complaint module.
"""

Loading