048414c5cb
- PATCH/DELETE /api/locations/{person}/{datetime} and /api/poo/{timestamp}
- update only non-PK fields (PK immutable); 404 on missing PK
- delete scoped to exact full PK with rowcount guard (0->404, 1->ok);
no batch/truncate/drop path
- session + CSRF protected; bare ingestion endpoints untouched
- service helpers in app/services/location.py and poo.py; regenerate openapi/
- tests/test_api_record_crud.py
276 lines
8.5 KiB
Python
276 lines
8.5 KiB
Python
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",
|
|
)
|