M2-T04: add single-row record CRUD API (patch/delete)

- 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
This commit is contained in:
2026-06-12 23:33:08 +02:00
parent 9ce3f2a0b8
commit 048414c5cb
7 changed files with 1377 additions and 4 deletions
+152 -2
View File
@@ -1,24 +1,28 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
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_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"])
@@ -123,3 +127,149 @@ def get_public_ip(
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",
)
+16
View File
@@ -24,6 +24,14 @@ class LocationsResponse(BaseModel):
offset: int
class LocationUpdateRequest(BaseModel):
"""PATCH body for a location record — all fields optional; PK fields excluded."""
latitude: float | None = None
longitude: float | None = None
altitude: float | None = None
# ---------------------------------------------------------------------------
# Poo
# ---------------------------------------------------------------------------
@@ -42,6 +50,14 @@ class PooResponse(BaseModel):
offset: int
class PooUpdateRequest(BaseModel):
"""PATCH body for a poo record — all fields optional; PK field excluded."""
status: str | None = None
latitude: float | None = None
longitude: float | None = None
# ---------------------------------------------------------------------------
# Public IP
# ---------------------------------------------------------------------------
+56 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone
from sqlalchemy import insert
from sqlalchemy import delete, insert, select
from sqlalchemy.orm import Session
from app.models.location import Location
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
)
session.execute(stmt)
session.commit()
def update_location(
session: Session,
person: str,
datetime_pk: str,
*,
latitude: float | None,
longitude: float | None,
altitude: float | None,
) -> Location | None:
"""Update non-PK fields of a single location row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The caller must not pass PK fields — they are immutable.
Only fields with a non-``None`` value are written; ``altitude`` being
``None`` in the request means "leave unchanged", not "clear to NULL".
"""
row = session.execute(
select(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
).scalar_one_or_none()
if row is None:
return None
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
if altitude is not None:
row.altitude = altitude
session.commit()
session.refresh(row)
return row
def delete_location(session: Session, person: str, datetime_pk: str) -> bool:
"""Delete the single location row identified by its full composite PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
— no batch/truncate path exists.
"""
result = session.execute(
delete(Location).where(
Location.person == person,
Location.datetime == datetime_pk,
)
)
session.commit()
return result.rowcount == 1
+48 -1
View File
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
import logging
from sqlalchemy import desc, insert, select
from sqlalchemy import delete, desc, insert, select
from sqlalchemy.orm import Session
from app.config import Settings
@@ -74,6 +74,53 @@ def record_poo(
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc)
def update_poo_record(
session: Session,
timestamp_pk: str,
*,
status: str | None,
latitude: float | None,
longitude: float | None,
) -> PooRecord | None:
"""Update non-PK fields of a single poo record row.
Returns the updated ORM object, or ``None`` if the PK does not exist.
The ``timestamp`` PK is immutable and must not be passed as an update field.
Only fields with a non-``None`` value are written.
"""
row = session.execute(
select(PooRecord).where(PooRecord.timestamp == timestamp_pk)
).scalar_one_or_none()
if row is None:
return None
if status is not None:
row.status = status
if latitude is not None:
row.latitude = latitude
if longitude is not None:
row.longitude = longitude
session.commit()
session.refresh(row)
return row
def delete_poo_record(session: Session, timestamp_pk: str) -> bool:
"""Delete the single poo record row identified by its PK.
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
not exist (caller should raise 404). The DELETE is scoped to the exact PK
— no batch/truncate path exists.
"""
result = session.execute(
delete(PooRecord).where(PooRecord.timestamp == timestamp_pk)
)
session.commit()
return result.rowcount == 1
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
record = session.execute(stmt).scalar_one_or_none()