Pant0x's picture
Patch API to support X-Supabase-Auth header for Private Hugging Face Spaces
79b2455
Raw
History Blame Contribute Delete
245 kB
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import math
import os
import re
from datetime import datetime, timedelta, timezone
from pathlib import Path
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from uuid import uuid4
import cv2
import numpy as np
import requests
from fastapi import FastAPI, Body, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from ultralytics import YOLO
try:
from dotenv import load_dotenv
except Exception: # pragma: no cover
load_dotenv = None
BASE_DIR = Path(__file__).resolve().parent
MODELS_DIR = BASE_DIR / "Models"
MODEL_PATHS = {
"plate": MODELS_DIR / "car_plate_best.pt",
"ocr": MODELS_DIR / "model_ocr.pt",
}
LOGGER = logging.getLogger("ain_el_aql_api")
if not LOGGER.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
def _bool_env(var_name: str, default: bool = False) -> bool:
raw = os.getenv(var_name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _float_env(var_name: str, default: float) -> float:
raw = os.getenv(var_name)
if raw is None:
return default
value = raw.strip()
if not value:
return default
try:
return float(value)
except ValueError:
LOGGER.warning("Invalid float env for %s=%r. Falling back to %s", var_name, raw, default)
return default
def _int_env(var_name: str, default: int) -> int:
raw = os.getenv(var_name)
if raw is None:
return default
value = raw.strip()
if not value:
return default
try:
return int(value)
except ValueError:
LOGGER.warning("Invalid int env for %s=%r. Falling back to %s", var_name, raw, default)
return default
def _parse_csv_env_values(raw: str) -> List[str]:
if not raw or not raw.strip():
return []
values: List[str] = []
for part in raw.split(","):
normalized = part.strip().lower()
if normalized:
values.append(normalized)
return values
def _normalize_location_key_for_config(value: Optional[str]) -> Optional[str]:
if value is None:
return None
raw = str(value).strip().upper()
if not raw:
return None
key = "".join(raw.split())
return key or None
def _parse_billing_mode(raw: Optional[str], *, default: str = "hourly", env_name: str = "") -> str:
mode = str(raw or "").strip().lower()
if mode in {"hourly", "daily"}:
return mode
if mode:
label = env_name or "billing_mode"
LOGGER.warning("Invalid %s=%r. Falling back to %s", label, raw, default)
return default
def _parse_location_hourly_rates(raw: str) -> Dict[str, float]:
result: Dict[str, float] = {}
if not raw or not raw.strip():
return result
for item in raw.split(","):
part = item.strip()
if not part:
continue
location_part, separator, rate_part = part.partition(":")
if not separator:
LOGGER.warning(
"Ignoring invalid PARKING_LOCATION_HOURLY_RATES item '%s'. Expected format LOCATION:RATE",
part,
)
continue
location_key = _normalize_location_key_for_config(location_part)
if not location_key:
LOGGER.warning("Ignoring empty parking location key in PARKING_LOCATION_HOURLY_RATES item '%s'", part)
continue
try:
rate_value = float(rate_part.strip())
except ValueError:
LOGGER.warning("Ignoring invalid rate in PARKING_LOCATION_HOURLY_RATES item '%s'", part)
continue
if rate_value < 0:
LOGGER.warning("Ignoring negative rate in PARKING_LOCATION_HOURLY_RATES item '%s'", part)
continue
result[location_key] = rate_value
return result
def _parse_location_int_values(raw: str, *, env_name: str) -> Dict[str, int]:
result: Dict[str, int] = {}
if not raw or not raw.strip():
return result
for item in raw.split(","):
part = item.strip()
if not part:
continue
location_part, separator, value_part = part.partition(":")
if not separator:
LOGGER.warning("Ignoring invalid %s item '%s'. Expected format LOCATION:VALUE", env_name, part)
continue
location_key = _normalize_location_key_for_config(location_part)
if not location_key:
LOGGER.warning("Ignoring empty location key in %s item '%s'", env_name, part)
continue
try:
parsed_value = int(value_part.strip())
except ValueError:
LOGGER.warning("Ignoring invalid int value in %s item '%s'", env_name, part)
continue
if parsed_value < 0:
LOGGER.warning("Ignoring negative value in %s item '%s'", env_name, part)
continue
result[location_key] = parsed_value
return result
def _parse_location_pricing_configs(raw: str) -> Dict[str, Dict[str, Any]]:
result: Dict[str, Dict[str, Any]] = {}
if not raw or not raw.strip():
return result
for item in raw.split(","):
part = item.strip()
if not part:
continue
pieces = [piece.strip() for piece in part.split(":")]
if len(pieces) != 5:
LOGGER.warning(
"Ignoring invalid PARKING_LOCATION_PRICING item '%s'. Expected LOCATION:MODE:BASE:EXTRA_AFTER_FIRST_HOUR:CAPACITY",
part,
)
continue
location_key = _normalize_location_key_for_config(pieces[0])
if not location_key:
LOGGER.warning("Ignoring empty location key in PARKING_LOCATION_PRICING item '%s'", part)
continue
billing_mode = _parse_billing_mode(pieces[1], default="hourly", env_name="PARKING_LOCATION_PRICING.mode")
try:
base_rate_egp = float(pieces[2])
extra_after_first_hour_egp = float(pieces[3])
capacity = int(pieces[4])
except ValueError:
LOGGER.warning("Ignoring invalid numeric values in PARKING_LOCATION_PRICING item '%s'", part)
continue
if base_rate_egp < 0 or extra_after_first_hour_egp < 0 or capacity < 0:
LOGGER.warning("Ignoring negative values in PARKING_LOCATION_PRICING item '%s'", part)
continue
result[location_key] = {
"billing_mode": billing_mode,
"base_rate_egp": base_rate_egp,
"extra_after_first_hour_egp": extra_after_first_hour_egp,
"capacity": capacity,
}
return result
SUPABASE_URL = os.getenv("SUPABASE_URL", "").strip().rstrip("/")
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "").strip()
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "").strip()
SUPABASE_ENFORCE_AUTH = _bool_env("SUPABASE_ENFORCE_AUTH", default=False)
SUPABASE_RAW_BUCKET = os.getenv("SUPABASE_RAW_BUCKET", "car-raw-images").strip()
SUPABASE_PROCESSED_BUCKET = os.getenv("SUPABASE_PROCESSED_BUCKET", "car-processed-images").strip()
SUPABASE_TIMEOUT_SECONDS = float(os.getenv("SUPABASE_TIMEOUT_SECONDS", "20"))
BARRIER_API_TOKEN = os.getenv("BARRIER_API_TOKEN", "").strip()
_raw_barrier_tokens = [BARRIER_API_TOKEN]
_raw_barrier_tokens.extend(part.strip() for part in os.getenv("BARRIER_API_TOKENS", "").split(","))
BARRIER_API_TOKENS = {token for token in _raw_barrier_tokens if token}
del _raw_barrier_tokens
BARRIER_SYNTHETIC_USER_ID = os.getenv("BARRIER_SYNTHETIC_USER_ID", "barrier-device").strip() or "barrier-device"
PASSWORD_RESET_REDIRECT_URL = os.getenv("PASSWORD_RESET_REDIRECT_URL", "").strip()
INTERNAL_LOGIN_EMAIL_SUFFIX = "@ainel-aql.local"
REDIRECT_ALLOWED_HTTP_HOSTS = set(_parse_csv_env_values(os.getenv("ALLOWED_REDIRECT_HOSTS", "")))
_configured_password_reset_redirect_host = (urlparse(PASSWORD_RESET_REDIRECT_URL).hostname or "").strip().lower()
if _configured_password_reset_redirect_host:
REDIRECT_ALLOWED_HTTP_HOSTS.add(_configured_password_reset_redirect_host)
del _configured_password_reset_redirect_host
REDIRECT_ALLOWED_DEEPLINK_SCHEMES = set(
_parse_csv_env_values(os.getenv("ALLOWED_REDIRECT_DEEPLINK_SCHEMES", "ainelaql"))
)
if not REDIRECT_ALLOWED_DEEPLINK_SCHEMES:
REDIRECT_ALLOWED_DEEPLINK_SCHEMES = {"ainelaql"}
PARKING_HOURLY_RATE_EGP = _float_env("PARKING_HOURLY_RATE_EGP", 30.0)
PARKING_LOCATION_HOURLY_RATES = _parse_location_hourly_rates(os.getenv("PARKING_LOCATION_HOURLY_RATES", ""))
PARKING_BILLING_MODE = _parse_billing_mode(os.getenv("PARKING_BILLING_MODE", "hourly"), default="hourly", env_name="PARKING_BILLING_MODE")
PARKING_DAILY_RATE_EGP = _float_env("PARKING_DAILY_RATE_EGP", 200.0)
PARKING_EXTRA_AFTER_FIRST_HOUR_EGP = _float_env("PARKING_EXTRA_AFTER_FIRST_HOUR_EGP", PARKING_HOURLY_RATE_EGP)
GARAGE_TOTAL_CAPACITY = max(0, _int_env("GARAGE_TOTAL_CAPACITY", 1000))
PARKING_LOCATION_CAPACITIES = _parse_location_int_values(
os.getenv("PARKING_LOCATION_CAPACITIES", ""),
env_name="PARKING_LOCATION_CAPACITIES",
)
PARKING_LOCATION_PRICING = _parse_location_pricing_configs(os.getenv("PARKING_LOCATION_PRICING", ""))
APP_SERVICE_FEE_EGP = _float_env("APP_SERVICE_FEE_EGP", 0.0)
CORS_ALLOW_ORIGINS = [origin.strip() for origin in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if origin.strip()]
PAYMOB_BASE_URL = os.getenv("PAYMOB_BASE_URL", "https://accept.paymob.com/api").strip().rstrip("/")
PAYMOB_API_KEY = os.getenv("PAYMOB_API_KEY", "").strip()
PAYMOB_INTEGRATION_ID = os.getenv("PAYMOB_INTEGRATION_ID", "").strip()
PAYMOB_IFRAME_ID = os.getenv("PAYMOB_IFRAME_ID", "").strip()
PAYMOB_CURRENCY = os.getenv("PAYMOB_CURRENCY", "EGP").strip().upper() or "EGP"
MODEL_INFERENCE_PROVIDER_RAW = os.getenv("MODEL_INFERENCE_PROVIDER", "local").strip().lower()
if MODEL_INFERENCE_PROVIDER_RAW in {"local", "remote"}:
MODEL_INFERENCE_PROVIDER = MODEL_INFERENCE_PROVIDER_RAW
else:
LOGGER.warning(
"Invalid MODEL_INFERENCE_PROVIDER=%r. Falling back to 'local'.",
MODEL_INFERENCE_PROVIDER_RAW,
)
MODEL_INFERENCE_PROVIDER = "local"
MODEL_SERVICE_URL = os.getenv("MODEL_SERVICE_URL", "https://pant0x-ealpr-ocr.hf.space").strip().rstrip("/")
MODEL_SERVICE_INFER_PATH = os.getenv("MODEL_SERVICE_INFER_PATH", "/model/infer").strip() or "/model/infer"
MODEL_SERVICE_TIMEOUT_SECONDS = _float_env("MODEL_SERVICE_TIMEOUT_SECONDS", 45.0)
MODEL_SERVICE_SHARED_SECRET = os.getenv("MODEL_SERVICE_SHARED_SECRET", "").strip()
MODEL_SERVICE_HEADER_NAME = os.getenv("MODEL_SERVICE_HEADER_NAME", "X-Model-Service-Secret").strip() or "X-Model-Service-Secret"
MODEL_SERVICE_ENABLE_LOCAL_ENDPOINT = _bool_env(
"MODEL_SERVICE_ENABLE_LOCAL_ENDPOINT",
default=MODEL_INFERENCE_PROVIDER == "local",
)
STAFF_ROLES = {"admin", "security"}
BARRIER_ROLES = {"barrier"}
DEVELOPER_ROLES = {"developer"}
CORE_TABLES = {"profiles", "vehicles", "parking_sessions", "car_events"}
PARKING_LOCATION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _-]{0,31}(?:\s*,\s*[A-Za-z0-9][A-Za-z0-9 _-]{0,31})*$")
CAMERA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
PHONE_PATTERN = re.compile(r"^\+?[0-9]{7,15}$")
NATIONAL_ID_PATTERN = re.compile(r"^[A-Za-z0-9-]{6,32}$")
PROFILE_SELECT_FIELDS = "id,first_name,last_name,full_name,username,phone_number,email,national_id,role,staff_id"
VEHICLE_SELECT_FIELDS = "id,owner_id,plate_letters_ar,plate_numbers_ar,plate_letters_en,plate_numbers_en,car_name,car_model,car_color,is_active,is_verified,verified_at,verified_by,created_at,updated_at"
SUPPORTED_EVENT_TYPES = {"entry", "exit", "ocr_scan"}
SUPPORTED_OTP_CHANNELS = {"email", "phone"}
MOBILE_REDIRECT_SCHEME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$")
def _supabase_configured() -> bool:
return bool(SUPABASE_URL and SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY)
def _paymob_configured() -> bool:
return bool(PAYMOB_BASE_URL and PAYMOB_API_KEY and PAYMOB_INTEGRATION_ID and PAYMOB_IFRAME_ID)
def _model_service_configured() -> bool:
return bool(MODEL_SERVICE_URL)
def _resolve_model_service_infer_url() -> Optional[str]:
if not _model_service_configured():
return None
path = MODEL_SERVICE_INFER_PATH
if not path.startswith("/"):
path = f"/{path}"
return f"{MODEL_SERVICE_URL}{path}"
def _should_load_local_models() -> bool:
return MODEL_INFERENCE_PROVIDER == "local" or MODEL_SERVICE_ENABLE_LOCAL_ENDPOINT
_PLATE_MODEL: Optional[YOLO] = None
_OCR_MODEL: Optional[YOLO] = None
def _ensure_models_loaded() -> None:
global _PLATE_MODEL, _OCR_MODEL
with _model_lock:
if _PLATE_MODEL is not None and _OCR_MODEL is not None:
return
try:
if _PLATE_MODEL is None:
_PLATE_MODEL = YOLO(str(MODEL_PATHS["plate"]))
if _OCR_MODEL is None:
_OCR_MODEL = YOLO(str(MODEL_PATHS["ocr"]))
except Exception as exc:
LOGGER.error("Failed to load local YOLO models: %s", exc)
raise RuntimeError(f"Model initialization failed: {exc}") from exc
def _normalize_spaces(value: str) -> str:
return " ".join((value or "").split())
def _normalize_name_part(value: Any) -> str:
return _normalize_spaces(str(value or ""))
def _compose_full_name(first_name: Any, last_name: Any) -> str:
return _normalize_spaces(f"{_normalize_name_part(first_name)} {_normalize_name_part(last_name)}")
def _split_full_name_parts(full_name: Any) -> Tuple[str, str]:
normalized_full_name = _normalize_name_part(full_name)
if not normalized_full_name:
return "", ""
parts = normalized_full_name.split(" ", 1)
if len(parts) == 1:
return parts[0], ""
return parts[0], _normalize_spaces(parts[1])
def _resolve_profile_name_fields(
payload: Optional[Dict[str, Any]],
*,
fallback_full_name: Optional[Any] = None,
fallback_first_name: Optional[Any] = None,
fallback_last_name: Optional[Any] = None,
) -> Tuple[str, str, str]:
body = payload if isinstance(payload, dict) else {}
first_name = _normalize_name_part(body.get("first_name") or body.get("firstName") or fallback_first_name)
last_name = _normalize_name_part(body.get("last_name") or body.get("lastName") or fallback_last_name)
legacy_full_name = _normalize_name_part(body.get("full_name") or body.get("name") or fallback_full_name)
if not first_name and not last_name and legacy_full_name:
first_name, last_name = _split_full_name_parts(legacy_full_name)
full_name = _compose_full_name(first_name, last_name)
if not full_name and legacy_full_name:
full_name = legacy_full_name
if not first_name and not last_name:
first_name, last_name = _split_full_name_parts(legacy_full_name)
return first_name, last_name, full_name
def _is_valid_http_url(url: str) -> bool:
if not url:
return False
parsed = urlparse(url)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def _is_valid_redirect_url(url: str, *, allowed_http_hosts: Optional[List[str]] = None) -> bool:
if not url:
return False
parsed = urlparse(url)
scheme = (parsed.scheme or "").strip()
if not scheme or not MOBILE_REDIRECT_SCHEME_PATTERN.fullmatch(scheme):
return False
normalized_scheme = scheme.lower()
if normalized_scheme in {"http", "https"}:
hostname = (parsed.hostname or "").strip().lower()
if not hostname:
return False
effective_allowed_hosts = set(REDIRECT_ALLOWED_HTTP_HOSTS)
if allowed_http_hosts:
for host in allowed_http_hosts:
normalized_host = (host or "").strip().lower()
if normalized_host:
effective_allowed_hosts.add(normalized_host)
if not effective_allowed_hosts:
return False
return hostname in effective_allowed_hosts
if normalized_scheme not in REDIRECT_ALLOWED_DEEPLINK_SCHEMES:
return False
# Mobile deep links such as ainelaql://auth/reset are allowed.
return bool(parsed.netloc or parsed.path)
def _resolve_public_base_url(request: Request) -> str:
forwarded_proto = _normalize_spaces((request.headers.get("x-forwarded-proto") or "").split(",")[0])
forwarded_host = _normalize_spaces((request.headers.get("x-forwarded-host") or "").split(",")[0])
if forwarded_proto and forwarded_host:
return f"{forwarded_proto}://{forwarded_host}".rstrip("/")
return str(request.base_url).rstrip("/")
def _resolve_default_password_reset_redirect_url(request: Request) -> str:
configured = _normalize_spaces(PASSWORD_RESET_REDIRECT_URL)
if configured:
parsed = urlparse(configured)
if parsed.scheme and parsed.scheme not in {"http", "https"}:
# Supabase can always deliver web callbacks; the bridge then opens the mobile deep link.
return f"{_resolve_public_base_url(request)}/auth/reset-password-bridge"
return configured
base_url = _resolve_public_base_url(request)
return f"{base_url}/auth/reset-password-page"
def _resolve_password_reset_deep_link_url() -> str:
configured = _normalize_spaces(PASSWORD_RESET_REDIRECT_URL)
if _is_valid_redirect_url(configured):
parsed = urlparse(configured)
if parsed.scheme not in {"http", "https"}:
return configured
return "ainelaql://auth/reset"
def _split_arabic_plate(plate_text_ar: str) -> Tuple[Optional[str], Optional[str]]:
raw = _normalize_spaces(plate_text_ar)
if not raw or raw.upper() == "N/A" or "|" not in raw:
return None, None
letters, numbers = [part.strip() for part in raw.split("|", 1)]
letters = _normalize_spaces(letters)
numbers = _normalize_spaces(numbers)
if not letters or not numbers:
return None, None
return letters, numbers
def _split_english_plate(plate_text_en: str) -> Tuple[Optional[str], Optional[str]]:
raw = _normalize_spaces(plate_text_en)
if not raw or raw.upper() == "N/A" or "|" not in raw:
return None, None
letters, numbers = [part.strip() for part in raw.split("|", 1)]
letters = _normalize_spaces(letters)
numbers = numbers.replace(" ", "")
if not letters and not numbers:
return None, None
return letters or None, numbers or None
def _normalize_plate_text(text: Optional[str]) -> str:
"""Normalize plate text for matching: remove spaces, convert Arabic-Indic -> Western digits."""
if not text:
return ''
# Convert to string and handle case
result = str(text).strip().lower()
# Strip common OCR debug labels
for prefix in ["car", "vehicle", "license", "plate", "lp", "ar", "en"]:
if result.startswith(prefix):
result = result[len(prefix):].strip()
# Remove all whitespace and non-alphanumeric separators
result = "".join(c for c in result if c.isalnum() or c in "|-")
# Convert Arabic-Indic digits to Western
_ARABIC_INDIC = '\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669'
_WESTERN = '0123456789'
for ar, en in zip(_ARABIC_INDIC, _WESTERN):
result = result.replace(ar, en)
return result.strip().upper()
def _format_vehicle_plate_label(vehicle_row: Optional[Dict[str, Any]]) -> str:
if not isinstance(vehicle_row, dict):
return ""
letters_ar = _normalize_spaces(str(vehicle_row.get("plate_letters_ar") or ""))
numbers_ar = _normalize_spaces(str(vehicle_row.get("plate_numbers_ar") or ""))
if letters_ar or numbers_ar:
return _normalize_spaces(f"{letters_ar} {numbers_ar}")
letters_en = _normalize_spaces(str(vehicle_row.get("plate_letters_en") or ""))
numbers_en = _normalize_spaces(str(vehicle_row.get("plate_numbers_en") or ""))
if letters_en or numbers_en:
return _normalize_spaces(f"{letters_en} {numbers_en}")
return ""
def _resolve_plate_label_for_session(session_row: Optional[Dict[str, Any]]) -> str:
if not isinstance(session_row, dict):
return ""
plate_text = _normalize_spaces(str(session_row.get("plate_arabic") or ""))
if plate_text:
return plate_text
plate_text = _normalize_spaces(str(session_row.get("plate_english") or ""))
if plate_text:
return plate_text
vehicle_id = str(session_row.get("vehicle_id") or "").strip()
if not vehicle_id:
return ""
vehicle_map = _fetch_vehicle_map_by_ids([vehicle_id])
return _format_vehicle_plate_label(vehicle_map.get(vehicle_id))
def _error_response(status_code: int, message: str, code: str) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={
"status": "error",
"message": message,
"code": code,
},
)
def _plate_letters_variants(text: Optional[str]) -> List[str]:
"""Return deduplicated letter variants to tolerate spacing differences in DB/OCR."""
raw = _normalize_spaces(str(text or ""))
if not raw:
return []
variants: List[str] = [raw]
no_space = "".join(raw.split())
if no_space and no_space not in variants:
variants.append(no_space)
return variants
def _plate_numbers_variants(text: Optional[str]) -> List[str]:
"""Return deduplicated number variants (Arabic-Indic and Western)."""
raw = _normalize_spaces(str(text or ""))
if not raw:
return []
normalized = _normalize_plate_text(raw)
variants: List[str] = [raw]
if normalized and normalized not in variants:
variants.append(normalized)
return variants
def _normalize_event_type(value: Optional[str]) -> str:
raw = (value or "ocr_scan").strip().lower()
if raw not in SUPPORTED_EVENT_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid event_type '{raw}'. Allowed values: {sorted(SUPPORTED_EVENT_TYPES)}",
)
return raw
def _build_plate_payload(plate_info: Dict[str, Any]) -> Dict[str, Any]:
arabic_text = str(plate_info.get("arabic") or "N/A")
english_text = str(plate_info.get("english") or "N/A")
ar_letters, ar_numbers = _split_arabic_plate(arabic_text)
en_letters, en_numbers = _split_english_plate(english_text)
return {
"arabic_text": arabic_text,
"english_text": english_text,
"arabic": {
"letters": ar_letters,
"numbers": ar_numbers,
},
"english": {
"letters": en_letters,
"numbers": en_numbers,
},
}
def _normalize_parking_location(value: Optional[str]) -> Optional[str]:
raw = _normalize_spaces(value or "")
if not raw:
return None
if not PARKING_LOCATION_PATTERN.fullmatch(raw):
raise HTTPException(
status_code=400,
detail="Invalid parking_location format. Use values like OPERA or A1, and comma-separated forms like OPERA,CINEMA.",
)
parts = [
location_key
for location_key in [_normalize_location_key_for_config(part) for part in raw.split(",")]
if location_key
]
return ",".join(parts) if parts else None
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
raw = _normalize_spaces(value or "")
if not raw:
return None
if not CAMERA_SOURCE_PATTERN.fullmatch(raw):
raise HTTPException(
status_code=400,
detail="Invalid camera_source. Use alphanumeric, underscore, or dash (max 64 chars).",
)
return raw
def _is_plate_detected(plate_info: Dict[str, Any]) -> bool:
if not isinstance(plate_info, dict):
return False
arabic = str(plate_info.get("arabic") or "").strip().upper()
english = str(plate_info.get("english") or "").strip().upper()
if arabic not in {"", "N/A"} or english not in {"", "N/A"}:
return True
# Even if chars are detected, if there's no readable string, we can't track it.
return False
def _is_staff_role(role: Optional[str]) -> bool:
return (role or "").strip().lower() in STAFF_ROLES
def _is_barrier_role(role: Optional[str]) -> bool:
return (role or "").strip().lower() in BARRIER_ROLES
def _can_view_global_records(role: Optional[str]) -> bool:
return _is_staff_role(role) or _is_barrier_role(role)
def _is_developer_role(role: Optional[str]) -> bool:
return (role or "").strip().lower() in DEVELOPER_ROLES
def _extract_created_by_user_id(request_user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(request_user, dict):
return None
if str(request_user.get("auth_type") or "").strip().lower() != "supabase":
return None
user_id = str(request_user.get("id") or "").strip()
return user_id or None
def _extract_bearer_token(authorization: Optional[str]) -> Optional[str]:
if not authorization:
return None
parts = authorization.strip().split(" ", 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
token = parts[1].strip()
return token if token else None
def _looks_like_jwt(token: str) -> bool:
parts = token.split(".")
if len(parts) != 3:
return False
return bool(parts[0] and parts[1])
def _extract_response_error_message(response: requests.Response) -> str:
try:
payload = response.json()
except ValueError:
payload = None
if isinstance(payload, dict):
for key in ["error_description", "message", "error", "msg"]:
value = payload.get(key)
if value is not None:
text = str(value).strip()
if text:
return text
return response.text[:250].strip()
def _supabase_headers(*, api_key: str, bearer: str, content_type: Optional[str] = None, prefer: Optional[str] = None) -> Dict[str, str]:
headers: Dict[str, str] = {
"apikey": api_key,
"Authorization": f"Bearer {bearer}",
}
if content_type:
headers["Content-Type"] = content_type
if prefer:
headers["Prefer"] = prefer
return headers
def _verify_barrier_token(token: str) -> Optional[Dict[str, Any]]:
if not token or not BARRIER_API_TOKENS:
return None
for configured_token in BARRIER_API_TOKENS:
if hmac.compare_digest(token, configured_token):
return {
"id": BARRIER_SYNTHETIC_USER_ID,
"email": None,
"role": "barrier",
"auth_type": "barrier_token",
}
return None
def _verify_supabase_user_token(token: str) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase auth is not configured on backend.")
if not _looks_like_jwt(token):
raise HTTPException(
status_code=401,
detail="Invalid Supabase access token format. Send session.access_token as Bearer token.",
)
url = f"{SUPABASE_URL}/auth/v1/user"
try:
response = requests.get(
url,
headers={
"apikey": SUPABASE_ANON_KEY,
"Authorization": f"Bearer {token}",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise HTTPException(status_code=503, detail=f"Supabase auth is unreachable: {exc}") from exc
if response.status_code != 200:
supabase_error = _extract_response_error_message(response)
err_lc = supabase_error.lower()
if "expired" in err_lc:
detail = "Supabase access token expired. Refresh session and retry."
elif any(hint in err_lc for hint in ["jwt", "signature", "malformed", "invalid"]):
detail = "Invalid Supabase access token. Send session.access_token as Bearer token."
else:
detail = "Invalid or expired Supabase token."
LOGGER.info("Supabase token verify failed with status %s: %s", response.status_code, supabase_error)
raise HTTPException(status_code=401, detail=detail)
payload = response.json()
if not isinstance(payload, dict):
raise HTTPException(status_code=401, detail="Invalid Supabase auth response payload.")
user_id = payload.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="Supabase token did not return a user id.")
user_context = {
"id": user_id,
"email": payload.get("email"),
"auth_type": "supabase",
"user_metadata": payload.get("user_metadata") if isinstance(payload.get("user_metadata"), dict) else {},
"app_metadata": payload.get("app_metadata") if isinstance(payload.get("app_metadata"), dict) else {},
}
_ensure_profile_row_exists_for_verified_user(user_context)
return user_context
def _require_authenticated_user(authorization: Optional[str]) -> Optional[Dict[str, Any]]:
token = _extract_bearer_token(authorization)
if SUPABASE_ENFORCE_AUTH and not token:
raise HTTPException(status_code=401, detail="Missing Bearer token.")
if not token:
return None
barrier_user = _verify_barrier_token(token)
if barrier_user is not None:
return barrier_user
return _verify_supabase_user_token(token)
def _upload_to_supabase_storage(bucket: str, object_path: str, content: bytes, content_type: str = "image/jpeg") -> None:
if not _supabase_configured():
raise RuntimeError("Supabase is not configured.")
endpoint = f"{SUPABASE_URL}/storage/v1/object/{bucket}/{object_path}"
headers = _supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type=content_type,
)
headers["x-upsert"] = "true"
response = requests.post(
endpoint,
data=content,
headers=headers,
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
raise RuntimeError(f"Storage upload failed ({response.status_code}): {response.text[:400]}")
def _delete_from_supabase_storage(bucket: str, object_path: str) -> None:
if not _supabase_configured() or not bucket or not object_path:
return
endpoint = f"{SUPABASE_URL}/storage/v1/object/{bucket}/{object_path}"
response = requests.delete(
endpoint,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204, 404}:
raise RuntimeError(f"Storage delete failed ({response.status_code}): {response.text[:400]}")
def _supabase_list_buckets() -> List[str]:
if not _supabase_configured():
return []
response = requests.get(
f"{SUPABASE_URL}/storage/v1/bucket",
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise RuntimeError(f"Bucket list request failed ({response.status_code}): {response.text[:250]}")
rows = response.json()
if not isinstance(rows, list):
return []
return [str(row.get("id")) for row in rows if isinstance(row, dict) and row.get("id")]
def _supabase_table_exists(table_name: str) -> Tuple[bool, Optional[str]]:
if not _supabase_configured():
return False, "Supabase not configured"
response = requests.get(
f"{SUPABASE_URL}/rest/v1/{table_name}",
params={"select": "*", "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 True, None
return False, f"HTTP {response.status_code}: {response.text[:220]}"
def _supabase_get_profile_role(user_id: Optional[str]) -> Optional[str]:
normalized_user_id = str(user_id or "").strip()
if normalized_user_id and normalized_user_id == BARRIER_SYNTHETIC_USER_ID:
return "barrier"
profile = _supabase_get_profile_by_user_id(user_id)
if not isinstance(profile, dict):
return None
role = str(profile.get("role") or "").strip().lower()
return role or None
def _resolve_requester_role(request_user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(request_user, dict):
return None
inline_role = str(request_user.get("role") or "").strip().lower()
if inline_role:
return inline_role
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
return None
return _supabase_get_profile_role(requester_id)
def _require_developer_user(authorization: Optional[str]) -> Tuple[Dict[str, Any], str]:
request_user = _require_authenticated_user(authorization)
if request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required.")
requester_role = _resolve_requester_role(request_user)
if not _is_developer_role(requester_role):
raise HTTPException(status_code=403, detail="Only developers can access this endpoint.")
return request_user, (requester_role or "").strip().lower()
def _supabase_get_profile_by_user_id(user_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not user_id or not _supabase_configured():
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={
"select": PROFILE_SELECT_FIELDS,
"id": f"eq.{user_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:
LOGGER.warning("Failed to fetch profile for %s: %s", user_id, response.text[:240])
return None
rows = response.json()
if not isinstance(rows, list) or not rows:
return None
first_row = rows[0]
if not isinstance(first_row, dict):
return None
return first_row
def _ensure_profile_row_exists_for_verified_user(auth_user: Dict[str, Any]) -> None:
if not _supabase_configured() or not isinstance(auth_user, dict):
return
user_id = str(auth_user.get("id") or "").strip()
if not user_id:
return
existing_profile = _supabase_get_profile_by_user_id(user_id)
if isinstance(existing_profile, dict):
return
email = _normalize_spaces(str(auth_user.get("email") or "")).lower()
payload: Dict[str, Any] = {
"id": user_id,
}
if email:
payload["email"] = email
try:
response = requests.post(
f"{SUPABASE_URL}/rest/v1/profiles",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="resolution=merge-duplicates,return=minimal",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201, 204}:
LOGGER.warning(
"Failed to auto-create profile for authenticated user %s: %s",
user_id,
response.text[:240],
)
except requests.RequestException as exc:
LOGGER.warning("Failed to auto-create profile for authenticated user %s: %s", user_id, exc)
def _supabase_get_profile_by_field(
field: str,
value: str,
*,
case_insensitive: bool = False,
) -> Optional[Dict[str, Any]]:
if not _supabase_configured() or not value:
return None
operator = "ilike" if case_insensitive else "eq"
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={
"select": PROFILE_SELECT_FIELDS,
field: f"{operator}.{value}",
"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:
LOGGER.warning("Failed to fetch profile by %s: %s", field, response.text[:240])
return None
rows = response.json()
if not isinstance(rows, list) or not rows:
return None
first_row = rows[0]
if not isinstance(first_row, dict):
return None
return first_row
def _supabase_find_profile_by_identifier(identifier: Optional[str]) -> Optional[Dict[str, Any]]:
if not _supabase_configured():
return None
normalized_identifier = _normalize_spaces(str(identifier or ""))
if not normalized_identifier:
return None
lookup_chain: List[Tuple[str, str, bool]] = []
if "@" in normalized_identifier:
lookup_chain.append(("email", normalized_identifier.lower(), True))
lookup_chain.extend(
[
("username", normalized_identifier, False),
("phone_number", normalized_identifier, False),
("national_id", normalized_identifier, False),
]
)
if "@" not in normalized_identifier:
lookup_chain.append(("email", normalized_identifier.lower(), True))
seen: set = set()
for field_name, field_value, case_insensitive in lookup_chain:
key = (field_name, field_value, case_insensitive)
if key in seen:
continue
seen.add(key)
profile = _supabase_get_profile_by_field(
field_name,
field_value,
case_insensitive=case_insensitive,
)
if isinstance(profile, dict):
return profile
return None
def _supabase_password_login(
*,
password: str,
email: Optional[str] = None,
phone_number: Optional[str] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
if not _supabase_configured():
return None, "Supabase is not configured."
login_payload: Dict[str, str] = {"password": password}
if email:
login_payload["email"] = email.strip().lower()
elif phone_number:
login_payload["phone"] = phone_number.strip()
else:
return None, "No login identifier was provided."
response = requests.post(
f"{SUPABASE_URL}/auth/v1/token?grant_type=password",
json=login_payload,
headers={
"apikey": SUPABASE_ANON_KEY,
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code == 200:
payload = response.json()
if isinstance(payload, dict):
return payload, None
return None, "Supabase login returned an unexpected response payload."
try:
error_data = response.json()
except ValueError:
error_data = {}
error_message = ""
if isinstance(error_data, dict):
error_message = str(error_data.get("error_description") or error_data.get("message") or "").strip()
if not error_message:
error_message = response.text[:200]
return None, error_message or f"Login failed with status {response.status_code}."
def _is_supabase_duplicate_auth_user_error(error_message: Optional[str]) -> bool:
normalized = _normalize_spaces(str(error_message or "")).lower()
if not normalized:
return False
duplicate_hints = [
"already registered",
"already exists",
"already in use",
"user already registered",
"email address already in use",
"duplicate key value",
]
return any(hint in normalized for hint in duplicate_hints)
def _find_supabase_auth_user_by_email(email: str) -> Optional[Dict[str, Any]]:
if not _supabase_configured():
return None
normalized_email = _normalize_spaces(email).lower()
if not normalized_email or "@" not in normalized_email:
return None
max_pages = 20
per_page = 200
for page in range(1, max_pages + 1):
try:
response = requests.get(
f"{SUPABASE_URL}/auth/v1/admin/users",
params={
"page": str(page),
"per_page": str(per_page),
},
headers={
"apikey": SUPABASE_SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
LOGGER.warning("Failed to list Supabase auth users while searching for %s: %s", normalized_email, exc)
return None
if response.status_code != 200:
LOGGER.warning(
"Supabase auth users listing failed (%s) while searching for %s: %s",
response.status_code,
normalized_email,
_extract_response_error_message(response),
)
return None
try:
payload = response.json()
except ValueError:
LOGGER.warning("Supabase auth users listing returned invalid JSON while searching for %s", normalized_email)
return None
users_payload: Any
if isinstance(payload, dict):
users_payload = payload.get("users")
else:
users_payload = payload
users = users_payload if isinstance(users_payload, list) else []
for entry in users:
if not isinstance(entry, dict):
continue
candidate_email = _normalize_spaces(str(entry.get("email") or "")).lower()
if candidate_email == normalized_email:
return entry
total_count: Optional[int] = None
if isinstance(payload, dict):
total_raw = payload.get("total")
if total_raw is not None:
try:
total_count = int(total_raw)
except (TypeError, ValueError):
total_count = None
if total_count is not None and page * per_page >= total_count:
break
if len(users) < per_page:
break
return None
def _update_supabase_auth_user(user_id: str, patch_payload: Dict[str, Any]) -> None:
if not user_id or not patch_payload:
return
try:
response = requests.put(
f"{SUPABASE_URL}/auth/v1/admin/users/{user_id}",
json=patch_payload,
headers={
"apikey": SUPABASE_SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise RuntimeError(f"Supabase auth update failed: {exc}") from exc
if response.status_code not in {200, 201}:
error_msg = _extract_response_error_message(response)
raise RuntimeError(f"Supabase auth update failed ({response.status_code}): {error_msg}")
def _extract_supabase_session_tokens(payload: Dict[str, Any]) -> Dict[str, Any]:
session_obj = payload.get("session") if isinstance(payload.get("session"), dict) else {}
access_token = _normalize_spaces(str(payload.get("access_token") or session_obj.get("access_token") or ""))
refresh_token = _normalize_spaces(str(payload.get("refresh_token") or session_obj.get("refresh_token") or ""))
expires_in_raw = payload.get("expires_in")
if expires_in_raw is None:
expires_in_raw = session_obj.get("expires_in")
return {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": expires_in_raw,
}
def _build_internal_login_email(username: str, phone_number: str) -> str:
seed = re.sub(r"[^a-z0-9]+", "", f"{username}-{phone_number}".lower())
if not seed:
seed = uuid4().hex[:12]
return f"{seed[:24]}-{uuid4().hex[:8]}@ainel-aql.local"
def _is_internal_login_email(email: Optional[str]) -> bool:
normalized_email = _normalize_spaces(str(email or "")).lower()
if not normalized_email:
return False
return normalized_email.endswith(INTERNAL_LOGIN_EMAIL_SUFFIX)
def _missing_schema_hint(raw_error: str) -> str:
err = (raw_error or "").strip()
if "42703" in err:
return (
"Database schema is missing new auth/profile columns. "
"Run supabase/06_profile_name_parts_upgrade.sql (or rerun supabase/01_schema.sql) first."
)
return err[:280] if err else "Schema validation failed."
def _ensure_auth_profile_schema_ready() -> None:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={
"select": "id,first_name,last_name,full_name,username,national_id,phone_number,email,role",
"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
raise HTTPException(
status_code=503,
detail=f"Auth schema check failed: {_missing_schema_hint(response.text)}",
)
def _extract_registration_vehicle_payload(payload: Dict[str, Any]) -> Optional[Dict[str, str]]:
raw_vehicle = payload.get("vehicle") if isinstance(payload.get("vehicle"), dict) else {}
def _pick(*keys: str) -> Optional[str]:
for key in keys:
value = raw_vehicle.get(key) if isinstance(raw_vehicle, dict) else None
if value is None:
value = payload.get(key)
if value is None:
continue
normalized_value = _normalize_spaces(str(value))
if normalized_value:
return normalized_value
return None
plate_letters_ar = _pick("plate_letters_ar", "plate_letters")
plate_numbers_ar = _pick("plate_numbers_ar", "plate_numbers")
plate_letters_en = _pick("plate_letters_en")
plate_numbers_en = _pick("plate_numbers_en")
car_name = _pick("car_name", "nickname")
car_model = _pick("car_model", "model")
car_color = _pick("car_color", "color")
has_any_vehicle_field = any(
[
plate_letters_ar,
plate_numbers_ar,
plate_letters_en,
plate_numbers_en,
car_name,
car_model,
car_color,
]
)
if not has_any_vehicle_field:
return None
if not plate_letters_ar or not plate_numbers_ar:
raise HTTPException(
status_code=400,
detail="Vehicle plate details are incomplete. Provide plate_letters_ar and plate_numbers_ar.",
)
vehicle_payload = {
"plate_letters_ar": plate_letters_ar,
"plate_numbers_ar": plate_numbers_ar,
}
if plate_letters_en:
vehicle_payload["plate_letters_en"] = plate_letters_en
if plate_numbers_en:
vehicle_payload["plate_numbers_en"] = plate_numbers_en
if car_name:
vehicle_payload["car_name"] = car_name
if car_model:
vehicle_payload["car_model"] = car_model
if car_color:
vehicle_payload["car_color"] = car_color
return vehicle_payload
def _upsert_profile_after_register(
*,
user_id: str,
first_name: str,
last_name: str,
username: str,
phone_number: str,
email: str,
national_id: Optional[str],
role: str = "user",
) -> Optional[Dict[str, Any]]:
resolved_first_name = _normalize_name_part(first_name)
resolved_last_name = _normalize_name_part(last_name)
resolved_full_name = _compose_full_name(resolved_first_name, resolved_last_name) or _normalize_name_part(username)
payload: Dict[str, Any] = {
"id": user_id,
"first_name": resolved_first_name,
"last_name": resolved_last_name,
"full_name": resolved_full_name,
"username": username,
"phone_number": phone_number,
"email": email,
}
if national_id:
payload["national_id"] = national_id
if role:
payload["role"] = role.lower()
response = requests.post(
f"{SUPABASE_URL}/rest/v1/profiles",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="resolution=merge-duplicates,return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
raise RuntimeError(f"Failed to upsert profile ({response.status_code}): {_missing_schema_hint(response.text)}")
rows = response.json()
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return _supabase_get_profile_by_user_id(user_id)
def _create_vehicle_for_owner(
*,
owner_id: str,
vehicle_payload: Dict[str, str],
) -> Optional[Dict[str, Any]]:
if not owner_id or not vehicle_payload:
return None
payload: Dict[str, Any] = {
"owner_id": owner_id,
"plate_letters_ar": vehicle_payload["plate_letters_ar"],
"plate_numbers_ar": vehicle_payload["plate_numbers_ar"],
}
for optional_key in ["plate_letters_en", "plate_numbers_en", "car_name", "car_model", "car_color"]:
optional_value = vehicle_payload.get(optional_key)
if optional_value:
payload[optional_key] = optional_value
response = requests.post(
f"{SUPABASE_URL}/rest/v1/vehicles",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
hint = _missing_schema_hint(response.text)
raise HTTPException(
status_code=400,
detail=f"Failed to save vehicle details: {hint}",
)
rows = response.json()
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return None
def _extract_vehicle_payload(payload: Dict[str, Any], *, require_plate: bool) -> Dict[str, str]:
def _pick(*keys: str) -> Optional[str]:
for key in keys:
value = payload.get(key)
if value is None:
continue
normalized_value = _normalize_spaces(str(value))
if normalized_value:
return normalized_value
return None
plate_letters_ar = _pick("plate_letters_ar", "plate_letters")
plate_numbers_ar = _pick("plate_numbers_ar", "plate_numbers")
plate_letters_en = _pick("plate_letters_en")
plate_numbers_en = _pick("plate_numbers_en")
car_name = _pick("car_name", "nickname")
car_model = _pick("car_model", "model")
car_color = _pick("car_color", "color")
payload_out: Dict[str, str] = {}
if plate_letters_ar:
payload_out["plate_letters_ar"] = plate_letters_ar
if plate_numbers_ar:
payload_out["plate_numbers_ar"] = plate_numbers_ar
if plate_letters_en:
payload_out["plate_letters_en"] = plate_letters_en
if plate_numbers_en:
payload_out["plate_numbers_en"] = plate_numbers_en
if car_name:
payload_out["car_name"] = car_name
if car_model:
payload_out["car_model"] = car_model
if car_color:
payload_out["car_color"] = car_color
if require_plate and (not plate_letters_ar or not plate_numbers_ar):
raise HTTPException(
status_code=400,
detail="plate_letters_ar and plate_numbers_ar are required.",
)
if not payload_out:
raise HTTPException(status_code=400, detail="No vehicle fields were provided.")
return payload_out
def _fetch_vehicle_for_owner(owner_id: str, vehicle_id: str) -> Optional[Dict[str, Any]]:
if not owner_id or not vehicle_id:
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"select": VEHICLE_SELECT_FIELDS,
"id": f"eq.{vehicle_id}",
"owner_id": f"eq.{owner_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:
raise HTTPException(status_code=502, detail=f"Failed to fetch vehicle: {response.text[:260]}")
rows = response.json()
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return None
def _fetch_vehicle_by_plate_for_owner(
owner_id: str,
plate_letters_ar: str,
plate_numbers_ar: str,
) -> Optional[Dict[str, Any]]:
response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"select": VEHICLE_SELECT_FIELDS,
"owner_id": f"eq.{owner_id}",
"plate_letters_ar": f"eq.{plate_letters_ar}",
"plate_numbers_ar": f"eq.{plate_numbers_ar}",
"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:
raise HTTPException(status_code=502, detail=f"Failed to fetch vehicle by plate: {response.text[:260]}")
rows = response.json()
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return None
def _list_vehicles_for_owner(owner_id: str) -> List[Dict[str, Any]]:
if not owner_id:
return []
response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"select": VEHICLE_SELECT_FIELDS,
"owner_id": f"eq.{owner_id}",
"order": "created_at.desc",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise HTTPException(status_code=502, detail=f"Failed to list vehicles: {response.text[:260]}")
rows = response.json()
if not isinstance(rows, list):
return []
return [row for row in rows if isinstance(row, dict)]
def _update_vehicle_for_owner(owner_id: str, vehicle_id: str, update_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not owner_id or not vehicle_id:
return None
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"id": f"eq.{vehicle_id}",
"owner_id": f"eq.{owner_id}",
"select": VEHICLE_SELECT_FIELDS,
},
json=update_payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
error_hint = _missing_schema_hint(response.text)
raise HTTPException(status_code=400, detail=f"Failed to update vehicle: {error_hint}")
if not response.text:
return _fetch_vehicle_for_owner(owner_id, vehicle_id)
rows = response.json()
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return _fetch_vehicle_for_owner(owner_id, vehicle_id)
def _delete_vehicle_for_owner(owner_id: str, vehicle_id: str) -> bool:
if not owner_id or not vehicle_id:
return False
response = requests.delete(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"id": f"eq.{vehicle_id}",
"owner_id": f"eq.{owner_id}",
"select": "id",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
error_hint = _missing_schema_hint(response.text)
raise HTTPException(status_code=400, detail=f"Failed to delete vehicle: {error_hint}")
if not response.text:
return True
rows = response.json()
return isinstance(rows, list) and len(rows) > 0
def _normalize_otp_channel(raw_value: Optional[str]) -> str:
raw = _normalize_spaces(str(raw_value or "")).lower()
if raw in {"phone", "sms", "phone_number"}:
return "phone"
if raw in {"email", "mail"}:
return "email"
raise HTTPException(status_code=400, detail="channel must be 'email' or 'phone'.")
def _resolve_otp_identifier(
*,
channel: str,
payload: Dict[str, Any],
profile: Optional[Dict[str, Any]],
) -> str:
if channel not in SUPPORTED_OTP_CHANNELS:
raise HTTPException(status_code=400, detail="Unsupported OTP channel.")
if channel == "email":
candidate = _normalize_spaces(str(payload.get("email") or (profile or {}).get("email") or "")).lower()
if not candidate or "@" not in candidate:
raise HTTPException(status_code=400, detail="A valid email is required for email OTP.")
return candidate
candidate = _normalize_spaces(
str(
payload.get("phone")
or payload.get("phone_number")
or (profile or {}).get("phone_number")
or ""
)
)
if not candidate or not PHONE_PATTERN.fullmatch(candidate):
raise HTTPException(status_code=400, detail="A valid phone number is required for phone OTP.")
return candidate
def _request_supabase_otp(
*,
channel: str,
identifier: str,
create_user: bool,
redirect_to: Optional[str],
purpose: Optional[str],
) -> None:
request_payload: Dict[str, Any] = {
"create_user": bool(create_user),
}
if channel == "email":
request_payload["email"] = identifier
if redirect_to:
if not _is_valid_redirect_url(redirect_to):
raise HTTPException(status_code=400, detail="redirect_to must be a valid URL or deep-link.")
request_payload["email_redirect_to"] = redirect_to
request_payload["options"] = {
"emailRedirectTo": redirect_to,
}
else:
request_payload["phone"] = identifier
request_payload["channel"] = "sms"
try:
response = requests.post(
f"{SUPABASE_URL}/auth/v1/otp",
json=request_payload,
headers={
"apikey": SUPABASE_ANON_KEY,
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise HTTPException(status_code=503, detail=f"Supabase OTP request failed: {exc}") from exc
if response.status_code not in {200, 201}:
error_data = response.json() if response.text else {}
error_msg = ""
if isinstance(error_data, dict):
error_msg = str(
error_data.get("message")
or error_data.get("error_description")
or error_data.get("msg")
or ""
).strip()
if not error_msg:
error_msg = response.text[:220]
normalized_error = _normalize_spaces(error_msg).lower()
normalized_purpose = _normalize_spaces(str(purpose or "")).lower()
if "otp_disabled" in normalized_error or "signups not allowed for otp" in normalized_error:
if normalized_purpose in {"register", "signup"}:
raise HTTPException(
status_code=422,
detail=(
"OTP signup is disabled in backend auth configuration (otp_disabled). "
"purpose=register cannot proceed with OTP until OTP signups are enabled."
),
)
raise HTTPException(
status_code=422,
detail="OTP provider is disabled in backend auth configuration (otp_disabled).",
)
raise HTTPException(status_code=response.status_code, detail=f"OTP request failed: {error_msg}")
def _verify_supabase_otp(
*,
channel: str,
identifier: str,
token: str,
verify_type: Optional[str],
) -> Dict[str, Any]:
token_value = _normalize_spaces(token)
if not token_value:
raise HTTPException(status_code=400, detail="otp_token is required.")
resolved_type = _normalize_spaces(str(verify_type or "")).lower()
if not resolved_type:
resolved_type = "email" if channel == "email" else "sms"
request_payload: Dict[str, Any] = {
"token": token_value,
"type": resolved_type,
}
if channel == "email":
request_payload["email"] = identifier
else:
request_payload["phone"] = identifier
try:
response = requests.post(
f"{SUPABASE_URL}/auth/v1/verify",
json=request_payload,
headers={
"apikey": SUPABASE_ANON_KEY,
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise HTTPException(status_code=503, detail=f"Supabase OTP verify failed: {exc}") from exc
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = ""
if isinstance(error_data, dict):
error_msg = str(error_data.get("message") or error_data.get("error_description") or "").strip()
if not error_msg:
error_msg = response.text[:220]
raise HTTPException(status_code=401, detail=f"OTP verification failed: {error_msg}")
response_payload = response.json() if response.text else {}
if not isinstance(response_payload, dict):
return {}
return response_payload
def _upsert_notification_device_token(
*,
user_id: str,
fcm_token: str,
platform: Optional[str],
device_id: Optional[str],
) -> Optional[Dict[str, Any]]:
payload: Dict[str, Any] = {
"user_id": user_id,
"fcm_token": fcm_token,
"is_active": True,
"last_seen_at": datetime.now(timezone.utc).isoformat(),
}
if platform:
payload["platform"] = platform
if device_id:
payload["device_id"] = device_id
response = requests.post(
f"{SUPABASE_URL}/rest/v1/notification_device_tokens",
params={"on_conflict": "fcm_token"},
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="resolution=merge-duplicates,return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
raise HTTPException(status_code=502, detail=f"Failed to save device token: {response.text[:220]}")
rows = response.json() if response.text else []
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return None
def _insert_notification_event(
*,
user_id: Optional[str],
event_type: str,
title: str,
body: str,
data: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
if not _supabase_configured() or not user_id:
return None
payload: Dict[str, Any] = {
"user_id": user_id,
"event_type": event_type,
"title": title,
"body": body,
"data": data or {},
}
response = requests.post(
f"{SUPABASE_URL}/rest/v1/notification_events",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
LOGGER.warning("Failed to insert notification event: %s", response.text[:220])
return None
rows = response.json() if response.text else []
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
notification_id = rows[0].get("id")
if notification_id:
return str(notification_id)
return None
def _list_security_user_ids() -> List[str]:
if not _supabase_configured():
return []
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={
"select": "id",
"role": "eq.security",
"limit": "1000",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch security profiles: %s", response.text[:240])
return []
rows = response.json()
if not isinstance(rows, list):
return []
return [str(row.get("id")) for row in rows if isinstance(row, dict) and row.get("id")]
def _list_security_user_ids_with_tokens() -> List[str]:
security_user_ids = _list_security_user_ids()
if not security_user_ids:
return []
response = requests.get(
f"{SUPABASE_URL}/rest/v1/notification_device_tokens",
params={
"select": "user_id",
"user_id": f"in.({','.join(sorted(set(security_user_ids)))})",
"is_active": "eq.true",
"limit": "10000",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch security device tokens: %s", response.text[:240])
return []
rows = response.json()
if not isinstance(rows, list):
return []
return sorted(
{
str(row.get("user_id"))
for row in rows
if isinstance(row, dict) and row.get("user_id")
}
)
def _notify_security_staff(title: str, body: str, data: Optional[Dict[str, Any]] = None) -> int:
security_user_ids = _list_security_user_ids_with_tokens()
if not security_user_ids:
return 0
count = 0
for user_id in security_user_ids:
notification_id = _insert_notification_event(
user_id=user_id,
event_type="cash_payment_needed",
title=title,
body=body,
data=data,
)
if notification_id:
count += 1
return count
def _list_notification_events_for_user(user_id: str, limit: int) -> List[Dict[str, Any]]:
response = requests.get(
f"{SUPABASE_URL}/rest/v1/notification_events",
params={
"select": "id,event_type,title,body,data,read_at,created_at",
"user_id": f"eq.{user_id}",
"order": "created_at.desc",
"limit": str(limit),
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise HTTPException(status_code=502, detail=f"Failed to load notifications: {response.text[:240]}")
rows = response.json()
if not isinstance(rows, list):
return []
return [row for row in rows if isinstance(row, dict)]
def _fetch_vehicle_ids_for_owner(owner_id: str) -> List[str]:
if not owner_id or not _supabase_configured():
return []
response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"select": "id",
"owner_id": f"eq.{owner_id}",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to list vehicles for owner %s: %s", owner_id, response.text[:240])
return []
rows = response.json()
if not isinstance(rows, list):
return []
vehicle_ids: List[str] = []
for row in rows:
if not isinstance(row, dict):
continue
vehicle_id = row.get("id")
if vehicle_id:
vehicle_ids.append(str(vehicle_id))
return vehicle_ids
def _resolve_or_create_open_parking_session(
*,
vehicle_id: Optional[str],
created_by: Optional[str],
parking_location: Optional[str],
plate_arabic: Optional[str] = None,
plate_english: Optional[str] = None,
) -> Optional[str]:
if not _supabase_configured():
return None
params: Dict[str, str] = {
"select": "id,parking_location,status,check_out_at",
"check_out_at": "is.null",
"status": "not.in.(exited,left_without_payment)",
"order": "check_in_at.desc",
"limit": "1",
}
if vehicle_id:
params["vehicle_id"] = f"eq.{vehicle_id}"
elif plate_arabic or plate_english:
params["vehicle_id"] = "is.null"
# Try to match by either plate
plate_filters = []
if plate_arabic:
plate_filters.append(f"plate_arabic.eq.{plate_arabic}")
if plate_english:
plate_filters.append(f"plate_english.eq.{plate_english}")
if plate_filters:
params["or"] = f"({','.join(plate_filters)})"
else:
return None
open_session_response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params=params,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if open_session_response.status_code != 200:
raise RuntimeError(
f"Failed to query parking_sessions ({open_session_response.status_code}): "
f"{open_session_response.text[:280]}"
)
rows = open_session_response.json()
if isinstance(rows, list) and rows:
session_id = rows[0].get("id")
if not session_id:
return None
current_location = rows[0].get("parking_location")
if parking_location and parking_location != current_location:
update_response = requests.patch(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"id": f"eq.{session_id}"},
json={"parking_location": parking_location},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if update_response.status_code not in {200, 204}:
raise RuntimeError(
f"Failed to update parking_location ({update_response.status_code}): "
f"{update_response.text[:280]}"
)
return str(session_id)
payload: Dict[str, Any] = {
"vehicle_id": vehicle_id,
"status": "entered",
"plate_arabic": plate_arabic,
"plate_english": plate_english,
"check_in_at": datetime.now(timezone.utc).isoformat(),
}
if created_by:
payload["created_by"] = created_by
if parking_location:
payload["parking_location"] = parking_location
create_response = requests.post(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if create_response.status_code not in {200, 201}:
raise RuntimeError(
f"Failed to create parking_session ({create_response.status_code}): {create_response.text[:300]}"
)
created_rows = create_response.json()
if isinstance(created_rows, list) and created_rows:
session_id = created_rows[0].get("id")
return str(session_id) if session_id else None
return None
def _insert_payment_transaction_record(
*,
created_by: Optional[str],
session_id: str,
amount_cents: int,
currency: str,
merchant_order_id: str,
paymob_order_id: Optional[str],
parking_location: Optional[str],
request_payload: Dict[str, Any],
response_payload: Dict[str, Any],
provider: str = "paymob",
status: str = "pending",
payment_reference: Optional[str] = None,
) -> Optional[str]:
if not _supabase_configured():
return None
payload: Dict[str, Any] = {
"provider": provider,
"status": status,
"session_id": session_id,
"amount_cents": amount_cents,
"currency": currency,
"merchant_order_id": merchant_order_id,
"request_payload": request_payload,
"response_payload": response_payload,
}
if created_by:
payload["created_by"] = created_by
if paymob_order_id:
payload["paymob_order_id"] = paymob_order_id
if payment_reference:
payload["payment_reference"] = payment_reference
if parking_location:
payload["parking_location"] = parking_location
response = requests.post(
f"{SUPABASE_URL}/rest/v1/payment_transactions",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
raise RuntimeError(f"Failed to insert payment_transactions row ({response.status_code}): {response.text[:400]}")
rows = response.json()
if isinstance(rows, list) and rows:
payment_id = rows[0].get("id")
return str(payment_id) if payment_id else None
return None
def _update_payment_transaction_by_merchant_order_id(
*,
merchant_order_id: str,
status: str,
payment_reference: Optional[str],
paymob_order_id: Optional[str],
response_payload: Dict[str, Any],
) -> None:
if not _supabase_configured() or not merchant_order_id:
return
patch_payload: Dict[str, Any] = {
"status": status,
"response_payload": response_payload,
}
if payment_reference:
patch_payload["payment_reference"] = payment_reference
if paymob_order_id:
patch_payload["paymob_order_id"] = paymob_order_id
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/payment_transactions",
params={"merchant_order_id": f"eq.{merchant_order_id}"},
json=patch_payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(
f"Failed to update payment transaction ({response.status_code}): {response.text[:280]}"
)
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 _mark_parking_session_paid(session_id: str, payment_reference: Optional[str] = None) -> None:
if not _supabase_configured() or not session_id:
return
patch_payload: Dict[str, Any] = {
"payment_confirmed_at": datetime.now(timezone.utc).isoformat(),
"status": "paid",
}
if payment_reference:
patch_payload["notes"] = f"Paymob ref: {payment_reference}"
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"id": f"eq.{session_id}"},
json=patch_payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(f"Failed to update parking session as paid ({response.status_code}): {response.text[:280]}")
def _set_parking_session_location(session_id: str, parking_location: Optional[str]) -> None:
if not _supabase_configured() or not session_id or not parking_location:
return
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"id": f"eq.{session_id}"},
json={"parking_location": parking_location},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(f"Failed to set parking location ({response.status_code}): {response.text[:280]}")
def _get_payment_session_id_by_merchant_order_id(merchant_order_id: str) -> Optional[str]:
if not _supabase_configured() or not merchant_order_id:
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/payment_transactions",
params={
"select": "session_id",
"merchant_order_id": f"eq.{merchant_order_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 not isinstance(rows, list) or not rows:
return None
session_id = rows[0].get("session_id")
return str(session_id) if session_id else None
def _call_paymob_api(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
url = f"{PAYMOB_BASE_URL}{path}"
try:
response = requests.post(url, json=payload, timeout=SUPABASE_TIMEOUT_SECONDS)
except requests.RequestException as exc:
raise RuntimeError(f"Paymob API call failed: {exc}") from exc
if response.status_code not in {200, 201}:
raise RuntimeError(f"Paymob API error ({response.status_code}): {response.text[:400]}")
try:
body = response.json()
except ValueError as exc:
raise RuntimeError("Paymob returned a non-JSON response.") from exc
if not isinstance(body, dict):
raise RuntimeError("Unexpected Paymob response format.")
return body
def _paymob_create_checkout(
*,
amount_cents: int,
currency: str,
merchant_order_id: str,
request_user: Dict[str, Any],
) -> Dict[str, Any]:
auth_payload = {
"api_key": PAYMOB_API_KEY,
}
auth_response = _call_paymob_api("/auth/tokens", auth_payload)
auth_token = str(auth_response.get("token") or "").strip()
if not auth_token:
raise RuntimeError("Paymob did not return an auth token.")
order_payload = {
"auth_token": auth_token,
"delivery_needed": False,
"amount_cents": str(amount_cents),
"currency": currency,
"merchant_order_id": merchant_order_id,
"items": [],
}
order_response = _call_paymob_api("/ecommerce/orders", order_payload)
order_id = order_response.get("id")
if order_id is None:
raise RuntimeError("Paymob did not return an order id.")
billing_data = {
"first_name": "Ain",
"last_name": "User",
"email": request_user.get("email") or "user@example.com",
"phone_number": "NA",
"apartment": "NA",
"floor": "NA",
"street": "NA",
"building": "NA",
"shipping_method": "NA",
"postal_code": "NA",
"city": "Cairo",
"country": "EG",
"state": "Cairo",
}
payment_key_payload = {
"auth_token": auth_token,
"amount_cents": str(amount_cents),
"expiration": 3600,
"order_id": order_id,
"currency": currency,
"integration_id": int(PAYMOB_INTEGRATION_ID),
"billing_data": billing_data,
}
payment_key_response = _call_paymob_api("/acceptance/payment_keys", payment_key_payload)
payment_token = str(payment_key_response.get("token") or "").strip()
if not payment_token:
raise RuntimeError("Paymob did not return a payment token.")
return {
"paymob_order_id": str(order_id),
"payment_token": payment_token,
"iframe_url": f"{PAYMOB_BASE_URL}/acceptance/iframes/{PAYMOB_IFRAME_ID}?payment_token={payment_token}",
"auth_response": auth_response,
"order_response": order_response,
"payment_key_response": payment_key_response,
}
def _run_weekly_refresh_non_core() -> Dict[str, Any]:
if not _supabase_configured():
raise RuntimeError("Supabase is not configured.")
response = requests.post(
f"{SUPABASE_URL}/rest/v1/rpc/weekly_refresh_non_core",
json={},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201, 204}:
raise RuntimeError(
f"Failed to run weekly refresh RPC ({response.status_code}): {response.text[:360]}"
)
if response.status_code == 204 or not response.text:
return {"truncated_tables": [], "count": 0}
result = response.json()
if isinstance(result, dict):
return result
return {"result": result}
def _resolve_vehicle_record_by_plate(
plate_text_ar: str,
plate_text_en: str,
user_id: Optional[str],
) -> Optional[Dict[str, Any]]:
if not _supabase_configured():
return None
plate_letters_ar, plate_numbers_ar = _split_arabic_plate(plate_text_ar)
plate_letters_en, plate_numbers_en = _split_english_plate(plate_text_en)
def _number_variants(num_str: Optional[str]) -> List[str]:
"""Build all plausible stored forms of a plate number string."""
if not num_str:
return []
raw = num_str.strip()
if not raw:
return []
no_space = "".join(raw.split())
western = no_space
arabic_indic = no_space
_ARABIC_INDIC = '\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669'
_WESTERN = '0123456789'
for ar, en in zip(_ARABIC_INDIC, _WESTERN):
western = western.replace(ar, en)
arabic_indic = arabic_indic.replace(en, ar)
# Collect unique non-empty variants
candidates = set()
candidates.add(raw)
candidates.add(no_space)
candidates.add(western)
candidates.add(arabic_indic)
# Also add spaced variants (e.g. "1 2 4 5")
if len(western) > 1:
candidates.add(" ".join(list(western)))
if len(arabic_indic) > 1:
candidates.add(" ".join(list(arabic_indic)))
# Add reversed variants ΓÇö OCR can mis-order digit bboxes on some plates
# (e.g. reads 748 as 847). Query includes reversed forms so the candidate
# row is fetched; normalize_for_cmp still does exact-order check first,
# then a sorted-digits fallback.
rev_western = western[::-1]
rev_arabic = arabic_indic[::-1]
if rev_western != western:
candidates.add(rev_western)
if rev_arabic != arabic_indic:
candidates.add(rev_arabic)
if len(rev_western) > 1 and rev_western != western:
candidates.add(" ".join(list(rev_western)))
if len(rev_arabic) > 1 and rev_arabic != arabic_indic:
candidates.add(" ".join(list(rev_arabic)))
return [v for v in candidates if v]
def normalize_for_cmp(text: Optional[str]) -> str:
if not text:
return ""
t = "".join(text.split()).lower()
t = t.replace('\u0627', '\u0623').replace('\u0625', '\u0623').replace('\u0622', '\u0623')
t = t.replace('\u0629', '\u0647')
t = t.replace('\u064a', '\u0649')
_ARABIC_INDIC = '\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669'
_WESTERN = '0123456789'
for ar, en in zip(_ARABIC_INDIC, _WESTERN):
t = t.replace(ar, en)
return t
matched_vehicles: List[Tuple[Dict[str, Any], str]] = []
def _fetch_and_filter(ocr_numbers: Optional[str], ocr_letters: Optional[str], lang: str):
if not ocr_numbers or not ocr_letters:
return
variants = _number_variants(ocr_numbers)
if not variants:
return
col = "plate_numbers_ar" if lang == "ar" else "plate_numbers_en"
# Build PostgREST OR filter with exact eq matches for every variant
# Values with spaces MUST be double-quoted in PostgREST filters.
conditions = []
for v in variants:
safe_v = v.replace('"', '')
conditions.append(f'{col}.eq."{safe_v}"')
or_filter = "(" + ",".join(conditions) + ")"
params = {
"select": "id,owner_id,plate_letters_ar,plate_numbers_ar,plate_letters_en,plate_numbers_en,car_model,car_color,is_active",
"is_active": "eq.true",
"or": or_filter,
}
LOGGER.info(
"plate_query | lang=%s | col=%s | variants=%s | or_filter=%s",
lang, col, variants, or_filter,
)
response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params=params,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code == 200:
rows = response.json()
if isinstance(rows, list):
ocr_num_norm = normalize_for_cmp(ocr_numbers)
ocr_let_norm = normalize_for_cmp(ocr_letters)
let_col = "plate_letters_ar" if lang == "ar" else "plate_letters_en"
LOGGER.info(
"plate_lookup | lang=%s | ocr_num_raw='%s' ocr_let_raw='%s' | "
"ocr_num_norm='%s' ocr_let_norm='%s' | candidate_rows=%d",
lang, ocr_numbers, ocr_letters,
ocr_num_norm, ocr_let_norm, len(rows),
)
for row in rows:
db_num_norm = normalize_for_cmp(row.get(col, ""))
db_let_norm = normalize_for_cmp(row.get(let_col, ""))
# Primary: exact digit order + exact letters
is_match = (db_num_norm == ocr_num_norm and db_let_norm == ocr_let_norm)
# Fallback: same digits in any order (handles OCR bbox mis-ordering)
# Only applies when letters match exactly to avoid false positives.
if not is_match and db_let_norm == ocr_let_norm:
is_match = sorted(db_num_norm) == sorted(ocr_num_norm)
if is_match:
LOGGER.warning(
"plate_row_cmp | ANAGRAM_MATCH (OCR digit order wrong) | "
"lang=%s | vehicle_id=%s | db_num='%s' ocr_num='%s'",
lang, row.get("id"), db_num_norm, ocr_num_norm,
)
LOGGER.info(
"plate_row_cmp | lang=%s | vehicle_id=%s | "
"db_num_raw='%s' db_let_raw='%s' | "
"db_num_norm='%s' db_let_norm='%s' | match=%s",
lang, row.get("id"),
row.get(col, ""), row.get(let_col, ""),
db_num_norm, db_let_norm, is_match,
)
if is_match:
matched_vehicles.append((row, lang))
else:
LOGGER.warning("Failed to resolve vehicle by plate %s (%s): %s", lang, response.status_code, response.text[:300])
_fetch_and_filter(plate_numbers_ar, plate_letters_ar, "ar")
if not matched_vehicles:
_fetch_and_filter(plate_numbers_en, plate_letters_en, "en")
if not matched_vehicles:
LOGGER.info("plate_resolution | NO_MATCH | ar='%s' en='%s'", plate_text_ar, plate_text_en)
return None
# If user_id is provided, prioritize their vehicle
if user_id:
for v, lang in matched_vehicles:
if str(v.get("owner_id")) == str(user_id):
v["_match_path"] = f"{lang}_local_filter"
LOGGER.info("plate_resolution | match_path=%s_local_filter | user_id=%s | vehicle_id=%s", lang, user_id, v.get("id"))
return v
# Otherwise return the first matched
v, lang = matched_vehicles[0]
v["_match_path"] = f"{lang}_local_filter"
LOGGER.info("plate_resolution | match_path=%s_local_filter | global | vehicle_id=%s", lang, v.get("id"))
return v
def _parse_iso_datetime(value: Optional[Any]) -> Optional[datetime]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _resolve_location_capacity(
location_key: Optional[str],
*,
explicit_capacity: Optional[int] = None,
) -> Tuple[int, str]:
if explicit_capacity is not None:
return max(0, int(explicit_capacity)), "location_pricing"
if location_key and location_key in PARKING_LOCATION_CAPACITIES:
return max(0, int(PARKING_LOCATION_CAPACITIES[location_key])), "location_capacity_map"
return GARAGE_TOTAL_CAPACITY, "default"
def _resolve_location_pricing_policy(parking_location: Optional[Any]) -> Dict[str, Any]:
candidates: List[str] = []
if parking_location is not None:
raw = str(parking_location).strip()
if raw:
for part in raw.split(","):
location_key = _normalize_location_key_for_config(part)
if location_key:
candidates.append(location_key)
for location_key in candidates:
location_policy = PARKING_LOCATION_PRICING.get(location_key)
if isinstance(location_policy, dict):
capacity_value, capacity_source = _resolve_location_capacity(
location_key,
explicit_capacity=int(location_policy.get("capacity", GARAGE_TOTAL_CAPACITY)),
)
return {
"pricing_location": location_key,
"billing_mode": _parse_billing_mode(location_policy.get("billing_mode"), default="hourly"),
"base_rate_egp": max(0.0, float(location_policy.get("base_rate_egp", 0.0))),
"extra_after_first_hour_egp": max(0.0, float(location_policy.get("extra_after_first_hour_egp", 0.0))),
"rate_source": "location_pricing",
"location_capacity": capacity_value,
"location_capacity_source": capacity_source,
}
hourly_rate_override = PARKING_LOCATION_HOURLY_RATES.get(location_key)
if hourly_rate_override is not None:
capacity_value, capacity_source = _resolve_location_capacity(location_key)
return {
"pricing_location": location_key,
"billing_mode": "hourly",
"base_rate_egp": max(0.0, float(hourly_rate_override)),
"extra_after_first_hour_egp": max(0.0, PARKING_EXTRA_AFTER_FIRST_HOUR_EGP),
"rate_source": "location_hourly_legacy",
"location_capacity": capacity_value,
"location_capacity_source": capacity_source,
}
fallback_location = candidates[0] if candidates else None
default_capacity, default_capacity_source = _resolve_location_capacity(fallback_location)
default_base_rate = PARKING_HOURLY_RATE_EGP if PARKING_BILLING_MODE == "hourly" else PARKING_DAILY_RATE_EGP
return {
"pricing_location": fallback_location,
"billing_mode": PARKING_BILLING_MODE,
"base_rate_egp": max(0.0, default_base_rate),
"extra_after_first_hour_egp": max(0.0, PARKING_EXTRA_AFTER_FIRST_HOUR_EGP),
"rate_source": "default",
"location_capacity": default_capacity,
"location_capacity_source": default_capacity_source,
}
def _compute_parking_fee_cents(
*,
duration_seconds: float,
billing_mode: str,
base_rate_egp: float,
extra_after_first_hour_egp: float,
) -> Tuple[int, int, int]:
if duration_seconds <= 0:
return 0, 0, 0
base_rate_cents = max(0, int(round(max(0.0, base_rate_egp) * 100.0)))
extra_rate_cents = max(0, int(round(max(0.0, extra_after_first_hour_egp) * 100.0)))
if billing_mode == "daily":
base_window_seconds = 24 * 3600
else:
base_window_seconds = 3600
extra_hours_billed = 0
if duration_seconds > base_window_seconds and extra_rate_cents > 0:
extra_hours_billed = int(math.ceil((duration_seconds - base_window_seconds) / 3600.0))
fee_cents = base_rate_cents + (extra_hours_billed * extra_rate_cents)
return max(0, fee_cents), extra_hours_billed, int(base_window_seconds // 3600)
def _compute_session_time_and_fee(
session_row: Optional[Dict[str, Any]],
now_utc: Optional[datetime] = None,
parking_location_override: Optional[str] = None,
) -> Dict[str, Any]:
reference_now = now_utc or datetime.now(timezone.utc)
location_for_pricing = parking_location_override
if isinstance(session_row, dict):
location_for_pricing = session_row.get("parking_location") or parking_location_override
pricing_policy = _resolve_location_pricing_policy(location_for_pricing)
billing_mode = str(pricing_policy.get("billing_mode") or "hourly")
base_rate_egp = max(0.0, float(pricing_policy.get("base_rate_egp") or 0.0))
extra_after_first_hour_egp = max(0.0, float(pricing_policy.get("extra_after_first_hour_egp") or 0.0))
pricing_location = pricing_policy.get("pricing_location")
rate_source = str(pricing_policy.get("rate_source") or "default")
location_capacity = max(0, int(pricing_policy.get("location_capacity") or 0))
location_capacity_source = str(pricing_policy.get("location_capacity_source") or "default")
hourly_rate_egp = base_rate_egp if billing_mode == "hourly" else round(base_rate_egp / 24.0, 2)
app_fee_egp = max(0.0, APP_SERVICE_FEE_EGP)
app_fee_cents = int(round(app_fee_egp * 100.0))
result = {
"pricing_location": pricing_location,
"rate_source": rate_source,
"billing_mode": billing_mode,
"base_rate_egp": round(base_rate_egp, 2),
"extra_after_first_hour_egp": round(extra_after_first_hour_egp, 2),
"location_capacity": location_capacity,
"location_capacity_source": location_capacity_source,
"base_window_hours": 24 if billing_mode == "daily" else 1,
"hourly_rate_egp": round(hourly_rate_egp, 2),
"app_fee_egp": round(app_fee_egp, 2),
"app_fee_cents": app_fee_cents,
"duration_minutes": 0.0,
"extra_hours_billed": 0,
"parking_fee_egp": 0.0,
"parking_fee_cents": 0,
"total_fee_egp": 0.0,
"total_fee_cents": 0,
"billing_reference_time": reference_now.isoformat(),
}
if not isinstance(session_row, dict):
return result
check_in_dt = _parse_iso_datetime(session_row.get("check_in_at"))
if check_in_dt is None:
return result
check_out_dt = _parse_iso_datetime(session_row.get("check_out_at"))
end_dt = check_out_dt or reference_now
duration_seconds = max(0.0, (end_dt - check_in_dt).total_seconds())
duration_minutes = round(duration_seconds / 60.0, 2)
parking_fee_cents, extra_hours_billed, base_window_hours = _compute_parking_fee_cents(
duration_seconds=duration_seconds,
billing_mode=billing_mode,
base_rate_egp=base_rate_egp,
extra_after_first_hour_egp=extra_after_first_hour_egp,
)
total_fee_cents = parking_fee_cents + app_fee_cents
result["duration_minutes"] = duration_minutes
result["extra_hours_billed"] = extra_hours_billed
result["base_window_hours"] = base_window_hours
result["parking_fee_cents"] = parking_fee_cents
result["parking_fee_egp"] = round(parking_fee_cents / 100.0, 2)
result["total_fee_cents"] = total_fee_cents
result["total_fee_egp"] = round(total_fee_cents / 100.0, 2)
return result
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,vehicle_id,check_in_at,payment_confirmed_at,check_out_at,left_within_5_minutes,status,parking_location,created_by,notes,created_at,updated_at",
"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:
LOGGER.warning("Failed to fetch parking session by id %s: %s", session_id, response.text[:300])
return None
rows = response.json()
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
return rows[0]
def _fetch_open_parking_session_by_vehicle(vehicle_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not _supabase_configured() or not vehicle_id:
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={
"select": "id,vehicle_id,check_in_at,payment_confirmed_at,check_out_at,left_within_5_minutes,status,parking_location,created_by,notes,created_at,updated_at",
"vehicle_id": f"eq.{vehicle_id}",
"check_out_at": "is.null",
"order": "check_in_at.desc",
"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:
LOGGER.warning("Failed to fetch open parking session for vehicle %s: %s", vehicle_id, response.text[:300])
return None
rows = response.json()
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
return rows[0]
def _fetch_latest_parking_session_by_vehicle(vehicle_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not _supabase_configured() or not vehicle_id:
return None
response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={
"select": "id,vehicle_id,check_in_at,payment_confirmed_at,check_out_at,left_within_5_minutes,status,parking_location,created_by,notes,created_at,updated_at",
"vehicle_id": f"eq.{vehicle_id}",
"order": "check_in_at.desc",
"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:
LOGGER.warning("Failed to fetch latest parking session for vehicle %s: %s", vehicle_id, response.text[:300])
return None
rows = response.json()
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
return rows[0]
def _insert_parking_session(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not _supabase_configured():
return None
response = requests.post(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
LOGGER.error("Failed to insert parking session (%s): %s", response.status_code, response.text[:300])
return None
rows = response.json()
if not isinstance(rows, list) or not rows or not isinstance(rows[0], dict):
return None
return rows[0]
def _link_unmatched_events_to_session(plate_ar: str, session_id: str) -> None:
if not _supabase_configured() or not plate_ar or not session_id:
return
# Search for all events where session_id is null and plate matches
# We use plate_arabic or ocr_arabic in metadata
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/car_events",
params={
"session_id": "is.null",
"ocr_arabic": f"eq.{plate_ar}"
},
json={"session_id": session_id},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
LOGGER.warning("Failed to link unmatched events for plate %s to session %s: %s", plate_ar, session_id, response.text[:300])
def _update_parking_session(session_id: str, patch_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not _supabase_configured() or not session_id:
return None
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={"id": f"eq.{session_id}"},
json=patch_payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise RuntimeError(f"Failed to update parking session ({response.status_code}): {response.text[:300]}")
if response.status_code == 204:
return _fetch_parking_session_by_id(session_id)
rows = response.json()
if isinstance(rows, list) and rows and isinstance(rows[0], dict):
return rows[0]
return _fetch_parking_session_by_id(session_id)
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 _resolve_session_for_event(
*,
event_type: str,
vehicle_id: Optional[str],
created_by: Optional[str],
parking_location: Optional[str],
plate_arabic: Optional[str] = None,
plate_english: Optional[str] = None,
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
if not _supabase_configured():
return None, None
if event_type == "entry":
session_id = _resolve_or_create_open_parking_session(
vehicle_id=vehicle_id,
created_by=created_by,
parking_location=parking_location,
plate_arabic=plate_arabic,
plate_english=plate_english,
)
return session_id, _fetch_parking_session_by_id(session_id)
if event_type == "exit":
open_session = None
if vehicle_id:
open_session = _fetch_open_parking_session_by_vehicle(vehicle_id)
elif plate_arabic or plate_english:
# Find open session for guest car by plate
open_session_response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={
"select": "*",
"vehicle_id": "is.null",
"check_out_at": "is.null",
"or": f"(plate_arabic.eq.{plate_arabic},plate_english.eq.{plate_english})",
"order": "check_in_at.desc",
"limit": "1",
},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if open_session_response.status_code == 200:
rows = open_session_response.json()
if isinstance(rows, list) and rows:
open_session = rows[0]
if not open_session:
# Fallback to latest session (even if closed)
latest_session = None
if vehicle_id:
latest_session = _fetch_latest_parking_session_by_vehicle(vehicle_id)
elif plate_arabic or plate_english:
latest_session_response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={
"select": "*",
"vehicle_id": "is.null",
"or": f"(plate_arabic.eq.{plate_arabic},plate_english.eq.{plate_english})",
"order": "check_in_at.desc",
"limit": "1",
},
headers=_supabase_headers(api_key=SUPABASE_SERVICE_ROLE_KEY, bearer=SUPABASE_SERVICE_ROLE_KEY),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if latest_session_response.status_code == 200:
rows = latest_session_response.json()
if isinstance(rows, list) and rows:
latest_session = rows[0]
session_id = str(latest_session.get("id")) if isinstance(latest_session, dict) and latest_session.get("id") else None
return session_id, latest_session
session_id = str(open_session.get("id") or "").strip()
if not session_id:
return None, None
if parking_location and parking_location != open_session.get("parking_location"):
_set_parking_session_location(session_id, parking_location)
open_session["parking_location"] = parking_location
now_utc = datetime.now(timezone.utc)
payment_dt = _parse_iso_datetime(open_session.get("payment_confirmed_at"))
if payment_dt is None:
# Keep session open when payment is missing; this powers gate deny logic.
return session_id, open_session
left_within_5 = now_utc <= (payment_dt + timedelta(minutes=5))
patch_payload: Dict[str, Any]
if left_within_5:
patch_payload = {
"check_out_at": now_utc.isoformat(),
"status": "exited",
}
else:
# Grace window expired after payment; RESET session to start a new billing cycle (penalty).
patch_payload = {
"status": "entered",
"payment_confirmed_at": None,
"check_in_at": now_utc.isoformat(),
"notes": f"{open_session.get('notes') or ''} | Grace expired. Session reset. Old payment: {open_session.get('payment_confirmed_at')}".strip()
}
if parking_location:
patch_payload["parking_location"] = parking_location
updated_session = _update_parking_session(session_id, patch_payload)
return session_id, updated_session
# ocr_scan: keep latest context but do not modify session state.
open_session = _fetch_open_parking_session_by_vehicle(vehicle_id)
if open_session:
session_id = str(open_session.get("id") or "").strip() or None
if session_id and parking_location and parking_location != open_session.get("parking_location"):
_set_parking_session_location(session_id, parking_location)
open_session["parking_location"] = parking_location
return session_id, open_session
latest_session = _fetch_latest_parking_session_by_vehicle(vehicle_id)
session_id = str(latest_session.get("id")) if isinstance(latest_session, dict) and latest_session.get("id") else None
return session_id, latest_session
def _insert_car_event_record(
*,
event_type: str,
created_by: Optional[str],
vehicle_id: Optional[str],
session_id: Optional[str],
raw_image_path: str,
user_split_image_path: str,
admin_annotated_image_path: str,
ocr_arabic: str,
ocr_english: str,
ocr_raw_labels: List[str],
ocr_confidence: Optional[float],
parking_location: Optional[str],
event_metadata: Dict[str, Any],
) -> Optional[str]:
if not _supabase_configured():
return None
payload: Dict[str, Any] = {
"event_type": event_type,
"raw_image_path": raw_image_path,
"user_split_image_path": user_split_image_path,
"admin_annotated_image_path": admin_annotated_image_path,
"ocr_arabic": ocr_arabic,
"ocr_english": ocr_english,
"ocr_raw_labels": ocr_raw_labels,
"event_metadata": event_metadata,
}
if created_by:
payload["created_by"] = created_by
if vehicle_id:
payload["vehicle_id"] = vehicle_id
if session_id:
payload["session_id"] = session_id
if ocr_confidence is not None:
payload["ocr_confidence"] = ocr_confidence
if parking_location:
payload["parking_location"] = parking_location
response = requests.post(
f"{SUPABASE_URL}/rest/v1/car_events",
json=payload,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 201}:
raise RuntimeError(f"Failed to insert car_events row ({response.status_code}): {response.text[:400]}")
rows = response.json()
if isinstance(rows, list) and rows:
return rows[0].get("id")
return None
def _build_new_car_alert_payload(
*,
plate_info: Dict[str, Any],
vehicle_id: Optional[str],
owner_id: Optional[str],
) -> Dict[str, Any]:
plate_detected = _is_plate_detected(plate_info)
plate_arabic_raw = _normalize_spaces(str(plate_info.get("arabic") or ""))
plate_english_raw = _normalize_spaces(str(plate_info.get("english") or ""))
plate_arabic = plate_arabic_raw if plate_arabic_raw and plate_arabic_raw.upper() != "N/A" else None
plate_english = plate_english_raw if plate_english_raw and plate_english_raw.upper() != "N/A" else None
payload: Dict[str, Any] = {
"is_new_car": False,
"notify_roles": [],
"action": "none",
"reason": "",
"message": "",
"vehicle_id": vehicle_id,
"owner_id": owner_id,
"plate_arabic": plate_arabic,
"plate_english": plate_english,
}
if not plate_detected:
payload.update(
{
"reason": "plate_not_detected",
"message": "Could not detect a plate, so registration status is unknown.",
}
)
return payload
if vehicle_id:
payload.update(
{
"reason": "already_registered",
"message": "Car is already registered by a user.",
}
)
return payload
payload.update(
{
"is_new_car": True,
"notify_roles": ["admin", "security"],
"action": "vendor_outreach_recommended",
"reason": "car_not_registered_by_any_user",
"message": "New car detected. Admin/security should contact a vendor to call the driver and introduce the app.",
}
)
return payload
def _persist_prediction_to_supabase(
*,
original_image_bytes: bytes,
response_payload: Dict[str, Any],
request_user: Optional[Dict[str, Any]],
parking_location: Optional[str],
event_type: str,
camera_source: Optional[str],
) -> Dict[str, Any]:
default_new_car_alert = {
"is_new_car": None,
"notify_roles": ["admin", "security"],
"action": "lookup_unavailable",
"reason": "supabase_unavailable",
"message": "Could not verify registration status because Supabase is unavailable.",
"vehicle_id": None,
"owner_id": None,
"plate_arabic": None,
"plate_english": None,
}
if not _supabase_configured():
return {
"enabled": False,
"saved": False,
"reason": "Supabase env vars are not configured.",
"payment_status": "unknown",
"pricing": _compute_session_time_and_fee(None, parking_location_override=parking_location),
"gate_decision": _build_gate_decision(event_type=event_type, session_row=None, plate_detected=False),
"app_registration": {
"is_registered": False,
"vehicle_id": None,
"owner_id": None,
},
"new_car_alert": default_new_car_alert,
}
user_folder = request_user["id"] if request_user else "anonymous"
now = datetime.now(timezone.utc)
object_base = f"{user_folder}/{now:%Y/%m/%d}/{uuid4().hex}"
raw_path = f"{object_base}_raw.jpg"
user_split_path = f"{object_base}_user.jpg"
admin_annotated_path = f"{object_base}_admin.jpg"
new_car_alert_payload: Dict[str, Any] = dict(default_new_car_alert)
uploaded_objects: List[Tuple[str, str]] = []
event_persisted = False
try:
user_page_payload = response_payload.get("user_page") if isinstance(response_payload.get("user_page"), dict) else {}
admin_page_payload = response_payload.get("admin_page") if isinstance(response_payload.get("admin_page"), dict) else {}
split_base64 = user_page_payload.get("split_image_base64")
car_focus_base64 = user_page_payload.get("car_focus_base64")
admin_annotated_base64 = admin_page_payload.get("annotated_image_base64")
plates_detected = int(response_payload.get("plates_detected_in_selected_car") or 0)
processed_image_variant = "split"
if plates_detected <= 0 and isinstance(car_focus_base64, str) and car_focus_base64.strip():
user_split_bytes = base64.b64decode(car_focus_base64)
processed_image_variant = "car_focus_fallback_no_plate"
elif isinstance(split_base64, str) and split_base64.strip():
user_split_bytes = base64.b64decode(split_base64)
elif isinstance(car_focus_base64, str) and car_focus_base64.strip():
user_split_bytes = base64.b64decode(car_focus_base64)
processed_image_variant = "car_focus_fallback_missing_split"
else:
user_split_bytes = original_image_bytes
processed_image_variant = "raw_fallback"
if isinstance(admin_annotated_base64, str) and admin_annotated_base64.strip():
admin_annotated_bytes = base64.b64decode(admin_annotated_base64)
else:
admin_annotated_bytes = user_split_bytes
plate_info = response_payload.get("plate_info", {})
plate_detected = _is_plate_detected(plate_info)
request_user_actor_id = str((request_user or {}).get("id") or "").strip() or None
request_user_id = _extract_created_by_user_id(request_user)
vehicle_record = _resolve_vehicle_record_by_plate(
plate_info.get("arabic", ""),
plate_info.get("english", ""),
request_user_id,
)
vehicle_id = str(vehicle_record.get("id")) if isinstance(vehicle_record, dict) and vehicle_record.get("id") else None
owner_id = str(vehicle_record.get("owner_id")) if isinstance(vehicle_record, dict) and vehicle_record.get("owner_id") else None
match_path = str(vehicle_record.get("_match_path") or "") if isinstance(vehicle_record, dict) else ""
if match_path.startswith("arabic"):
registration_match_source = "arabic_ocr"
elif match_path.startswith("english"):
registration_match_source = "english_ocr"
else:
registration_match_source = None
LOGGER.info(
"plate_resolution | ocr_ar='%s' ocr_en='%s' | vehicle_found=%s | vehicle_id=%s | owner_id=%s",
plate_info.get("arabic", ""), plate_info.get("english", ""),
vehicle_id is not None, vehicle_id, owner_id,
)
new_car_alert_payload = _build_new_car_alert_payload(
plate_info=plate_info,
vehicle_id=vehicle_id,
owner_id=owner_id,
)
session_id, session_row = _resolve_session_for_event(
event_type=event_type,
vehicle_id=vehicle_id,
created_by=request_user_id,
parking_location=parking_location,
plate_arabic=plate_info.get("arabic"),
plate_english=plate_info.get("english"),
)
if event_type == "entry" and vehicle_id and session_id is None:
LOGGER.error("SESSION_NOT_CREATED for registered vehicle %s on entry", vehicle_id)
LOGGER.info(
"session_resolution | event_type=%s | vehicle_id=%s | session_id=%s",
event_type, vehicle_id, session_id,
)
payment_status = _derive_payment_status_from_session(session_row)
left_within_5 = bool(session_row.get("left_within_5_minutes")) if isinstance(session_row, dict) else False
pricing = _compute_session_time_and_fee(session_row, parking_location_override=parking_location)
gate_decision = _build_gate_decision(
event_type=event_type,
session_row=session_row,
plate_detected=plate_detected,
)
_upload_to_supabase_storage(SUPABASE_RAW_BUCKET, raw_path, original_image_bytes)
uploaded_objects.append((SUPABASE_RAW_BUCKET, raw_path))
_upload_to_supabase_storage(SUPABASE_PROCESSED_BUCKET, user_split_path, user_split_bytes)
uploaded_objects.append((SUPABASE_PROCESSED_BUCKET, user_split_path))
_upload_to_supabase_storage(SUPABASE_PROCESSED_BUCKET, admin_annotated_path, admin_annotated_bytes)
uploaded_objects.append((SUPABASE_PROCESSED_BUCKET, admin_annotated_path))
chars = plate_info.get("characters", [])
ocr_confidence = None
if chars:
avg_conf = sum(float(item.get("confidence", 0.0)) for item in chars) / len(chars)
ocr_confidence = round(avg_conf, 4)
event_id = _insert_car_event_record(
event_type=event_type,
created_by=request_user_id,
vehicle_id=vehicle_id,
session_id=session_id,
raw_image_path=f"{SUPABASE_RAW_BUCKET}/{raw_path}",
user_split_image_path=f"{SUPABASE_PROCESSED_BUCKET}/{user_split_path}",
admin_annotated_image_path=f"{SUPABASE_PROCESSED_BUCKET}/{admin_annotated_path}",
ocr_arabic=plate_info.get("arabic", "N/A"),
ocr_english=plate_info.get("english", "N/A"),
ocr_raw_labels=plate_info.get("raw_ordered_labels", []),
ocr_confidence=ocr_confidence,
parking_location=parking_location,
event_metadata={
"event_type": event_type,
"cars_detected": response_payload.get("cars_detected", 0),
"plates_detected_in_selected_car": response_payload.get("plates_detected_in_selected_car", 0),
"processed_image_variant": processed_image_variant,
"selected_car": response_payload.get("selected_car"),
"selected_plate": response_payload.get("selected_plate"),
"filename": response_payload.get("filename"),
"parking_location": parking_location,
"camera_source": camera_source,
"payment_status": payment_status,
"left_within_5_minutes": left_within_5,
"gate_decision": gate_decision,
"pricing": pricing,
"app_registration": {
"is_registered": vehicle_id is not None,
"vehicle_id": vehicle_id,
"owner_id": owner_id,
"match_source": registration_match_source,
},
"new_car_alert": new_car_alert_payload,
},
)
if not event_id:
raise RuntimeError("Failed to persist car event id.")
event_persisted = True
notification_event_ids: List[str] = []
if owner_id and event_type in {"entry", "exit"}:
plate_label = str(plate_info.get("arabic") or plate_info.get("english") or "your vehicle")
timestamp_label = datetime.now(timezone.utc).isoformat()
if event_type == "entry":
notification_title = "Vehicle Entry Detected"
notification_body = f"{plate_label} entered at {timestamp_label}."
else:
notification_title = "Vehicle Exit Detected"
notification_body = f"{plate_label} exited at {timestamp_label}."
notification_id = _insert_notification_event(
user_id=owner_id,
event_type=f"vehicle_{event_type}",
title=notification_title,
body=notification_body,
data={
"event_id": event_id,
"session_id": session_id,
"vehicle_id": vehicle_id,
"parking_location": parking_location,
"payment_status": payment_status,
},
)
if notification_id:
notification_event_ids.append(notification_id)
if owner_id and event_type == "exit" and not bool(gate_decision.get("allowed")):
amount_due = 0.0
try:
amount_due = float((pricing or {}).get("total_fee_egp") or 0.0)
except (TypeError, ValueError):
amount_due = 0.0
payment_reminder_id = _insert_notification_event(
user_id=owner_id,
event_type="payment_reminder",
title="Payment Required",
body=f"You have a balance of {amount_due:.2f} EGP.",
data={
"event_id": event_id,
"session_id": session_id,
"vehicle_id": vehicle_id,
"amount_due_egp": round(max(0.0, amount_due), 2),
"payment_status": payment_status,
"gate_decision": gate_decision,
},
)
if payment_reminder_id:
notification_event_ids.append(payment_reminder_id)
return {
"enabled": True,
"saved": True,
"event_type": event_type,
"event_id": event_id,
"session_id": session_id,
"payment_status": payment_status,
"left_within_5_minutes": left_within_5,
"pricing": pricing,
"gate_decision": gate_decision,
"processed_image_variant": processed_image_variant,
"app_registration": {
"is_registered": vehicle_id is not None,
"vehicle_id": vehicle_id,
"owner_id": owner_id,
"matched_to_request_user": bool(request_user_actor_id and owner_id and request_user_actor_id == owner_id),
"match_source": registration_match_source,
},
"new_car_alert": new_car_alert_payload,
"notification_event_ids": notification_event_ids,
"parking_location": parking_location,
"raw_image_path": f"{SUPABASE_RAW_BUCKET}/{raw_path}",
"user_split_image_path": f"{SUPABASE_PROCESSED_BUCKET}/{user_split_path}",
"processed_image_path": f"{SUPABASE_PROCESSED_BUCKET}/{user_split_path}",
"admin_annotated_image_path": f"{SUPABASE_PROCESSED_BUCKET}/{admin_annotated_path}",
}
except Exception as exc: # pragma: no cover
LOGGER.exception("Supabase persistence failed")
if uploaded_objects and not event_persisted:
for bucket, object_path in reversed(uploaded_objects):
try:
_delete_from_supabase_storage(bucket, object_path)
except Exception as cleanup_exc: # pragma: no cover
LOGGER.warning(
"Supabase cleanup failed for %s/%s: %s",
bucket,
object_path,
cleanup_exc,
)
return {
"enabled": True,
"saved": False,
"error": str(exc),
"payment_status": "unknown",
"pricing": _compute_session_time_and_fee(None, parking_location_override=parking_location),
"gate_decision": _build_gate_decision(event_type=event_type, session_row=None, plate_detected=False),
"app_registration": {
"is_registered": False,
"vehicle_id": None,
"owner_id": None,
},
"new_car_alert": new_car_alert_payload,
"notification_event_ids": [],
}
app = FastAPI(title="Ain El Aql Local Plate Recognition API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ALLOW_ORIGINS or ["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http")
async def rewrite_supabase_auth_header(request: Request, call_next):
x_supa = request.headers.get("x-supabase-auth")
if x_supa:
new_headers = []
for name, value in request.scope["headers"]:
if name == b"authorization":
continue
new_headers.append((name, value))
new_headers.append((b"authorization", x_supa.encode("latin-1")))
request.scope["headers"] = new_headers
return await call_next(request)
@app.get("/")
def root() -> Dict[str, Any]:
return {
"status": "ok",
"service": "ain-el-aql-backend",
"model_inference_provider": MODEL_INFERENCE_PROVIDER,
"docs_url": "/docs",
"health_url": "/health",
"supabase_health_url": "/supabase/health",
}
_model_lock = Lock()
# ---------------------------------------------------------------------------
# Character map for OCR label → (Arabic, English) translation
# Derived from the arabic_mapping in app.py ΓÇö used by _decode_ocr_result()
# ---------------------------------------------------------------------------
CHAR_MAP = {
# Letters
"aain": ("╪╣", "E"),
"alef": ("أ", "A"),
"a": ("أ", "A"),
"alf": ("أ", "A"),
"baa": ("ب", "B"),
"daal": ("د", "D"),
"dal": ("د", "D"),
"e": ("╪╣", "E"),
"faa": ("ف", "F"),
"geem": ("ج", "G"),
"haa": ("هـ", "H"),
"kaaf": ("ك", "K"),
"laam": ("ل", "L"),
"meem": ("م", "M"),
"noon": ("ن", "N"),
"qaf": ("ق", "Q"),
"raa": ("╪▒", "R"),
"sad": ("╪╡", "S"),
"seen": ("╪│", "C"),
"taa": ("╪╖", "T"),
"waaw": ("و", "W"),
"waw": ("و", "W"),
"yaa": ("ى", "Y"),
"zay": ("╪▓", "Z"),
"dad": ("╪╢", "DD"),
# Digits
"0": ("0", "0"), "1": ("1", "1"), "2": ("2", "2"), "3": ("3", "3"), "4": ("4", "4"),
"5": ("5", "5"), "6": ("6", "6"), "7": ("7", "7"), "8": ("8", "8"), "9": ("9", "9"),
"٠": ("0", "0"), "١": ("1", "1"), "٢": ("2", "2"), "٣": ("3", "3"), "٤": ("4", "4"),
"٥": ("5", "5"), "٦": ("6", "6"), "٧": ("7", "7"), "٨": ("8", "8"), "٩": ("9", "9"),
}
def _clamp_bbox(x1: float, y1: float, x2: float, y2: float, width: int, height: int) -> Tuple[int, int, int, int]:
left = max(0, min(int(x1), width - 1))
top = max(0, min(int(y1), height - 1))
right = max(1, min(int(x2), width))
bottom = max(1, min(int(y2), height))
if right <= left:
right = min(width, left + 1)
if bottom <= top:
bottom = min(height, top + 1)
return left, top, right, bottom
def _encode_image_base64(image_bgr: np.ndarray) -> str:
ok, encoded = cv2.imencode(".jpg", image_bgr)
if not ok:
raise HTTPException(status_code=500, detail="Failed to encode image.")
return base64.b64encode(encoded.tobytes()).decode("utf-8")
def _decode_image_bytes(image_bytes: bytes) -> np.ndarray:
image_np = np.frombuffer(image_bytes, dtype=np.uint8)
image_bgr = cv2.imdecode(image_np, cv2.IMREAD_COLOR)
if image_bgr is None:
raise HTTPException(status_code=400, detail="Unable to decode image.")
return image_bgr
def _fit_into_canvas(image_bgr: np.ndarray, target_w: int, target_h: int) -> np.ndarray:
canvas = np.full((target_h, target_w, 3), 18, dtype=np.uint8)
if image_bgr.size == 0:
return canvas
src_h, src_w = image_bgr.shape[:2]
scale = min(target_w / max(1, src_w), target_h / max(1, src_h))
new_w = max(1, int(src_w * scale))
new_h = max(1, int(src_h * scale))
resized = cv2.resize(image_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA)
x_off = (target_w - new_w) // 2
y_off = (target_h - new_h) // 2
canvas[y_off : y_off + new_h, x_off : x_off + new_w] = resized
return canvas
def _compose_user_split_image(plate_focus_bgr: np.ndarray, car_focus_bgr: np.ndarray) -> np.ndarray:
half_h = max(plate_focus_bgr.shape[0], car_focus_bgr.shape[0], 220)
half_w = max(plate_focus_bgr.shape[1], car_focus_bgr.shape[1], 320)
left_half = _fit_into_canvas(plate_focus_bgr, half_w, half_h)
right_half = _fit_into_canvas(car_focus_bgr, half_w, half_h)
split_image = np.concatenate([left_half, right_half], axis=1)
return split_image
def _build_plate_placeholder(reference_bgr: np.ndarray) -> np.ndarray:
placeholder = np.full(reference_bgr.shape, 18, dtype=np.uint8)
h, w = placeholder.shape[:2]
text = "NO PLATE DETECTED"
font_scale = 0.8 if w >= 500 else 0.6
thickness = 2
text_size, _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
text_x = max(10, (w - text_size[0]) // 2)
text_y = max(28, h // 2)
cv2.putText(
placeholder,
text,
(text_x, text_y),
cv2.FONT_HERSHEY_SIMPLEX,
font_scale,
(0, 220, 220),
thickness,
cv2.LINE_AA,
)
return placeholder
def _decode_ocr_result(result: Any) -> Dict[str, Any]:
if result.boxes is None or len(result.boxes) == 0:
return {
"raw_ordered_labels": [],
"characters": [],
"arabic": "N/A",
"english": "N/A",
}
names = result.names if hasattr(result, "names") else {}
detections: List[Dict[str, Any]] = []
for box in result.boxes:
cls_idx = int(box.cls[0].item()) if box.cls is not None else -1
raw_label = names.get(cls_idx, str(cls_idx)) if isinstance(names, dict) else str(cls_idx)
norm_label = str(raw_label).strip().lower()
xyxy = box.xyxy[0].tolist()
confidence = float(box.conf[0].item()) if box.conf is not None else 0.0
center_x = (xyxy[0] + xyxy[2]) / 2.0
ar_char, en_char = CHAR_MAP.get(norm_label, (str(raw_label), str(raw_label)))
detections.append(
{
"label": str(raw_label),
"normalized_label": norm_label,
"arabic": ar_char,
"english": en_char,
"is_digit": norm_label.isdigit(),
"confidence": round(confidence, 4),
"bbox": [int(xyxy[0]), int(xyxy[1]), int(xyxy[2]), int(xyxy[3])],
"center_x": center_x,
}
)
# Egyptian plate layout:
# - Letters are read right-to-left (Arabic reading direction) → sort RTL
# - Numbers are always written left-to-right → sort LTR
# Splitting before sorting ensures each group gets the correct direction.
letter_detections = [d for d in detections if not d["is_digit"]]
number_detections = [d for d in detections if d["is_digit"]]
letter_detections.sort(key=lambda item: item["center_x"], reverse=True) # RTL
number_detections.sort(key=lambda item: item["center_x"], reverse=False) # LTR
ar_letters: List[str] = [d["arabic"] for d in letter_detections]
ar_numbers: List[str] = [d["arabic"] for d in number_detections]
en_letters: List[str] = [d["english"] for d in letter_detections]
en_numbers: List[str] = [d["english"] for d in number_detections]
if ar_letters or ar_numbers:
arabic_text = f"{' '.join(ar_letters)} | {' '.join(ar_numbers)}"
else:
arabic_text = "N/A"
if en_letters or en_numbers:
english_text = f"{' '.join(en_letters)} | {''.join(en_numbers)}"
else:
english_text = "N/A"
clean_chars = [
{
"label": item["label"],
"arabic": item["arabic"],
"english": item["english"],
"confidence": item["confidence"],
"bbox": item["bbox"],
}
for item in detections
]
return {
"raw_ordered_labels": [item["label"] for item in detections],
"characters": clean_chars,
"arabic": arabic_text,
"english": english_text,
}
def _run_pipeline_remote(*, image_bytes: bytes, filename: Optional[str], content_type: Optional[str]) -> Dict[str, Any]:
try:
from gradio_client import Client, handle_file
except ImportError as exc:
raise HTTPException(status_code=500, detail="gradio_client is not installed on the backend. Please run 'pip install gradio_client'.") from exc
import tempfile
import base64
import os
# Resolve which HF Space to call from env (default to the V2 space with new models)
hf_space_id = os.getenv("HF_SPACE_ID", "Pant0x/EALPR_OCR_V2")
# Save the uploaded bytes to a temp file for gradio_client
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
tmp.write(image_bytes)
tmp_path = tmp.name
try:
client = Client(hf_space_id)
result = client.predict(
img=handle_file(tmp_path),
api_name="/predict_plate",
)
# The HF Gradio app returns a tuple: (annotated_filepath, numbers_ar, letters_ar)
if not isinstance(result, tuple) or len(result) < 3:
raise HTTPException(status_code=502, detail=f"Unexpected response from Hugging Face: {result}")
annotated_img_path = result[0]
numbers_ar = str(result[1] or "").strip()
letters_ar = str(result[2] or "").strip()
arabic_text = f"{letters_ar} | {numbers_ar}" if (letters_ar or numbers_ar) else "N/A"
# Build english text from the arabic chars using CHAR_MAP reverse lookup
_ar_to_en = {v[0]: v[1] for v in CHAR_MAP.values()}
letters_en_parts = [_ar_to_en.get(ch, ch) for ch in letters_ar.split(" ") if ch]
numbers_en_parts = [_ar_to_en.get(ch, ch) for ch in numbers_ar.split(" ") if ch]
letters_en = " ".join(letters_en_parts)
numbers_en = "".join(numbers_en_parts)
english_text = f"{letters_en} | {numbers_en}" if (letters_en or numbers_en) else "N/A"
# Read annotated image back to base64 so Flutter/Admin can display it
annotated_b64 = ""
if annotated_img_path and os.path.exists(str(annotated_img_path)):
with open(str(annotated_img_path), "rb") as f:
annotated_b64 = base64.b64encode(f.read()).decode("utf-8")
return {
"plate_info": {
"arabic": arabic_text,
"english": english_text,
"characters": [],
"raw_ordered_labels": [],
},
"user_page": {},
"admin_page": {
"annotated_image_base64": annotated_b64,
},
}
except HTTPException:
raise
except Exception as exc:
LOGGER.exception("Remote model service failed")
raise HTTPException(status_code=502, detail=f"Remote model service failed: {exc}") from exc
finally:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
@app.post("/auth/register")
def auth_register(
payload: Dict[str, Any] = Body(...),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
_ensure_auth_profile_schema_ready()
username = _normalize_spaces(str(payload.get("username") or ""))
phone_number = _normalize_spaces(str(payload.get("phone") or payload.get("phone_number") or ""))
email = _normalize_spaces(str(payload.get("email") or "")).lower()
national_id = _normalize_spaces(str(payload.get("nid") or payload.get("national_id") or ""))
password = str(payload.get("password") or "").strip()
first_name, last_name, full_name = _resolve_profile_name_fields(payload, fallback_first_name=username)
if not first_name:
first_name = username
full_name = _compose_full_name(first_name, last_name) or username
if not username:
raise HTTPException(status_code=400, detail="username is required.")
if not USERNAME_PATTERN.fullmatch(username):
raise HTTPException(
status_code=400,
detail="username must be 3-32 chars and only contain letters, numbers, underscore, dot, or dash.",
)
if not phone_number:
raise HTTPException(status_code=400, detail="phone is required.")
if not PHONE_PATTERN.fullmatch(phone_number):
raise HTTPException(status_code=400, detail="phone must be 7-15 digits and may start with +.")
if email and "@" not in email:
raise HTTPException(status_code=400, detail="email format is invalid.")
if national_id and not NATIONAL_ID_PATTERN.fullmatch(national_id):
raise HTTPException(
status_code=400,
detail="nid format is invalid. Use 6-32 chars with letters, numbers, or dash.",
)
if len(password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters.")
registration_email = email or _build_internal_login_email(username, phone_number)
existing_auth_user_id: Optional[str] = None
if email:
existing_auth_user = _find_supabase_auth_user_by_email(registration_email)
existing_auth_user_id = str((existing_auth_user or {}).get("id") or "").strip() or None
def _has_profile_conflict(profile_row: Optional[Dict[str, Any]]) -> bool:
if not isinstance(profile_row, dict):
return False
profile_user_id = str(profile_row.get("id") or "").strip()
if not profile_user_id:
return True
if existing_auth_user_id and profile_user_id == existing_auth_user_id:
return False
return True
if _has_profile_conflict(_supabase_get_profile_by_field("username", username)):
raise HTTPException(status_code=409, detail="username is already in use.")
if _has_profile_conflict(_supabase_get_profile_by_field("phone_number", phone_number)):
raise HTTPException(status_code=409, detail="phone is already in use.")
if national_id and _has_profile_conflict(_supabase_get_profile_by_field("national_id", national_id)):
raise HTTPException(status_code=409, detail="nid is already in use.")
if email and _has_profile_conflict(_supabase_get_profile_by_field("email", email, case_insensitive=True)):
raise HTTPException(status_code=409, detail="email is already in use.")
vehicle_payload = _extract_registration_vehicle_payload(payload)
auth_user_metadata = {
"first_name": first_name,
"last_name": last_name,
"full_name": full_name,
"username": username,
"phone_number": phone_number,
"national_id": national_id,
}
auth_payload = {
"email": registration_email,
"password": password,
"email_confirm": True,
"user_metadata": auth_user_metadata,
}
user_id: Optional[str] = None
if existing_auth_user_id:
try:
_update_supabase_auth_user(
existing_auth_user_id,
{
"email": registration_email,
"password": password,
"email_confirm": True,
"user_metadata": auth_user_metadata,
},
)
except RuntimeError as exc:
raise HTTPException(
status_code=502,
detail=f"Register failed while finalizing existing auth user: {exc}",
) from exc
user_id = existing_auth_user_id
else:
try:
response = requests.post(
f"{SUPABASE_URL}/auth/v1/admin/users",
json=auth_payload,
headers={
"apikey": SUPABASE_SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise HTTPException(status_code=503, detail=f"Supabase register failed: {exc}") from exc
if response.status_code in {200, 201}:
try:
auth_user = response.json() if response.text else {}
except ValueError:
auth_user = {}
if not isinstance(auth_user, dict):
auth_user = {}
user_id = str(auth_user.get("id") or auth_user.get("user", {}).get("id") or "").strip()
else:
error_msg = _extract_response_error_message(response)
if registration_email and _is_supabase_duplicate_auth_user_error(error_msg):
existing_auth_user = _find_supabase_auth_user_by_email(registration_email)
existing_user_id = str((existing_auth_user or {}).get("id") or "").strip()
if not existing_user_id:
raise HTTPException(status_code=409, detail="Register failed: email is already in use.")
try:
_update_supabase_auth_user(
existing_user_id,
{
"email": registration_email,
"password": password,
"email_confirm": True,
"user_metadata": auth_user_metadata,
},
)
except RuntimeError as exc:
raise HTTPException(
status_code=502,
detail=f"Register failed while finalizing existing auth user: {exc}",
) from exc
user_id = existing_user_id
else:
raise HTTPException(status_code=response.status_code, detail=f"Register failed: {error_msg}")
if not user_id:
raise HTTPException(status_code=502, detail="Supabase did not return a user id.")
try:
profile = _upsert_profile_after_register(
user_id=user_id,
first_name=first_name,
last_name=last_name,
username=username,
phone_number=phone_number,
email=registration_email,
national_id=national_id or None,
role=str(payload.get("role") or "user"),
)
created_vehicle = _create_vehicle_for_owner(owner_id=user_id, vehicle_payload=vehicle_payload) if vehicle_payload else None
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to finalize registration profile: {exc}") from exc
session_data, login_error = _supabase_password_login(password=password, email=registration_email)
if not isinstance(session_data, dict):
raise HTTPException(status_code=502, detail=f"Registration succeeded but login failed: {login_error}")
resolved_profile = profile if isinstance(profile, dict) else _supabase_get_profile_by_user_id(user_id)
role = str((resolved_profile or {}).get("role") or "user")
resolved_first_name, resolved_last_name, resolved_full_name = _resolve_profile_name_fields(
resolved_profile if isinstance(resolved_profile, dict) else None,
fallback_full_name=full_name,
fallback_first_name=first_name,
fallback_last_name=last_name,
)
return {
"status": "ok",
"user": {
"id": user_id,
"first_name": resolved_first_name,
"last_name": resolved_last_name,
"full_name": resolved_full_name,
"username": (resolved_profile or {}).get("username") or username,
"phone_number": (resolved_profile or {}).get("phone_number") or phone_number,
"email": (resolved_profile or {}).get("email") or registration_email,
"national_id": (resolved_profile or {}).get("national_id"),
"role": role,
"is_staff": _is_staff_role(role),
},
"session": {
"access_token": session_data.get("access_token"),
"refresh_token": session_data.get("refresh_token"),
"expires_in": session_data.get("expires_in"),
},
"vehicle": created_vehicle,
}
@app.post("/auth/login")
def auth_login(
payload: Dict[str, Any] = Body(...),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
_ensure_auth_profile_schema_ready()
identifier = _normalize_spaces(
str(
payload.get("identifier")
or payload.get("email")
or payload.get("phone")
or payload.get("phone_number")
or payload.get("username")
or payload.get("nid")
or payload.get("national_id")
or ""
)
)
password = str(payload.get("password") or "").strip()
if not identifier or not password:
raise HTTPException(status_code=400, detail="identifier and password are required.")
profile = _supabase_find_profile_by_identifier(identifier)
login_candidates: List[Tuple[Optional[str], Optional[str]]] = []
if isinstance(profile, dict):
profile_email = _normalize_spaces(str(profile.get("email") or "")).lower()
profile_phone = _normalize_spaces(str(profile.get("phone_number") or ""))
if profile_email:
login_candidates.append((profile_email, None))
if profile_phone:
login_candidates.append((None, profile_phone))
if "@" in identifier:
login_candidates.append((_normalize_spaces(identifier).lower(), None))
if PHONE_PATTERN.fullmatch(identifier):
login_candidates.append((None, identifier))
if not login_candidates:
raise HTTPException(status_code=401, detail="Login failed: account was not found.")
data: Optional[Dict[str, Any]] = None
last_error = "Invalid login credentials"
seen_candidates: set = set()
for candidate_email, candidate_phone in login_candidates:
candidate_key = (candidate_email or "", candidate_phone or "")
if candidate_key in seen_candidates:
continue
seen_candidates.add(candidate_key)
login_result, login_error = _supabase_password_login(
password=password,
email=candidate_email,
phone_number=candidate_phone,
)
if isinstance(login_result, dict):
data = login_result
break
if login_error:
last_error = login_error
if not isinstance(data, dict):
raise HTTPException(status_code=401, detail=f"Login failed: {last_error}")
user_id = str(data.get("user", {}).get("id") or "").strip()
resolved_profile = _supabase_get_profile_by_user_id(user_id) if user_id else profile
role = str((resolved_profile or {}).get("role") or "user")
resolved_first_name, resolved_last_name, resolved_full_name = _resolve_profile_name_fields(
resolved_profile if isinstance(resolved_profile, dict) else None,
fallback_full_name=(resolved_profile or {}).get("full_name") if isinstance(resolved_profile, dict) else None,
)
return {
"status": "ok",
"user": {
"id": user_id,
"first_name": resolved_first_name,
"last_name": resolved_last_name,
"full_name": resolved_full_name,
"username": (resolved_profile or {}).get("username"),
"phone_number": (resolved_profile or {}).get("phone_number"),
"email": (resolved_profile or {}).get("email") or data.get("user", {}).get("email"),
"national_id": (resolved_profile or {}).get("national_id"),
"role": role,
"is_staff": _is_staff_role(role),
},
"session": {
"access_token": data.get("access_token"),
"refresh_token": data.get("refresh_token"),
"expires_in": data.get("expires_in"),
},
}
@app.get("/auth/reset-password-page", response_class=HTMLResponse)
def auth_reset_password_page() -> HTMLResponse:
html = """<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Reset Password</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; background: #f5f7fb; color: #0f172a; }
.wrap { max-width: 420px; margin: 40px auto; background: #ffffff; border-radius: 12px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); }
h1 { font-size: 20px; margin: 0 0 14px; }
label { display: block; margin: 12px 0 6px; font-size: 14px; }
input { width: 100%; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 14px; box-sizing: border-box; }
button { width: 100%; margin-top: 16px; background: #111827; color: #fff; border: 0; border-radius: 8px; padding: 10px 14px; font-size: 14px; cursor: pointer; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.hidden { display: none; }
.msg { margin-top: 12px; font-size: 14px; }
.ok { color: #166534; }
.err { color: #991b1b; }
</style>
</head>
<body>
<div class=\"wrap\">
<div id=\"resetFormView\">
<h1>Reset your password</h1>
<p id=\"tokenState\" class=\"msg\"></p>
<label for=\"password\">New password</label>
<input id=\"password\" type=\"password\" minlength=\"6\" placeholder=\"At least 6 characters\" />
<label for=\"confirm\">Confirm password</label>
<input id=\"confirm\" type=\"password\" minlength=\"6\" placeholder=\"Repeat password\" />
<button id=\"submitBtn\">Reset password</button>
<p id=\"result\" class=\"msg\"></p>
</div>
<div id=\"successView\" class=\"hidden\">
<h1>Password updated</h1>
<p class=\"msg ok\">Your password has been changed.</p>
</div>
</div>
<script>
function getTokenMap() {
const hash = new URLSearchParams((window.location.hash || '').replace(/^#/, ''));
const query = new URLSearchParams(window.location.search || '');
return {
accessToken: hash.get('access_token') || query.get('access_token') || '',
tokenHash: hash.get('token_hash') || query.get('token_hash') || '',
code: hash.get('code') || query.get('code') || '',
type: hash.get('type') || query.get('type') || 'recovery',
email: hash.get('email') || query.get('email') || ''
};
}
let token = '';
const tokenState = document.getElementById('tokenState');
const result = document.getElementById('result');
const submitBtn = document.getElementById('submitBtn');
const resetFormView = document.getElementById('resetFormView');
const successView = document.getElementById('successView');
submitBtn.disabled = true;
function showSuccessRedirectScreen() {
resetFormView.classList.add('hidden');
successView.classList.remove('hidden');
}
async function resolveAccessToken() {
const tokenMap = getTokenMap();
if (tokenMap.accessToken) {
token = tokenMap.accessToken;
tokenState.textContent = 'Reset token detected. Enter your new password.';
tokenState.className = 'msg ok';
submitBtn.disabled = false;
return;
}
if (!tokenMap.tokenHash && !tokenMap.code) {
tokenState.textContent = 'This reset link is invalid or expired.';
tokenState.className = 'msg err';
return;
}
tokenState.textContent = 'Verifying reset link...';
tokenState.className = 'msg';
try {
const response = await fetch('/auth/reset-password/resolve-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token_hash: tokenMap.tokenHash,
code: tokenMap.code,
type: tokenMap.type,
email: tokenMap.email
})
});
const data = await response.json().catch(function () { return {}; });
if (!response.ok) {
throw new Error((data && data.detail) ? data.detail : 'Reset link could not be verified.');
}
token = (data && data.session && data.session.access_token) ? data.session.access_token : '';
if (!token) {
throw new Error('Reset link could not be verified.');
}
tokenState.textContent = 'Reset token detected. Enter your new password.';
tokenState.className = 'msg ok';
submitBtn.disabled = false;
} catch (error) {
tokenState.textContent = (error && error.message) ? error.message : 'This reset link is invalid or expired.';
tokenState.className = 'msg err';
}
}
resolveAccessToken();
submitBtn.addEventListener('click', async function () {
if (!token) {
result.textContent = 'This reset link is invalid or expired.';
result.className = 'msg err';
return;
}
const password = document.getElementById('password').value || '';
const confirm = document.getElementById('confirm').value || '';
if (password.length < 6) {
result.textContent = 'Password must be at least 6 characters.';
result.className = 'msg err';
return;
}
if (password !== confirm) {
result.textContent = 'Passwords do not match.';
result.className = 'msg err';
return;
}
submitBtn.disabled = true;
result.textContent = 'Updating password...';
result.className = 'msg';
try {
const response = await fetch('/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token: token, new_password: password })
});
const data = await response.json().catch(function () { return {}; });
if (!response.ok) {
throw new Error((data && data.detail) ? data.detail : 'Reset failed.');
}
result.textContent = '';
result.className = 'msg';
showSuccessRedirectScreen();
} catch (error) {
result.textContent = (error && error.message) ? error.message : 'Reset failed.';
result.className = 'msg err';
} finally {
submitBtn.disabled = false;
}
});
</script>
</body>
</html>
"""
return HTMLResponse(content=html)
@app.get("/auth/reset-password-bridge", response_class=HTMLResponse)
def auth_reset_password_bridge() -> HTMLResponse:
deep_link_base = _resolve_password_reset_deep_link_url()
html = f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Open Ain El Aql App</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 0; background: #f4f7fb; color: #0f172a; }}
.wrap {{ max-width: 480px; margin: 40px auto; background: #fff; border-radius: 12px; padding: 22px; box-shadow: 0 10px 24px rgba(0,0,0,0.08); }}
h1 {{ font-size: 22px; margin: 0 0 12px; }}
p {{ font-size: 14px; line-height: 1.5; }}
.actions {{ display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }}
a.btn {{ display: inline-block; text-decoration: none; text-align: center; border-radius: 8px; padding: 11px 14px; font-size: 14px; }}
a.primary {{ background: #111827; color: #fff; }}
a.secondary {{ background: #e2e8f0; color: #0f172a; }}
.hint {{ margin-top: 14px; color: #475569; }}
</style>
</head>
<body>
<div class=\"wrap\">
<h1>Open the app to reset password</h1>
<p id=\"status\">Preparing your secure reset link...</p>
<div class=\"actions\">
<a id=\"openApp\" class=\"btn primary\" href=\"#\">Open Ain El Aql App</a>
<a id=\"openWeb\" class=\"btn secondary\" href=\"/auth/reset-password-page\">Continue in browser</a>
</div>
<p class=\"hint\">If the app does not open automatically, tap \"Open Ain El Aql App\".</p>
</div>
<script>
const deepLinkBase = {json.dumps(deep_link_base)};
function getTokenMap() {{
const hash = new URLSearchParams((window.location.hash || '').replace(/^#/, ''));
const query = new URLSearchParams(window.location.search || '');
const accessToken = hash.get('access_token') || query.get('access_token') || '';
const refreshToken = hash.get('refresh_token') || query.get('refresh_token') || '';
const tokenType = hash.get('token_type') || query.get('token_type') || '';
const tokenHash = hash.get('token_hash') || query.get('token_hash') || '';
const code = hash.get('code') || query.get('code') || '';
const type = hash.get('type') || query.get('type') || '';
const email = hash.get('email') || query.get('email') || '';
return {{ accessToken, refreshToken, tokenType, tokenHash, code, type, email }};
}}
function appendTokens(baseUrl, tokenMap) {{
const params = new URLSearchParams();
if (tokenMap.accessToken) params.set('access_token', tokenMap.accessToken);
if (tokenMap.refreshToken) params.set('refresh_token', tokenMap.refreshToken);
if (tokenMap.tokenType) params.set('token_type', tokenMap.tokenType);
if (tokenMap.tokenHash) params.set('token_hash', tokenMap.tokenHash);
if (tokenMap.code) params.set('code', tokenMap.code);
if (tokenMap.type) params.set('type', tokenMap.type);
if (tokenMap.email) params.set('email', tokenMap.email);
const query = params.toString();
if (!query) return baseUrl;
const separator = baseUrl.includes('?') ? '&' : '?';
return `${{baseUrl}}${{separator}}${{query}}`;
}}
const statusEl = document.getElementById('status');
const openAppEl = document.getElementById('openApp');
const openWebEl = document.getElementById('openWeb');
const tokenMap = getTokenMap();
const deepLinkUrl = appendTokens(deepLinkBase, tokenMap);
const fallbackUrl = appendTokens(`${{window.location.origin}}/auth/reset-password-page`, tokenMap);
openAppEl.href = deepLinkUrl;
openWebEl.href = fallbackUrl;
if (!tokenMap.accessToken && !tokenMap.tokenHash && !tokenMap.code) {{
statusEl.textContent = 'No reset token found in this link. Please request a new reset email.';
}} else {{
statusEl.textContent = 'Attempting to open the app now...';
setTimeout(function() {{
window.location.href = deepLinkUrl;
}}, 400);
}}
</script>
</body>
</html>
"""
return HTMLResponse(content=html)
@app.post("/auth/forgot-password")
def auth_forgot_password(
request: Request,
payload: Dict[str, Any] = Body(...),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
identifier = _normalize_spaces(
str(
payload.get("identifier")
or payload.get("email")
or payload.get("phone")
or payload.get("phone_number")
or payload.get("username")
or payload.get("nid")
or payload.get("national_id")
or ""
)
)
if not identifier:
raise HTTPException(status_code=400, detail="identifier is required.")
profile = _supabase_find_profile_by_identifier(identifier)
resolved_email = _normalize_spaces(str((profile or {}).get("email") or "")).lower()
resolved_phone = _normalize_spaces(str((profile or {}).get("phone_number") or ""))
if not resolved_email and "@" in identifier:
resolved_email = _normalize_spaces(identifier).lower()
has_real_email = bool(resolved_email and "@" in resolved_email and not _is_internal_login_email(resolved_email))
# Do not leak whether account exists.
if not has_real_email and not resolved_phone:
return {
"status": "ok",
"message": "If the account exists, reset instructions were sent.",
}
if has_real_email:
request_payload: Dict[str, Any] = {"email": resolved_email}
payload_redirect_to = _normalize_spaces(str(payload.get("redirect_to") or ""))
redirect_to = payload_redirect_to or _resolve_default_password_reset_redirect_url(request)
request_host = _normalize_spaces(urlparse(_resolve_public_base_url(request)).hostname or "").lower()
allowed_hosts = [request_host] if request_host else None
if not _is_valid_redirect_url(redirect_to, allowed_http_hosts=allowed_hosts):
raise HTTPException(status_code=400, detail="redirect_to must be an allow-listed URL or deep-link.")
request_payload["redirect_to"] = redirect_to
try:
response = requests.post(
f"{SUPABASE_URL}/auth/v1/recover",
json=request_payload,
headers={
"apikey": SUPABASE_ANON_KEY,
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise HTTPException(status_code=503, detail=f"Supabase forgot-password failed: {exc}") from exc
if response.status_code not in {200, 201, 204}:
error_data = response.json() if response.text else {}
error_msg = ""
if isinstance(error_data, dict):
error_msg = str(error_data.get("message") or error_data.get("error_description") or "").strip()
if not error_msg:
error_msg = response.text[:220]
LOGGER.warning("Forgot-password provider response %s: %s", response.status_code, error_msg)
else:
try:
_request_supabase_otp(
channel="phone",
identifier=resolved_phone,
create_user=False,
redirect_to=None,
purpose="password_reset",
)
except HTTPException as exc:
LOGGER.warning("Forgot-password phone OTP response %s: %s", exc.status_code, exc.detail)
return {
"status": "ok",
"message": "If the account exists, reset instructions were sent.",
}
@app.post("/auth/reset-password/resolve-token")
def auth_reset_password_resolve_token(
payload: Dict[str, Any] = Body(...),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
direct_access_token = _normalize_spaces(str(payload.get("access_token") or payload.get("token") or ""))
if direct_access_token:
return {
"status": "ok",
"session": {
"access_token": direct_access_token,
"refresh_token": _normalize_spaces(str(payload.get("refresh_token") or "")),
"expires_in": payload.get("expires_in"),
},
}
verify_type = _normalize_spaces(str(payload.get("type") or "recovery")).lower() or "recovery"
resolved_email = _normalize_spaces(str(payload.get("email") or "")).lower()
token_candidates: List[str] = []
token_hash = _normalize_spaces(str(payload.get("token_hash") or payload.get("tokenHash") or ""))
if token_hash:
token_candidates.append(token_hash)
code = _normalize_spaces(str(payload.get("code") or ""))
if code and code not in token_candidates:
token_candidates.append(code)
if not token_candidates:
raise HTTPException(status_code=400, detail="token_hash or code is required when access_token is missing.")
last_error = ""
for candidate in token_candidates:
verify_payload: Dict[str, Any] = {
"type": verify_type,
"token_hash": candidate,
}
if resolved_email:
verify_payload["email"] = resolved_email
try:
response = requests.post(
f"{SUPABASE_URL}/auth/v1/verify",
json=verify_payload,
headers={
"apikey": SUPABASE_ANON_KEY,
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise HTTPException(status_code=503, detail=f"Supabase verify failed: {exc}") from exc
if response.status_code == 200:
try:
data = response.json() if response.text else {}
except ValueError:
data = {}
if not isinstance(data, dict):
last_error = "Supabase verify returned an unexpected payload."
continue
session_payload = _extract_supabase_session_tokens(data)
if session_payload.get("access_token"):
return {
"status": "ok",
"session": session_payload,
}
last_error = "Supabase verify did not include an access token."
continue
try:
error_data = response.json() if response.text else {}
except ValueError:
error_data = {}
error_msg = ""
if isinstance(error_data, dict):
error_msg = str(error_data.get("message") or error_data.get("error_description") or "").strip()
if not error_msg:
error_msg = response.text[:220]
last_error = error_msg or f"Supabase verify failed with status {response.status_code}."
raise HTTPException(status_code=401, detail=f"Could not resolve reset token: {last_error or 'Invalid or expired link.'}")
@app.post("/auth/reset-password")
def auth_reset_password(
payload: Dict[str, Any] = Body(...),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
access_token = _normalize_spaces(str(payload.get("access_token") or payload.get("token") or ""))
new_password = str(payload.get("new_password") or payload.get("password") or "").strip()
if not access_token:
raise HTTPException(status_code=400, detail="access_token is required.")
if len(new_password) < 6:
raise HTTPException(status_code=400, detail="new_password must be at least 6 characters.")
try:
response = requests.put(
f"{SUPABASE_URL}/auth/v1/user",
json={"password": new_password},
headers={
"apikey": SUPABASE_ANON_KEY,
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise HTTPException(status_code=503, detail=f"Supabase reset-password failed: {exc}") from exc
if response.status_code != 200:
error_data = response.json() if response.text else {}
error_msg = ""
if isinstance(error_data, dict):
error_msg = str(error_data.get("message") or error_data.get("error_description") or "").strip()
if not error_msg:
error_msg = response.text[:220]
raise HTTPException(status_code=response.status_code, detail=f"Reset-password failed: {error_msg}")
return {
"status": "ok",
"message": "Password reset successful.",
}
@app.post("/auth/otp/request")
def auth_otp_request(
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
purpose = _normalize_spaces(str(payload.get("purpose") or "register")).lower() or "register"
raw_channel = payload.get("channel") or payload.get("otp_channel")
if raw_channel is None:
has_email = bool(_normalize_spaces(str(payload.get("email") or "")))
has_phone = bool(_normalize_spaces(str(payload.get("phone") or payload.get("phone_number") or "")))
raw_channel = "email" if has_email else ("phone" if has_phone else "email")
channel = _normalize_otp_channel(str(raw_channel))
request_user: Optional[Dict[str, Any]] = None
requester_id: Optional[str] = None
profile: Optional[Dict[str, Any]] = None
if purpose in {"vehicle_verify", "profile_update"}:
request_user = _require_authenticated_user(authorization)
if request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required for this OTP purpose.")
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
raise HTTPException(status_code=401, detail="Authenticated user id is missing.")
profile = _supabase_get_profile_by_user_id(requester_id)
identifier = _resolve_otp_identifier(channel=channel, payload=payload, profile=profile)
raw_create_user = payload.get("create_user")
if isinstance(raw_create_user, bool):
create_user = raw_create_user
elif raw_create_user is None:
create_user = purpose in {"register", "signup"}
else:
create_user = str(raw_create_user).strip().lower() in {"1", "true", "yes", "on"}
# Registration OTP must allow creating missing auth users, even if client sends create_user=false.
if purpose in {"register", "signup"}:
create_user = True
raw_redirect_to = _normalize_spaces(str(payload.get("redirect_to") or ""))
redirect_to: Optional[str] = raw_redirect_to or None
if channel == "email" and not redirect_to:
default_redirect = _normalize_spaces(PASSWORD_RESET_REDIRECT_URL)
if default_redirect:
redirect_to = default_redirect
_request_supabase_otp(
channel=channel,
identifier=identifier,
create_user=create_user,
redirect_to=redirect_to,
purpose=purpose,
)
return {
"status": "ok",
"purpose": purpose,
"channel": channel,
"identifier": identifier,
"message": "OTP was sent successfully.",
}
@app.post("/auth/otp/verify")
def auth_otp_verify(
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
purpose = _normalize_spaces(str(payload.get("purpose") or "register")).lower() or "register"
channel = _normalize_otp_channel(str(payload.get("channel") or payload.get("otp_channel") or "email"))
request_user: Optional[Dict[str, Any]] = None
token = _extract_bearer_token(authorization)
if token:
barrier_user = _verify_barrier_token(token)
if barrier_user is not None:
request_user = barrier_user
else:
request_user = _verify_supabase_user_token(token)
if purpose in {"vehicle_verify", "profile_update"} and request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required for this OTP purpose.")
requester_profile = None
if request_user is not None:
requester_id = str(request_user.get("id") or "").strip()
if requester_id:
requester_profile = _supabase_get_profile_by_user_id(requester_id)
identifier = _resolve_otp_identifier(
channel=channel,
payload=payload,
profile=requester_profile,
)
verify_payload = _verify_supabase_otp(
channel=channel,
identifier=identifier,
token=str(payload.get("otp_token") or payload.get("token") or ""),
verify_type=str(payload.get("type") or payload.get("verify_type") or "").strip() or None,
)
user_payload = verify_payload.get("user") if isinstance(verify_payload.get("user"), dict) else {}
return {
"status": "ok",
"purpose": purpose,
"channel": channel,
"user": {
"id": user_payload.get("id"),
"email": user_payload.get("email"),
"phone": user_payload.get("phone"),
},
"session": {
"access_token": verify_payload.get("access_token"),
"refresh_token": verify_payload.get("refresh_token"),
"expires_in": verify_payload.get("expires_in"),
"token_type": verify_payload.get("token_type"),
},
"message": "OTP verified successfully.",
}
@app.put("/profile/update")
def profile_update(
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
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.")
current_profile = _supabase_get_profile_by_user_id(requester_id) or {}
current_first_name, current_last_name, _ = _resolve_profile_name_fields(
current_profile if isinstance(current_profile, dict) else None,
fallback_full_name=(current_profile or {}).get("full_name") if isinstance(current_profile, dict) else None,
)
name_fields_provided = any(
key in payload
for key in ["first_name", "firstName", "last_name", "lastName", "full_name", "name"]
)
requested_first_name, requested_last_name, _ = _resolve_profile_name_fields(payload)
email = _normalize_spaces(str(payload.get("email") or "")).lower()
phone_number = _normalize_spaces(str(payload.get("phone") or payload.get("phone_number") or ""))
profile_patch: Dict[str, Any] = {}
if name_fields_provided:
next_first_name = requested_first_name or current_first_name
next_last_name = requested_last_name or current_last_name
next_full_name = _compose_full_name(next_first_name, next_last_name)
if not next_full_name:
raise HTTPException(status_code=400, detail="Name update requires at least first_name or full_name.")
profile_patch["first_name"] = next_first_name
profile_patch["last_name"] = next_last_name
profile_patch["full_name"] = next_full_name
if email:
if "@" not in email:
raise HTTPException(status_code=400, detail="email format is invalid.")
other_with_email = _supabase_get_profile_by_field("email", email, case_insensitive=True)
if isinstance(other_with_email, dict) and str(other_with_email.get("id") or "") != requester_id:
raise HTTPException(status_code=409, detail="email is already in use.")
profile_patch["email"] = email
if phone_number:
if not PHONE_PATTERN.fullmatch(phone_number):
raise HTTPException(status_code=400, detail="phone must be 7-15 digits and may start with +.")
other_with_phone = _supabase_get_profile_by_field("phone_number", phone_number)
if isinstance(other_with_phone, dict) and str(other_with_phone.get("id") or "") != requester_id:
raise HTTPException(status_code=409, detail="phone is already in use.")
profile_patch["phone_number"] = phone_number
if not profile_patch:
raise HTTPException(status_code=400, detail="No profile fields were provided.")
response = requests.patch(
f"{SUPABASE_URL}/rest/v1/profiles",
params={
"id": f"eq.{requester_id}",
"select": PROFILE_SELECT_FIELDS,
},
json=profile_patch,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
content_type="application/json",
prefer="return=representation",
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code not in {200, 204}:
raise HTTPException(status_code=400, detail=f"Failed to update profile: {_missing_schema_hint(response.text)}")
updated_rows = response.json() if response.text else []
updated_profile = (
updated_rows[0]
if isinstance(updated_rows, list) and updated_rows and isinstance(updated_rows[0], dict)
else (_supabase_get_profile_by_user_id(requester_id) or current_profile)
)
auth_patch: Dict[str, Any] = {}
user_metadata_patch: Dict[str, Any] = {}
if "email" in profile_patch:
auth_patch["email"] = profile_patch["email"]
if "first_name" in profile_patch:
user_metadata_patch["first_name"] = profile_patch["first_name"]
if "last_name" in profile_patch:
user_metadata_patch["last_name"] = profile_patch["last_name"]
if "full_name" in profile_patch:
user_metadata_patch["full_name"] = profile_patch["full_name"]
if "phone_number" in profile_patch:
user_metadata_patch["phone_number"] = profile_patch["phone_number"]
user_metadata_patch["phone"] = profile_patch["phone_number"]
if user_metadata_patch:
auth_patch["user_metadata"] = user_metadata_patch
if auth_patch:
try:
auth_response = requests.put(
f"{SUPABASE_URL}/auth/v1/admin/users/{requester_id}",
json=auth_patch,
headers={
"apikey": SUPABASE_SERVICE_ROLE_KEY,
"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}",
"Content-Type": "application/json",
},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if auth_response.status_code not in {200, 201}:
LOGGER.warning("Auth metadata update failed for %s: %s", requester_id, auth_response.text[:220])
except requests.RequestException as exc:
LOGGER.warning("Auth metadata update request failed for %s: %s", requester_id, exc)
return {
"status": "ok",
"profile": updated_profile,
"message": "Profile updated successfully.",
}
@app.get("/users")
def get_all_users(
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
request_user = _require_authenticated_user(authorization)
if request_user is None:
raise HTTPException(status_code=401, detail="Authentication is required.")
requester_role = _resolve_requester_role(request_user)
if not _is_staff_role(requester_role):
raise HTTPException(status_code=403, detail="Admin/Security role required to view users.")
try:
response = requests.get(
f"{SUPABASE_URL}/rest/v1/profiles",
params={"select": PROFILE_SELECT_FIELDS},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise HTTPException(status_code=502, detail="Failed to fetch users from database.")
users = response.json()
if isinstance(users, dict) and "error" in users:
users = []
return {"status": "ok", "users": users}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/vehicles/add")
def vehicles_add(
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
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.")
vehicle_payload = _extract_vehicle_payload(payload, require_plate=True)
created_vehicle = _create_vehicle_for_owner(owner_id=requester_id, vehicle_payload=vehicle_payload)
if not isinstance(created_vehicle, dict):
raise HTTPException(status_code=502, detail="Vehicle was not created.")
return {
"status": "ok",
"vehicle": created_vehicle,
}
@app.get("/vehicles/my")
def vehicles_my(
include_inactive: bool = False,
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.")
rows = _list_vehicles_for_owner(requester_id)
if not include_inactive:
rows = [row for row in rows if bool(row.get("is_active", True))]
return {
"status": "ok",
"count": len(rows),
"vehicles": rows,
}
@app.put("/vehicles/{vehicle_id}")
def vehicles_update(
vehicle_id: str,
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.")
update_payload: Dict[str, Any] = {}
try:
update_payload.update(_extract_vehicle_payload(payload, require_plate=False))
except HTTPException as exc:
if str(exc.detail) != "No vehicle fields were provided.":
raise
if "is_active" in payload:
raw_is_active = payload.get("is_active")
if isinstance(raw_is_active, bool):
update_payload["is_active"] = raw_is_active
elif raw_is_active is not None:
update_payload["is_active"] = str(raw_is_active).strip().lower() in {"1", "true", "yes", "on"}
if not update_payload:
raise HTTPException(status_code=400, detail="No updatable vehicle fields were provided.")
updated_vehicle = _update_vehicle_for_owner(requester_id, vehicle_id, update_payload)
if not isinstance(updated_vehicle, dict):
raise HTTPException(status_code=404, detail="Vehicle was not found.")
return {
"status": "ok",
"vehicle": updated_vehicle,
}
@app.delete("/vehicles/{vehicle_id}")
def vehicles_delete(
vehicle_id: str,
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.")
vehicle_record = _fetch_vehicle_for_owner(requester_id, vehicle_id)
if not isinstance(vehicle_record, dict):
raise HTTPException(status_code=404, detail="Vehicle was not found.")
deleted = _delete_vehicle_for_owner(requester_id, vehicle_id)
if not deleted:
raise HTTPException(status_code=404, detail="Vehicle was not found.")
return {
"status": "ok",
"deleted_vehicle_id": vehicle_id,
}
@app.post("/vehicles/verify")
def vehicles_verify(
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.")
profile = _supabase_get_profile_by_user_id(requester_id)
if not isinstance(profile, dict):
raise HTTPException(status_code=404, detail="Profile was not found.")
vehicle_id = _normalize_spaces(str(payload.get("vehicle_id") or ""))
if vehicle_id:
vehicle = _fetch_vehicle_for_owner(requester_id, vehicle_id)
else:
vehicle_plate_payload = _extract_vehicle_payload(payload, require_plate=True)
vehicle = _fetch_vehicle_by_plate_for_owner(
requester_id,
vehicle_plate_payload["plate_letters_ar"],
vehicle_plate_payload["plate_numbers_ar"],
)
if not isinstance(vehicle, dict):
raise HTTPException(status_code=404, detail="Vehicle was not found.")
channel = _normalize_otp_channel(str(payload.get("channel") or payload.get("otp_channel") or "email"))
identifier = _resolve_otp_identifier(channel=channel, payload=payload, profile=profile)
_verify_supabase_otp(
channel=channel,
identifier=identifier,
token=str(payload.get("otp_token") or payload.get("token") or ""),
verify_type=str(payload.get("type") or payload.get("verify_type") or "").strip() or None,
)
resolved_vehicle_id = str(vehicle.get("id") or "").strip()
updated_vehicle = _update_vehicle_for_owner(
requester_id,
resolved_vehicle_id,
{
"is_verified": True,
"verified_at": datetime.now(timezone.utc).isoformat(),
"verified_by": requester_id,
},
)
return {
"status": "ok",
"channel": channel,
"vehicle": updated_vehicle or vehicle,
"message": "Vehicle ownership verified.",
}
@app.post("/notifications/fcm-token")
def notifications_register_fcm_token(
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if not _supabase_configured():
raise HTTPException(status_code=503, detail="Supabase is not configured.")
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.")
fcm_token = _normalize_spaces(str(payload.get("fcm_token") or payload.get("token") or ""))
if not fcm_token:
raise HTTPException(status_code=400, detail="fcm_token is required.")
platform = _normalize_spaces(str(payload.get("platform") or "")) or None
device_id = _normalize_spaces(str(payload.get("device_id") or "")) or None
row = _upsert_notification_device_token(
user_id=requester_id,
fcm_token=fcm_token,
platform=platform,
device_id=device_id,
)
return {
"status": "ok",
"device": row,
"message": "FCM token registered.",
}
@app.get("/notifications/my")
def notifications_my(
limit: int = 50,
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.")
resolved_limit = max(1, min(200, int(limit)))
rows = _list_notification_events_for_user(requester_id, resolved_limit)
return {
"status": "ok",
"count": len(rows),
"notifications": rows,
}
@app.get("/health")
def health() -> Dict[str, Any]:
local_models_required = _should_load_local_models()
local_models_loaded = False
local_model_error: Optional[str] = None
if local_models_required:
try:
_ensure_models_loaded()
local_models_loaded = True
except Exception as exc: # pragma: no cover
local_model_error = str(exc)
status_value = "ok" if (not local_models_required or local_models_loaded) else "degraded"
model_inference_payload: Dict[str, Any] = {
"provider": MODEL_INFERENCE_PROVIDER,
"local_models_required": local_models_required,
"local_models_loaded": local_models_loaded,
"local_endpoint_enabled": MODEL_SERVICE_ENABLE_LOCAL_ENDPOINT,
"remote_service_configured": _model_service_configured(),
"remote_infer_url": _resolve_model_service_infer_url(),
"header_name": MODEL_SERVICE_HEADER_NAME,
}
if local_model_error:
model_inference_payload["local_model_error"] = local_model_error
return {
"status": status_value,
"models_dir": str(MODELS_DIR),
"ocr_model_file": MODEL_PATHS["ocr"].name,
"model_inference": model_inference_payload,
"supabase_configured": _supabase_configured(),
"supabase_auth_enforced": SUPABASE_ENFORCE_AUTH,
"pricing": {
"default_billing_mode": PARKING_BILLING_MODE,
"default_hourly_rate_egp": round(max(0.0, PARKING_HOURLY_RATE_EGP), 2),
"default_daily_rate_egp": round(max(0.0, PARKING_DAILY_RATE_EGP), 2),
"default_extra_after_first_hour_egp": round(max(0.0, PARKING_EXTRA_AFTER_FIRST_HOUR_EGP), 2),
"location_hourly_rates": PARKING_LOCATION_HOURLY_RATES,
"location_pricing": PARKING_LOCATION_PRICING,
"location_capacities": PARKING_LOCATION_CAPACITIES,
"garage_total_capacity": GARAGE_TOTAL_CAPACITY,
"app_service_fee_egp": round(max(0.0, APP_SERVICE_FEE_EGP), 2),
},
}
@app.get("/supabase/health")
def supabase_health() -> Dict[str, Any]:
if not _supabase_configured():
return {
"status": "disabled",
"reason": "Set SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY",
}
try:
response = requests.get(
f"{SUPABASE_URL}/auth/v1/settings",
headers={"apikey": SUPABASE_ANON_KEY},
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
return {
"status": "error",
"http_status": response.status_code,
"detail": response.text[:400],
}
existing_buckets = set(_supabase_list_buckets())
required_buckets = {SUPABASE_RAW_BUCKET, SUPABASE_PROCESSED_BUCKET}
missing_buckets = sorted(required_buckets - existing_buckets)
table_checks: Dict[str, Dict[str, Any]] = {}
all_tables_ready = True
for table_name in sorted(CORE_TABLES):
exists, detail = _supabase_table_exists(table_name)
table_checks[table_name] = {
"exists": exists,
"detail": detail,
}
if not exists:
all_tables_ready = False
optional_checks: Dict[str, Dict[str, Any]] = {}
for table_name in ["payment_transactions", "notification_device_tokens", "notification_events", "app_links"]:
exists, detail = _supabase_table_exists(table_name)
optional_checks[table_name] = {
"exists": exists,
"detail": detail,
}
ready_for_persistence = all_tables_ready and not missing_buckets
except requests.RequestException as exc:
return {
"status": "error",
"detail": str(exc),
}
except Exception as exc:
return {
"status": "error",
"detail": str(exc),
}
return {
"status": "ok" if ready_for_persistence else "partial",
"url": SUPABASE_URL,
"raw_bucket": SUPABASE_RAW_BUCKET,
"processed_bucket": SUPABASE_PROCESSED_BUCKET,
"auth_enforced": SUPABASE_ENFORCE_AUTH,
"readiness": {
"ready_for_persistence": ready_for_persistence,
"missing_buckets": missing_buckets,
"tables": table_checks,
"optional_tables": optional_checks,
},
}
@app.get("/models")
def models() -> Dict[str, Any]:
local_models_required = _should_load_local_models()
if local_models_required:
_ensure_models_loaded()
return {
"inference_provider": MODEL_INFERENCE_PROVIDER,
"local_models_required": local_models_required,
"local_endpoint_enabled": MODEL_SERVICE_ENABLE_LOCAL_ENDPOINT,
"remote_service_configured": _model_service_configured(),
"remote_infer_url": _resolve_model_service_infer_url(),
"plate_detector": str(MODEL_PATHS["plate"]) if local_models_required else None,
"ocr_model": str(MODEL_PATHS["ocr"]) if local_models_required else None,
}
@app.get("/developer/models")
def developer_models(
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
request_user, requester_role = _require_developer_user(authorization)
payload = models()
payload["request_user"] = {
"id": request_user.get("id"),
"email": request_user.get("email"),
"role": requester_role,
}
return payload
@app.post("/model/infer")
async def model_infer(
image: UploadFile = File(...),
model_service_secret: Optional[str] = Header(default=None, alias=MODEL_SERVICE_HEADER_NAME),
) -> Dict[str, Any]:
# Always use remote HuggingFace Space for inference
if image.content_type and not image.content_type.startswith("image/") and image.content_type != "application/octet-stream":
raise HTTPException(status_code=400, detail="Only image uploads are supported.")
image_bytes = await image.read()
if not image_bytes:
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
response = _run_pipeline_remote(
image_bytes=image_bytes,
filename=image.filename,
content_type=image.content_type,
)
response["filename"] = image.filename
response["inference_provider"] = "remote"
return response
def _resolve_history_scope(requester_id: str, requester_has_global_scope: bool, for_user_id: Optional[str]) -> Optional[str]:
target_user_id = (for_user_id or "").strip() or None
if target_user_id and not requester_has_global_scope and target_user_id != requester_id:
raise HTTPException(status_code=403, detail="You can only view your own records.")
if not requester_has_global_scope:
target_user_id = requester_id
return target_user_id
def _fetch_active_inside_counts_by_location() -> Tuple[int, Dict[str, int]]:
if not _supabase_configured():
return 0, {}
response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={
"select": "parking_location",
"check_out_at": "is.null",
"limit": "10000",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch inside occupancy counts: %s", response.text[:280])
return 0, {}
rows = response.json()
if not isinstance(rows, list):
return 0, {}
inside_by_location: Dict[str, int] = {}
total_inside = 0
for row in rows:
if not isinstance(row, dict):
continue
total_inside += 1
raw_location = str(row.get("parking_location") or "").strip()
if not raw_location:
continue
location_key = _normalize_location_key_for_config(raw_location.split(",", 1)[0])
if not location_key:
continue
inside_by_location[location_key] = inside_by_location.get(location_key, 0) + 1
return total_inside, inside_by_location
def _build_parking_locations_snapshot() -> Dict[str, Any]:
total_inside_from_sessions, inside_by_location_from_sessions = _fetch_active_inside_counts_by_location()
total_inside_inferred, inside_by_location_inferred = _count_inferred_inside_by_location()
total_inside = total_inside_from_sessions + total_inside_inferred
inside_by_location: Dict[str, int] = dict(inside_by_location_from_sessions)
for location_key, inferred_count in inside_by_location_inferred.items():
inside_by_location[location_key] = inside_by_location.get(location_key, 0) + inferred_count
location_keys = set(PARKING_LOCATION_PRICING.keys())
location_keys.update(PARKING_LOCATION_HOURLY_RATES.keys())
location_keys.update(PARKING_LOCATION_CAPACITIES.keys())
location_keys.update(inside_by_location.keys())
if not location_keys:
location_keys.add("DEFAULT")
locations: List[Dict[str, Any]] = []
for location_key in sorted(location_keys):
policy = _resolve_location_pricing_policy(location_key)
location_capacity = max(0, int(policy.get("location_capacity") or 0))
inside_count = inside_by_location.get(location_key, 0)
left_count = max(0, location_capacity - inside_count)
base_window_hours = 24 if str(policy.get("billing_mode") or "hourly") == "daily" else 1
locations.append(
{
"parking_location": location_key,
"pricing": {
"billing_mode": str(policy.get("billing_mode") or "hourly"),
"base_rate_egp": round(max(0.0, float(policy.get("base_rate_egp") or 0.0)), 2),
"extra_after_first_hour_egp": round(max(0.0, float(policy.get("extra_after_first_hour_egp") or 0.0)), 2),
"app_service_fee_egp": round(max(0.0, APP_SERVICE_FEE_EGP), 2),
"base_window_hours": base_window_hours,
"rate_source": str(policy.get("rate_source") or "default"),
},
"occupancy": {
"inside": inside_count,
"inside_from_registered_sessions": inside_by_location_from_sessions.get(location_key, 0),
"inside_from_inferred_unmatched": inside_by_location_inferred.get(location_key, 0),
"total_capacity": location_capacity,
"left": left_count,
"display": f"{inside_count}/{location_capacity}",
"capacity_source": str(policy.get("location_capacity_source") or "default"),
},
}
)
unknown_location_inside = max(0, total_inside - sum(inside_by_location.values()))
garage_left = max(0, GARAGE_TOTAL_CAPACITY - total_inside)
return {
"garage": {
"inside": total_inside,
"inside_from_registered_sessions": total_inside_from_sessions,
"inside_from_inferred_unmatched": total_inside_inferred,
"total_capacity": GARAGE_TOTAL_CAPACITY,
"left": garage_left,
"display": f"{total_inside}/{GARAGE_TOTAL_CAPACITY}",
"unknown_location_inside": unknown_location_inside,
},
"locations": locations,
}
@app.get("/parking/locations")
def parking_locations(
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
snapshot = _build_parking_locations_snapshot()
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"pricing_defaults": {
"billing_mode": PARKING_BILLING_MODE,
"hourly_rate_egp": round(max(0.0, PARKING_HOURLY_RATE_EGP), 2),
"daily_rate_egp": round(max(0.0, PARKING_DAILY_RATE_EGP), 2),
"extra_after_first_hour_egp": round(max(0.0, PARKING_EXTRA_AFTER_FIRST_HOUR_EGP), 2),
"app_service_fee_egp": round(max(0.0, APP_SERVICE_FEE_EGP), 2),
},
"garage": snapshot["garage"],
"locations": snapshot["locations"],
}
@app.get("/parking/occupancy")
def parking_occupancy(
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
snapshot = _build_parking_locations_snapshot()
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"garage": snapshot["garage"],
"locations": [
{
"parking_location": row.get("parking_location"),
"occupancy": row.get("occupancy"),
}
for row in snapshot["locations"]
],
}
def _fetch_vehicle_map_by_ids(vehicle_ids: List[str]) -> Dict[str, Dict[str, Any]]:
if not vehicle_ids or not _supabase_configured():
return {}
response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"select": "id,owner_id,plate_letters_ar,plate_numbers_ar,plate_letters_en,plate_numbers_en,car_name,car_model,car_color,is_active,is_verified,verified_at,verified_by,created_at,updated_at",
"id": f"in.({','.join(sorted(set(vehicle_ids)))})",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch vehicle map: %s", response.text[:280])
return {}
rows = response.json()
if not isinstance(rows, list):
return {}
result: Dict[str, Dict[str, Any]] = {}
for row in rows:
if isinstance(row, dict) and row.get("id"):
result[str(row["id"])] = row
return result
def _fetch_session_map_by_ids(session_ids: List[str]) -> Dict[str, Dict[str, Any]]:
if not session_ids or not _supabase_configured():
return {}
response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params={
"select": "id,vehicle_id,check_in_at,payment_confirmed_at,check_out_at,left_within_5_minutes,status,parking_location,created_by,notes,created_at,updated_at",
"id": f"in.({','.join(sorted(set(session_ids)))})",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch session map: %s", response.text[:280])
return {}
rows = response.json()
if not isinstance(rows, list):
return {}
result: Dict[str, Dict[str, Any]] = {}
for row in rows:
if isinstance(row, dict) and row.get("id"):
result[str(row["id"])] = row
return result
def _fetch_latest_event_by_session_ids(session_ids: List[str]) -> Dict[str, Dict[str, Any]]:
if not session_ids or not _supabase_configured():
return {}
response = requests.get(
f"{SUPABASE_URL}/rest/v1/car_events",
params={
"select": "id,session_id,vehicle_id,event_type,ocr_arabic,ocr_english,ocr_confidence,parking_location,raw_image_path,user_split_image_path,admin_annotated_image_path,event_metadata,captured_at,created_at",
"session_id": f"in.({','.join(sorted(set(session_ids)))})",
"order": "captured_at.desc",
"limit": str(max(len(session_ids) * 6, 200)),
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
LOGGER.warning("Failed to fetch latest events by session ids: %s", response.text[:280])
return {}
rows = response.json()
if not isinstance(rows, list):
return {}
result: Dict[str, Dict[str, Any]] = {}
for row in rows:
if not isinstance(row, dict):
continue
session_id = str(row.get("session_id") or "").strip()
if not session_id or session_id in result:
continue
result[session_id] = row
return result
def _event_plate_value(value: Any) -> Optional[str]:
raw = _normalize_spaces(str(value or ""))
if not raw or raw.upper() == "N/A":
return None
return raw
def _build_unmatched_plate_key(event_row: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
plate_arabic = _event_plate_value(event_row.get("ocr_arabic"))
plate_english = _event_plate_value(event_row.get("ocr_english"))
if plate_arabic:
return f"AR::{plate_arabic}", plate_arabic, plate_english
if plate_english:
plate_english_upper = plate_english.upper()
return f"EN::{plate_english_upper}", None, plate_english_upper
return None, None, None
def _resolve_event_timestamp(event_row: Dict[str, Any]) -> Optional[datetime]:
for field_name in ["captured_at", "created_at"]:
parsed = _parse_iso_datetime(event_row.get(field_name))
if parsed is not None:
return parsed
return None
def _resolve_event_location_key(event_row: Dict[str, Any]) -> Optional[str]:
raw_location = _normalize_spaces(str(event_row.get("parking_location") or ""))
if not raw_location:
event_metadata = event_row.get("event_metadata") if isinstance(event_row.get("event_metadata"), dict) else {}
raw_location = _normalize_spaces(str(event_metadata.get("parking_location") or ""))
if not raw_location:
return None
return _normalize_location_key_for_config(raw_location.split(",", 1)[0])
def _fetch_unmatched_car_events(limit: int = 4000) -> List[Dict[str, Any]]:
if not _supabase_configured():
return []
response = requests.get(
f"{SUPABASE_URL}/rest/v1/car_events",
params={
"select": "id,session_id,vehicle_id,event_type,ocr_arabic,ocr_english,ocr_confidence,parking_location,event_metadata,captured_at,created_at",
"session_id": "is.null",
"order": "captured_at.desc",
"limit": str(max(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:
LOGGER.warning("Failed to fetch unmatched car events: %s", response.text[:280])
return []
rows = response.json()
if not isinstance(rows, list):
return []
return [row for row in rows if isinstance(row, dict)]
def _build_inferred_unmatched_session_rows(limit: int = 300, *, inside_only: bool) -> List[Dict[str, Any]]:
events = _fetch_unmatched_car_events(limit=max(limit * 8, 400))
if not events:
return []
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
sorted_events = sorted(events, key=lambda row: _resolve_event_timestamp(row) or epoch)
state_by_plate: Dict[str, Dict[str, Any]] = {}
for event in sorted_events:
event_type = str(event.get("event_type") or "").strip().lower()
if event_type not in SUPPORTED_EVENT_TYPES:
continue
event_timestamp = _resolve_event_timestamp(event)
if event_timestamp is None:
continue
plate_key, plate_arabic, plate_english = _build_unmatched_plate_key(event)
if not plate_key:
continue
state = state_by_plate.setdefault(
plate_key,
{
"plate_key": plate_key,
"plate_arabic": plate_arabic,
"plate_english": plate_english,
"inside": False,
"has_transition": False,
"check_in_at": None,
"check_out_at": None,
"parking_location": None,
"latest_event": None,
"latest_event_at": None,
"event_count": 0,
},
)
if plate_arabic and not state.get("plate_arabic"):
state["plate_arabic"] = plate_arabic
if plate_english and not state.get("plate_english"):
state["plate_english"] = plate_english
state["event_count"] = int(state.get("event_count") or 0) + 1
state["latest_event"] = event
state["latest_event_at"] = event_timestamp
resolved_location = _resolve_event_location_key(event)
if resolved_location:
state["parking_location"] = resolved_location
if event_type == "entry":
state["inside"] = True
state["has_transition"] = True
state["check_in_at"] = event_timestamp
state["check_out_at"] = None
if resolved_location:
state["parking_location"] = resolved_location
elif event_type == "exit":
state["inside"] = False
state["has_transition"] = True
state["check_out_at"] = event_timestamp
if state.get("check_in_at") is None:
state["check_in_at"] = event_timestamp
inferred_rows: List[Dict[str, Any]] = []
for state in state_by_plate.values():
if not bool(state.get("has_transition")):
continue
is_inside = bool(state.get("inside"))
if inside_only and not is_inside:
continue
latest_event = state.get("latest_event") if isinstance(state.get("latest_event"), dict) else None
latest_event_at = state.get("latest_event_at") if isinstance(state.get("latest_event_at"), datetime) else None
if latest_event is None or latest_event_at is None:
continue
check_in_at = state.get("check_in_at") if isinstance(state.get("check_in_at"), datetime) else latest_event_at
check_out_at = state.get("check_out_at") if isinstance(state.get("check_out_at"), datetime) else None
if not is_inside and check_out_at is None:
check_out_at = latest_event_at
parking_location = state.get("parking_location")
if not parking_location:
parking_location = _resolve_event_location_key(latest_event)
plate_key = str(state.get("plate_key") or "")
session_id = f"inferred:{hashlib.sha1(plate_key.encode('utf-8')).hexdigest()[:20]}"
session_row: Dict[str, Any] = {
"id": session_id,
"vehicle_id": None,
"check_in_at": check_in_at.isoformat(),
"payment_confirmed_at": None,
"check_out_at": check_out_at.isoformat() if check_out_at else None,
"left_within_5_minutes": False,
"status": "inferred_inside_unmatched" if is_inside else "inferred_exited_unmatched",
"parking_location": parking_location,
"created_by": None,
"notes": "Inferred from unmatched OCR events.",
"created_at": check_in_at.isoformat(),
"updated_at": latest_event_at.isoformat(),
"inferred": True,
}
pricing = _compute_session_time_and_fee(session_row, parking_location_override=parking_location)
inferred_rows.append(
{
"session": session_row,
"vehicle": None,
"latest_event": latest_event,
"events": [latest_event],
"plate": {
"key": plate_key,
"arabic": state.get("plate_arabic"),
"english": state.get("plate_english"),
},
"event_count": int(state.get("event_count") or 0),
"inferred_unmatched": True,
"payment_status": _derive_payment_status_from_session(session_row),
"left_within_5_minutes": False,
"pricing": pricing,
"gate_decision_if_exit_attempted": _build_gate_decision(
event_type="exit",
session_row=session_row,
plate_detected=True,
),
}
)
inferred_rows.sort(
key=lambda row: _resolve_event_timestamp(row.get("latest_event") if isinstance(row.get("latest_event"), dict) else {}) or epoch,
reverse=True,
)
return inferred_rows[:limit]
def _count_inferred_inside_by_location() -> Tuple[int, Dict[str, int]]:
inferred_inside_rows = _build_inferred_unmatched_session_rows(limit=1200, inside_only=True)
inside_by_location: Dict[str, int] = {}
total_inside = 0
for row in inferred_inside_rows:
session_row = row.get("session") if isinstance(row.get("session"), dict) else {}
total_inside += 1
location_key = _normalize_location_key_for_config(str(session_row.get("parking_location") or ""))
if not location_key:
continue
inside_by_location[location_key] = inside_by_location.get(location_key, 0) + 1
return total_inside, inside_by_location
def _list_event_feed(
*,
limit: int,
target_user_id: Optional[str],
event_type: str,
within_5_only: bool,
) -> List[Dict[str, Any]]:
fetch_limit = max(limit * 4, 300) if within_5_only else limit
event_params: Dict[str, str] = {
"select": "id,session_id,vehicle_id,event_type,ocr_arabic,ocr_english,ocr_confidence,parking_location,raw_image_path,user_split_image_path,admin_annotated_image_path,event_metadata,captured_at,created_at",
"event_type": f"eq.{event_type}",
"order": "captured_at.desc",
"limit": str(fetch_limit),
}
if target_user_id:
vehicle_ids = _fetch_vehicle_ids_for_owner(target_user_id)
if not vehicle_ids:
return []
event_params["vehicle_id"] = f"in.({','.join(vehicle_ids)})"
response = requests.get(
f"{SUPABASE_URL}/rest/v1/car_events",
params=event_params,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise HTTPException(status_code=502, detail=f"Failed to load event feed: {response.text[:320]}")
events_rows = response.json()
events = events_rows if isinstance(events_rows, list) else []
session_ids: List[str] = []
vehicle_ids: List[str] = []
for event in events:
if not isinstance(event, dict):
continue
sid = str(event.get("session_id") or "").strip()
vid = str(event.get("vehicle_id") or "").strip()
if sid:
session_ids.append(sid)
if vid:
vehicle_ids.append(vid)
session_map = _fetch_session_map_by_ids(session_ids)
vehicle_map = _fetch_vehicle_map_by_ids(vehicle_ids)
filtered_events: List[Dict[str, Any]] = []
for event in events:
if not isinstance(event, dict):
continue
event_metadata = event.get("event_metadata") if isinstance(event.get("event_metadata"), dict) else {}
session_id = str(event.get("session_id") or "").strip()
session_row = session_map.get(session_id)
left_within_5 = bool(
(session_row or {}).get("left_within_5_minutes")
or event_metadata.get("left_within_5_minutes")
)
if within_5_only and not left_within_5:
continue
filtered_events.append(event)
if len(filtered_events) >= limit:
break
items: List[Dict[str, Any]] = []
for event in filtered_events:
session_id = str(event.get("session_id") or "").strip()
vehicle_id = str(event.get("vehicle_id") or "").strip()
session_row = session_map.get(session_id)
event_metadata = event.get("event_metadata") if isinstance(event.get("event_metadata"), dict) else {}
payment_status = event_metadata.get("payment_status") or _derive_payment_status_from_session(session_row)
pricing = (
event_metadata.get("pricing")
if isinstance(event_metadata.get("pricing"), dict)
else _compute_session_time_and_fee(
session_row,
parking_location_override=(
str(event.get("parking_location") or "").strip()
or str(event_metadata.get("parking_location") or "").strip()
or None
),
)
)
plate_detected = str(event.get("ocr_arabic") or "").strip().upper() not in {"", "N/A"} or str(event.get("ocr_english") or "").strip().upper() not in {"", "N/A"}
gate_decision = event_metadata.get("gate_decision") if isinstance(event_metadata.get("gate_decision"), dict) else _build_gate_decision(
event_type=event_type,
session_row=session_row,
plate_detected=plate_detected,
)
items.append(
{
"event": event,
"vehicle": vehicle_map.get(vehicle_id),
"session": session_row,
"payment_status": payment_status,
"left_within_5_minutes": bool(
(session_row or {}).get("left_within_5_minutes")
or event_metadata.get("left_within_5_minutes")
),
"pricing": pricing,
"gate_decision": gate_decision,
}
)
return items
@app.get("/events/entered-cars")
def entered_cars_feed(
limit: int = 100,
for_user_id: Optional[str] = None,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if limit < 1 or limit > 500:
raise HTTPException(status_code=400, detail="limit must be between 1 and 500.")
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
requester_has_global_scope = _can_view_global_records(requester_role)
target_user_id = _resolve_history_scope(requester_id, requester_has_global_scope, for_user_id)
items = _list_event_feed(
limit=limit,
target_user_id=target_user_id,
event_type="entry",
within_5_only=False,
)
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"scope": {
"for_user_id": target_user_id,
"limit": limit,
"event_type": "entry",
},
"items": items,
}
@app.get("/events/leaving-cars-within-5-minutes")
def leaving_within_5_minutes_feed(
limit: int = 100,
for_user_id: Optional[str] = None,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if limit < 1 or limit > 500:
raise HTTPException(status_code=400, detail="limit must be between 1 and 500.")
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
requester_has_global_scope = _can_view_global_records(requester_role)
target_user_id = _resolve_history_scope(requester_id, requester_has_global_scope, for_user_id)
items = _list_event_feed(
limit=limit,
target_user_id=target_user_id,
event_type="exit",
within_5_only=True,
)
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"scope": {
"for_user_id": target_user_id,
"limit": limit,
"event_type": "exit",
"within_5_minutes_after_payment": True,
},
"items": items,
}
@app.get("/events/new-cars")
def new_cars_feed(
limit: int = 100,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if limit < 1 or limit > 500:
raise HTTPException(status_code=400, detail="limit must be between 1 and 500.")
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 = _resolve_requester_role(request_user)
if not _is_staff_role(requester_role):
raise HTTPException(status_code=403, detail="Only admin/security can view new-car alerts.")
fetch_limit = max(limit * 8, 400)
response = requests.get(
f"{SUPABASE_URL}/rest/v1/car_events",
params={
"select": "id,session_id,vehicle_id,event_type,ocr_arabic,ocr_english,ocr_confidence,parking_location,raw_image_path,user_split_image_path,admin_annotated_image_path,event_metadata,captured_at,created_at",
"order": "captured_at.desc",
"limit": str(fetch_limit),
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if response.status_code != 200:
raise HTTPException(status_code=502, detail=f"Failed to fetch new-car alerts: {response.text[:320]}")
events_rows = response.json()
events = events_rows if isinstance(events_rows, list) else []
session_ids: List[str] = []
vehicle_ids: List[str] = []
for event in events:
if not isinstance(event, dict):
continue
sid = str(event.get("session_id") or "").strip()
vid = str(event.get("vehicle_id") or "").strip()
if sid:
session_ids.append(sid)
if vid:
vehicle_ids.append(vid)
session_map = _fetch_session_map_by_ids(session_ids)
vehicle_map = _fetch_vehicle_map_by_ids(vehicle_ids)
items: List[Dict[str, Any]] = []
for event in events:
if not isinstance(event, dict):
continue
event_metadata = event.get("event_metadata") if isinstance(event.get("event_metadata"), dict) else {}
new_car_alert = event_metadata.get("new_car_alert") if isinstance(event_metadata.get("new_car_alert"), dict) else {}
if new_car_alert.get("is_new_car") is not True:
app_registration = event_metadata.get("app_registration") if isinstance(event_metadata.get("app_registration"), dict) else {}
if app_registration.get("is_registered") is False:
new_car_alert = {
"is_new_car": True,
"notify_roles": ["admin", "security"],
"action": "vendor_outreach_recommended",
"reason": "car_not_registered_by_any_user",
"message": "New car detected. Admin/security should contact a vendor to call the driver and introduce the app.",
}
else:
continue
session_id = str(event.get("session_id") or "").strip()
vehicle_id = str(event.get("vehicle_id") or "").strip()
items.append(
{
"event": event,
"vehicle": vehicle_map.get(vehicle_id),
"session": session_map.get(session_id),
"new_car_alert": new_car_alert,
}
)
if len(items) >= limit:
break
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": True,
},
"scope": {
"limit": limit,
"event_type": "new_car_alert",
"notify_roles": ["admin", "security"],
"action": "vendor_outreach_recommended",
},
"items": items,
}
@app.get("/events/left-cars")
def left_cars_feed(
limit: int = 100,
for_user_id: Optional[str] = None,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if limit < 1 or limit > 500:
raise HTTPException(status_code=400, detail="limit must be between 1 and 500.")
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
requester_has_global_scope = _can_view_global_records(requester_role)
target_user_id = _resolve_history_scope(requester_id, requester_has_global_scope, for_user_id)
items = _list_event_feed(
limit=limit,
target_user_id=target_user_id,
event_type="exit",
within_5_only=False,
)
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"scope": {
"for_user_id": target_user_id,
"limit": limit,
"event_type": "exit",
"within_5_minutes_after_payment": False,
},
"items": items,
}
@app.get("/events/inside-cars")
def inside_cars_feed(
limit: int = 100,
for_user_id: Optional[str] = None,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if limit < 1 or limit > 500:
raise HTTPException(status_code=400, detail="limit must be between 1 and 500.")
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
requester_has_global_scope = _can_view_global_records(requester_role)
target_user_id = _resolve_history_scope(requester_id, requester_has_global_scope, for_user_id)
session_params: Dict[str, str] = {
"select": "id,vehicle_id,plate_arabic,plate_english,check_in_at,payment_confirmed_at,check_out_at,left_within_5_minutes,status,parking_location,created_by,notes,created_at,updated_at",
"check_out_at": "is.null",
"status": "not.in.(exited,left_without_payment)",
"order": "check_in_at.desc",
"limit": str(limit),
}
if target_user_id:
vehicle_ids = _fetch_vehicle_ids_for_owner(target_user_id)
if not vehicle_ids:
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"scope": {
"for_user_id": target_user_id,
"limit": limit,
"event_type": "inside",
},
"items": [],
}
session_params["vehicle_id"] = f"in.({','.join(vehicle_ids)})"
sessions_response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params=session_params,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if sessions_response.status_code != 200:
raise HTTPException(status_code=502, detail=f"Failed to fetch inside sessions: {sessions_response.text[:320]}")
sessions_rows = sessions_response.json()
sessions = sessions_rows if isinstance(sessions_rows, list) else []
vehicle_ids_in_sessions = [
str(row.get("vehicle_id"))
for row in sessions
if isinstance(row, dict) and row.get("vehicle_id")
]
vehicle_map = _fetch_vehicle_map_by_ids(vehicle_ids_in_sessions)
session_ids = [
str(row.get("id"))
for row in sessions
if isinstance(row, dict) and row.get("id")
]
latest_event_by_session = _fetch_latest_event_by_session_ids(session_ids)
items: List[Dict[str, Any]] = []
for session in sessions:
if not isinstance(session, dict):
continue
session_id = str(session.get("id") or "").strip()
vehicle_id = str(session.get("vehicle_id") or "").strip()
# Self-healing: Hide and close old ghost sessions created before the fix
v_id = vehicle_id.upper()
plate_ar = str(session.get("plate_arabic") or "").strip().upper()
plate_en = str(session.get("plate_english") or "").strip().upper()
if v_id in {"", "NONE", "NULL"} and plate_ar in {"", "N/A", "NONE", "NULL", "-"} and plate_en in {"", "N/A", "NONE", "NULL", "-"}:
LOGGER.warning("Self-healing: Closing invalid ghost session %s", session_id)
_update_parking_session(session_id, {
"status": "exited",
"check_out_at": datetime.now(timezone.utc).isoformat(),
"notes": "Closed invalid ghost session automatically."
})
continue
latest_event = latest_event_by_session.get(session_id)
# Pricing and Status calculation
pricing = _compute_session_time_and_fee(session)
payment_status = _derive_payment_status_from_session(session)
plate_detected_in_latest_event = bool(
isinstance(latest_event, dict)
and (
str(latest_event.get("ocr_arabic") or "").strip().upper() not in {"", "N/A"}
or str(latest_event.get("ocr_english") or "").strip().upper() not in {"", "N/A"}
)
)
items.append(
{
"session": session,
"vehicle": vehicle_map.get(vehicle_id),
"latest_event": latest_event,
"payment_status": payment_status,
"pricing": pricing,
"gate_decision_if_exit_attempted": _build_gate_decision(
event_type="exit",
session_row=session,
plate_detected=True,
),
"plate_detected_in_latest_event": plate_detected_in_latest_event,
"inferred_unmatched": False,
}
)
if target_user_id is None:
real_session_plates = set()
for item in items:
s = item.get("session") or {}
plate_ar = _normalize_plate_text(s.get("plate_arabic"))
plate_en = _normalize_plate_text(s.get("plate_english"))
if plate_ar: real_session_plates.add(plate_ar)
if plate_en: real_session_plates.add(plate_en)
inferred_items = _build_inferred_unmatched_session_rows(limit=max(limit * 3, 300), inside_only=True)
for inferred in inferred_items:
inf_plate = inferred.get("plate")
inf_plate_norm = _normalize_plate_text(inf_plate)
if inf_plate_norm in real_session_plates:
continue
items.append(
{
"session": inferred.get("session"),
"vehicle": None,
"latest_event": inferred.get("latest_event"),
"payment_status": inferred.get("payment_status"),
"pricing": inferred.get("pricing"),
"gate_decision_if_exit_attempted": inferred.get("gate_decision_if_exit_attempted"),
"plate_detected_in_latest_event": True,
"inferred_unmatched": True,
"plate": inf_plate,
"event_count": inferred.get("event_count"),
}
)
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
items.sort(
key=lambda row: (
_parse_iso_datetime(((row.get("session") if isinstance(row.get("session"), dict) else {}).get("check_in_at")))
or _resolve_event_timestamp((row.get("latest_event") if isinstance(row.get("latest_event"), dict) else {}))
or epoch
),
reverse=True,
)
items = items[:limit]
parking_snapshot = _build_parking_locations_snapshot()
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"scope": {
"for_user_id": target_user_id,
"limit": limit,
"event_type": "inside",
},
"garage_occupancy": parking_snapshot["garage"],
"location_pricing": parking_snapshot["locations"],
"items": items,
}
@app.get("/parking/history")
def parking_history(
limit: int = 100,
for_user_id: Optional[str] = None,
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if limit < 1 or limit > 500:
raise HTTPException(status_code=400, detail="limit must be between 1 and 500.")
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
requester_has_global_scope = _can_view_global_records(requester_role)
target_user_id = (for_user_id or "").strip() or None
if target_user_id and not requester_has_global_scope and target_user_id != requester_id:
raise HTTPException(status_code=403, detail="You can only view your own parking history.")
if not requester_has_global_scope:
target_user_id = requester_id
session_params: Dict[str, str] = {
"select": "id,vehicle_id,plate_arabic,plate_english,check_in_at,payment_confirmed_at,check_out_at,left_within_5_minutes,status,parking_location,created_by,notes,created_at,updated_at",
"order": "check_in_at.desc",
"limit": str(limit),
}
if target_user_id:
vehicle_ids = _fetch_vehicle_ids_for_owner(target_user_id)
if not vehicle_ids:
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"scope": {
"for_user_id": target_user_id,
"limit": limit,
},
"history": [],
}
session_params["vehicle_id"] = f"in.({','.join(vehicle_ids)})"
sessions_response = requests.get(
f"{SUPABASE_URL}/rest/v1/parking_sessions",
params=session_params,
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if sessions_response.status_code != 200:
raise HTTPException(
status_code=502,
detail=f"Failed to fetch parking history: {sessions_response.text[:320]}",
)
sessions_rows = sessions_response.json()
sessions = sessions_rows if isinstance(sessions_rows, list) else []
vehicle_ids_in_history: List[str] = []
for row in sessions:
if isinstance(row, dict) and row.get("vehicle_id"):
vehicle_ids_in_history.append(str(row["vehicle_id"]))
vehicle_map: Dict[str, Dict[str, Any]] = {}
if vehicle_ids_in_history:
vehicle_response = requests.get(
f"{SUPABASE_URL}/rest/v1/vehicles",
params={
"select": "id,owner_id,plate_letters_ar,plate_numbers_ar,plate_letters_en,plate_numbers_en,car_model,car_color,is_active",
"id": f"in.({','.join(sorted(set(vehicle_ids_in_history)))})",
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if vehicle_response.status_code == 200:
vehicle_rows = vehicle_response.json()
if isinstance(vehicle_rows, list):
for vehicle in vehicle_rows:
if isinstance(vehicle, dict) and vehicle.get("id"):
vehicle_map[str(vehicle["id"])] = vehicle
session_ids: List[str] = []
for row in sessions:
if isinstance(row, dict) and row.get("id"):
session_ids.append(str(row["id"]))
events_by_session: Dict[str, List[Dict[str, Any]]] = {}
if session_ids:
events_response = requests.get(
f"{SUPABASE_URL}/rest/v1/car_events",
params={
"select": "id,session_id,event_type,ocr_arabic,ocr_english,ocr_confidence,parking_location,raw_image_path,user_split_image_path,admin_annotated_image_path,event_metadata,captured_at,created_at",
"session_id": f"in.({','.join(sorted(set(session_ids)))})",
"order": "captured_at.desc",
"limit": str(max(limit * 6, 200)),
},
headers=_supabase_headers(
api_key=SUPABASE_SERVICE_ROLE_KEY,
bearer=SUPABASE_SERVICE_ROLE_KEY,
),
timeout=SUPABASE_TIMEOUT_SECONDS,
)
if events_response.status_code == 200:
events_rows = events_response.json()
if isinstance(events_rows, list):
for event in events_rows:
if not isinstance(event, dict):
continue
sid = str(event.get("session_id") or "").strip()
if not sid:
continue
events_by_session.setdefault(sid, []).append(event)
history_items: List[Dict[str, Any]] = []
for session in sessions:
if not isinstance(session, dict):
continue
session_id = str(session.get("id") or "").strip()
vehicle_id = str(session.get("vehicle_id") or "").strip()
pricing = _compute_session_time_and_fee(session)
payment_status = _derive_payment_status_from_session(session)
history_items.append(
{
"session": session,
"payment_status": payment_status,
"left_within_5_minutes": bool(session.get("left_within_5_minutes")),
"pricing": pricing,
"gate_decision_if_exit_attempted": _build_gate_decision(
event_type="exit",
session_row=session,
plate_detected=True,
),
"vehicle": vehicle_map.get(vehicle_id),
"events": events_by_session.get(session_id, []),
"inferred_unmatched": False,
}
)
if target_user_id is None:
inferred_history = _build_inferred_unmatched_session_rows(limit=max(limit * 3, 300), inside_only=False)
for inferred in inferred_history:
history_items.append(
{
"session": inferred.get("session"),
"payment_status": inferred.get("payment_status"),
"left_within_5_minutes": False,
"pricing": inferred.get("pricing"),
"gate_decision_if_exit_attempted": inferred.get("gate_decision_if_exit_attempted"),
"vehicle": None,
"events": inferred.get("events", []),
"inferred_unmatched": True,
"plate": inferred.get("plate"),
"event_count": inferred.get("event_count"),
}
)
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
history_items.sort(
key=lambda row: (
_parse_iso_datetime(((row.get("session") if isinstance(row.get("session"), dict) else {}).get("check_in_at")))
or _resolve_event_timestamp(((row.get("events") or [None])[0] if isinstance((row.get("events") or [None])[0], dict) else {}))
or epoch
),
reverse=True,
)
history_items = history_items[:limit]
parking_snapshot = _build_parking_locations_snapshot()
return {
"status": "ok",
"requester": {
"id": requester_id,
"role": requester_role or "user",
"is_staff": requester_is_staff,
},
"scope": {
"for_user_id": target_user_id,
"limit": limit,
},
"garage_occupancy": parking_snapshot["garage"],
"location_pricing": parking_snapshot["locations"],
"history": history_items,
}
@app.post("/payments/manual/cash-confirm")
def manual_cash_confirm_payment(
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 = _resolve_requester_role(request_user)
if not _is_staff_role(requester_role):
raise HTTPException(status_code=403, detail="Only admin/security can confirm manual cash payments.")
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id is required.")
parking_location = _normalize_parking_location(payload.get("parking_location"))
# === GHOST FIX: Handle Inferred Sessions (Unregistered Cars) ===
if session_id.startswith("inferred:"):
LOGGER.info("Handling inferred session checkout for: %s", session_id)
# Extract plate info from inferred session (if possible) or use provided plate
# For simplicity, we search for the latest unmatched event for this 'inferred' car
# and create a real session for it.
plate_ar = str(payload.get("plate_arabic") or payload.get("plate") or "").strip()
if not plate_ar:
# Fallback: try to find it in the inside feed cache or inferred logic
# But usually the dashboard will send the plate now.
raise HTTPException(status_code=400, detail="plate is required for inferred session checkout.")
# 1. Create a REAL session
new_session = _insert_parking_session({
"plate_arabic": plate_ar,
"status": "inside",
"check_in_at": datetime.now(timezone.utc).isoformat(),
"parking_location": parking_location or "OPERA",
"notes": f"Inferred promote from {session_id}"
})
if not new_session:
raise HTTPException(status_code=500, detail="Failed to promote inferred session to real session.")
real_sid = str(new_session.get("id"))
LOGGER.info("Promoted inferred session to real ID: %s", real_sid)
# 2. Link any unmatched events for this plate to the new session
_link_unmatched_events_to_session(plate_ar, real_sid)
# 3. Use the REAL session ID for the rest of the flow
session_id = real_sid
session_row = new_session
else:
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.")
if parking_location:
_set_parking_session_location(session_id, parking_location)
session_row = _fetch_parking_session_by_id(session_id) or session_row
pricing = _compute_session_time_and_fee(session_row)
amount_raw = payload.get("amount_cents")
if amount_raw is None:
amount_cents = max(int(pricing.get("total_fee_cents") or 0), 1)
else:
try:
amount_cents = int(amount_raw)
except Exception:
raise HTTPException(status_code=400, detail="amount_cents must be a positive integer.")
if amount_cents <= 0:
raise HTTPException(status_code=400, detail="amount_cents must be greater than zero.")
currency = str(payload.get("currency") or PAYMOB_CURRENCY).strip().upper() or PAYMOB_CURRENCY
manual_reference = str(payload.get("manual_reference") or f"cash-{uuid4().hex[:18]}").strip()
manual_reference = manual_reference or f"cash-{uuid4().hex[:18]}"
already_paid = _parse_iso_datetime(session_row.get("payment_confirmed_at")) is not None
force_exit = bool(payload.get("force_exit", False))
if not already_paid:
_mark_parking_session_paid(session_id, payment_reference=manual_reference)
if force_exit:
now_str = datetime.now(timezone.utc).isoformat()
_update_parking_session(session_id, {
"status": "exited",
"check_out_at": now_str,
"left_within_5_minutes": True,
"notes": f"Staff cash-confirm checkout ({requester_id})"
})
plate_label = _resolve_plate_label_for_session(session_row)
if not plate_label:
plate_label = _normalize_spaces(str(payload.get("plate_arabic") or payload.get("plate") or ""))
if plate_label:
_notification_title = "✅ Cash Payment Received"
_notification_body = f"تم استلام الدفع كاش للعربية {plate_label} — تم فتح البوابة"
_notify_security_staff(
title=_notification_title,
body=_notification_body,
data={
"session_id": session_id,
"plate": plate_label,
"payment_method": "cash",
},
)
# Force checkout for manual cash confirmations (Guard Hub workflow)
_update_parking_session(session_id, {
"check_out_at": datetime.now(timezone.utc).isoformat(),
"status": "exited",
"left_within_5_minutes": True
})
payment_transaction_id: Optional[str] = None
if not already_paid:
payment_transaction_id = _insert_payment_transaction_record(
created_by=requester_id,
session_id=session_id,
amount_cents=amount_cents,
currency=currency,
merchant_order_id=f"cash-{session_id[:8]}-{uuid4().hex[:12]}",
paymob_order_id=None,
parking_location=parking_location,
request_payload={
"session_id": session_id,
"amount_cents": amount_cents,
"currency": currency,
"manual_reference": manual_reference,
"parking_location": parking_location,
},
response_payload={
"manual_reference": manual_reference,
"confirmed_by": requester_id,
"confirmed_by_role": requester_role,
},
provider="cash",
status="paid",
payment_reference=manual_reference,
)
updated_session = _fetch_parking_session_by_id(session_id) or session_row
updated_pricing = _compute_session_time_and_fee(updated_session)
return {
"status": "ok",
"provider": "cash",
"already_paid": already_paid,
"session_id": session_id,
"payment_transaction_id": payment_transaction_id,
"amount_cents": amount_cents,
"currency": currency,
"payment_reference": manual_reference,
"pricing": updated_pricing,
"payment_status": _derive_payment_status_from_session(updated_session),
"gate_decision": _build_gate_decision(
event_type="exit",
session_row=updated_session,
plate_detected=True,
),
}
@app.post("/payments/demo/confirm")
def demo_confirm_payment(
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id is required.")
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 owner_id and not requester_is_staff and owner_id != requester_id:
raise HTTPException(status_code=403, detail="You can only confirm payments for your own sessions.")
already_paid = _parse_iso_datetime(session_row.get("payment_confirmed_at")) is not None
if not already_paid:
_mark_parking_session_paid(session_id)
pricing = _compute_session_time_and_fee(session_row)
amount_cents = max(int(pricing.get("total_fee_cents") or 0), 1)
_insert_payment_transaction_record(
created_by=requester_id,
session_id=session_id,
amount_cents=amount_cents,
currency=PAYMOB_CURRENCY,
merchant_order_id=f"demo-{session_id[:8]}-{uuid4().hex[:10]}",
paymob_order_id=None,
parking_location=_normalize_parking_location(session_row.get("parking_location")),
request_payload={
"session_id": session_id,
"provider": "demo_online",
},
response_payload={
"status": "paid",
},
provider="demo_online",
status="paid",
)
if owner_id:
_insert_notification_event(
user_id=owner_id,
event_type="payment_confirmed",
title="Payment Confirmed :white_check_mark:",
body="تم تأكيد دفعك — عندك 5 دقايق للخروج",
data={
"session_id": session_id,
"payment_method": "demo_online",
},
)
updated_session = _fetch_parking_session_by_id(session_id) or session_row
return {
"session_id": session_id,
"payment_status": _derive_payment_status_from_session(updated_session),
"gate_decision": _build_gate_decision(
event_type="exit",
session_row=updated_session,
plate_detected=True,
),
"left_within_5_minutes": bool(updated_session.get("left_within_5_minutes")),
}
@app.post("/payments/cash/request")
def request_cash_payment(
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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
raise HTTPException(status_code=400, detail="session_id is required.")
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 owner_id and not requester_is_staff and owner_id != requester_id:
raise HTTPException(status_code=403, detail="You can only request cash for your own sessions.")
profile = _supabase_get_profile_by_user_id(requester_id) or {}
requester_name = _normalize_spaces(
str(profile.get("full_name") or profile.get("username") or profile.get("first_name") or "")
)
if not requester_name:
requester_name = "اليوزر"
plate_label = _resolve_plate_label_for_session(session_row)
if not plate_label:
plate_label = _normalize_spaces(str(payload.get("plate_arabic") or payload.get("plate") or ""))
if not plate_label:
plate_label = "غير معروف"
_notification_title = ":dollar: Cash Payment Needed"
_notification_body = f"اليوزر {requester_name} عايز يدفع كاش — {plate_label}"
_notify_security_staff(
title=_notification_title,
body=_notification_body,
data={
"session_id": session_id,
"plate": plate_label,
"requester_id": requester_id,
"payment_method": "cash",
},
)
old_notes = session_row.get("notes") or ""
new_notes = old_notes
if "intent_cash" not in old_notes:
new_notes = f"{old_notes} | intent_cash".strip(" |")
update_payload = {"notes": new_notes}
if session_row.get("status") == "paid":
update_payload["status"] = "entered"
update_payload["payment_confirmed_at"] = None
update_payload["notes"] = f"{new_notes} | switched_to_cash".strip(" |")
_update_parking_session(session_id, update_payload)
return {
"status": "notified",
"message": "تم إبلاغ الأمن",
}
@app.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 = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
requester_has_global_scope = _can_view_global_records(requester_role)
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,
},
}
@app.post("/payments/paymob/create")
def create_paymob_payment(
payload: Dict[str, Any] = Body(...),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
if not _paymob_configured():
return _error_response(
503,
"Payment gateway not configured",
"PAYMOB_NOT_CONFIGURED",
)
try:
request_user = _require_authenticated_user(authorization)
except HTTPException as exc:
return _error_response(exc.status_code, str(exc.detail), "AUTH_REQUIRED")
if request_user is None:
return _error_response(401, "Authentication is required.", "AUTH_REQUIRED")
requester_id = str(request_user.get("id") or "").strip()
if not requester_id:
return _error_response(401, "Authenticated user id is missing.", "AUTH_REQUIRED")
requester_role = _resolve_requester_role(request_user)
requester_is_staff = _is_staff_role(requester_role)
session_id = str(payload.get("session_id") or "").strip()
if not session_id:
return _error_response(400, "session_id is required.", "SESSION_ID_REQUIRED")
session_row = _fetch_parking_session_by_id(session_id)
if not isinstance(session_row, dict):
return _error_response(404, "parking session was not found.", "SESSION_NOT_FOUND")
parking_location = _normalize_parking_location(payload.get("parking_location"))
pricing = _compute_session_time_and_fee(session_row, parking_location_override=parking_location)
amount_raw = payload.get("amount_cents")
if amount_raw is None:
amount_cents = max(int(pricing.get("total_fee_cents") or 0), 1)
else:
try:
amount_cents = int(amount_raw)
except Exception:
return _error_response(400, "amount_cents must be a positive integer.", "INVALID_AMOUNT")
if amount_cents <= 0:
amount_cents = max(int(pricing.get("total_fee_cents") or 0), 1)
currency = str(payload.get("currency") or PAYMOB_CURRENCY).strip().upper() or PAYMOB_CURRENCY
owner_id = _get_parking_session_owner_id(session_id)
if not owner_id:
return _error_response(404, "parking session was not found.", "SESSION_NOT_FOUND")
if not requester_is_staff and requester_id != owner_id:
return _error_response(
403,
"You can only create payments for your own sessions.",
"FORBIDDEN_SESSION",
)
if _parse_iso_datetime(session_row.get("payment_confirmed_at")) is not None:
return _error_response(409, "Session is already paid.", "ALREADY_PAID")
try:
if parking_location:
_set_parking_session_location(session_id, parking_location)
merchant_order_id = f"ain-{session_id[:8]}-{uuid4().hex[:12]}"
checkout = _paymob_create_checkout(
amount_cents=amount_cents,
currency=currency,
merchant_order_id=merchant_order_id,
request_user=request_user,
)
payment_id = _insert_payment_transaction_record(
created_by=requester_id,
session_id=session_id,
amount_cents=amount_cents,
currency=currency,
merchant_order_id=merchant_order_id,
paymob_order_id=checkout.get("paymob_order_id"),
parking_location=parking_location,
request_payload={
"session_id": session_id,
"amount_cents": amount_cents,
"currency": currency,
"merchant_order_id": merchant_order_id,
"parking_location": parking_location,
},
response_payload={
"paymob_order_id": checkout.get("paymob_order_id"),
"iframe_url": checkout.get("iframe_url"),
},
)
return {
"status": "ok",
"provider": "paymob",
"payment_transaction_id": payment_id,
"merchant_order_id": merchant_order_id,
"paymob_order_id": checkout.get("paymob_order_id"),
"session_id": session_id,
"amount_cents": amount_cents,
"currency": currency,
"pricing": pricing,
"parking_location": parking_location,
"payment_url": checkout.get("iframe_url"),
}
except HTTPException as exc:
return _error_response(exc.status_code, str(exc.detail), "PAYMOB_CREATE_FAILED")
except Exception as exc:
return _error_response(502, f"Failed to create Paymob payment: {exc}", "PAYMOB_CREATE_FAILED")
@app.post("/payments/paymob/webhook")
def paymob_webhook(payload: Dict[str, Any] = Body(...)) -> Dict[str, Any]:
obj = payload.get("obj") if isinstance(payload, dict) else None
if not isinstance(obj, dict):
obj = payload if isinstance(payload, dict) else {}
order_obj = obj.get("order") if isinstance(obj.get("order"), dict) else {}
merchant_order_id = str(order_obj.get("merchant_order_id") or payload.get("merchant_order_id") or "").strip()
if not merchant_order_id:
raise HTTPException(status_code=400, detail="merchant_order_id was not found in webhook payload.")
paymob_order_id = str(order_obj.get("id") or obj.get("order_id") or payload.get("order_id") or "").strip() or None
payment_reference = str(obj.get("id") or payload.get("id") or "").strip() or None
is_pending = bool(obj.get("pending"))
is_success = bool(obj.get("success")) and not is_pending
status = "pending"
if is_success:
status = "paid"
elif not is_pending:
status = "failed"
try:
_update_payment_transaction_by_merchant_order_id(
merchant_order_id=merchant_order_id,
status=status,
payment_reference=payment_reference,
paymob_order_id=paymob_order_id,
response_payload=payload if isinstance(payload, dict) else {},
)
session_id = _get_payment_session_id_by_merchant_order_id(merchant_order_id)
if is_success and session_id:
_mark_parking_session_paid(session_id, payment_reference=payment_reference)
return {
"status": "ok",
"payment_status": status,
"merchant_order_id": merchant_order_id,
"paymob_order_id": paymob_order_id,
"session_id": session_id,
}
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to process Paymob webhook: {exc}") from exc
@app.post("/admin/maintenance/weekly-refresh")
def admin_weekly_refresh(
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()
requester_role = _resolve_requester_role(request_user)
if not _is_staff_role(requester_role):
raise HTTPException(status_code=403, detail="Only admin/security can run weekly refresh.")
try:
refresh_result = _run_weekly_refresh_non_core()
return {
"status": "ok",
"requested_by": requester_id,
"requested_by_role": requester_role,
"core_tables_protected": sorted(CORE_TABLES),
"refresh": refresh_result,
}
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Weekly refresh failed: {exc}") from exc
@app.post("/predict")
async def predict(
image: UploadFile = File(...),
event_type: Optional[str] = Form(default="entry"),
parking_location: Optional[str] = Form(default=None),
camera_source: Optional[str] = Form(default=None),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
request_user = _require_authenticated_user(authorization)
normalized_parking_location = _normalize_parking_location(parking_location)
normalized_camera_source = _normalize_camera_source(camera_source)
normalized_event_type = _normalize_event_type(event_type)
if image.content_type and not image.content_type.startswith("image/") and image.content_type != "application/octet-stream":
raise HTTPException(status_code=400, detail="Only image uploads are supported.")
image_bytes = await image.read()
if not image_bytes:
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
# Always use remote HuggingFace Space for inference
response = _run_pipeline_remote(
image_bytes=image_bytes,
filename=image.filename,
content_type=image.content_type,
)
if not isinstance(response, dict):
raise HTTPException(status_code=502, detail="Inference pipeline returned an invalid payload.")
response["inference_provider"] = "remote"
response["filename"] = image.filename
response["event_type"] = normalized_event_type
response["parking_location"] = normalized_parking_location
response["camera_source"] = normalized_camera_source
plate_payload = _build_plate_payload(response.get("plate_info", {}))
response["plate_payload"] = plate_payload
response["plate"] = plate_payload.get("arabic_text") or "N/A"
response["plate_text_ar"] = plate_payload.get("arabic_text") or "N/A"
response["plate_text_en"] = plate_payload.get("english_text") or "N/A"
# Flat plate fields for Flutter
_ar_parts = plate_payload.get("arabic") if isinstance(plate_payload.get("arabic"), dict) else {}
_en_parts = plate_payload.get("english") if isinstance(plate_payload.get("english"), dict) else {}
response["plate_letters_ar"] = _ar_parts.get("letters") or ""
response["plate_numbers_ar"] = _ar_parts.get("numbers") or ""
response["plate_letters_en"] = _en_parts.get("letters") or ""
response["plate_numbers_en"] = _en_parts.get("numbers") or ""
original_b64 = base64.b64encode(image_bytes).decode("utf-8")
response["processed_image_base64"] = str(
(response.get("user_page", {}) or {}).get("split_image_base64") or original_b64
)
response["annotated_image_base64"] = str(
(response.get("admin_page", {}) or {}).get("annotated_image_base64") or original_b64
)
if request_user:
response["request_user"] = {
"id": request_user.get("id"),
"email": request_user.get("email"),
}
response["supabase"] = _persist_prediction_to_supabase(
original_image_bytes=image_bytes,
response_payload=response,
request_user=request_user,
parking_location=normalized_parking_location,
event_type=normalized_event_type,
camera_source=normalized_camera_source,
)
supabase_payload = response.get("supabase") if isinstance(response.get("supabase"), dict) else {}
response["app_registration"] = supabase_payload.get(
"app_registration",
{
"is_registered": False,
"vehicle_id": None,
"owner_id": None,
},
)
response["new_car_alert"] = supabase_payload.get(
"new_car_alert",
{
"is_new_car": None,
"notify_roles": ["admin", "security"],
"action": "lookup_unavailable",
"reason": "supabase_unavailable",
"message": "Could not verify registration status.",
"vehicle_id": None,
"owner_id": None,
"plate_arabic": None,
"plate_english": None,
},
)
response["payment_status"] = supabase_payload.get("payment_status", "unknown")
response["left_within_5_minutes"] = bool(supabase_payload.get("left_within_5_minutes", False))
response["pricing"] = supabase_payload.get(
"pricing",
_compute_session_time_and_fee(None, parking_location_override=normalized_parking_location),
)
response["gate_decision"] = supabase_payload.get(
"gate_decision",
_build_gate_decision(
event_type=normalized_event_type,
session_row=None,
plate_detected=_is_plate_detected(response.get("plate_info", {})),
),
)
response["session_id"] = supabase_payload.get("session_id")
response["event_id"] = supabase_payload.get("event_id")
# === DEBUG LOGS (remove after bugs confirmed fixed) ===
LOGGER.info("=== PREDICT START ===")
LOGGER.info("event_type: %s", normalized_event_type)
LOGGER.info("plate_ar: %s", response.get("plate_text_ar"))
LOGGER.info("plate_en: %s", response.get("plate_text_en"))
LOGGER.info("vehicle_found: %s", response.get("app_registration", {}).get("is_registered"))
LOGGER.info("vehicle_id: %s", response.get("app_registration", {}).get("vehicle_id"))
LOGGER.info("session_id: %s", response.get("session_id"))
LOGGER.info("is_registered: %s", response.get("app_registration", {}).get("is_registered"))
LOGGER.info("=== PREDICT END ===")
return response
@app.post("/developer/predict")
async def developer_predict(
image: UploadFile = File(...),
event_type: Optional[str] = Form(default="entry"),
parking_location: Optional[str] = Form(default=None),
camera_source: Optional[str] = Form(default=None),
authorization: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
request_user, requester_role = _require_developer_user(authorization)
response = await predict(
image=image,
event_type=event_type,
parking_location=parking_location,
camera_source=camera_source,
authorization=authorization,
)
response["developer_test"] = {
"enabled": True,
"request_user": {
"id": request_user.get("id"),
"email": request_user.get("email"),
"role": requester_role,
},
}
return response
# ─── ESP32 Gate Command Queue ────────────────────────────────────
# In-memory command queue for ESP32 gate controllers.
# POST /gate/trigger → queues an "open" command (called by security dashboard)
# GET /gate/poll → ESP32 polls this; returns & clears the pending command
_gate_command_lock = Lock()
_gate_pending_commands: List[Dict[str, Any]] = []
@app.post("/gate/trigger")
def gate_trigger(
payload: Dict[str, Any] = Body(default={}),
) -> Dict[str, Any]:
"""Queue a gate-open command for ESP32 devices to pick up."""
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(timezone.utc).isoformat(),
}
with _gate_command_lock:
_gate_pending_commands.append(cmd)
# Keep only last 20 commands to prevent memory leak
if len(_gate_pending_commands) > 20:
_gate_pending_commands.pop(0)
LOGGER.info("Gate trigger queued: %s", cmd)
return {"status": "queued", "command": cmd}
@app.get("/gate/poll")
def gate_poll(
location: str = "OPERA",
) -> Dict[str, Any]:
"""ESP32 polls this endpoint. Returns the oldest pending command and removes it."""
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"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"API:app",
host=os.getenv("API_HOST", "0.0.0.0"),
port=int(os.getenv("PORT", os.getenv("API_PORT", "8000"))),
reload=False,
)