from __future__ import annotations from fastapi import APIRouter, Body, Depends, HTTPException, Query, status from sqlalchemy import desc, select from sqlalchemy.orm import Session from app.api.routes.api.deps import require_csrf, require_session from app.dependencies import get_db from app.models.location import Location from app.models.poo import PooRecord from app.models.public_ip import PublicIPHistory, PublicIPState from app.schemas.data import ( LocationRecord, LocationUpdateRequest, LocationsResponse, PooRecord as PooRecordSchema, PooResponse, PooUpdateRequest, PublicIPHistorySchema, PublicIPResponse, PublicIPStateSchema, ) from app.services.auth import AuthenticatedSession from app.services.location import delete_location, update_location from app.services.poo import delete_poo_record, update_poo_record router = APIRouter(prefix="/api", tags=["api-data"]) @router.get("/locations", response_model=LocationsResponse) def get_locations( limit: int = Query(default=1000, ge=1, le=5000), offset: int = Query(default=0, ge=0), start: str | None = Query(default=None), end: str | None = Query(default=None), db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), ) -> LocationsResponse: """ Return location records with optional time-window filtering and pagination. - ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds. - Results are ordered by ``datetime`` ascending. - ``limit`` is capped at 5000 to prevent full-table exports. """ stmt = select(Location) if start is not None: stmt = stmt.where(Location.datetime >= start) if end is not None: stmt = stmt.where(Location.datetime <= end) stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit) rows = db.execute(stmt).scalars().all() items = [ LocationRecord( person=row.person, datetime=row.datetime, latitude=row.latitude, longitude=row.longitude, altitude=row.altitude, ) for row in rows ] return LocationsResponse(items=items, limit=limit, offset=offset) @router.get("/poo", response_model=PooResponse) def get_poo( limit: int = Query(default=100, ge=1, le=1000), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), ) -> PooResponse: """ Return poo records ordered by timestamp descending (most recent first). ``limit`` is capped at 1000 to prevent full-table exports. """ stmt = ( select(PooRecord) .order_by(desc(PooRecord.timestamp)) .offset(offset) .limit(limit) ) rows = db.execute(stmt).scalars().all() items = [ PooRecordSchema( timestamp=row.timestamp, status=row.status, latitude=row.latitude, longitude=row.longitude, ) for row in rows ] return PooResponse(items=items, limit=limit, offset=offset) @router.get("/public-ip", response_model=PublicIPResponse) def get_public_ip( limit: int = Query(default=100, ge=1, le=1000), db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), ) -> PublicIPResponse: """ Return the current public IP state and recent history. - ``state`` is ``null`` if no IP check has been performed yet. - ``history`` is ordered by ``observed_at`` descending (most recent first). - ``limit`` applies to the history list and is capped at 1000. """ state_row = db.execute( select(PublicIPState).where(PublicIPState.id == 1).limit(1) ).scalar_one_or_none() history_rows = db.execute( select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit) ).scalars().all() state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None history = [PublicIPHistorySchema.model_validate(row) for row in history_rows] return PublicIPResponse(state=state, history=history) # --------------------------------------------------------------------------- # PATCH /api/locations/{person}/{datetime} # --------------------------------------------------------------------------- @router.patch("/locations/{person}/{datetime}", response_model=LocationRecord) def patch_location( person: str, datetime: str, body: LocationUpdateRequest = Body(default=LocationUpdateRequest()), db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf), ) -> LocationRecord: """ Update the non-PK fields of a single location record. - ``person`` and ``datetime`` identify the row (composite PK) and are immutable. - Only ``latitude``, ``longitude``, and ``altitude`` may be updated. - Omitted body fields are left unchanged. - Returns **404** if the PK does not exist. """ row = update_location( db, person, datetime, latitude=body.latitude, longitude=body.longitude, altitude=body.altitude, ) if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="location record not found", ) return LocationRecord( person=row.person, datetime=row.datetime, latitude=row.latitude, longitude=row.longitude, altitude=row.altitude, ) # --------------------------------------------------------------------------- # DELETE /api/locations/{person}/{datetime} # --------------------------------------------------------------------------- @router.delete( "/locations/{person}/{datetime}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, ) def delete_location_record( person: str, datetime: str, db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf), ) -> None: """ Delete the single location record identified by its composite PK. - Exactly one row is deleted; **404** if the PK does not exist. - No batch delete / truncate path is available. """ deleted = delete_location(db, person, datetime) if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="location record not found", ) # --------------------------------------------------------------------------- # PATCH /api/poo/{timestamp} # --------------------------------------------------------------------------- @router.patch("/poo/{timestamp}", response_model=PooRecordSchema) def patch_poo( timestamp: str, body: PooUpdateRequest = Body(default=PooUpdateRequest()), db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf), ) -> PooRecordSchema: """ Update the non-PK fields of a single poo record. - ``timestamp`` is the PK and is immutable. - Only ``status``, ``latitude``, and ``longitude`` may be updated. - Omitted body fields are left unchanged. - Returns **404** if the PK does not exist. """ row = update_poo_record( db, timestamp, status=body.status, latitude=body.latitude, longitude=body.longitude, ) if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="poo record not found", ) return PooRecordSchema( timestamp=row.timestamp, status=row.status, latitude=row.latitude, longitude=row.longitude, ) # --------------------------------------------------------------------------- # DELETE /api/poo/{timestamp} # --------------------------------------------------------------------------- @router.delete( "/poo/{timestamp}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, ) def delete_poo( timestamp: str, db: Session = Depends(get_db), _auth: AuthenticatedSession = Depends(require_session), _csrf: None = Depends(require_csrf), ) -> None: """ Delete the single poo record identified by its PK. - Exactly one row is deleted; **404** if the PK does not exist. - No batch delete / truncate path is available. """ deleted = delete_poo_record(db, timestamp) if not deleted: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="poo record not found", )