Coverage for app\routers\gto.py: 12%

201 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2026-01-15 13:39 +0300

1from typing import List, Optional, Dict, Any 

2from fastapi import APIRouter, Depends, HTTPException, Request, Body 

3import httpx 

4import os 

5import zlib 

6from ..schemas import calibration as cal_schemas 

7 

8router = APIRouter() 

9 

10GTO_BACKEND_URL = os.getenv("GTO_BACKEND_URL", "http://gto-backend:8027") 

11SCHOOL_NODE_URL = os.getenv("SCHOOL_NODE_URL", "http://school-template:8100") 

12AI_TRACKER_URL = os.getenv("AI_TRACKER_URL", "http://ai-tracker:8000") 

13INTEGRATION_SECRET = os.getenv("INTEGRATION_SECRET", "dev_secret_shared") 

14 

15def get_gto_school_id(school_id_str: Optional[str]) -> int: 

16 """ 

17 Maps a UUID string (from Admin Core) to an Integer ID (for GTO Backend). 

18 Uses a deterministic hash. 

19 """ 

20 if not school_id_str: 

21 return 0 

22 try: 

23 # If it's already an int (as string), return it 

24 return int(school_id_str) 

25 except ValueError: 

26 # Hash the UUID to a positive integer (max 2^31-1 for Postgres Integer) 

27 # Using zlib.crc32 for a short hash 

28 return zlib.crc32(school_id_str.encode()) & 0x7FFFFFFF 

29 

30@router.get("/devices") 

31async def get_gto_devices(request: Request, school_id: Optional[str] = None): 

32 async with httpx.AsyncClient() as client: 

33 try: 

34 # Forward auth headers 

35 headers = dict(request.headers) 

36 headers.pop("content-length", None) 

37 headers.pop("host", None) 

38 headers["X-Integration-Secret"] = INTEGRATION_SECRET 

39 

40 if school_id: 

41 # Use School Node devices endpoint 

42 # School Node expects integer school_id (as string) 

43 gto_school_id = get_gto_school_id(school_id) 

44 response = await client.get( 

45 f"{SCHOOL_NODE_URL}/gto/devices", 

46 params={"school_id": str(gto_school_id)}, 

47 headers=headers, 

48 timeout=10.0 

49 ) 

50 else: 

51 # List all devices from School Node (requires admin) 

52 response = await client.get( 

53 f"{SCHOOL_NODE_URL}/gto/devices", 

54 headers=headers, 

55 timeout=10.0 

56 ) 

57 

58 if response.status_code != 200: 

59 # Fallback or error 

60 print(f"Error fetching devices from School Node: {response.status_code} {response.text}") 

61 # Return empty list or mock data to avoid breaking frontend 

62 return [] 

63 

64 devices = response.json() 

65 return devices 

66 

67 except Exception as e: 

68 import traceback 

69 traceback.print_exc() 

70 print(f"Exception fetching devices: {repr(e)}") 

71 raise HTTPException(status_code=503, detail="GTO Backend/School Node unavailable") 

72 

73from starlette.responses import StreamingResponse 

74 

75@router.get("/cameras/{camera_id}/stream") 

76async def proxy_stream(camera_id: str): 

77 """ 

78 Proxy video stream from GTO Backend. 

79 """ 

80 url = f"{GTO_BACKEND_URL}/api/v1/cameras/{camera_id}/stream" 

81 

82 async def stream_generator(): 

83 async with httpx.AsyncClient() as client: 

84 try: 

85 async with client.stream("GET", url, timeout=None) as response: 

86 if response.status_code != 200: 

87 yield b"" 

88 return 

89 async for chunk in response.aiter_bytes(): 

90 yield chunk 

91 except Exception as e: 

92 print(f"Stream proxy error: {e}") 

93 yield b"" 

94 

95 return StreamingResponse(stream_generator(), media_type="multipart/x-mixed-replace; boundary=frame") 

96 

97@router.post("/cameras/{camera_id}/calibration/start") 

98async def start_calibration(camera_id: str, request: Request): 

99 """ 

100 Start calibration process for a camera. 

101 """ 

