Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 

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