"""ORM models for the courseware generator. Four tables, all serving state previously held in JSON files under web/data/: users — login accounts (added in Phase 2) jobs — replaces history.json daily_activity — replaces activity.json token_usage — replaces tokens.json Phase 1 creates the schema and migrates existing JSON data into it. Phase 2 wires up auth and the User.set_password/check_password helpers. """ from __future__ import annotations from datetime import datetime import bcrypt from sqlalchemy import ( Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Text, ) from sqlalchemy.orm import relationship from web.db import Base class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) email = Column(String(255), unique=True, nullable=False, index=True) name = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False) is_admin = Column(Boolean, default=False, nullable=False) is_active = Column(Boolean, default=True, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) last_login_at = Column(DateTime, nullable=True) def set_password(self, plain: str) -> None: """Hash *plain* with bcrypt and store in ``password_hash``.""" salt = bcrypt.gensalt() self.password_hash = bcrypt.hashpw( plain.encode("utf-8"), salt ).decode("utf-8") def check_password(self, plain: str) -> bool: """True if *plain* verifies against the stored hash.""" if not self.password_hash: return False try: return bcrypt.checkpw( plain.encode("utf-8"), self.password_hash.encode("utf-8"), ) except (ValueError, TypeError): return False def __repr__(self) -> str: return f"" class Job(Base): __tablename__ = "jobs" # Matches the 8-char job_id the dashboard already uses. id = Column(String(8), primary_key=True) # Nullable for Phase 1 (no auth yet). Phase 2 makes new rows NOT NULL. user_id = Column(Integer, ForeignKey("users.id"), nullable=True) course_title = Column(String(500), nullable=False, default="") template_name = Column(String(500), nullable=True) duration_days = Column(Integer, nullable=True) language = Column(String(8), nullable=True) status = Column(String(32), nullable=False) # queued|running|complete|failed progress = Column(Integer, nullable=False, default=0) # 0..100 log_message = Column(Text, nullable=True) slide_count = Column(Integer, nullable=True) qa_score = Column(Integer, nullable=True) qa_verdict = Column(String(16), nullable=True) duration_sec = Column(Integer, nullable=True) # Output location. Phase 1: local path. Phase 3b: also S3 key. output_local = Column(String(500), nullable=True) output_s3_key = Column(String(500), nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) completed_at = Column(DateTime, nullable=True) user = relationship("User") def __repr__(self) -> str: return f"" class DailyActivity(Base): __tablename__ = "daily_activity" date = Column(Date, primary_key=True) courses = Column(Integer, nullable=False, default=0) slides = Column(Integer, nullable=False, default=0) def __repr__(self) -> str: return f"" class TokenUsage(Base): __tablename__ = "token_usage" id = Column(Integer, primary_key=True) # Nullable because some usage (e.g. planner sanity-checks) may run # outside a Job context. job_id = Column(String(8), ForeignKey("jobs.id"), nullable=True, index=True) timestamp = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) model = Column(String(64), nullable=False) input_tokens = Column(Integer, nullable=False, default=0) output_tokens = Column(Integer, nullable=False, default=0) cache_creation_tokens = Column(Integer, nullable=False, default=0) cache_read_tokens = Column(Integer, nullable=False, default=0) job = relationship("Job") def __repr__(self) -> str: return (f"")