import logging from collections.abc import Mapping from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from sqlalchemy import text try: from app.security import get_current_user_id from app.service import get_db_session from app.schemas.location import ( DeviceLocationCurrentResponse, DeviceLocationTrajectoryItem, DeviceLocationTrajectoryResponse, ) from app.service.location import get_device_current_location, get_device_trajectory except ModuleNotFoundError: from security import get_current_user_id from service import get_db_session from schemas.location import ( DeviceLocationCurrentResponse, DeviceLocationTrajectoryItem, DeviceLocationTrajectoryResponse, ) from service.location import get_device_current_location, get_device_trajectory router = APIRouter(prefix="/devices", tags=["devices"]) logger = logging.getLogger("app.devices") class DeviceMessageItem(BaseModel): id: int conversation_id: int role_key: str is_user: bool speaker: str content: str timestamp: float created_at: datetime class DeviceMessageListResponse(BaseModel): items: list[DeviceMessageItem] total: int next_cursor: int | None = None def _ensure_device_access(db, device_id: str, user_id: int) -> None: row = ( db.execute( text( """ SELECT 1 FROM device_bindings WHERE device_id = :device_id AND owner_user_id = :user_id AND status = 1 LIMIT 1 """ ), {"device_id": device_id, "user_id": user_id}, ) .mappings() .first() ) if row is None: raise HTTPException(status_code=404, detail="device not found") def _row_to_message_item(row: Mapping) -> DeviceMessageItem: is_user = bool(row["is_user"]) return DeviceMessageItem( id=int(row["id"]), conversation_id=int(row["conversation_id"]), role_key=str(row["role_key"]), is_user=is_user, speaker="user" if is_user else "assistant", content=str(row["content"]), timestamp=float(row["timestamp"]), created_at=row["created_at"], ) def _row_to_current_location_response(row: Mapping) -> DeviceLocationCurrentResponse: return DeviceLocationCurrentResponse( child_id=int(row["child_id"]), child_name=row.get("child_name"), device_id=str(row["device_id"]), coord_type=str(row["coord_type"]), lat=float(row["lat"]), lng=float(row["lng"]), accuracy_m=row["accuracy_m"], altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None, speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None, heading_deg=row["heading_deg"], source=int(row["source"]), battery_pct=row["battery_pct"], device_time=row["device_time"], server_time=row["server_time"], updated_at=row["updated_at"], ) def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLocationTrajectoryItem: return DeviceLocationTrajectoryItem( id=int(row["id"]), child_id=int(row["child_id"]), child_name=child_name, device_id=str(row["device_id"]), coord_type=str(row["coord_type"]), lat=float(row["lat"]), lng=float(row["lng"]), accuracy_m=row["accuracy_m"], altitude_m=float(row["altitude_m"]) if row["altitude_m"] is not None else None, speed_mps=float(row["speed_mps"]) if row["speed_mps"] is not None else None, heading_deg=row["heading_deg"], source=int(row["source"]), battery_pct=row["battery_pct"], device_time=row["device_time"], server_time=row["server_time"], created_at=row["created_at"], ) @router.get("/{device_id}/messages", response_model=DeviceMessageListResponse) def list_device_messages( device_id: str, request: Request, cursor: int | None = Query(default=None, ge=1), limit: int = Query(default=20, ge=1, le=100), current_user_id: int = Depends(get_current_user_id), db=Depends(get_db_session), ) -> DeviceMessageListResponse: _ensure_device_access(db=db, device_id=device_id, user_id=current_user_id) params = {"device_id": device_id, "limit": limit + 1} where = "ch.device_id = :device_id" if cursor is not None: where += " AND cm.id < :cursor" params["cursor"] = cursor rows = ( db.execute( text( f""" SELECT cm.id, ch.id AS conversation_id, ch.role_key, cm.is_user, cm.content, cm.timestamp, cm.created_at FROM conversation_messages AS cm JOIN conversation_histories AS ch ON ch.id = cm.conversation_id WHERE {where} ORDER BY cm.id DESC LIMIT :limit """ ), params, ) .mappings() .all() ) has_more = len(rows) > limit rows = rows[:limit] next_cursor = int(rows[-1]["id"]) if has_more and rows else None logger.info( "listed device ai messages", extra={ "event": "device_messages", "request_id": getattr(request.state, "request_id", None), "user_id": current_user_id, "device_id": device_id, "returned_count": len(rows), }, ) return DeviceMessageListResponse( items=[_row_to_message_item(row) for row in rows], total=len(rows), next_cursor=next_cursor, ) @router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse) def get_current_device_location( device_id: str, request: Request, current_user_id: int = Depends(get_current_user_id), db=Depends(get_db_session), ) -> DeviceLocationCurrentResponse: row = get_device_current_location(db=db, device_id=device_id, user_id=current_user_id) logger.info( "device current location fetched", extra={ "event": "device_current_location", "request_id": getattr(request.state, "request_id", None), "user_id": current_user_id, "device_id": device_id, "child_id": int(row["child_id"]), }, ) return _row_to_current_location_response(row) @router.get("/{device_id}/trajectory", response_model=DeviceLocationTrajectoryResponse) def get_device_location_trajectory( device_id: str, request: Request, start_at: datetime | None = Query(default=None), end_at: datetime | None = Query(default=None), limit: int = Query(default=200, ge=1, le=1000), current_user_id: int = Depends(get_current_user_id), db=Depends(get_db_session), ) -> DeviceLocationTrajectoryResponse: if start_at and end_at and start_at > end_at: raise HTTPException(status_code=422, detail="start_at must be earlier than end_at") access, rows = get_device_trajectory( db=db, device_id=device_id, user_id=current_user_id, start_at=start_at, end_at=end_at, limit=limit, ) logger.info( "device trajectory fetched", extra={ "event": "device_trajectory", "request_id": getattr(request.state, "request_id", None), "user_id": current_user_id, "device_id": device_id, "child_id": access.child_id, "count": len(rows), }, ) return DeviceLocationTrajectoryResponse( items=[_row_to_trajectory_item(row, child_name=access.child_name) for row in rows], total=len(rows), start_at=start_at, end_at=end_at, )