Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,
)
25 changes: 25 additions & 0 deletions backend/src/modules/complaint/complaint_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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


class ComplaintCreate(BaseModel):
"""Input DTO for creating a complaint for a location."""

location_id: int
complaint_datetime: datetime
description: str = ""
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, ComplaintCreate
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: ComplaintCreate,
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: ComplaintCreate,
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)
92 changes: 92 additions & 0 deletions backend/src/modules/complaint/complaint_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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 ConflictException, NotFoundException

from .complaint_entity import ComplaintEntity
from .complaint_model import Complaint, ComplaintCreate


class ComplaintNotFoundException(NotFoundException):
def __init__(self, complaint_id: int):
super().__init__(f"Complaint with ID {complaint_id} not found")


class ComplaintConflictException(ConflictException):
def __init__(self, message: str):
super().__init__(message)


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: ComplaintCreate
) -> 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:
raise ComplaintConflictException(f"Failed to create complaint: {str(e)}")
await self.session.refresh(new_complaint)
return new_complaint.to_model()

async def update_complaint(
self, complaint_id: int, location_id: int, data: ComplaintCreate
) -> 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:
raise ComplaintConflictException(f"Failed to update complaint: {str(e)}")
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 @@ -38,9 +40,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

return Location(
id=self.id,
google_place_id=self.google_place_id,
Expand All @@ -58,6 +73,9 @@ def to_model(self) -> Location:
warning_count=self.warning_count,
citation_count=self.citation_count,
hold_expiration=self.hold_expiration,
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 @@ -3,6 +3,7 @@

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


class AutocompleteInput(BaseModel):
Expand Down Expand Up @@ -66,6 +67,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