import os import re import logging from pathlib import Path from typing import Dict, List, Optional, Any from datetime import timezone from urllib.parse import urlparse BASE_DIR = Path(__file__).resolve().parent.parent.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") if not LOGGER.handlers: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") TZ_UTC = timezone.utc 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 try: return float(raw.strip()) except ValueError: LOGGER.warning("Invalid float env for %s=%r. Using default %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 try: return int(raw.strip()) except ValueError: LOGGER.warning("Invalid int env for %s=%r. Using default %s", var_name, raw, default) return default 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" PARKING_HOURLY_RATE_EGP = _float_env("PARKING_HOURLY_RATE_EGP", 30.0) def _normalize_location_key_for_config_internal(value: Optional[str]) -> Optional[str]: if value is None: return None raw = str(value).strip().upper() if not raw: return None return "".join(raw.split()) or None 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_internal(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_internal(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_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_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_internal(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 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()] 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 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"} 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 = os.getenv("MODEL_INFERENCE_PROVIDER", "remote").strip().lower() MODEL_SERVICE_URL = os.getenv("MODEL_SERVICE_URL", "https://pant0x-ealpr-ocr-v2.hf.space").strip().rstrip("/") MODEL_SERVICE_INFER_PATH = os.getenv("MODEL_SERVICE_INFER_PATH", "/predict").strip() or "/predict" MODEL_SERVICE_ENABLE_LOCAL_ENDPOINT = _bool_env("MODEL_SERVICE_ENABLE_LOCAL_ENDPOINT", default=False) 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" HF_SPACE_ID = os.getenv("HF_SPACE_ID", "Pant0x/EALPR_OCR_V2").strip() STAFF_ROLES = {"admin", "security"} BARRIER_ROLES = {"barrier"} DEVELOPER_ROLES = {"developer"} CORE_TABLES = {"profiles", "vehicles", "parking_sessions", "car_events"} SUPPORTED_EVENT_TYPES = {"entry", "exit", "ocr_scan"} SUPPORTED_OTP_CHANNELS = {"email", "phone"} 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}$") 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}$") MOBILE_REDIRECT_SCHEME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*$") 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" 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" 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