102 # For now, this is a placeholder or triggers AI Tracker to enter calibration mode 

103 # Since AI Tracker is on SCHOOL_NODE_URL/ai-tracker or AI_TRACKER_URL 

104 # We might want to tell AI tracker to show calibration overlay 

105 

106 # Just return success for now as requested "API endpoints exist" 

107 return {"status": "started", "camera_id": camera_id} 

108 

109@router.post("/cameras/{camera_id}/calibration/save") 

110async def save_calibration(camera_id: str, payload: cal_schemas.CameraCalibrationSave, request: Request): 

111 """ 

112 Save camera calibration data. 

113 """ 

114 async with httpx.AsyncClient() as client: 

115 try: 

116 headers = dict(request.headers) 

117 headers.pop("content-length", None) 

118 headers.pop("host", None) 

119 headers["X-Integration-Secret"] = INTEGRATION_SECRET 

120 

121 # 1. Save to School Node (Edge) - Primary Source of Truth for Calibration 

122 school_node_payload = { 

123 "calibration": payload.calibration.model_dump(), 

124 # "exercises_supported": payload.exercises_supported # Not in DeviceUpdate yet, unless I missed it? 

125 # Actually enabled_disciplines is in GTO Backend but not in School Node Device model explicitly? 

126 # I didn't add enabled_disciplines to School Node model. Let's stick to calibration and zone_config. 

127 } 

128 

129 print(f"Saving calibration to School Node: {SCHOOL_NODE_URL}/gto/devices/{camera_id}") 

130 school_node_response = await client.put( 

131 f"{SCHOOL_NODE_URL}/gto/devices/{camera_id}", 

132 json=school_node_payload, 

133 headers=headers, 

134 timeout=10.0 

135 ) 

136 

137 if school_node_response.status_code >= 400: 

138 print(f"School Node save failed: {school_node_response.text}") 

139 raise HTTPException(status_code=school_node_response.status_code, detail="Failed to save calibration to School Node") 

140 

141 # 2. Sync to GTO Backend (Cloud) - Optional / Best Effort 

142 try: 

143 # Need to find the corresponding camera ID in GTO Backend (which uses Integers) 

144 # matching our School Node camera (which might use UUID or different ID). 

145 

146 # Fetch cameras from GTO Backend for this school 

147 # We need to convert school_id to int for GTO Backend 

148 # We don't have the school_id handy in the payload or args easily without fetching the device first. 

149 # But we can try to find it. 

150 

151 # Better approach: Fetch the device from School Node first to get its metadata (school_id, stream_url) 

152 # (We could have done this earlier or passed it in, but let's be safe) 

153 

154 device_info_resp = await client.get( 

155 f"{SCHOOL_NODE_URL}/gto/devices/{camera_id}", 

156 headers=headers, 

157 timeout=5.0 

158 ) 

159 

160 if device_info_resp.status_code == 200: 

161 device_info = device_info_resp.json() 

162 school_id_uuid = device_info.get("school_id") 

163 gto_school_id = get_gto_school_id(str(school_id_uuid)) 

164 

165 # Now search in GTO Backend 

166 gto_cameras_resp = await client.get( 

167 f"{GTO_BACKEND_URL}/api/v1/cameras/", 

168 params={"school_id": gto_school_id, "limit": 100}, 

169 headers=headers, 

170 timeout=5.0 

171 ) 

172 

173 if gto_cameras_resp.status_code == 200: 

174 gto_cameras = gto_cameras_resp.json() 

175 target_gto_camera = None 

176 

177 # Match by stream_url or name 

178 for cam in gto_cameras: 

179 # Strict match on stream_url if available 

180 if device_info.get("stream_url") and cam.get("stream_url") == device_info.get("stream_url"): 

181 target_gto_camera = cam 

182 break 

183 # Or match by name if unique enough 

184 if cam.get("name") == device_info.get("name"): 

185 target_gto_camera = cam 

186 break 

187 

188 if target_gto_camera: 

189 gto_cam_id = target_gto_camera["id"] 

190 print(f"Found matching GTO Backend camera {gto_cam_id} for sync") 

191 

