"""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