Spaces:
Running on Zero
Running on Zero
| 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, | |
| } | |
| 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, | |
| }, | |
| } | |
| 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} | |
| 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"} | |