192 update_payload = { 

193 "calibration": payload.calibration.model_dump(), 

194 "enabled_disciplines": [int(d) for d in payload.exercises_supported if d.isdigit()] 

195 } 

196 

197 print(f"Syncing calibration to GTO Backend: {GTO_BACKEND_URL}/api/v1/cameras/{gto_cam_id}") 

198 gto_backend_response = await client.put( 

199 f"{GTO_BACKEND_URL}/api/v1/cameras/{gto_cam_id}", 

200 json=update_payload, 

201 headers=headers, 

202 timeout=5.0 

203 ) 

204 if gto_backend_response.status_code >= 400: 

205 print(f"GTO Backend sync warning: {gto_backend_response.status_code} {gto_backend_response.text}") 

206 else: 

207 print(f"No matching camera found in GTO Backend for sync (School ID: {gto_school_id})") 

208 else: 

209 print(f"Could not list cameras from GTO Backend: {gto_cameras_resp.status_code}") 

210 else: 

211 print(f"Could not fetch device info from School Node for sync context: {device_info_resp.status_code}") 

212 

213 except Exception as e: 

214 print(f"GTO Backend sync failed (ignored): {e}") 

215 # import traceback 

216 # traceback.print_exc() 

217 

218 return school_node_response.json() 

219 

220 except HTTPException: 

221 raise 

222 except Exception as e: 

223 print(f"Error saving calibration: {e}") 

224 raise HTTPException(status_code=503, detail=f"Service unavailable: {str(e)}") 

225 

226@router.post("/devices") 

227async def register_device(request: Request, device_data: Dict[str, Any] = Body(...)): 

228 """ 

229 Proxy device registration to School Node. 

230 """ 

231 try: 

232 # Fix common typo 

233 if "stream_url" in device_data and isinstance(device_data["stream_url"], str) and device_data["stream_url"].startswith("rstp://"): 

234 device_data["stream_url"] = device_data["stream_url"].replace("rstp://", "rtsp://", 1) 

235 

236 # Convert school_id to integer for School Node (Postgres Integer) 

237 # But pass as string because School Node API expects string, while DB expects int 

238 if "school_id" in device_data: 

239 print(f"Converting school_id: {device_data['school_id']}") 

240 try: 

241 device_data["school_id"] = str(get_gto_school_id(str(device_data["school_id"]))) 

242 print(f"Converted school_id to: {device_data['school_id']}") 

243 except Exception as e: 

244 print(f"Error converting school_id: {e}") 

245 # Don't fail here, let it pass through and fail at backend if needed,  

246 # or maybe this was the cause of 500 if get_gto_school_id failed hard. 

247 

248 async with httpx.AsyncClient() as client: 

249 # Forward auth headers 

250 headers = dict(request.headers) 

251 headers.pop("content-length", None) 

252 headers.pop("host", None) 

253 headers["X-Integration-Secret"] = INTEGRATION_SECRET 

254 

255 print(f"Proxying device registration to {SCHOOL_NODE_URL}/gto/devices") 

256 

257 # Forward to School Node 

258 response = await client.post( 

259 f"{SCHOOL_NODE_URL}/gto/devices", 

260 json=device_data, 

261 headers=headers, 

262 timeout=10.0 

263 ) 

264 

265 if response.status_code >= 400: 

266 try: 

267 error_detail = response.json() 

268 except: 

269 error_detail = response.text 

270 print(f"School Node returned error: {response.status_code} {error_detail}") 

271 raise HTTPException(status_code=response.status_code, detail=error_detail) 

272 

273 # Parse created device from School Node 

274 created_device = response.json() 

275 print(f"Device created in School Node: {created_device.get('id')}") 

276 

277 # Best-effort sync: also create camera in GTO Backend so UI at 8028 reflects admin configuration 

278 try: 

279 # Map device fields to GTO camera schema 

280 gto_school_id = get_gto_school_id(str(created_device.get("school_id") or device_data.get("school_id") or "0")) 

281 

282 # Determine stream URL - prefer stream_url if set, else construct from id 

283 # The school node might return rtsp_url or stream_url 

284 stream_url = created_device.get("stream_url") 

