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:
+152
-2
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user