Files
banban/mini-program/app/service/location.py

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()
)