Newer
Older
bugtrail / server / app / models.py
import secrets
import uuid
from datetime import UTC, datetime

from sqlalchemy import (
    BigInteger,
    CheckConstraint,
    DateTime,
    ForeignKey,
    Index,
    Integer,
    String,
    Text,
    func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

from .db import Base, gen_uuid7

TZDateTime = DateTime(timezone=True)


def gen_share_token() -> str:
    # 128 bits of randomness, URL-safe, 22 chars — the "unguessable" part of public links.
    return secrets.token_urlsafe(16)


def utcnow() -> datetime:
    return datetime.now(UTC)


class User(Base):
    __tablename__ = "users"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
    email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
    nickname: Mapped[str] = mapped_column(Text, nullable=False)
    password_hash: Mapped[str] = mapped_column(Text, nullable=False)
    created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())


class Session(Base):
    __tablename__ = "sessions"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
    user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False)  # sha256 hex of the token
    client: Mapped[str] = mapped_column(Text, nullable=False)  # 'web' | 'extension'
    expires_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False)
    created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, default=utcnow)
    last_used_at: Mapped[datetime | None] = mapped_column(TZDateTime, nullable=True)

    user: Mapped[User] = relationship(lazy="joined")


class Project(Base):
    __tablename__ = "projects"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
    owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    name: Mapped[str] = mapped_column(Text, nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    share_token: Mapped[str] = mapped_column(Text, unique=True, nullable=False, default=gen_share_token)
    created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())


class File(Base):
    __tablename__ = "files"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
    sha256: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
    path: Mapped[str] = mapped_column(Text, nullable=False)  # relative to FILES_DIR
    mime: Mapped[str] = mapped_column(Text, nullable=False)
    size: Mapped[int] = mapped_column(BigInteger, nullable=False)
    created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())


class Report(Base):
    __tablename__ = "reports"
    __table_args__ = (CheckConstraint("type IN ('element_note', 'recording')", name="ck_report_type"),)

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
    project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
    author_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    type: Mapped[str] = mapped_column(Text, nullable=False)  # 'element_note' | 'recording'
    title: Mapped[str] = mapped_column(Text, nullable=False)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    status: Mapped[str] = mapped_column(Text, nullable=False, default="open")  # open|fixed|wont_fix
    page_url: Mapped[str | None] = mapped_column(Text, nullable=True)
    page_title: Mapped[str | None] = mapped_column(Text, nullable=True)
    environment: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
    element: Mapped[dict | None] = mapped_column(JSONB, nullable=True)  # element_note context
    share_token: Mapped[str] = mapped_column(Text, unique=True, nullable=False, default=gen_share_token)
    created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now(), onupdate=utcnow)

    project: Mapped[Project] = relationship(lazy="joined")
    author: Mapped[User] = relationship(lazy="joined")
    attachments: Mapped[list["Attachment"]] = relationship(
        back_populates="report", cascade="all, delete-orphan", lazy="selectin"
    )
    steps: Mapped[list["RecordingStep"]] = relationship(
        back_populates="report", cascade="all, delete-orphan", order_by="RecordingStep.step_index", lazy="selectin"
    )


class Attachment(Base):
    __tablename__ = "attachments"

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
    report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
    file_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("files.id"), nullable=False)
    kind: Mapped[str] = mapped_column(Text, nullable=False)  # screenshot|annotation|document|image|other
    filename: Mapped[str] = mapped_column(Text, nullable=False)
    mime: Mapped[str] = mapped_column(Text, nullable=False)
    size: Mapped[int] = mapped_column(BigInteger, nullable=False)
    annotation_shapes: Mapped[list | None] = mapped_column(JSONB, nullable=True)
    created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())

    report: Mapped[Report] = relationship(back_populates="attachments")
    file: Mapped[File] = relationship(lazy="joined")


class RecordingStep(Base):
    __tablename__ = "recording_steps"
    __table_args__ = (Index("ix_recording_steps_report_idx", "report_id", "step_index"),)

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
    report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
    step_index: Mapped[int] = mapped_column(Integer, nullable=False)
    type: Mapped[str] = mapped_column(Text, nullable=False)  # click|input|url_change|navigation|note|screenshot
    offset_ms: Mapped[int] = mapped_column(Integer, nullable=False)
    data: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
    screenshot_attachment_id: Mapped[uuid.UUID | None] = mapped_column(
        ForeignKey("attachments.id", ondelete="SET NULL"), nullable=True
    )
    created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())

    report: Mapped[Report] = relationship(back_populates="steps")
    screenshot: Mapped[Attachment | None] = relationship(lazy="joined")