Spaces:
Runtime error
Runtime error
| """Organizer-only endpoints. Every route here requires a valid admin session | |
| token (see utils/auth.require_admin) verified server-side — unlike the old | |
| frontend gate, the password check and the resulting authorization never touch | |
| the browser bundle.""" | |
| import logging | |
| import re | |
| import secrets | |
| from typing import Annotated | |
| from fastapi import APIRouter, Depends, HTTPException | |
| import database | |
| import random | |
| from config import CATEGORIES, NON_BIASED_TARGET, QUOTAS | |
| from models.schemas import ( | |
| AdminAccountResponse, | |
| CreateAdminRequest, | |
| CreateJudgeRequest, | |
| CreateTeamRequest, | |
| JudgeResponse, | |
| MarkReviewedRequest, | |
| SampleForJudgingRequest, | |
| SampleForJudgingResponse, | |
| TeamResponse, | |
| ) | |
| from services.admin_service import hash_password | |
| from services.email_service import send_team_access_code | |
| from services.judge_service import build_judge_report | |
| from services.leaderboard_service import build_leaderboard | |
| from services.qa_batch import run_qa_batch | |
| from utils.auth import require_admin | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter(prefix="/admin", tags=["admin"]) | |
| AdminSession = Annotated[dict, Depends(require_admin)] | |
| _ADMIN_COLUMNS = ( | |
| "id,team_id,text," | |
| + ",".join(f'"{c}"' if c[0].isupper() else c for c in CATEGORIES) | |
| + ",source_platform,comment,submitted_at,flag_duplicate,flag_pii,judge_reviewed" | |
| ) | |
| def all_submissions(_: AdminSession): | |
| """Full submissions table — admin only. Teams cannot reach this endpoint.""" | |
| return database.fetch_all_submissions(_ADMIN_COLUMNS) | |
| def leaderboard(_: AdminSession): | |
| """Full ranked team standings — team_id, team_name, raw + credited | |
| submission counts, and quota completion %, ranked by completion % (see | |
| services/leaderboard_service). Every team is included, even ones with | |
| zero submissions so far. A trimmed rank/name/% view of the same ranking | |
| is also available to every logged-in team at GET /leaderboard.""" | |
| return build_leaderboard(force_refresh=True) | |
| def mark_reviewed(body: MarkReviewedRequest, _: AdminSession): | |
| database.update_judge_reviewed(body.id, body.reviewed) | |
| return {"ok": True} | |
| def qa_batch(_: AdminSession): | |
| logger.info("Admin triggered QA batch") | |
| return run_qa_batch() | |
| def export_json(_: AdminSession): | |
| """Full export with exact published dataset column names.""" | |
| rows = database.fetch_all_submissions(_ADMIN_COLUMNS) | |
| export_keys = [ | |
| "team_id", "text", "gender", "religional", "caste", "religion", | |
| "appearence", "socialstatus", "amiguity", "political", "Age", | |
| "Disablity", "source_platform", "comment", "submitted_at", | |
| "flag_duplicate", "flag_pii", "judge_reviewed", | |
| ] | |
| return [{key: row.get(key) for key in export_keys} for row in rows] | |
| def quota_report(_: AdminSession): | |
| rows = database.fetch_all_submissions( | |
| "team_id," + ",".join(f'"{c}"' if c[0].isupper() else c for c in CATEGORIES) | |
| ) | |
| team_ids = sorted({r["team_id"] for r in rows if r.get("team_id")}) | |
| report = {} | |
| for team_id in team_ids: | |
| team_rows = [r for r in rows if r.get("team_id") == team_id] | |
| team_report = {} | |
| for category in CATEGORIES: | |
| count = sum(1 for r in team_rows if int(r.get(category) or 0) == 1) | |
| team_report[category] = {"count": count, "required": QUOTAS.get(category, 0)} | |
| non_biased = sum( | |
| 1 for r in team_rows if all(int(r.get(c) or 0) == 0 for c in CATEGORIES) | |
| ) | |
| team_report["non_biased"] = {"count": non_biased, "required": NON_BIASED_TARGET} | |
| report[team_id] = team_report | |
| return report | |
| def _slugify(name: str) -> str: | |
| slug = re.sub(r"[^a-z0-9]+", "_", name.strip().lower()).strip("_") | |
| return slug or "team" | |
| def _generate_access_code(slug: str) -> str: | |
| # e.g. "everest-73920" -- team name slug + a random 5-digit number, shared | |
| # by the whole team. secrets.randbelow keeps this cryptographically random | |
| # (not guessable/sequential) while staying short enough to type by hand. | |
| suffix = f"{secrets.randbelow(100_000):05d}" | |
| return f"{slug[:20]}-{suffix}" | |
| def get_teams(_: AdminSession): | |
| """List all teams with their access codes, so an organizer can copy one and | |
| send it manually via Gmail/whatever mail tool. Only reachable with an admin | |
| session — access codes never appear anywhere a team member can see them.""" | |
| return database.list_teams() | |
| _MAX_ACCESS_CODE_ATTEMPTS = 5 | |
| def add_team(body: CreateTeamRequest, _: AdminSession): | |
| """Create a new team with a freshly generated access code and email it to | |
| every member. This replaces hand-editing seed.sql for every team -- | |
| organizers can add teams as they register, any time before or during the | |
| event. | |
| Login model: ONE shared access code per team (format | |
| "<team_name_slug>-<5 digit number>", e.g. "everest-73920"). Every member | |
| types the same code to log in -- there's no separate per-member token. | |
| Requires 2-4 member_emails (validated by CreateTeamRequest). The access | |
| code is emailed to all of them via Resend (services/email_service.py). If | |
| RESEND_API_KEY isn't configured, or the send fails, team creation still | |
| succeeds -- the response's email_sent flag tells the organizer whether | |
| they need to copy the access_code and send it manually instead. | |
| """ | |
| slug = _slugify(body.team_name) | |
| team_id = f"team_{slug}_{secrets.token_hex(2)}" | |
| row = None | |
| last_exc: Exception | None = None | |
| for _attempt in range(_MAX_ACCESS_CODE_ATTEMPTS): | |
| access_code = _generate_access_code(slug) | |
| try: | |
| row = database.create_team(team_id, body.team_name.strip(), access_code, body.member_emails) | |
| break | |
| except Exception as exc: # likely a unique constraint hit on access_code | |
| last_exc = exc | |
| logger.warning("Access code collision for team_id=%s, retrying: %s", team_id, exc) | |
| if row is None: | |
| logger.error("Failed to create team after %d attempts: %s", _MAX_ACCESS_CODE_ATTEMPTS, last_exc) | |
| raise HTTPException(status_code=500, detail="Failed to create team — access_code collision, please retry.") | |
| email_sent = send_team_access_code(row["team_name"], row["access_code"], row.get("member_emails") or []) | |
| logger.info("Admin created team team_id=%s email_sent=%s", team_id, email_sent) | |
| return TeamResponse(**row, email_sent=email_sent) | |
| # --- Judging (post-event blind review) -------------------------------------- | |
| def get_judges(_: AdminSession): | |
| """List all judges with their access codes, so an organizer can hand them | |
| out. Same pattern as GET /admin/teams.""" | |
| return database.list_judges() | |
| def add_judge(body: CreateJudgeRequest, _: AdminSession): | |
| """Create a judge with a freshly generated access code. Judges are a | |
| separate identity from teams/admin -- this code only ever grants access | |
| to GET /judge/queue and POST /judge/label, never submission or admin | |
| routes.""" | |
| slug = _slugify(body.judge_name) | |
| row = None | |
| last_exc: Exception | None = None | |
| for _attempt in range(_MAX_ACCESS_CODE_ATTEMPTS): | |
| access_code = _generate_access_code(slug) | |
| try: | |
| row = database.create_judge(body.judge_name.strip(), access_code) | |
| break | |
| except Exception as exc: # likely a unique constraint hit on access_code | |
| last_exc = exc | |
| logger.warning("Access code collision creating judge %s, retrying: %s", body.judge_name, exc) | |
| if row is None: | |
| logger.error("Failed to create judge after %d attempts: %s", _MAX_ACCESS_CODE_ATTEMPTS, last_exc) | |
| raise HTTPException(status_code=500, detail="Failed to create judge — access_code collision, please retry.") | |
| logger.info("Admin created judge judge_id=%s", row["judge_id"]) | |
| return JudgeResponse(**row) | |
| # --- Admin account management ------------------------------------------------ | |
| # The very FIRST admin account is created via the unauthenticated | |
| # POST /admin/bootstrap (see routers/auth.py), gated by ADMIN_BOOTSTRAP_SECRET. | |
| # Every admin account after that is created here, by an already-logged-in | |
| # admin -- same "authenticated action creates the next credential" pattern | |
| # as team/judge creation. | |
| def get_admins(_: AdminSession): | |
| """List all admin accounts (name + email only, never password hashes).""" | |
| return database.list_admins() | |
| def add_admin(body: CreateAdminRequest, _: AdminSession): | |
| """Create another admin account. Requires being logged in as an admin | |
| already -- there's no open signup for admin access.""" | |
| password_hash = hash_password(body.password) | |
| try: | |
| row = database.create_admin(body.admin_name.strip(), body.email.strip(), password_hash) | |
| except Exception as exc: | |
| logger.warning("Failed to create admin %s (likely duplicate email): %s", body.email, exc) | |
| raise HTTPException(status_code=400, detail="That email is already registered as an admin.") | |
| logger.info("Admin created another admin account admin_id=%s", row["admin_id"]) | |
| return AdminAccountResponse(admin_id=row["admin_id"], admin_name=row["admin_name"], email=row["email"]) | |
| def sample_for_judging(body: SampleForJudgingRequest, _: AdminSession): | |
| """Randomly pick `per_team` submissions from EACH team (not a flat pool) | |
| and mark them for judging. Stratified per team so every team gets a | |
| fair, comparable sample regardless of how many teams there are -- a flat | |
| random pick across all submissions could easily leave some teams with | |
| zero items sampled by chance. Teams with fewer unsampled/non-duplicate | |
| rows than `per_team` just get all of what they have (never an error). | |
| Intended to run once, after the event closes and the QA batch | |
| (POST /admin/qa-batch) has already flagged duplicates.""" | |
| candidates_by_team = database.fetch_unsampled_submission_ids_by_team() | |
| chosen: list[str] = [] | |
| teams_sampled = 0 | |
| teams_skipped = 0 | |
| for team_id, ids in candidates_by_team.items(): | |
| if not ids: | |
| teams_skipped += 1 | |
| continue | |
| take = min(body.per_team, len(ids)) | |
| chosen.extend(random.sample(ids, k=take)) | |
| teams_sampled += 1 | |
| if chosen: | |
| database.mark_sampled_for_judging(chosen) | |
| logger.info( | |
| "Admin sampled %d submissions for judging across %d teams (%d skipped, no candidates)", | |
| len(chosen), teams_sampled, teams_skipped, | |
| ) | |
| return SampleForJudgingResponse( | |
| sampled=len(chosen), teams_sampled=teams_sampled, teams_skipped_insufficient=teams_skipped | |
| ) | |
| def judge_report(_: AdminSession): | |
| """Compares each judge's blind label against the participant's original | |
| label, per category, for every sampled submission. Admin-only -- this is | |
| the one place original labels and judge labels are ever shown together.""" | |
| return build_judge_report() | |