Spaces:
Running on Zero
Running on Zero
| from datetime import datetime, timezone | |
| from typing import Any, Dict, List, Optional | |
| from fastapi import APIRouter, Body, Header, HTTPException | |
| import requests | |
| from app.core.config import ( | |
| SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_TIMEOUT_SECONDS, | |
| VEHICLE_SELECT_FIELDS, supabase_configured, LOGGER, SUPABASE_ANON_KEY, | |
| ) | |
| from app.security.auth import ( | |
| require_authenticated_user, _supabase_headers, _normalize_spaces, | |
| ) | |
| from app.api.helpers import _resolve_otp_identifier, _verify_supabase_otp, _normalize_otp_channel | |
| from app.services.supabase_client import supabase_get_profile_by_user_id | |
| router = APIRouter(prefix="/vehicles", tags=["vehicles"]) | |
| 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 _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 opt in ["plate_letters_en", "plate_numbers_en", "car_name", "car_model", "car_color"]: | |
| if vehicle_payload.get(opt): | |
| payload[opt] = vehicle_payload[opt] | |
| 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}: | |
| raise HTTPException(status_code=400, detail=f"Failed to save 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_for_owner(owner_id: str, vehicle_id: str) -> Optional[Dict[str, Any]]: | |
| 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 _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]]: | |
| 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}: | |
| raise HTTPException(status_code=400, detail=f"Failed to update vehicle: {response.text[:260]}") | |
| 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: | |
| 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}: | |
| raise HTTPException(status_code=400, detail=f"Failed to delete vehicle: {response.text[:260]}") | |
| if not response.text: | |
| return True | |
| rows = response.json() | |
| return isinstance(rows, list) and len(rows) > 0 | |
| 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 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(requester_id, vehicle_payload) | |
| if not isinstance(created_vehicle, dict): | |
| raise HTTPException(status_code=502, detail="Vehicle was not created.") | |
| return {"status": "ok", "vehicle": created_vehicle} | |
| 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} | |
| 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() | |
| 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 = payload.get("is_active") | |
| if isinstance(raw, bool): | |
| update_payload["is_active"] = raw | |
| elif raw is not None: | |
| update_payload["is_active"] = str(raw).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} | |
| 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() | |
| 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} | |
| 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, | |
| purpose="vehicle_verify", | |
| ) | |
| 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.", | |
| } | |