"""All SQLAlchemy 2.0 ORM models for RehearsalHub.""" from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import ( BigInteger, Boolean, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass # ── Members ─────────────────────────────────────────────────────────────────── class Member(Base): __tablename__ = "members" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) display_name: Mapped[str] = mapped_column(String(255), nullable=False) avatar_url: Mapped[str | None] = mapped_column(Text) nc_username: Mapped[str | None] = mapped_column(String(255)) nc_url: Mapped[str | None] = mapped_column(Text) nc_password: Mapped[str | None] = mapped_column(Text) password_hash: Mapped[str] = mapped_column(Text, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) band_memberships: Mapped[list[BandMember]] = relationship( "BandMember", back_populates="member", cascade="all, delete-orphan" ) authored_songs: Mapped[list[Song]] = relationship("Song", back_populates="creator") uploaded_versions: Mapped[list[AudioVersion]] = relationship( "AudioVersion", back_populates="uploader" ) annotations: Mapped[list[Annotation]] = relationship( "Annotation", back_populates="author", foreign_keys="Annotation.author_id" ) reactions: Mapped[list[Reaction]] = relationship( "Reaction", back_populates="member", cascade="all, delete-orphan" ) # ── Bands ───────────────────────────────────────────────────────────────────── class Band(Base): __tablename__ = "bands" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(255), nullable=False) slug: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) nc_folder_path: Mapped[str | None] = mapped_column(Text) nc_user: Mapped[str | None] = mapped_column(String(255)) genre_tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) memberships: Mapped[list[BandMember]] = relationship( "BandMember", back_populates="band", cascade="all, delete-orphan" ) songs: Mapped[list[Song]] = relationship( "Song", back_populates="band", cascade="all, delete-orphan" ) sessions: Mapped[list[RehearsalSession]] = relationship( "RehearsalSession", back_populates="band", cascade="all, delete-orphan" ) class BandMember(Base): __tablename__ = "band_members" __table_args__ = (UniqueConstraint("band_id", "member_id", name="uq_band_member"),) band_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), primary_key=True ) member_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), primary_key=True ) role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") joined_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) instrument: Mapped[str | None] = mapped_column(String(100)) band: Mapped[Band] = relationship("Band", back_populates="memberships") member: Mapped[Member] = relationship("Member", back_populates="band_memberships") class BandInvite(Base): __tablename__ = "band_invites" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) band_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True ) token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") created_by: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False ) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) used_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL") ) band: Mapped[Band] = relationship("Band") creator: Mapped[Member] = relationship("Member", foreign_keys=[created_by]) # ── Rehearsal Sessions ──────────────────────────────────────────────────────── class RehearsalSession(Base): __tablename__ = "rehearsal_sessions" __table_args__ = (UniqueConstraint("band_id", "date", name="uq_session_band_date"),) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) band_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True ) date: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False) nc_folder_path: Mapped[str | None] = mapped_column(Text) label: Mapped[str | None] = mapped_column(String(255)) notes: Mapped[str | None] = mapped_column(Text) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) band: Mapped[Band] = relationship("Band", back_populates="sessions") songs: Mapped[list[Song]] = relationship("Song", back_populates="session") # ── Songs ───────────────────────────────────────────────────────────────────── class Song(Base): __tablename__ = "songs" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) band_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("bands.id", ondelete="CASCADE"), nullable=False, index=True ) session_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("rehearsal_sessions.id", ondelete="SET NULL"), index=True ) title: Mapped[str] = mapped_column(String(500), nullable=False) nc_folder_path: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column(String(20), nullable=False, default="jam") tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) global_key: Mapped[str | None] = mapped_column(String(30)) global_bpm: Mapped[float | None] = mapped_column(Numeric(6, 2)) notes: Mapped[str | None] = mapped_column(Text) created_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL") ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) band: Mapped[Band] = relationship("Band", back_populates="songs") session: Mapped[RehearsalSession | None] = relationship("RehearsalSession", back_populates="songs") creator: Mapped[Member | None] = relationship("Member", back_populates="authored_songs") versions: Mapped[list[AudioVersion]] = relationship( "AudioVersion", back_populates="song", cascade="all, delete-orphan" ) comments: Mapped[list[SongComment]] = relationship( "SongComment", back_populates="song", cascade="all, delete-orphan" ) class SongComment(Base): __tablename__ = "song_comments" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) song_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True ) author_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False ) body: Mapped[str] = mapped_column(Text, nullable=False) timestamp: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) tag: Mapped[str | None] = mapped_column(String(32), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) song: Mapped[Song] = relationship("Song", back_populates="comments") author: Mapped[Member] = relationship("Member") # ── Audio Versions ──────────────────────────────────────────────────────────── class AudioVersion(Base): __tablename__ = "audio_versions" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) song_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("songs.id", ondelete="CASCADE"), nullable=False, index=True ) version_number: Mapped[int] = mapped_column(Integer, nullable=False) label: Mapped[str | None] = mapped_column(String(255)) nc_file_path: Mapped[str] = mapped_column(Text, nullable=False) nc_file_etag: Mapped[str | None] = mapped_column(String(255)) cdn_hls_base: Mapped[str | None] = mapped_column(Text) waveform_url: Mapped[str | None] = mapped_column(Text) duration_ms: Mapped[int | None] = mapped_column(Integer) format: Mapped[str | None] = mapped_column(String(10)) file_size_bytes: Mapped[int | None] = mapped_column(BigInteger) analysis_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") uploaded_by: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="SET NULL") ) uploaded_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) song: Mapped[Song] = relationship("Song", back_populates="versions") uploader: Mapped[Member | None] = relationship( "Member", back_populates="uploaded_versions" ) annotations: Mapped[list[Annotation]] = relationship( "Annotation", back_populates="version", cascade="all, delete-orphan" ) range_analyses: Mapped[list[RangeAnalysis]] = relationship( "RangeAnalysis", back_populates="version", cascade="all, delete-orphan" ) # ── Annotations ─────────────────────────────────────────────────────────────── class Annotation(Base): __tablename__ = "annotations" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) version_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("audio_versions.id", ondelete="CASCADE"), nullable=False, index=True, ) author_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False ) type: Mapped[str] = mapped_column(String(10), nullable=False) # 'point' | 'range' timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False) range_end_ms: Mapped[int | None] = mapped_column(Integer) body: Mapped[str | None] = mapped_column(Text) voice_note_url: Mapped[str | None] = mapped_column(Text) label: Mapped[str | None] = mapped_column(String(255)) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list, nullable=False) parent_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="SET NULL") ) resolved: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) version: Mapped[AudioVersion] = relationship("AudioVersion", back_populates="annotations") author: Mapped[Member] = relationship( "Member", back_populates="annotations", foreign_keys=[author_id] ) replies: Mapped[list[Annotation]] = relationship( "Annotation", foreign_keys=[parent_id], back_populates="parent" ) parent: Mapped[Annotation | None] = relationship( "Annotation", foreign_keys=[parent_id], back_populates="replies", remote_side=[id] ) reactions: Mapped[list[Reaction]] = relationship( "Reaction", back_populates="annotation", cascade="all, delete-orphan" ) range_analysis: Mapped[RangeAnalysis | None] = relationship( "RangeAnalysis", back_populates="annotation", uselist=False ) # ── Range Analyses ──────────────────────────────────────────────────────────── class RangeAnalysis(Base): __tablename__ = "range_analyses" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) annotation_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="CASCADE"), unique=True, nullable=False, ) version_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("audio_versions.id", ondelete="CASCADE"), nullable=False, index=True, ) start_ms: Mapped[int] = mapped_column(Integer, nullable=False) end_ms: Mapped[int] = mapped_column(Integer, nullable=False) bpm: Mapped[float | None] = mapped_column(Numeric(7, 2)) bpm_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3)) key: Mapped[str | None] = mapped_column(String(30)) key_confidence: Mapped[float | None] = mapped_column(Numeric(4, 3)) scale: Mapped[str | None] = mapped_column(String(10)) avg_loudness_lufs: Mapped[float | None] = mapped_column(Numeric(6, 2)) peak_loudness_dbfs: Mapped[float | None] = mapped_column(Numeric(6, 2)) spectral_centroid: Mapped[float | None] = mapped_column(Numeric(10, 2)) energy: Mapped[float | None] = mapped_column(Numeric(5, 4)) danceability: Mapped[float | None] = mapped_column(Numeric(5, 4)) chroma_vector: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric)) mfcc_mean: Mapped[list[float] | None] = mapped_column(ARRAY(Numeric)) analysis_version: Mapped[str | None] = mapped_column(String(20)) computed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) annotation: Mapped[Annotation] = relationship( "Annotation", back_populates="range_analysis" ) version: Mapped[AudioVersion] = relationship( "AudioVersion", back_populates="range_analyses" ) # ── Reactions ───────────────────────────────────────────────────────────────── class Reaction(Base): __tablename__ = "reactions" __table_args__ = ( UniqueConstraint("annotation_id", "member_id", "emoji", name="uq_reaction"), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) annotation_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("annotations.id", ondelete="CASCADE"), nullable=False, index=True, ) member_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("members.id", ondelete="CASCADE"), nullable=False ) emoji: Mapped[str] = mapped_column(String(10), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) annotation: Mapped[Annotation] = relationship("Annotation", back_populates="reactions") member: Mapped[Member] = relationship("Member", back_populates="reactions") # ── Jobs ────────────────────────────────────────────────────────────────────── class Job(Base): __tablename__ = "jobs" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) payload: Mapped[dict] = mapped_column(JSONB, nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True) attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0) error: Mapped[str | None] = mapped_column(Text) queued_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))