Files
home-automation/app/services/location.py
T
tliu93 048414c5cb 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
2026-06-12 23:33:08 +02:00

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