from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from typing import Any from fastapi import HTTPException, status from sqlalchemy import text from sqlalchemy.orm import Session try: from app.db_compat import current_timestamp_sql, select_for_update_clause from app.schemas.location import DeviceLocationReportRequest from app.service.im import DeviceIdentity, authenticate_device_identity except ModuleNotFoundError: from db_compat import current_timestamp_sql, select_for_update_clause from schemas.location import DeviceLocationReportRequest from service.im import DeviceIdentity, authenticate_device_identity @dataclass(frozen=True) class ParentDeviceAccess: device_id: str child_id: int child_name: str | None def assert_parent_device_access(db: Session, *, device_id: str, user_id: int) -> ParentDeviceAccess: row = ( db.execute( text( """ SELECT db.device_id, db.child_id, c.child_name FROM device_bindings AS db LEFT JOIN children AS c ON c.child_id = db.child_id AND c.status = 1 WHERE db.device_id = :device_id AND db.owner_user_id = :user_id AND db.status = 1 LIMIT 1 """ ), {"device_id": device_id, "user_id": user_id}, ) .mappings() .first() ) if not row: raise HTTPException(status_code=404, detail="device not found") if row["child_id"] is None: raise HTTPException(status_code=404, detail="device not bound to a child") return ParentDeviceAccess( device_id=str(row["device_id"]), child_id=int(row["child_id"]), child_name=row["child_name"], ) def report_device_location( db: Session, *, device_id: str, serial_number: str, payload: DeviceLocationReportRequest, ) -> tuple[DeviceIdentity, Mapping[str, Any]]: device_identity = authenticate_device_identity( db=db, device_id=device_id, serial_number=serial_number, ) now_sql = current_timestamp_sql(db) child_id = device_identity.child_id try: current_row = _get_current_location_row(db, child_id=child_id, lock=True) params = { "child_id": child_id, "device_id": device_identity.device_id, "coord_type": payload.coord_type, "lat": payload.lat, "lng": payload.lng, "accuracy_m": payload.accuracy_m, "altitude_m": payload.altitude_m, "speed_mps": payload.speed_mps, "heading_deg": payload.heading_deg, "source": payload.source, "battery_pct": payload.battery_pct, "device_time": payload.device_time, } if current_row: db.execute( text( f""" UPDATE child_location_current SET device_id = :device_id, coord_type = :coord_type, lat = :lat, lng = :lng, accuracy_m = :accuracy_m, altitude_m = :altitude_m, speed_mps = :speed_mps, heading_deg = :heading_deg, source = :source, battery_pct = :battery_pct, device_time = :device_time, server_time = {now_sql}, updated_at = {now_sql} WHERE child_id = :child_id """ ), params, ) else: db.execute( text( f""" INSERT INTO child_location_current ( child_id, device_id, coord_type, lat, lng, accuracy_m, altitude_m, speed_mps, heading_deg, source, battery_pct, device_time, server_time, updated_at ) VALUES ( :child_id, :device_id, :coord_type, :lat, :lng, :accuracy_m, :altitude_m, :speed_mps, :heading_deg, :source, :battery_pct, :device_time, {now_sql}, {now_sql} ) """ ), params, ) history_id = _next_primary_key(db, "child_location_history") insert_history_sql = f""" INSERT INTO child_location_history ( {'id,' if history_id is not None else ''} child_id, device_id, coord_type, lat, lng, accuracy_m, altitude_m, speed_mps, heading_deg, source, battery_pct, device_time, server_time, created_at ) VALUES ( {':id,' if history_id is not None else ''} :child_id, :device_id, :coord_type, :lat, :lng, :accuracy_m, :altitude_m, :speed_mps, :heading_deg, :source, :battery_pct, :device_time, {now_sql}, {now_sql} ) """ history_params = dict(params) if history_id is not None: history_params["id"] = history_id db.execute(text(insert_history_sql), history_params) db.commit() except Exception: db.rollback() raise current_row = _get_current_location_row(db, child_id=child_id, lock=False) if not current_row: raise RuntimeError("current location not found after report") return device_identity, current_row def get_device_current_location( db: Session, *, device_id: str, user_id: int, ) -> Mapping[str, Any]: access = assert_parent_device_access(db, device_id=device_id, user_id=user_id) row = _get_current_location_row(db, child_id=access.child_id, lock=False) if not row or str(row["device_id"]) != device_id: raise HTTPException(status_code=404, detail="location not found") return {**row, "child_name": access.child_name} def get_device_trajectory( db: Session, *, device_id: str, user_id: int, start_at: datetime | None, end_at: datetime | None, limit: int, ) -> tuple[ParentDeviceAccess, list[Mapping[str, Any]]]: access = assert_parent_device_access(db, device_id=device_id, user_id=user_id) params: dict[str, Any] = { "device_id": device_id, "child_id": access.child_id, "fetch_limit": limit, } where = """ device_id = :device_id AND child_id = :child_id """ if start_at is not None: where += " AND device_time >= :start_at" params["start_at"] = start_at if end_at is not None: where += " AND device_time <= :end_at" params["end_at"] = end_at rows = ( db.execute( text( f""" SELECT id, child_id, device_id, coord_type, lat, lng, accuracy_m, altitude_m, speed_mps, heading_deg, source, battery_pct, device_time, server_time, created_at FROM child_location_history WHERE {where} ORDER BY device_time DESC, id DESC LIMIT :fetch_limit """ ), params, ) .mappings() .all() ) rows = list(rows) rows.reverse() return access, rows def _get_current_location_row( db: Session, *, child_id: int, lock: bool, ) -> Mapping[str, Any] | None: lock_clause = select_for_update_clause(db) if lock else "" return ( db.execute( text( f""" SELECT child_id, device_id, coord_type, lat, lng, accuracy_m, altitude_m, speed_mps, heading_deg, source, battery_pct, device_time, server_time, updated_at FROM child_location_current WHERE child_id = :child_id LIMIT 1{lock_clause} """ ), {"child_id": child_id}, ) .mappings() .first() ) def _next_primary_key(db: Session, table_name: str) -> int | None: bind = db.get_bind() if bind is None or bind.dialect.name != "sqlite": return None return int( db.execute(text(f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table_name}")).scalar_one() )