from __future__ import annotations from datetime import datetime, timezone from typing import List, Optional import pytz from flask_sqlalchemy import SQLAlchemy from flask import current_app from sqlalchemy import ( Boolean, Column, DateTime, String, TypeDecorator, ForeignKey, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship db: SQLAlchemy = SQLAlchemy() deck_password = db.Table( "deck_password", Column("deck_id", db.Integer, db.ForeignKey("deck.id"), primary_key=True), Column("password_id", db.Integer, db.ForeignKey("password.id"), primary_key=True), ) deck_token = db.Table( "deck_token", Column("deck_id", db.Integer, db.ForeignKey("deck.id"), primary_key=True), Column("token_id", db.Integer, db.ForeignKey("token.id"), primary_key=True), ) class UTCDateTime(TypeDecorator): impl = DateTime def process_bind_param(self, value, dialect): # Ensure datetime is stored in naive UTC if value is not None and value.tzinfo is not None: value = value.astimezone(pytz.utc).replace(tzinfo=None) return value def process_result_value(self, value, dialect): # Re-attach UTC tzinfo when reading if value is not None: value = value.replace(tzinfo=pytz.utc) return value class Password(db.Model): __tablename__ = "password" id: Mapped[int] = mapped_column(primary_key=True) value: Mapped[str] = mapped_column(String(60), unique=True) expires_at: Mapped[Optional[datetime]] = mapped_column( UTCDateTime() ) # Optional expiration decks: Mapped[List[Deck]] = relationship( "Deck", secondary=deck_password, back_populates="passwords" ) def __init__(self, value: str, expires_at: datetime | None) -> None: self.value = value self.expires_at = expires_at def is_expired(self) -> bool: """Check if the password is expired""" return ( self.expires_at is not None and datetime.now(timezone.utc) > self.expires_at ) def __repr__(self) -> str: return f"" class Deck(db.Model): __tablename__ = "deck" __table_args__ = ( UniqueConstraint("course_id", "deck", name="_course_deck_uc"), UniqueConstraint("course_id", "name", name="_course_name_uc"), ) id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(60), nullable=False) deck: Mapped[str] = mapped_column(String(60), nullable=False) description: Mapped[str] = mapped_column(String(200)) index_file: Mapped[str] = mapped_column(String(40), nullable=False) export_file: Mapped[Optional[str]] = mapped_column(String(40), nullable=True) passwords: Mapped[List[Password]] = relationship( "Password", secondary=deck_password, back_populates="decks" ) tokens: Mapped[List[Token]] = relationship( "Token", secondary=deck_token, back_populates="decks" ) course_id: Mapped[int] = mapped_column(ForeignKey("course.id")) course: Mapped["Course"] = relationship("Course", back_populates="decks") def __init__( self, name: str, deck: str, course: Course, description: str = "", index_file: str = "index.html", export_file: str | None = None, ) -> None: self.name = name self.deck = deck self.description = description self.course = course self.index_file = index_file if export_file: self.export_file = export_file def __repr__(self) -> str: return f"" class Token(db.Model): __tablename__ = "token" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False) created_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False) active_course_id: Mapped[int] = mapped_column( ForeignKey("course.id"), nullable=True ) active_course: Mapped[Course] = relationship( "Course", foreign_keys=[active_course_id] ) decks: Mapped[List[Deck]] = relationship( "Deck", secondary=deck_token, back_populates="tokens" ) def __init__(self, name: str) -> None: self.name = name self.created_at = datetime.now(timezone.utc) self.active_course_id = current_app.config["DEFAULT_COURSE"] def __repr__(self) -> str: return f"" def is_unlocked(self, deck: str) -> bool: return deck in [deck.deck for deck in self.decks] class Course(db.Model): __tablename__ = "course" id: Mapped[int] = mapped_column(primary_key=True) folder: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False) decks: Mapped[List["Deck"]] = relationship(back_populates="course") def __init__(self, name: str, folder: str) -> None: self.name = name self.folder = folder def __repr__(self) -> str: return f"" class InfoBanner(db.Model): __tablename__ = "info_banner" id: Mapped[int] = mapped_column(primary_key=True) content: Mapped[str] = mapped_column(String(280)) is_active: Mapped[bool] = mapped_column(Boolean) def __init__(self, content: str) -> None: self.content = content self.is_active = False class PageView(db.Model): __tablename__ = "page_view" id: Mapped[int] = mapped_column(primary_key=True) deck_id: Mapped[int] = mapped_column(ForeignKey("deck.id"), nullable=False) token_id: Mapped[int] = mapped_column(ForeignKey("token.id"), nullable=False) viewed_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False) deck: Mapped["Deck"] = relationship("Deck") token: Mapped["Token"] = relationship("Token") def __init__(self, deck_id: int, token_id: int) -> None: self.deck_id = deck_id self.token_id = token_id self.viewed_at = datetime.now(timezone.utc)