Spaces:
Runtime error
Runtime error
| """Supabase client and data access helpers.""" | |
| from typing import Optional | |
| from fastapi import HTTPException | |
| from supabase import Client, create_client | |
| from config import SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL | |
| _supabase: Optional[Client] = None | |
| def get_supabase() -> Client: | |
| """Return a singleton Supabase client using the service role key.""" | |
| global _supabase | |
| if _supabase is None: | |
| if not SUPABASE_URL or not SUPABASE_SERVICE_ROLE_KEY: | |
| raise HTTPException( | |
| status_code=500, | |
| detail="Server missing SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY secrets.", | |
| ) | |
| _supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) | |
| return _supabase | |
| def fetch_all_submissions(columns: str, limit: Optional[int] = None) -> list[dict]: | |
| """Fetch rows from submissions, paginating past Supabase's 1000-row cap.""" | |
| sb = get_supabase() | |
| rows: list[dict] = [] | |
| page = 0 | |
| page_size = 1000 | |
| while True: | |
| start = page * page_size | |
| end = start + page_size - 1 | |
| query = ( | |
| sb.table("submissions") | |
| .select(columns) | |
| .order("submitted_at", desc=True) | |
| .range(start, end) | |
| ) | |
| result = query.execute() | |
| batch = result.data or [] | |
| rows.extend(batch) | |
| if len(batch) < page_size: | |
| break | |
| if limit and len(rows) >= limit: | |
| break | |
| page += 1 | |
| return rows[:limit] if limit else rows | |
| def count_all_submissions() -> int: | |
| """Cheap row count, used to decide whether the in-process duplicate-check | |
| cache needs a full reload (see services/duplicate_service.py).""" | |
| sb = get_supabase() | |
| result = sb.table("submissions").select("id", count="exact", head=True).execute() | |
| return result.count or 0 | |
| def verify_access_code(code: str, email: str) -> Optional[dict]: | |
| """Look up a team by access code AND member email via the | |
| verify_access_code RPC, using the service role key (bypasses RLS — this | |
| is the only place access codes are checked). Both must match: the code | |
| alone is not enough.""" | |
| sb = get_supabase() | |
| result = sb.rpc("verify_access_code", {"code": code, "member_email": email}).execute() | |
| rows = result.data or [] | |
| row = rows[0] if isinstance(rows, list) else rows | |
| return row or None | |
| def insert_submission(row: dict) -> dict: | |
| sb = get_supabase() | |
| try: | |
| result = sb.table("submissions").insert(row).select("id").execute() | |
| except Exception as exc: | |
| client_submission_id = row.get("client_submission_id") | |
| team_id = row.get("team_id") | |
| # Unique violation on (team_id, client_submission_id) means this is a | |
| # retried submit (offline outbox) whose earlier attempt actually | |
| # succeeded server-side but never got its response back to the | |
| # client. Treat that as success and hand back the existing row | |
| # instead of erroring the retry. | |
| if client_submission_id and "duplicate key" in str(exc).lower(): | |
| existing = ( | |
| sb.table("submissions") | |
| .select("id") | |
| .eq("team_id", team_id) | |
| .eq("client_submission_id", client_submission_id) | |
| .limit(1) | |
| .execute() | |
| ) | |
| if existing.data: | |
| return existing.data[0] | |
| raise HTTPException(status_code=500, detail=f"Insert failed: {exc}") from exc | |
| data = result.data or [] | |
| if not data: | |
| raise HTTPException(status_code=500, detail="Insert did not return a row") | |
| return data[0] | |
| def fetch_submissions_for_team(team_id: str, columns: str) -> list[dict]: | |
| sb = get_supabase() | |
| result = ( | |
| sb.table("submissions") | |
| .select(columns) | |
| .eq("team_id", team_id) | |
| .order("submitted_at", desc=True) | |
| .execute() | |
| ) | |
| return result.data or [] | |
| def count_submissions_for_team(team_id: str) -> int: | |
| sb = get_supabase() | |
| result = ( | |
| sb.table("submissions") | |
| .select("id", count="exact", head=True) | |
| .eq("team_id", team_id) | |
| .execute() | |
| ) | |
| return result.count or 0 | |
| def update_judge_reviewed(submission_id: str, reviewed: bool) -> None: | |
| sb = get_supabase() | |
| sb.table("submissions").update({"judge_reviewed": reviewed}).eq("id", submission_id).execute() | |
| # --- Judging (post-event blind review) -------------------------------------- | |
| def verify_judge_code(code: str) -> Optional[dict]: | |
| """Look up a judge by access code via the verify_judge_code RPC, same | |
| pattern as verify_access_code for teams -- service role only.""" | |
| sb = get_supabase() | |
| result = sb.rpc("verify_judge_code", {"code": code}).execute() | |
| rows = result.data or [] | |
| row = rows[0] if isinstance(rows, list) else rows | |
| return row or None | |
| def create_judge(judge_name: str, access_code: str) -> dict: | |
| sb = get_supabase() | |
| result = sb.table("judges").insert( | |
| {"judge_name": judge_name, "access_code": access_code} | |
| ).execute() | |
| data = result.data or [] | |
| if not data: | |
| raise HTTPException(status_code=500, detail="Insert did not return a row") | |
| return data[0] | |
| def list_judges() -> list[dict]: | |
| sb = get_supabase() | |
| result = ( | |
| sb.table("judges") | |
| .select("judge_id,judge_name,access_code,created_at") | |
| .order("created_at", desc=True) | |
| .execute() | |
| ) | |
| return result.data or [] | |
| def fetch_unsampled_submission_ids_by_team() -> dict[str, list[str]]: | |
| """Candidates for judging, grouped by team_id: not already sampled, and | |
| not already flagged as a duplicate -- no point spending judge time on a | |
| row that won't be credited anyway. Grouped so sampling can be done | |
| per-team (stratified), rather than one flat pool that could leave some | |
| teams with zero items sampled just by chance.""" | |
| rows = fetch_all_submissions("id,team_id,sampled_for_judging,flag_duplicate") | |
| by_team: dict[str, list[str]] = {} | |
| for row in rows: | |
| if row.get("sampled_for_judging") or row.get("flag_duplicate"): | |
| continue | |
| team_id = row.get("team_id") | |
| if not team_id: | |
| continue | |
| by_team.setdefault(team_id, []).append(row["id"]) | |
| return by_team | |
| def mark_sampled_for_judging(submission_ids: list[str]) -> None: | |
| sb = get_supabase() | |
| for start in range(0, len(submission_ids), 200): | |
| batch = submission_ids[start : start + 200] | |
| if batch: | |
| sb.table("submissions").update( | |
| {"sampled_for_judging": True, "sampled_at": "now()"} | |
| ).in_("id", batch).execute() | |
| def fetch_judge_queue(judge_id: str) -> list[dict]: | |
| """All sampled submissions this judge hasn't labeled yet. Text only -- | |
| callers (routers/judge.py) must not select or return original labels.""" | |
| sb = get_supabase() | |
| already = ( | |
| sb.table("judge_labels") | |
| .select("submission_id") | |
| .eq("judge_id", judge_id) | |
| .execute() | |
| ) | |
| already_ids = {row["submission_id"] for row in (already.data or [])} | |
| result = ( | |
| sb.table("submissions") | |
| .select("id,text") | |
| .eq("sampled_for_judging", True) | |
| .execute() | |
| ) | |
| rows = result.data or [] | |
| return [row for row in rows if row["id"] not in already_ids] | |
| def upsert_judge_label(judge_id: str, row: dict) -> dict: | |
| sb = get_supabase() | |
| payload = {**row, "judge_id": judge_id} | |
| result = ( | |
| sb.table("judge_labels") | |
| .upsert(payload, on_conflict="submission_id,judge_id") | |
| .execute() | |
| ) | |
| data = result.data or [] | |
| if not data: | |
| raise HTTPException(status_code=500, detail="Judge label upsert did not return a row") | |
| return data[0] | |
| def fetch_sampled_submissions_with_labels(columns: str) -> list[dict]: | |
| """Sampled submissions (with their original labels) for the admin | |
| judge-report -- admin-only, never exposed to judges.""" | |
| sb = get_supabase() | |
| result = ( | |
| sb.table("submissions") | |
| .select(columns) | |
| .eq("sampled_for_judging", True) | |
| .execute() | |
| ) | |
| return result.data or [] | |
| def fetch_all_judge_labels() -> list[dict]: | |
| sb = get_supabase() | |
| result = sb.table("judge_labels").select("*").execute() | |
| return result.data or [] | |
| # --- Admin accounts (multiple named organizer logins) ---------------------- | |
| def fetch_admin_by_email(email: str) -> Optional[dict]: | |
| """Look up an admin by email via the verify_admin_email RPC (service | |
| role only) -- returns the bcrypt hash for admin_service.py to check, | |
| never compared in SQL.""" | |
| sb = get_supabase() | |
| result = sb.rpc("verify_admin_email", {"admin_email": email}).execute() | |
| rows = result.data or [] | |
| row = rows[0] if isinstance(rows, list) else rows | |
| return row or None | |
| def count_admins() -> int: | |
| sb = get_supabase() | |
| result = sb.table("admins").select("admin_id", count="exact").execute() | |
| return result.count or 0 | |
| def create_admin(admin_name: str, email: str, password_hash: str) -> dict: | |
| sb = get_supabase() | |
| result = ( | |
| sb.table("admins") | |
| .insert({"admin_name": admin_name, "email": email, "password_hash": password_hash}) | |
| .execute() | |
| ) | |
| data = result.data or [] | |
| if not data: | |
| raise HTTPException(status_code=500, detail="Insert did not return a row") | |
| return data[0] | |
| def list_admins() -> list[dict]: | |
| sb = get_supabase() | |
| result = ( | |
| sb.table("admins") | |
| .select("admin_id,admin_name,email,created_at") | |
| .order("created_at", desc=True) | |
| .execute() | |
| ) | |
| return result.data or [] | |
| def list_teams() -> list[dict]: | |
| sb = get_supabase() | |
| result = sb.table("teams").select( | |
| "team_id,team_name,access_code,member_emails,created_at" | |
| ).order("created_at", desc=True).execute() | |
| return result.data or [] | |
| def create_team(team_id: str, team_name: str, access_code: str, member_emails: list[str]) -> dict: | |
| sb = get_supabase() | |
| result = ( | |
| sb.table("teams") | |
| .insert( | |
| { | |
| "team_id": team_id, | |
| "team_name": team_name, | |
| "access_code": access_code, | |
| "member_emails": member_emails, | |
| } | |
| ) | |
| .execute() | |
| ) | |
| data = result.data or [] | |
| if not data: | |
| raise HTTPException(status_code=500, detail="Insert did not return a row") | |
| return data[0] | |