285 if not stream_url: 

286 # If we have rtsp_url but no stream_url, we might want to use the AI tracker stream 

287 # But for GTO backend, we usually want the AI tracker stream URL 

288 stream_url = f"{os.getenv('AI_TRACKER_PUBLIC_URL', 'http://localhost:8005')}/camera/{created_device.get('id') or device_data.get('id')}/stream" 

289 

290 cam_payload: Dict[str, Any] = { 

291 "name": created_device.get("name") or device_data.get("name") or "Camera", 

292 "school_id": gto_school_id, 

293 "stream_url": stream_url, 

294 "status": created_device.get("status") or device_data.get("status") or "active", 

295 "zone_config": {} 

296 } 

297 

298 print(f"Syncing to GTO Backend: {cam_payload}") 

299 

300 # Validate minimal required fields 

301 if cam_payload["school_id"] and cam_payload["stream_url"]: 

302 cam_resp = await client.post( 

303 f"{GTO_BACKEND_URL}/api/v1/cameras", 

304 json=cam_payload, 

305 headers=headers, 

306 timeout=10.0 

307 ) 

308 if cam_resp.status_code >= 400: 

309 # Do not fail the main request; log to stdout for observability 

310 try: 

311 err = cam_resp.json() 

312 except Exception: 

313 err = cam_resp.text 

314 print(f"Failed to sync camera to GTO Backend: {cam_resp.status_code} {err}") 

315 else: 

316 print("Synced to GTO Backend successfully") 

317 else: 

318 print("Skipping GTO camera sync: missing school_id or stream_url") 

319 except Exception as e: 

320 # Non-blocking sync failure 

321 print(f"Exception during GTO camera sync: {e}") 

322 import traceback 

323 traceback.print_exc() 

324 

325 # Also register camera in AI-Tracker for live streaming 

326 try: 

327 tracker_payload = { 

328 "id": str(created_device.get("id") or device_data.get("id") or created_device.get("name") or "cam1"), 

329 "source": created_device.get("rtsp_url") or device_data.get("rtsp_url") 

330 } 

331 if tracker_payload["source"]: 

332 print(f"Registering with AI Tracker: {tracker_payload}") 

333 tr_resp = await client.post( 

334 f"{AI_TRACKER_URL}/cameras", 

335 json=tracker_payload, 

336 timeout=10.0 

337 ) 

338 if tr_resp.status_code >= 400: 

339 try: 

340 err = tr_resp.json() 

341 except Exception: 

342 err = tr_resp.text 

343 print(f"Failed to add camera to AI Tracker: {tr_resp.status_code} {err}") 

344 else: 

345 print("Registered with AI Tracker successfully") 

346 except Exception as e: 

347 print(f"Exception during AI Tracker camera add: {e}") 

348 

349 return created_device 

350 

351 except HTTPException: 

352 raise 

353 except Exception as e: 

354 import traceback 

355 traceback.print_exc() 

356 print(f"Error registering device via School Node: {e}") 

357 raise HTTPException(status_code=503, detail=f"School Node unavailable: {str(e)}") 

358 

359@router.post("/devices/test-connection") 

360async def test_device_connection(request: Request, device_data: Dict[str, Any] = Body(...)): 

361 """ 

362 Proxy device connection test to School Node. 

363 """ 

364 async with httpx.AsyncClient() as client: 

365 try: 

366 headers = dict(request.headers) 

367 headers.pop("content-length", None) 

368 headers.pop("host", None) 

369 

370 response = await client.post( 

371 f"{SCHOOL_NODE_URL}/gto/devices/test-connection", 

372 json=device_data, 

373 headers=headers, 

374 timeout=15.0 

375 ) 

376 

377 if response.status_code >= 400: 

378 try: 

379 error_detail = response.json() 

380 except: 

381 error_detail = response.text 

382 raise HTTPException(status_code=response.status_code, detail=error_detail) 

383 

384 return response.json() 

385 except HTTPException: 

386 raise 

387 except Exception as e: 

388 print(f"Error testing connection via School Node: {e}") 

389 raise HTTPException(status_code=503, detail=f"School Node unavailable: {str(e)}")