328 lines
9.6 KiB
Python
328 lines
9.6 KiB
Python
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()
|
|
)
|