from datetime import datetime, timezone from threading import Lock from typing import Any, Dict, List, Optional from fastapi import APIRouter, Body, Header, HTTPException import requests from app.core.config import ( SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_TIMEOUT_SECONDS, supabase_configured, can_view_global_records, LOGGER, ) from app.security.auth import require_authenticated_user, _supabase_headers from app.api.helpers import _normalize_spaces from app.api.parking import compute_session_time_and_fee TZ_UTC = timezone.utc router = APIRouter(tags=["gate"]) _gate_command_lock = Lock() _gate_pending_commands: List[Dict[str, Any]] = [] def _fetch_parking_session_by_id(session_id: Optional[str]) -> Optional[Dict[str, Any]]: if not supabase_configured() or not session_id: return None response = requests.get( f"{SUPABASE_URL}/rest/v1/parking_sessions", params={"select": "*", "id": f"eq.{session_id}", "limit": "1"}, headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY), timeout=SUPABASE_TIMEOUT_SECONDS, ) if response.status_code != 200: return None rows = response.json() if isinstance(rows, list) and rows and isinstance(rows[0], dict): return rows[0] return None def _get_parking_session_owner_id(session_id: str) -> Optional[str]: if not supabase_configured() or not session_id: return None session_response = requests.get( f"{SUPABASE_URL}/rest/v1/parking_sessions", params={"select": "vehicle_id", "id": f"eq.{session_id}", "limit": "1"}, headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY), timeout=SUPABASE_TIMEOUT_SECONDS, ) if session_response.status_code != 200: return None session_rows = session_response.json() if not isinstance(session_rows, list) or not session_rows: return None vehicle_id = session_rows[0].get("vehicle_id") if not vehicle_id: return None vehicle_response = requests.get( f"{SUPABASE_URL}/rest/v1/vehicles", params={"select": "owner_id", "id": f"eq.{vehicle_id}", "limit": "1"}, headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY), timeout=SUPABASE_TIMEOUT_SECONDS, ) if vehicle_response.status_code != 200: return None vehicle_rows = vehicle_response.json() if not isinstance(vehicle_rows, list) or not vehicle_rows: return None owner_id = vehicle_rows[0].get("owner_id") return str(owner_id) if owner_id else None def _derive_payment_status_from_session(session_row: Optional[Dict[str, Any]]) -> str: if not isinstance(session_row, dict): return "no_active_session" payment_confirmed_at = session_row.get("payment_confirmed_at") check_out_at = session_row.get("check_out_at") left_within_5 = bool(session_row.get("left_within_5_minutes")) status_value = str(session_row.get("status") or "").strip().lower() if payment_confirmed_at is None and check_out_at is None: return "unpaid" if payment_confirmed_at is not None and check_out_at is None: if status_value == "overstayed": return "paid_grace_period_expired" return "paid_waiting_exit" if payment_confirmed_at is None and check_out_at is not None: return "left_without_payment" if left_within_5: return "left_within_5_minutes_after_payment" return "left_after_5_minutes_after_payment" def _build_gate_decision( *, event_type: str, session_row: Optional[Dict[str, Any]], plate_detected: bool, ) -> Dict[str, Any]: if event_type != "exit": return { "allowed": True, "action": "open_gate", "reason": "entry_access_granted", } if not plate_detected: return { "allowed": False, "action": "manual_open_only", "reason": "plate_not_detected", } payment_status = _derive_payment_status_from_session(session_row) if payment_status in {"paid_waiting_exit", "left_within_5_minutes_after_payment"}: return { "allowed": True, "action": "open_gate", "reason": "paid_within_grace_period", "payment_status": payment_status, } if payment_status == "paid_grace_period_expired": return { "allowed": False, "action": "deny_and_alert_security", "reason": "grace_period_expired", "payment_status": payment_status, } if payment_status == "no_active_session": return { "allowed": False, "action": "manual_review_required", "reason": "no_active_session", "payment_status": payment_status, } return { "allowed": False, "action": "deny_gate", "reason": "payment_not_confirmed", "payment_status": payment_status, } @router.post("/gate/decision") def gate_decision( payload: Dict[str, Any] = Body(...), authorization: Optional[str] = Header(default=None), ) -> Dict[str, Any]: request_user = require_authenticated_user(authorization) if request_user is None: raise HTTPException(status_code=401, detail="Authentication is required.") requester_id = str(request_user.get("id") or "").strip() if not requester_id: raise HTTPException(status_code=401, detail="Authenticated user id is missing.") requester_role = str(request_user.get("role") or "").strip().lower() requester_has_global_scope = can_view_global_records(requester_role) requester_is_staff = requester_role in {"admin", "security"} session_id = str(payload.get("session_id") or "").strip() if not session_id: raise HTTPException(status_code=400, detail="session_id is required.") plate_detected = bool(payload.get("plate_detected", True)) session_row = _fetch_parking_session_by_id(session_id) if not isinstance(session_row, dict): raise HTTPException(status_code=404, detail="parking session was not found.") owner_id = _get_parking_session_owner_id(session_id) if not requester_has_global_scope and owner_id and requester_id != owner_id: raise HTTPException(status_code=403, detail="You can only access your own session gate decision.") decision = _build_gate_decision( event_type="exit", session_row=session_row, plate_detected=plate_detected, ) return { "status": "ok", "session_id": session_id, "payment_status": _derive_payment_status_from_session(session_row), "pricing": compute_session_time_and_fee(session_row), "gate_decision": decision, "requester": { "id": requester_id, "role": requester_role or "user", "is_staff": requester_is_staff, }, } @router.post("/gate/trigger") def gate_trigger( payload: Dict[str, Any] = Body(default={}), ) -> Dict[str, Any]: location = str(payload.get("parking_location") or "OPERA").strip().upper() duration_sec = int(payload.get("duration_seconds") or 10) plate = str(payload.get("plate") or "").strip() cmd = { "action": "open", "parking_location": location, "duration_seconds": duration_sec, "plate": plate, "queued_at": datetime.now(TZ_UTC).isoformat(), } with _gate_command_lock: _gate_pending_commands.append(cmd) if len(_gate_pending_commands) > 20: _gate_pending_commands.pop(0) LOGGER.info("Gate trigger queued: %s", cmd) return {"status": "queued", "command": cmd} @router.get("/gate/poll") def gate_poll( location: str = "OPERA", ) -> Dict[str, Any]: loc = location.strip().upper() with _gate_command_lock: for i, cmd in enumerate(_gate_pending_commands): if cmd.get("parking_location") == loc: _gate_pending_commands.pop(i) return {"status": "command", "command": cmd} return {"status": "idle"}