Spaces:
Running
Running
| """Supabase persistence layer for ring-size-cv web demo. | |
| Graceful degradation: if SUPABASE_URL or SUPABASE_SERVICE_KEY env vars | |
| are missing, all functions return None/empty and the app works without | |
| persistence. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import os | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| logger = logging.getLogger(__name__) | |
| _client = None | |
| _initialized = False | |
| def _get_client(): | |
| """Lazy-init Supabase client. Returns None if persistence is disabled. | |
| Persistence is disabled when either: | |
| - SUPABASE_URL / SUPABASE_SERVICE_KEY is missing, or | |
| - RING_DISABLE_SUPABASE is set to a truthy value (explicit opt-out, so | |
| local dev sessions don't upload photos + result PNGs to the real | |
| bucket on every request). | |
| """ | |
| global _client, _initialized | |
| if _initialized: | |
| return _client | |
| _initialized = True | |
| disable = os.environ.get("RING_DISABLE_SUPABASE", "").strip().lower() | |
| if disable in ("1", "true", "yes", "on"): | |
| logger.info("RING_DISABLE_SUPABASE set — persistence disabled") | |
| return None | |
| url = os.environ.get("SUPABASE_URL", "").strip() | |
| key = os.environ.get("SUPABASE_SERVICE_KEY", "").strip() | |
| if not url or not key: | |
| logger.warning("SUPABASE_URL or SUPABASE_SERVICE_KEY not set — persistence disabled") | |
| return None | |
| try: | |
| from supabase import create_client | |
| _client = create_client(url, key) | |
| logger.info("Supabase client initialized (%s)", url) | |
| except Exception as e: | |
| logger.error("Failed to initialize Supabase client: %s", e) | |
| _client = None | |
| return _client | |
| BUCKET = "ring-measurements" | |
| def upload_file(local_path: str, storage_path: str) -> Optional[str]: | |
| """Upload a file to Supabase Storage. Returns public URL or None.""" | |
| client = _get_client() | |
| if client is None: | |
| return None | |
| try: | |
| with open(local_path, "rb") as f: | |
| data = f.read() | |
| # Determine content type | |
| suffix = Path(local_path).suffix.lower() | |
| content_type = { | |
| ".jpg": "image/jpeg", | |
| ".jpeg": "image/jpeg", | |
| ".png": "image/png", | |
| }.get(suffix, "application/octet-stream") | |
| resp = client.storage.from_(BUCKET).upload( | |
| storage_path, | |
| data, | |
| file_options={"content-type": content_type}, | |
| ) | |
| logger.info("Storage upload %s: %s bytes, response=%s", storage_path, len(data), resp) | |
| public_url = client.storage.from_(BUCKET).get_public_url(storage_path) | |
| return public_url | |
| except Exception as e: | |
| logger.error("Failed to upload %s (%s bytes): %s", storage_path, | |
| os.path.getsize(local_path) if os.path.exists(local_path) else "missing", e) | |
| return None | |
| def save_measurement(record: Dict[str, Any]) -> Optional[str]: | |
| """Insert a measurement record. Returns row UUID or None.""" | |
| client = _get_client() | |
| if client is None: | |
| return None | |
| try: | |
| resp = client.table("measurements").insert(record).execute() | |
| if resp.data and len(resp.data) > 0: | |
| return resp.data[0].get("id") | |
| return None | |
| except Exception as e: | |
| logger.error("Failed to save measurement: %s", e) | |
| return None | |
| def list_measurements(limit: int = 200, offset: int = 0) -> List[Dict[str, Any]]: | |
| """Fetch measurements for admin page, newest first.""" | |
| client = _get_client() | |
| if client is None: | |
| return [] | |
| try: | |
| resp = ( | |
| client.table("measurements") | |
| .select("*") | |
| .order("created_at", desc=True) | |
| .range(offset, offset + limit - 1) | |
| .execute() | |
| ) | |
| return resp.data or [] | |
| except Exception as e: | |
| logger.error("Failed to list measurements: %s", e) | |
| return [] | |
| STATS_COLUMNS = ( | |
| "id,created_at,kol_name,mode,ring_model,confidence,fail_reason," | |
| "overall_best_size,ring_fit,gt_index_size,gt_middle_size,gt_ring_size," | |
| "finger_index,photo_url,per_finger,feedback_rating,feedback_message" | |
| ) | |
| def list_measurements_for_stats(limit: int = 5000) -> List[Dict[str, Any]]: | |
| """Lightweight projection used by the admin stats endpoint. | |
| Skips `result_json` (the heaviest blob). `per_finger` is included so the | |
| size-distribution chart can use the index-finger size and detect demo | |
| runs (`photo_url` is null when the user clicks "try with sample"). | |
| """ | |
| client = _get_client() | |
| if client is None: | |
| return [] | |
| try: | |
| resp = ( | |
| client.table("measurements") | |
| .select(STATS_COLUMNS) | |
| .order("created_at", desc=True) | |
| .limit(limit) | |
| .execute() | |
| ) | |
| return resp.data or [] | |
| except Exception as e: | |
| logger.error("Failed to list measurements for stats: %s", e) | |
| return [] | |
| FEEDBACK_ALLOWED_FIELDS = {"feedback_rating", "feedback_message"} | |
| # Return values for update_measurement_feedback. Stringly-typed because the | |
| # caller (the /api/feedback endpoint) needs to distinguish "persistence is | |
| # turned off in this env" (still report success to the user) from "the row | |
| # really doesn't exist" (surface a real error). | |
| FEEDBACK_OK = "ok" | |
| FEEDBACK_NO_ROW = "no_row" | |
| FEEDBACK_DISABLED = "disabled" | |
| FEEDBACK_ERROR = "error" | |
| def update_measurement_feedback(run_id: str, updates: Dict[str, Any]) -> str: | |
| """Attach feedback (rating + message) to an existing measurement row. | |
| Looks up the row by `run_id` (stamped at measure-time by app.py) and | |
| patches only the whitelisted feedback columns. Pass only the columns | |
| you actually want to change — keys not present are left untouched. | |
| Returns one of FEEDBACK_OK / FEEDBACK_NO_ROW / FEEDBACK_DISABLED / | |
| FEEDBACK_ERROR so the caller can decide whether to surface a failure. | |
| """ | |
| client = _get_client() | |
| if client is None: | |
| return FEEDBACK_DISABLED | |
| safe = {k: v for k, v in updates.items() if k in FEEDBACK_ALLOWED_FIELDS} | |
| if not safe or not run_id: | |
| return FEEDBACK_NO_ROW | |
| try: | |
| resp = client.table("measurements").update(safe).eq("run_id", run_id).execute() | |
| return FEEDBACK_OK if resp.data else FEEDBACK_NO_ROW | |
| except Exception as e: | |
| logger.error("Failed to update feedback for run %s: %s", run_id, e) | |
| return FEEDBACK_ERROR | |
| def delete_measurement(measurement_id: str) -> bool: | |
| """Delete a measurement record by ID.""" | |
| client = _get_client() | |
| if client is None: | |
| return False | |
| try: | |
| client.table("measurements").delete().eq("id", measurement_id).execute() | |
| return True | |
| except Exception as e: | |
| logger.error("Failed to delete %s: %s", measurement_id, e) | |
| return False | |
| GT_ALLOWED_FIELDS = {"gt_index_size", "gt_middle_size", "gt_ring_size", "ring_fit", "gt_best_finger", "gt_notes"} | |
| def update_ground_truth(measurement_id: str, updates: Dict[str, Any]) -> bool: | |
| """Update only ground-truth columns for a measurement.""" | |
| client = _get_client() | |
| if client is None: | |
| return False | |
| # Whitelist filter | |
| safe = {k: v for k, v in updates.items() if k in GT_ALLOWED_FIELDS} | |
| if not safe: | |
| return False | |
| try: | |
| client.table("measurements").update(safe).eq("id", measurement_id).execute() | |
| return True | |
| except Exception as e: | |
| logger.error("Failed to update GT for %s: %s", measurement_id, e) | |
| return False | |