"""ORM models. IDs are UUID strings (portable across SQLite/Postgres). JSON-ish payloads (column lists, construct items, job metadata/summaries) are stored as JSON text - they are read-mostly blobs, not queried relationally. """ import uuid from datetime import datetime, timezone from sqlalchemy import Boolean, Float, ForeignKey, String, Text from sqlalchemy.orm import Mapped, mapped_column from .db import Base def _uuid() -> str: return uuid.uuid4().hex def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") class User(Base): __tablename__ = "users" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) email: Mapped[str] = mapped_column(String(255), unique=True, index=True) name: Mapped[str] = mapped_column(String(120), default="") password_hash: Mapped[str] = mapped_column(Text) # scrypt$salt$digest (auth.py) role: Mapped[str] = mapped_column(String(16), default="external") # external | lab | maintainer | pi (auth.ROLES; legacy "member" reads as # external). lab+ = unlimited saved runs; maintainer/pi also get /admin. # Staff roles are grantable only by ADMIN_EMAILS env-allowlisted admins # (admin.py guards), so the API cannot self-escalate; the env allowlist # remains the bootstrap and break-glass admin path. created_at: Mapped[str] = mapped_column(String(32), default=_now) class RoleAssignment(Base): """Pre-provisioned access: a role bound to an email BEFORE the account exists (PI request 2026-07-22 - e.g. an external collaborator who must land with full credentials on first sign-in, Google or password). Unlike invite links (bearer tokens, external/lab only), assignments are email-bound and may carry staff roles - so creating one for pi/maintainer requires escalation rights. Claimed rows are kept as history.""" __tablename__ = "role_assignments" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) email: Mapped[str] = mapped_column(String(255), unique=True, index=True) role: Mapped[str] = mapped_column(String(16)) assigned_by: Mapped[str] = mapped_column(String(255), default="") created_at: Mapped[str] = mapped_column(String(32), default=_now) claimed_at: Mapped[str] = mapped_column(String(32), default="") # "" = pending class Invite(Base): """Invite links, stateful so they can be listed, revoked, and traced. The signed token (auth.py) still proves authenticity, but redemption requires this row to be live: not revoked, not expired. Revocation is soft (revoked_at) so history and redemptions stay visible. Redemptions are a read-mostly JSON list [{email, at}], per the repo's JSON-blob convention.""" __tablename__ = "invites" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) role: Mapped[str] = mapped_column(String(16)) token: Mapped[str] = mapped_column(Text) # stored so admins can re-copy the link created_by: Mapped[str] = mapped_column(String(255), default="") created_at: Mapped[str] = mapped_column(String(32), default=_now) expires_at: Mapped[str] = mapped_column(String(32)) # ISO date, inclusive revoked_at: Mapped[str] = mapped_column(String(32), default="") # "" = active redemptions_json: Mapped[str] = mapped_column(Text, default="[]") class AdminAudit(Base): """Append-only trail of admin actions (who did what to whom, when). What makes multiple admins trustworthy: role grants, password resets, deletions, invites, requeues, and verification changes all land here. Never updated or deleted from the app.""" __tablename__ = "admin_audit" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) at: Mapped[str] = mapped_column(String(32), default=_now) actor_email: Mapped[str] = mapped_column(String(255)) action: Mapped[str] = mapped_column(String(40)) # e.g. set_role, invite_created target: Mapped[str] = mapped_column(String(300), default="") # email/name acted on detail: Mapped[str] = mapped_column(Text, default="") class Project(Base): __tablename__ = "projects" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) name: Mapped[str] = mapped_column(String(200)) description: Mapped[str] = mapped_column(Text, default="") archived: Mapped[bool] = mapped_column(Boolean, default=False) owner_user_id: Mapped[str] = mapped_column(String(32), default="") # "" = anonymous created_at: Mapped[str] = mapped_column(String(32), default=_now) class Corpus(Base): __tablename__ = "corpora" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) project_id: Mapped[str] = mapped_column(ForeignKey("projects.id")) filename: Mapped[str] = mapped_column(String(300)) path: Mapped[str] = mapped_column(Text) n_rows: Mapped[int] = mapped_column() columns_json: Mapped[str] = mapped_column(Text) # list[str] parse_info_json: Mapped[str] = mapped_column(Text, default="{}") # how the file was parsed suggested_text_column: Mapped[str] = mapped_column(String(200), default="") created_at: Mapped[str] = mapped_column(String(32), default=_now) class Construct(Base): __tablename__ = "constructs" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) name: Mapped[str] = mapped_column(String(200)) description: Mapped[str] = mapped_column(Text, default="") reference: Mapped[str] = mapped_column(Text, default="") # citation items_json: Mapped[str] = mapped_column(Text) # list[str] reverse_flags_json: Mapped[str] = mapped_column(Text, default="[]") # list[bool], parallel to items is_seed: Mapped[bool] = mapped_column(Boolean, default=False) # Library identity (spec 0004): versioned append-only; hash via reference algorithm. construct_slug: Mapped[str] = mapped_column(String(120), default="") version: Mapped[int] = mapped_column(default=1) item_hash: Mapped[str] = mapped_column(String(64), default="") verification_status: Mapped[str] = mapped_column(String(24), default="draft") # draft | needs_verification | verified | archived language: Mapped[str] = mapped_column(String(12), default="en") category: Mapped[str] = mapped_column(String(80), default="") # AI-generated provenance: "" for typed/uploaded/library constructs; else # {"model", "prompt_version", "generated_at"} (ITEM_GENERATION.md). Set # when the construct was saved from a generated draft - the label persists # even after the researcher edits items (the seed was AI; item_hash covers # as-generated vs edited). generation_json: Mapped[str] = mapped_column(Text, default="") created_at: Mapped[str] = mapped_column(String(32), default=_now) class GenerationEvent(Base): """One row per successful LLM item generation - the per-user daily cap (CCR_USER_MAX_GENERATIONS_PER_DAY) counts today's rows. DB-backed rather than a cookie counter because generation spends real API money, and signed-in users could clear cookies; a table survives that (and restarts on Postgres deployments). Rows are tiny and append-only.""" __tablename__ = "generation_events" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) user_id: Mapped[str] = mapped_column(String(32), index=True) model: Mapped[str] = mapped_column(String(120), default="") created_at: Mapped[str] = mapped_column(String(32), default=_now) class Job(Base): __tablename__ = "jobs" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_uuid) project_id: Mapped[str] = mapped_column(ForeignKey("projects.id")) corpus_id: Mapped[str] = mapped_column(ForeignKey("corpora.id")) construct_id: Mapped[str] = mapped_column(ForeignKey("constructs.id")) # Multi-construct runs: the full ordered id list. construct_id stays the # first entry (FK + legacy rows, whose "[]" here means "just construct_id"). construct_ids_json: Mapped[str] = mapped_column(Text, default="[]") # Anchor-vector (bipolar) runs (spec 0006): the contrasting opposite-pole # construct and the similarity metric. "" on both = a normal, non-anchored # run. Plain String (not FK) so the additive SQLite auto-migration can add # it to existing DBs, mirroring construct_ids_json. opposite_construct_id: Mapped[str] = mapped_column(String(32), default="") similarity_metric: Mapped[str] = mapped_column(String(12), default="") # "" | "cosine" | "dot" text_column: Mapped[str] = mapped_column(String(200)) model_name: Mapped[str] = mapped_column(String(200)) # registry id (or test fake) language: Mapped[str] = mapped_column(String(12), default="en") # selected analysis language status: Mapped[str] = mapped_column(String(20), default="queued") # queued -> running -> completed | failed progress: Mapped[float] = mapped_column(Float, default=0.0) # 0..1 error: Mapped[str] = mapped_column(Text, default="") metadata_json: Mapped[str] = mapped_column(Text, default="{}") # reproducibility record summary_json: Mapped[str] = mapped_column(Text, default="{}") # results summary result_path: Mapped[str] = mapped_column(Text, default="") created_at: Mapped[str] = mapped_column(String(32), default=_now) started_at: Mapped[str] = mapped_column(String(32), default="") finished_at: Mapped[str] = mapped_column(String(32), default="")