"""SQLAlchemy models (async). SQLite by default, PostgreSQL via DATABASE_URL.""" from __future__ import annotations import uuid from datetime import UTC, datetime from sqlalchemy import ( JSON, Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship def new_id() -> str: return uuid.uuid4().hex def utcnow() -> datetime: return datetime.now(UTC).replace(tzinfo=None) class Base(DeclarativeBase): type_annotation_map = {dict: JSON, list: JSON} class User(Base): __tablename__ = "users" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) email: Mapped[str] = mapped_column(String(255), unique=True, index=True) role: Mapped[str] = mapped_column(String(16), default="student") display_name: Mapped[str | None] = mapped_column(String(120)) locale: Mapped[str] = mapped_column(String(8), default="fr-CA") preferences: Mapped[dict] = mapped_column(JSON, default=dict) password_hash: Mapped[str | None] = mapped_column(String(255)) consent_at: Mapped[datetime | None] = mapped_column(DateTime) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) last_seen_at: Mapped[datetime | None] = mapped_column(DateTime) deleted_at: Mapped[datetime | None] = mapped_column(DateTime) invited_at: Mapped[datetime | None] = mapped_column(DateTime) # last "choose password" e-mail class MagicLink(Base): """Single-use e-mailed tokens: login link, invitation (choose password), password reset.""" __tablename__ = "magic_links" token: Mapped[str] = mapped_column(String(64), primary_key=True) email: Mapped[str] = mapped_column(String(255), index=True) expires_at: Mapped[datetime] = mapped_column(DateTime) used_at: Mapped[datetime | None] = mapped_column(DateTime) purpose: Mapped[str | None] = mapped_column(String(16)) # None/"login" | "invite" | "reset" class Course(Base): __tablename__ = "courses" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) code: Mapped[str] = mapped_column(String(16), unique=True, index=True) title: Mapped[str] = mapped_column(String(255)) term: Mapped[str] = mapped_column(String(64), default="") syllabus: Mapped[dict] = mapped_column(JSON, default=dict) extra_system_prompt: Mapped[str] = mapped_column(Text, default="") announcement: Mapped[str] = mapped_column(Text, default="") settings: Mapped[dict] = mapped_column(JSON, default=dict) active: Mapped[bool] = mapped_column(Boolean, default=True) class Conversation(Base): __tablename__ = "conversations" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id"), index=True) course_code: Mapped[str] = mapped_column(String(16), default="IMM1003") title: Mapped[str] = mapped_column(String(255), default="Nouvelle conversation") pinned: Mapped[bool] = mapped_column(Boolean, default=False) archived: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow) messages: Mapped[list[Message]] = relationship( back_populates="conversation", cascade="all, delete-orphan", order_by="Message.created_at" ) Index("ix_conversations_user_updated", Conversation.user_id, Conversation.updated_at.desc()) class Message(Base): __tablename__ = "messages" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) conversation_id: Mapped[str] = mapped_column( String(32), ForeignKey("conversations.id"), index=True ) role: Mapped[str] = mapped_column(String(16)) # user | assistant | system content: Mapped[str] = mapped_column(Text, default="") content_redacted_at: Mapped[datetime | None] = mapped_column(DateTime) model: Mapped[str | None] = mapped_column(String(120)) tokens_in: Mapped[int] = mapped_column(Integer, default=0) tokens_out: Mapped[int] = mapped_column(Integer, default=0) cost_usd: Mapped[float] = mapped_column(Float, default=0.0) latency_ms: Mapped[int] = mapped_column(Integer, default=0) feedback: Mapped[str | None] = mapped_column(String(8)) # up | down attachments: Mapped[list] = mapped_column(JSON, default=list) # file ids created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) conversation: Mapped[Conversation] = relationship(back_populates="messages") tool_calls: Mapped[list[ToolCall]] = relationship( cascade="all, delete-orphan", order_by="ToolCall.created_at" ) Index("ix_messages_conv_created", Message.conversation_id, Message.created_at) class ToolCall(Base): __tablename__ = "tool_calls" id: Mapped[str] = mapped_column(String(64), primary_key=True) message_id: Mapped[str] = mapped_column(String(32), ForeignKey("messages.id"), index=True) name: Mapped[str] = mapped_column(String(64)) arguments: Mapped[dict] = mapped_column(JSON, default=dict) result_summary: Mapped[str] = mapped_column(Text, default="") result_payload: Mapped[dict] = mapped_column(JSON, default=dict) # for UI cards status: Mapped[str] = mapped_column(String(16), default="ok") duration_ms: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) class StoredFile(Base): """Uploaded files and tool artifacts (single table, `kind` discriminates).""" __tablename__ = "files" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) user_id: Mapped[str] = mapped_column(String(32), index=True) conversation_id: Mapped[str | None] = mapped_column(String(32), index=True) tool_call_id: Mapped[str | None] = mapped_column(String(64)) kind: Mapped[str] = mapped_column(String(16), default="upload") # upload | artifact type: Mapped[str] = mapped_column(String(16), default="file") # xlsx | image | csv | pdf ... filename: Mapped[str] = mapped_column(String(255)) mime: Mapped[str] = mapped_column(String(128), default="application/octet-stream") storage_key: Mapped[str] = mapped_column(String(255)) size_bytes: Mapped[int] = mapped_column(Integer, default=0) expires_at: Mapped[datetime | None] = mapped_column(DateTime) pinned: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) class Quiz(Base): __tablename__ = "quizzes" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) user_id: Mapped[str] = mapped_column(String(32), index=True) course_code: Mapped[str] = mapped_column(String(16)) topic: Mapped[str] = mapped_column(String(255)) payload: Mapped[dict] = mapped_column(JSON, default=dict) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) class QuizAttempt(Base): __tablename__ = "quiz_attempts" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) quiz_id: Mapped[str] = mapped_column(String(32), ForeignKey("quizzes.id"), index=True) answers: Mapped[dict] = mapped_column(JSON, default=dict) score: Mapped[float] = mapped_column(Float, default=0.0) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) class CourseDocument(Base): __tablename__ = "course_documents" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) course_code: Mapped[str] = mapped_column(String(16), index=True) filename: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255), default="") checksum: Mapped[str] = mapped_column(String(64), index=True) visibility: Mapped[str] = mapped_column(String(16), default="students") n_chunks: Mapped[int] = mapped_column(Integer, default=0) ingested_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) class CourseChunk(Base): __tablename__ = "course_chunks" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) document_id: Mapped[str] = mapped_column( String(32), ForeignKey("course_documents.id", ondelete="CASCADE"), index=True ) course_code: Mapped[str] = mapped_column(String(16), index=True) module: Mapped[str] = mapped_column(String(255), default="") section: Mapped[str] = mapped_column(String(255), default="") page: Mapped[str] = mapped_column(String(32), default="") url: Mapped[str] = mapped_column(String(512), default="") content: Mapped[str] = mapped_column(Text) embedding: Mapped[list | None] = mapped_column(JSON) metadata_: Mapped[dict] = mapped_column("metadata", JSON, default=dict) visibility: Mapped[str] = mapped_column(String(16), default="students") class AnalyticsEvent(Base): __tablename__ = "analytics_events" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) user_id_hash: Mapped[str] = mapped_column(String(64), index=True) course_code: Mapped[str] = mapped_column(String(16), default="") event_type: Mapped[str] = mapped_column(String(32)) topic: Mapped[str] = mapped_column(String(128), default="") question_sample: Mapped[str] = mapped_column(Text, default="") # reformulated, anonymised created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) class LLMUsage(Base): __tablename__ = "llm_usage" id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) generation_id: Mapped[str | None] = mapped_column(String(64)) task: Mapped[str] = mapped_column(String(32), default="tutor") course_code: Mapped[str] = mapped_column(String(16), default="") model: Mapped[str] = mapped_column(String(120), default="") provider: Mapped[str] = mapped_column(String(64), default="") tokens_in: Mapped[int] = mapped_column(Integer, default=0) tokens_out: Mapped[int] = mapped_column(Integer, default=0) cost_usd: Mapped[float] = mapped_column(Float, default=0.0) latency_ms: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) class AppSetting(Base): """Small key/value store for runtime settings editable by the professor (access code…).""" __tablename__ = "app_settings" key: Mapped[str] = mapped_column(String(64), primary_key=True) value: Mapped[str] = mapped_column(Text, default="") updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)