ring-sizer / web_demo /supabase_client.py
feng-x's picture
Upload folder using huggingface_hub
dddedae verified
"""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