Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.
 
 
 
 

958 rindas
28 KiB

  1. import asyncio
  2. import json
  3. import os
  4. import tempfile
  5. from urllib.parse import urlencode
  6. from uuid import UUID
  7. from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
  8. from fastapi.encoders import jsonable_encoder
  9. import httpx
  10. import config_env
  11. from typing import Any, Dict, List, Optional
  12. from schemas.reslevis import (
  13. BuildingItem,
  14. FloorItem,
  15. ZoneItem,
  16. ZoneAreaDefinitionItem,
  17. GatewayItem,
  18. TrackerItem,
  19. OperatorItem,
  20. SubjectItem,
  21. AlarmItem,
  22. AlarmCoreItem,
  23. AlarmStatusUpdateItem,
  24. TrackItem,
  25. TrackHistoryItem,
  26. TrackerZoneItem,
  27. SettingItem,
  28. GuiConfigItem,
  29. UserPreferencesItem,
  30. UserPreferencesUpdateItem,
  31. FloorMapUploadResponseItem,
  32. CoreSettingsItem,
  33. CoreSettingsUpdateItem,
  34. )
  35. from logica_reslevis.gateway import GatewayJsonRepository
  36. from logica_reslevis.building import BuildingJsonRepository
  37. from logica_reslevis.floor import FloorJsonRepository
  38. from logica_reslevis.zone import ZoneJsonRepository
  39. from logica_reslevis.zone_area_definition import ZoneAreaDefinitionJsonRepository
  40. from logica_reslevis.tracker import TrackerJsonRepository
  41. from logica_reslevis.operator import OperatorJsonRepository
  42. from logica_reslevis.setting import SettingJsonRepository
  43. from logica_reslevis.gui_config import GuiConfigJsonRepository
  44. from logica_reslevis.user_preferences import UserPreferencesJsonRepository
  45. from logica_reslevis.subject import SubjectJsonRepository
  46. from logica_reslevis.alarm import AlarmJsonRepository
  47. from logica_reslevis.track import TrackJsonRepository
  48. from logica_reslevis.tracker_zone import TrackerZoneJsonRepository
  49. from logica_reslevis.tracker_mode import get_mode_aware_trackers
  50. from security import get_current_user
  51. #CORE SYNC
  52. CORE_BASE_URL = config_env.CORE_API_URL.rstrip("/")
  53. ALERTS_CORE_BASE_URL = "http://localhost:1902"
  54. TRACKS_CORE_BASE_URL = "http://localhost:1902"
  55. SETTINGS_CORE_BASE_URL = "http://127.0.0.1:1902"
  56. CORE_TIMEOUT = 2.0 # secondi
  57. PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
  58. async def sync_core_get(request: Request) -> None:
  59. if request.method != "GET":
  60. return
  61. sync = CORE_GET_SYNC.get(request.url.path)
  62. if sync is None:
  63. return
  64. repo, normalizer = sync
  65. try:
  66. async with httpx.AsyncClient(timeout=CORE_TIMEOUT) as client:
  67. resp = await client.get(
  68. f"{CORE_BASE_URL}{request.url.path}",
  69. params=request.query_params,
  70. )
  71. if 200 <= resp.status_code < 300:
  72. data = resp.json()
  73. if isinstance(data, list):
  74. if normalizer:
  75. data = [normalizer(r) for r in data if isinstance(r, dict)]
  76. repo._write_all(data) # aggiorna i file locali
  77. except (httpx.RequestError, ValueError):
  78. # CORE giù o risposta non valida -> uso il file locale
  79. pass
  80. router = APIRouter(dependencies=[Depends(sync_core_get)])
  81. gateway_repo = GatewayJsonRepository()
  82. building_repo = BuildingJsonRepository()
  83. floor_repo = FloorJsonRepository()
  84. zone_repo = ZoneJsonRepository()
  85. zone_area_definition_repo = ZoneAreaDefinitionJsonRepository()
  86. tracker_repo = TrackerJsonRepository()
  87. operator_repo = OperatorJsonRepository()
  88. subject_repo = SubjectJsonRepository()
  89. alarm_repo = AlarmJsonRepository()
  90. track_repo = TrackJsonRepository()
  91. tracker_zone_repo = TrackerZoneJsonRepository()
  92. setting_repo = SettingJsonRepository()
  93. gui_config_repo = GuiConfigJsonRepository()
  94. user_preferences_repo = UserPreferencesJsonRepository()
  95. def _none_if_empty(v):
  96. return None if v in ("", None, 0, "0") else v
  97. def _str_or_none(v):
  98. if v in ("", None):
  99. return None
  100. if isinstance(v, (int, float, bool)):
  101. return str(v)
  102. return v
  103. def _uuid_list(values):
  104. if values in ("", None):
  105. return []
  106. if isinstance(values, str):
  107. values = [v for v in values.split(",") if v]
  108. if isinstance(values, (list, tuple, set)):
  109. cleaned = []
  110. for v in values:
  111. if isinstance(v, dict):
  112. v = v.get("id") or v.get("uuid")
  113. if v in ("", None, 0, "0"):
  114. continue
  115. cleaned.append(v)
  116. return cleaned
  117. return [values] if values not in ("", None, 0, "0") else []
  118. def _normalize_gateway(row: dict) -> dict:
  119. row = dict(row)
  120. row["floor"] = _none_if_empty(row.get("floor"))
  121. row["building"] = _none_if_empty(row.get("building"))
  122. return row
  123. def _normalize_track(row: dict) -> dict:
  124. row = dict(row)
  125. row["ID"] = row.get("ID")
  126. row["gateway"] = _none_if_empty(row.get("gateway"))
  127. row["tracker"] = _none_if_empty(row.get("tracker"))
  128. row["subject"] = _none_if_empty(row.get("subject"))
  129. row["floor"] = _none_if_empty(row.get("floor"))
  130. row["building"] = _none_if_empty(row.get("building"))
  131. row["timestamp"] = _str_or_none(row.get("timestamp"))
  132. row["type"] = _str_or_none(row.get("type"))
  133. row["status"] = _str_or_none(row.get("status"))
  134. row["gatewayMac"] = _str_or_none(row.get("gatewayMac"))
  135. row["trackerMac"] = _str_or_none(row.get("trackerMac"))
  136. row["subjectName"] = _str_or_none(row.get("subjectName"))
  137. row["x"] = None if row.get("x") in ("", None) else row.get("x")
  138. row["y"] = None if row.get("y") in ("", None) else row.get("y")
  139. row["z"] = None if row.get("z") in ("", None) else row.get("z")
  140. # signal resta float o None
  141. row["signal"] = None if row.get("signal") in ("", None) else row.get("signal")
  142. return row
  143. def _normalize_zone(row: dict) -> dict:
  144. row = dict(row)
  145. row["floor"] = _none_if_empty(row.get("floor"))
  146. row["building"] = _none_if_empty(row.get("building"))
  147. row["groups"] = _uuid_list(row.get("groups"))
  148. return row
  149. def _uid_from_claims(claims: dict) -> str:
  150. uid = (
  151. claims.get("preferred_username")
  152. or claims.get("username")
  153. or claims.get("email")
  154. or claims.get("sub")
  155. )
  156. if not uid:
  157. raise HTTPException(status_code=401, detail="User identity not found in token")
  158. return str(uid)
  159. def _uuid_or_none(value):
  160. if value in ("", None, 0, "0"):
  161. return None
  162. try:
  163. return str(UUID(str(value)))
  164. except (TypeError, ValueError, AttributeError):
  165. return None
  166. def _operator_uuid_by_name(value):
  167. name = str(value).strip()
  168. if not name:
  169. return None
  170. try:
  171. rows = operator_repo.list()
  172. except Exception:
  173. return None
  174. target = name.lower()
  175. for row in rows:
  176. if not isinstance(row, dict):
  177. continue
  178. if str(row.get("name") or "").strip().lower() == target:
  179. return _uuid_or_none(row.get("id"))
  180. return None
  181. def _normalize_alarm_core(row: dict) -> dict:
  182. row = dict(row)
  183. operator_raw = row.get("operator")
  184. operator_uuid = _uuid_or_none(operator_raw)
  185. if operator_uuid is None and operator_raw not in ("", None, 0, "0"):
  186. operator_uuid = _operator_uuid_by_name(operator_raw)
  187. if operator_uuid is None and not row.get("operatorName"):
  188. row["operatorName"] = str(operator_raw)
  189. row["operator"] = operator_uuid
  190. return row
  191. def _model_update_dict(model: Any) -> Dict[str, Any]:
  192. if hasattr(model, "model_dump"):
  193. data = model.model_dump(exclude_unset=True)
  194. else:
  195. data = model.dict(exclude_unset=True)
  196. return jsonable_encoder(data)
  197. def _floor_maps_index_path() -> str:
  198. return os.path.join(config_env.RESLEVIS_MAPS_DIR, "maps.json")
  199. def _read_floor_maps_index() -> Dict[str, Any]:
  200. path = _floor_maps_index_path()
  201. if not os.path.isfile(path):
  202. return {"items": [], "count": 0}
  203. try:
  204. with open(path, "r", encoding="utf-8") as fp:
  205. data = json.load(fp)
  206. except (OSError, ValueError):
  207. return {"items": [], "count": 0}
  208. if not isinstance(data, dict):
  209. return {"items": [], "count": 0}
  210. items = data.get("items")
  211. if not isinstance(items, list):
  212. items = []
  213. return {"items": items, "count": len(items)}
  214. def _write_floor_maps_index(index: Dict[str, Any]) -> None:
  215. maps_dir = config_env.RESLEVIS_MAPS_DIR
  216. os.makedirs(maps_dir, exist_ok=True)
  217. index["count"] = len(index.get("items") or [])
  218. payload = json.dumps(index, ensure_ascii=False, indent=2)
  219. temp_name = None
  220. try:
  221. with tempfile.NamedTemporaryFile("w", dir=maps_dir, delete=False, encoding="utf-8") as tmp:
  222. tmp.write(payload)
  223. tmp.flush()
  224. os.fsync(tmp.fileno())
  225. temp_name = tmp.name
  226. os.replace(temp_name, _floor_maps_index_path())
  227. try:
  228. os.chmod(_floor_maps_index_path(), 0o664)
  229. except OSError:
  230. pass
  231. except OSError as exc:
  232. if temp_name and os.path.exists(temp_name):
  233. try:
  234. os.remove(temp_name)
  235. except OSError:
  236. pass
  237. raise HTTPException(status_code=500, detail="Unable to update maps.json") from exc
  238. def _same_floor(row: Dict[str, Any], floor: int) -> bool:
  239. try:
  240. return int(row.get("floor")) == floor
  241. except (TypeError, ValueError):
  242. return False
  243. def _floor_sort_key(row: Dict[str, Any]) -> int:
  244. try:
  245. return int(row.get("floor"))
  246. except (TypeError, ValueError):
  247. return 0
  248. def _upsert_floor_map_record(floor: int, name: str, metadata: Dict[str, Any]) -> None:
  249. index = _read_floor_maps_index()
  250. item = {
  251. "floor": floor,
  252. "name": name,
  253. "mime_type": "image/png",
  254. "metadata": metadata,
  255. }
  256. items = [r for r in index["items"] if isinstance(r, dict) and not _same_floor(r, floor)]
  257. items.append(item)
  258. items.sort(key=_floor_sort_key)
  259. index["items"] = items
  260. _write_floor_maps_index(index)
  261. def _public_map_path(name: str) -> str:
  262. public_path = config_env.RESLEVIS_MAPS_PUBLIC_PATH
  263. return f"{public_path}/{name}" if public_path else name
  264. def _validate_map_target(maps_dir: str, name: str) -> str:
  265. maps_dir_abs = os.path.abspath(maps_dir)
  266. target_path = os.path.abspath(os.path.join(maps_dir_abs, name))
  267. if os.path.commonpath([maps_dir_abs, target_path]) != maps_dir_abs:
  268. raise HTTPException(status_code=400, detail="Invalid map filename")
  269. return target_path
  270. async def _write_upload_file(upload: UploadFile, target_path: str) -> None:
  271. maps_dir = os.path.dirname(target_path)
  272. temp_name = None
  273. try:
  274. with tempfile.NamedTemporaryFile("wb", dir=maps_dir, delete=False) as tmp:
  275. temp_name = tmp.name
  276. while True:
  277. chunk = await upload.read(1024 * 1024)
  278. if not chunk:
  279. break
  280. tmp.write(chunk)
  281. tmp.flush()
  282. os.fsync(tmp.fileno())
  283. os.replace(temp_name, target_path)
  284. try:
  285. os.chmod(target_path, 0o664)
  286. except OSError:
  287. pass
  288. except OSError as exc:
  289. if temp_name and os.path.exists(temp_name):
  290. try:
  291. os.remove(temp_name)
  292. except OSError:
  293. pass
  294. raise HTTPException(status_code=500, detail="Unable to save floor map") from exc
  295. CORE_GET_SYNC = {
  296. "/reslevis/getGateways": (gateway_repo, _normalize_gateway),
  297. "/reslevis/getZones": (zone_repo, _normalize_zone),
  298. }
  299. async def _fetch_tracks_for_tracker(
  300. tracker_id: str,
  301. params: Optional[dict] = None,
  302. ) -> List[dict]:
  303. query_string = urlencode(params or {})
  304. url = f"{TRACKS_CORE_BASE_URL}/reslevis/getTracks/{tracker_id}"
  305. if query_string:
  306. url = f"{url}?{query_string}"
  307. process = await asyncio.create_subprocess_exec(
  308. "curl",
  309. "-sS",
  310. "-X",
  311. "GET",
  312. url,
  313. stdout=asyncio.subprocess.PIPE,
  314. stderr=asyncio.subprocess.PIPE,
  315. )
  316. stdout, stderr = await process.communicate()
  317. if process.returncode != 0:
  318. detail = (stderr or stdout).decode("utf-8", errors="replace").strip() or "CORE curl request failed"
  319. raise HTTPException(status_code=502, detail=detail)
  320. try:
  321. payload = json.loads(stdout.decode("utf-8"))
  322. except ValueError as exc:
  323. raise HTTPException(status_code=502, detail="Invalid CORE response") from exc
  324. if not isinstance(payload, list):
  325. raise HTTPException(status_code=502, detail="Unexpected CORE response type")
  326. return [_normalize_track(row) for row in payload if isinstance(row, dict)]
  327. @router.get(
  328. "/getGateways",
  329. response_model=List[GatewayItem],
  330. tags=["Reslevis"],
  331. dependencies=[Depends(get_current_user)],
  332. )
  333. def getGateways():
  334. return gateway_repo.list()
  335. @router.post("/postGateway", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  336. def postGateway(item: GatewayItem):
  337. gateway_repo.add(item)
  338. return {"message": "OK"}
  339. @router.put("/updateGateway", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  340. def updateGateway(item: GatewayItem):
  341. gateway_repo.update(item)
  342. return {"message": "OK"}
  343. @router.delete("/removeGateway/{gateway_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  344. def removeGateway(gateway_id: str):
  345. gateway_repo.remove(gateway_id)
  346. return {"message": "OK"}
  347. @router.get(
  348. "/getBuildings",
  349. response_model=List[BuildingItem],
  350. tags=["Reslevis"],
  351. dependencies=[Depends(get_current_user)],
  352. )
  353. def getBuildings():
  354. return building_repo.list()
  355. @router.post("/postBuilding", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  356. def postBuilding(item: BuildingItem):
  357. building_repo.add(item)
  358. return {"message": "OK"}
  359. @router.put("/updateBuilding", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  360. def updateBuilding(item: BuildingItem):
  361. building_repo.update(item)
  362. return {"message": "OK"}
  363. @router.delete("/removeBuilding/{building_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  364. def removeBuilding(building_id: str):
  365. building_repo.remove(building_id)
  366. return {"message": "OK"}
  367. @router.get(
  368. "/getFloors",
  369. response_model=List[FloorItem],
  370. tags=["Reslevis"],
  371. dependencies=[Depends(get_current_user)],
  372. )
  373. def getFloors():
  374. return floor_repo.list()
  375. @router.post("/postFloor", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  376. def postFloor(item: FloorItem):
  377. floor_repo.add(item)
  378. return {"message": "OK"}
  379. @router.put("/updateFloor", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  380. def updateFloor(item: FloorItem):
  381. floor_repo.update(item)
  382. return {"message": "OK"}
  383. @router.delete("/removeFloor/{floor_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  384. def removeFloor(floor_id: str):
  385. floor_repo.remove(floor_id)
  386. return {"message": "OK"}
  387. @router.get(
  388. "/getZones",
  389. response_model=List[ZoneItem],
  390. tags=["Reslevis"],
  391. dependencies=[Depends(get_current_user)],
  392. )
  393. def getZones():
  394. return zone_repo.list()
  395. @router.post("/postZone", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  396. def postZone(item: ZoneItem):
  397. zone_repo.add(item)
  398. return {"message": "OK"}
  399. @router.put("/updateZone", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  400. def updateZone(item: ZoneItem):
  401. zone_repo.update(item)
  402. return {"message": "OK"}
  403. @router.delete("/removeZone/{zone_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  404. def removeZone(zone_id: str):
  405. zone_repo.remove(zone_id)
  406. return {"message": "OK"}
  407. @router.get(
  408. "/getZoneAreaDefinitions",
  409. response_model=List[ZoneAreaDefinitionItem],
  410. tags=["Reslevis"],
  411. dependencies=[Depends(get_current_user)],
  412. )
  413. def getZoneAreaDefinitions(UUID: str | None = None):
  414. return zone_area_definition_repo.list(UUID)
  415. @router.post("/postZoneAreaDefinition", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  416. def postZoneAreaDefinition(item: ZoneAreaDefinitionItem):
  417. zone_area_definition_repo.add(item)
  418. return {"message": "OK"}
  419. @router.put("/updateZoneAreaDefinition", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  420. def updateZoneAreaDefinition(item: ZoneAreaDefinitionItem):
  421. zone_area_definition_repo.update(item)
  422. return {"message": "OK"}
  423. @router.delete("/removeZoneAreaDefinition/{zone_area_definition_uuid}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  424. def removeZoneAreaDefinition(zone_area_definition_uuid: str):
  425. zone_area_definition_repo.remove(zone_area_definition_uuid)
  426. return {"message": "OK"}
  427. @router.get(
  428. "/getTrackers",
  429. response_model=List[TrackerItem],
  430. tags=["Reslevis"],
  431. dependencies=[Depends(get_current_user)],
  432. )
  433. async def getTrackers():
  434. return await get_mode_aware_trackers(
  435. tracker_repo,
  436. SETTINGS_CORE_BASE_URL,
  437. config_env.BLE_AI_INFER_CSV,
  438. CORE_TIMEOUT,
  439. )
  440. @router.post("/postTracker", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  441. def postTracker(item: TrackerItem):
  442. tracker_repo.add(item)
  443. return {"message": "OK"}
  444. @router.put("/updateTracker", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  445. def updateTracker(item: TrackerItem):
  446. tracker_repo.update(item)
  447. return {"message": "OK"}
  448. @router.delete("/removeTracker/{tracker_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  449. def removeTracker(tracker_id: str):
  450. tracker_repo.remove(tracker_id)
  451. return {"message": "OK"}
  452. @router.get(
  453. "/getTrackerZones",
  454. response_model=List[TrackerZoneItem],
  455. tags=["Reslevis"],
  456. dependencies=[Depends(get_current_user)],
  457. )
  458. def getTrackerZones():
  459. return tracker_zone_repo.list()
  460. @router.post("/postTrackerZone", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  461. def postTrackerZone(item: TrackerZoneItem):
  462. tracker_zone_repo.add(item)
  463. return {"message": "OK"}
  464. @router.put("/updateTrackerZone", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  465. def updateTrackerZone(item: TrackerZoneItem):
  466. tracker_zone_repo.update(item)
  467. return {"message": "OK"}
  468. @router.delete("/removeTrackerZone/{tracker_zone_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  469. def removeTrackerZone(tracker_zone_id: str):
  470. tracker_zone_repo.remove(tracker_zone_id)
  471. return {"message": "OK"}
  472. @router.get(
  473. "/getTracks",
  474. response_model=List[TrackHistoryItem],
  475. tags=["Reslevis"],
  476. dependencies=[Depends(get_current_user)],
  477. )
  478. async def getTracks(
  479. tracker_id: str = Query(..., alias="id"),
  480. limit: Optional[int] = Query(None, ge=1),
  481. from_: Optional[str] = Query(None, alias="from"),
  482. to: Optional[str] = Query(None),
  483. ):
  484. params = {}
  485. if limit is not None:
  486. params["limit"] = limit
  487. if from_:
  488. params["from"] = from_
  489. if to:
  490. params["to"] = to
  491. return await _fetch_tracks_for_tracker(tracker_id, params)
  492. @router.get(
  493. "/getTracks/{tracker_id}",
  494. response_model=List[TrackHistoryItem],
  495. tags=["Reslevis"],
  496. dependencies=[Depends(get_current_user)],
  497. )
  498. async def getTrack(
  499. tracker_id: str,
  500. limit: Optional[int] = Query(None, ge=1),
  501. from_: Optional[str] = Query(None, alias="from"),
  502. to: Optional[str] = Query(None),
  503. ):
  504. params = {}
  505. if limit is not None:
  506. params["limit"] = limit
  507. if from_:
  508. params["from"] = from_
  509. if to:
  510. params["to"] = to
  511. return await _fetch_tracks_for_tracker(tracker_id, params)
  512. @router.post("/postTrack", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  513. def postTrack(item: TrackItem):
  514. track_repo.add(item)
  515. return {"message": "OK"}
  516. @router.put("/updateTrack", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  517. def updateTrack(item: TrackItem):
  518. track_repo.update(item)
  519. return {"message": "OK"}
  520. @router.delete("/removeTrack/{track_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  521. def removeTrack(track_id: str):
  522. track_repo.remove(track_id)
  523. return {"message": "OK"}
  524. @router.get(
  525. "/getAlarms",
  526. response_model=List[AlarmCoreItem],
  527. tags=["Reslevis"],
  528. dependencies=[Depends(get_current_user)],
  529. )
  530. async def getAlarms():
  531. async with httpx.AsyncClient(timeout=CORE_TIMEOUT) as client:
  532. resp = await client.get(f"{ALERTS_CORE_BASE_URL}/reslevis/alerts")
  533. if resp.status_code >= 400:
  534. detail = resp.text.strip() or "CORE alerts request failed"
  535. raise HTTPException(status_code=resp.status_code, detail=detail)
  536. try:
  537. payload = resp.json()
  538. except ValueError as exc:
  539. raise HTTPException(status_code=502, detail="Invalid CORE response") from exc
  540. if not isinstance(payload, list):
  541. raise HTTPException(status_code=502, detail="Unexpected CORE response type")
  542. return [_normalize_alarm_core(row) for row in payload if isinstance(row, dict)]
  543. @router.put(
  544. "/updateAlarm",
  545. tags=["Reslevis"],
  546. dependencies=[Depends(get_current_user)],
  547. )
  548. async def updateAlarm(item: AlarmStatusUpdateItem):
  549. update_payload = _model_update_dict(item)
  550. update_payload.pop("id", None)
  551. if not update_payload:
  552. raise HTTPException(status_code=400, detail="No alarm fields to update")
  553. async with httpx.AsyncClient(timeout=CORE_TIMEOUT) as client:
  554. resp = await client.patch(
  555. f"{ALERTS_CORE_BASE_URL}/reslevis/alerts/{item.id}",
  556. json=update_payload,
  557. )
  558. if resp.status_code >= 400:
  559. detail = resp.text.strip() or "CORE alert update failed"
  560. raise HTTPException(status_code=resp.status_code, detail=detail)
  561. if not resp.content:
  562. return {"message": "OK"}
  563. try:
  564. payload = resp.json()
  565. except ValueError:
  566. return {"message": "OK"}
  567. if isinstance(payload, dict):
  568. return _normalize_alarm_core(payload)
  569. if isinstance(payload, list):
  570. return [_normalize_alarm_core(row) for row in payload if isinstance(row, dict)]
  571. return payload
  572. @router.get(
  573. "/getOperators",
  574. response_model=List[OperatorItem],
  575. tags=["Reslevis"],
  576. dependencies=[Depends(get_current_user)],
  577. )
  578. def getOperators():
  579. return operator_repo.list()
  580. @router.post("/postOperator", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  581. def postOperator(item: OperatorItem):
  582. operator_repo.add(item)
  583. return {"message": "OK"}
  584. @router.put("/updateOperator", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  585. def updateOperator(item: OperatorItem):
  586. operator_repo.update(item)
  587. return {"message": "OK"}
  588. @router.delete("/removeOperator/{operator_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  589. def removeOperator(operator_id: str):
  590. operator_repo.remove(operator_id)
  591. return {"message": "OK"}
  592. @router.get(
  593. "/getSubjects",
  594. response_model=List[SubjectItem],
  595. tags=["Reslevis"],
  596. dependencies=[Depends(get_current_user)],
  597. )
  598. def getSubjects():
  599. return subject_repo.list()
  600. @router.post("/postSubject", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  601. def postSubject(item: SubjectItem):
  602. subject_repo.add(item)
  603. return {"message": "OK"}
  604. @router.put("/updateSubject", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  605. def updateSubject(item: SubjectItem):
  606. subject_repo.update(item)
  607. return {"message": "OK"}
  608. @router.delete("/removeSubject/{subject_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  609. def removeSubject(subject_id: str):
  610. subject_repo.remove(subject_id)
  611. return {"message": "OK"}
  612. @router.get(
  613. "/getSettings",
  614. response_model=List[SettingItem],
  615. tags=["Reslevis"],
  616. dependencies=[Depends(get_current_user)],
  617. )
  618. def getSettings():
  619. return setting_repo.list()
  620. @router.post("/postSetting", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  621. def postSetting(item: SettingItem):
  622. setting_repo.add(item)
  623. return {"message": "OK"}
  624. @router.put("/updateSetting", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  625. def updateSetting(item: SettingItem):
  626. setting_repo.update(item)
  627. return {"message": "OK"}
  628. @router.delete("/removeSetting/{setting_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  629. def removeSetting(setting_id: str):
  630. setting_repo.remove(setting_id)
  631. return {"message": "OK"}
  632. @router.get(
  633. "/getGuiConfigs",
  634. response_model=List[GuiConfigItem],
  635. tags=["Reslevis"],
  636. dependencies=[Depends(get_current_user)],
  637. )
  638. def getGuiConfigs():
  639. return gui_config_repo.list()
  640. @router.get(
  641. "/getUserPreferences",
  642. response_model=UserPreferencesItem,
  643. tags=["Reslevis"],
  644. dependencies=[Depends(get_current_user)],
  645. )
  646. def getUserPreferences(current_user: dict = Depends(get_current_user)):
  647. uid = _uid_from_claims(current_user)
  648. return user_preferences_repo.get(uid)
  649. @router.put(
  650. "/updateUserPreferences",
  651. response_model=UserPreferencesItem,
  652. tags=["Reslevis"],
  653. dependencies=[Depends(get_current_user)],
  654. )
  655. def updateUserPreferences(
  656. item: UserPreferencesUpdateItem,
  657. current_user: dict = Depends(get_current_user),
  658. ):
  659. uid = _uid_from_claims(current_user)
  660. return user_preferences_repo.update(uid, item)
  661. @router.post(
  662. "/uploadFloorMap",
  663. response_model=FloorMapUploadResponseItem,
  664. tags=["Reslevis"],
  665. dependencies=[Depends(get_current_user)],
  666. )
  667. async def uploadFloorMap(
  668. floor: int = Form(...),
  669. file: UploadFile = File(...),
  670. pixel_ratio: float = Form(1),
  671. calibrated: bool = Form(False),
  672. origin_x: int = Form(0),
  673. origin_y: int = Form(0),
  674. grid_size: int = Form(50),
  675. ):
  676. if pixel_ratio <= 0:
  677. raise HTTPException(status_code=400, detail="pixel_ratio must be greater than 0")
  678. if grid_size <= 0:
  679. raise HTTPException(status_code=400, detail="grid_size must be greater than 0")
  680. content_type = (file.content_type or "").split(";")[0].strip().lower()
  681. if content_type and content_type != "image/png":
  682. raise HTTPException(status_code=400, detail="Only PNG files are allowed")
  683. signature = await file.read(len(PNG_SIGNATURE))
  684. if signature != PNG_SIGNATURE:
  685. raise HTTPException(status_code=400, detail="Only PNG files are allowed")
  686. await file.seek(0)
  687. maps_dir = config_env.RESLEVIS_MAPS_DIR
  688. os.makedirs(maps_dir, exist_ok=True)
  689. name = f"floor_{floor}.png"
  690. target_path = _validate_map_target(maps_dir, name)
  691. await _write_upload_file(file, target_path)
  692. metadata = {
  693. "pixel_ratio": pixel_ratio,
  694. "calibrated": calibrated,
  695. "origin": [origin_x, origin_y],
  696. "grid_size": grid_size,
  697. }
  698. _upsert_floor_map_record(floor, name, metadata)
  699. return {
  700. "floor": floor,
  701. "name": name,
  702. "path": _public_map_path(name),
  703. "mime_type": "image/png",
  704. "metadata": metadata,
  705. }
  706. @router.post("/postGuiConfig", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  707. def postGuiConfig(item: GuiConfigItem):
  708. gui_config_repo.add(item)
  709. return {"message": "OK"}
  710. @router.put("/updateGuiConfig", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  711. def updateGuiConfig(item: GuiConfigItem):
  712. gui_config_repo.update(item)
  713. return {"message": "OK"}
  714. @router.delete("/removeGuiConfig/{gui_config_id}", tags=["Reslevis"], dependencies=[Depends(get_current_user)])
  715. def removeGuiConfig(gui_config_id: str):
  716. gui_config_repo.remove(gui_config_id)
  717. return {"message": "OK"}
  718. @router.get(
  719. "/getCoreSettings",
  720. response_model=List[CoreSettingsItem],
  721. tags=["Reslevis"],
  722. dependencies=[Depends(get_current_user)],
  723. )
  724. async def getCoreSettings():
  725. async with httpx.AsyncClient(timeout=CORE_TIMEOUT) as client:
  726. resp = await client.get(f"{SETTINGS_CORE_BASE_URL}/reslevis/settings")
  727. if resp.status_code >= 400:
  728. detail = resp.text.strip() or "CORE settings request failed"
  729. raise HTTPException(status_code=resp.status_code, detail=detail)
  730. try:
  731. payload = resp.json()
  732. except ValueError as exc:
  733. raise HTTPException(status_code=502, detail="Invalid CORE response") from exc
  734. if not isinstance(payload, list):
  735. raise HTTPException(status_code=502, detail="Unexpected CORE response type")
  736. return payload
  737. @router.put(
  738. "/updateCoreSettings",
  739. tags=["Reslevis"],
  740. dependencies=[Depends(get_current_user)],
  741. )
  742. async def updateCoreSettings(item: CoreSettingsUpdateItem):
  743. async with httpx.AsyncClient(timeout=CORE_TIMEOUT) as client:
  744. resp = await client.patch(
  745. f"{SETTINGS_CORE_BASE_URL}/reslevis/settings",
  746. json=item.model_dump(exclude_none=True),
  747. )
  748. if resp.status_code >= 400:
  749. detail = resp.text.strip() or "CORE settings update failed"
  750. raise HTTPException(status_code=resp.status_code, detail=detail)
  751. if not resp.content:
  752. return {"message": "OK"}
  753. try:
  754. return resp.json()
  755. except ValueError:
  756. return {"message": "OK"}