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
98 lines
2.8 KiB
Python
98 lines
2.8 KiB
Python
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import delete, insert, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.location import Location
|
|
from app.schemas.location import LocationRecordRequest
|
|
|
|
|
|
def _parse_optional_float_compat(value: str | None) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _parse_required_float(value: str, field_name: str) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"Invalid numeric value for {field_name}") from exc
|
|
|
|
|
|
def _utc_now_rfc3339() -> str:
|
|
now = datetime.now(timezone.utc).replace(microsecond=0)
|
|
return now.isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def record_location(session: Session, payload: LocationRecordRequest) -> None:
|
|
stmt = (
|
|
insert(Location)
|
|
.prefix_with("OR IGNORE")
|
|
.values(
|
|
person=payload.person,
|
|
datetime=_utc_now_rfc3339(),
|
|
latitude=_parse_required_float(payload.latitude, "latitude"),
|
|
longitude=_parse_required_float(payload.longitude, "longitude"),
|
|
altitude=_parse_optional_float_compat(payload.altitude),
|
|
)
|
|
)
|
|
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
|