elmeg-demo/backend/models.py
fullsizemalt 2df93a75e4
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
feat: add Sequences feature for song groupings
2025-12-24 13:37:27 -08:00

419 lines
20 KiB
Python

from typing import List, Optional
from sqlmodel import Field, Relationship, SQLModel
from datetime import datetime
# --- Join Tables ---
class Performance(SQLModel, table=True):
"""Link table between Show and Song (Many-to-Many with extra data)"""
id: Optional[int] = Field(default=None, primary_key=True)
slug: Optional[str] = Field(default=None, unique=True, index=True, description="songslug-YYYY-MM-DD")
show_id: int = Field(foreign_key="show.id")
song_id: int = Field(foreign_key="song.id")
position: int = Field(description="Order in the setlist")
set_name: Optional[str] = Field(default=None, description="e.g., Set 1, Encore")
segue: bool = Field(default=False, description="Transition to next song >")
notes: Optional[str] = Field(default=None)
track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
youtube_link: Optional[str] = Field(default=None, description="YouTube video URL")
bandcamp_link: Optional[str] = Field(default=None, description="Bandcamp track URL")
nugs_link: Optional[str] = Field(default=None, description="Nugs.net track URL")
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
show: "Show" = Relationship(back_populates="performances")
song: "Song" = Relationship()
class ShowArtist(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
show_id: int = Field(foreign_key="show.id")
artist_id: int = Field(foreign_key="artist.id")
notes: Optional[str] = Field(default=None, description="Role e.g. Guest")
class PerformanceArtist(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
performance_id: int = Field(foreign_key="performance.id")
artist_id: int = Field(foreign_key="artist.id")
notes: Optional[str] = Field(default=None, description="Role e.g. Guest")
class PerformanceNickname(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
performance_id: int = Field(foreign_key="performance.id")
nickname: str = Field(index=True)
description: Optional[str] = Field(default=None)
status: str = Field(default="pending", index=True) # pending, approved, rejected
suggested_by: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
performance: "Performance" = Relationship(back_populates="nicknames")
user: "User" = Relationship()
class EntityTag(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
tag_id: int = Field(foreign_key="tag.id")
entity_type: str = Field(index=True) # "show", "song", "venue"
entity_id: int = Field(index=True)
# --- Core Entities ---
class Vertical(SQLModel, table=True):
"""Represents a Fandom Vertical (e.g., 'Phish', 'Goose', 'Star Wars')"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
slug: str = Field(unique=True, index=True)
description: Optional[str] = Field(default=None)
shows: List["Show"] = Relationship(back_populates="vertical")
songs: List["Song"] = Relationship(back_populates="vertical")
class Venue(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
city: str
state: Optional[str] = Field(default=None)
country: str
capacity: Optional[int] = Field(default=None)
notes: Optional[str] = Field(default=None)
shows: List["Show"] = Relationship(back_populates="venue")
class Tour(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
notes: Optional[str] = Field(default=None)
shows: List["Show"] = Relationship(back_populates="tour")
class Artist(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
slug: str = Field(unique=True, index=True)
bio: Optional[str] = Field(default=None)
image_url: Optional[str] = Field(default=None)
instrument: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None)
songs: List["Song"] = Relationship(back_populates="artist")
class Musician(SQLModel, table=True):
"""Individual human musicians (for tracking sit-ins and band membership)"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
slug: str = Field(unique=True, index=True)
bio: Optional[str] = Field(default=None)
image_url: Optional[str] = Field(default=None)
primary_instrument: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None)
# Relationships
memberships: List["BandMembership"] = Relationship(back_populates="musician")
guest_appearances: List["PerformanceGuest"] = Relationship(back_populates="musician")
class BandMembership(SQLModel, table=True):
"""Link between Musician and Band/Artist with role and dates"""
id: Optional[int] = Field(default=None, primary_key=True)
musician_id: int = Field(foreign_key="musician.id")
artist_id: int = Field(foreign_key="artist.id", description="The band/group")
role: Optional[str] = Field(default=None, description="e.g., Keyboards, Rhythm Guitar")
start_date: Optional[datetime] = Field(default=None)
end_date: Optional[datetime] = Field(default=None, description="Null = current member")
notes: Optional[str] = Field(default=None)
musician: Musician = Relationship(back_populates="memberships")
artist: Artist = Relationship()
class PerformanceGuest(SQLModel, table=True):
"""Link between Performance and Musician for sit-ins/guest appearances"""
id: Optional[int] = Field(default=None, primary_key=True)
performance_id: int = Field(foreign_key="performance.id")
musician_id: int = Field(foreign_key="musician.id")
instrument: Optional[str] = Field(default=None, description="What they played on this track")
notes: Optional[str] = Field(default=None)
musician: Musician = Relationship(back_populates="guest_appearances")
class Show(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
date: datetime = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
vertical_id: int = Field(foreign_key="vertical.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
tour_id: Optional[int] = Field(default=None, foreign_key="tour.id")
notes: Optional[str] = Field(default=None)
# External Links
bandcamp_link: Optional[str] = Field(default=None)
nugs_link: Optional[str] = Field(default=None)
youtube_link: Optional[str] = Field(default=None)
vertical: Vertical = Relationship(back_populates="shows")
venue: Optional[Venue] = Relationship(back_populates="shows")
tour: Optional[Tour] = Relationship(back_populates="shows")
attendances: List["Attendance"] = Relationship(back_populates="show")
performances: List["Performance"] = Relationship(back_populates="show")
class Song(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
title: str = Field(index=True)
slug: Optional[str] = Field(default=None, unique=True, index=True)
original_artist: Optional[str] = Field(default=None)
vertical_id: int = Field(foreign_key="vertical.id")
notes: Optional[str] = Field(default=None)
youtube_link: Optional[str] = Field(default=None)
# New Relation
artist_id: Optional[int] = Field(default=None, foreign_key="artist.id")
artist: Optional[Artist] = Relationship(back_populates="songs")
vertical: Vertical = Relationship(back_populates="songs")
class Sequence(SQLModel, table=True):
"""Named groupings of consecutive songs, e.g. 'Autumn Crossing' = Travelers > Elmeg the Wise"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, description="Human-readable name like 'Autumn Crossing'")
slug: str = Field(unique=True, index=True)
description: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None)
# Relationship to songs that make up this sequence
songs: List["SequenceSong"] = Relationship(back_populates="sequence")
class SequenceSong(SQLModel, table=True):
"""Join table linking songs to sequences with ordering"""
id: Optional[int] = Field(default=None, primary_key=True)
sequence_id: int = Field(foreign_key="sequence.id")
song_id: int = Field(foreign_key="song.id")
position: int = Field(description="Order in sequence, 1-indexed")
sequence: Sequence = Relationship(back_populates="songs")
class Tag(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True)
slug: str = Field(unique=True, index=True)
class Attendance(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
show_id: int = Field(foreign_key="show.id")
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
user: "User" = Relationship(back_populates="attendances")
show: "Show" = Relationship(back_populates="attendances")
class Comment(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
content: str
created_at: datetime = Field(default_factory=datetime.utcnow)
# Polymorphic-ish associations (nullable FKs)
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
parent_id: Optional[int] = Field(default=None, foreign_key="comment.id")
user: "User" = Relationship(back_populates="comments")
class Rating(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
score: float = Field(ge=1.0, le=10.0, description="Rating from 1.0 to 10.0")
created_at: datetime = Field(default_factory=datetime.utcnow)
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
performance_id: Optional[int] = Field(default=None, foreign_key="performance.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
tour_id: Optional[int] = Field(default=None, foreign_key="tour.id")
user: "User" = Relationship(back_populates="ratings")
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(unique=True, index=True)
hashed_password: str
is_active: bool = Field(default=True)
is_superuser: bool = Field(default=False)
role: str = Field(default="user") # user, moderator, admin
bio: Optional[str] = Field(default=None)
avatar: Optional[str] = Field(default=None)
avatar_bg_color: Optional[str] = Field(default="#3B82F6", description="Hex color for avatar background")
avatar_text: Optional[str] = Field(default=None, description="1-3 character text overlay on avatar")
# Privacy settings
profile_public: bool = Field(default=True, description="Allow others to view profile")
show_attendance_public: bool = Field(default=True, description="Show attended shows on profile")
appear_in_leaderboards: bool = Field(default=True, description="Appear in community leaderboards")
# Gamification
xp: int = Field(default=0, description="Experience points")
level: int = Field(default=1, description="User level based on XP")
streak_days: int = Field(default=0, description="Consecutive days active")
last_activity: Optional[datetime] = Field(default=None)
# Custom Titles & Flair (tracker forum style)
custom_title: Optional[str] = Field(default=None, description="Custom title chosen by user")
title_color: Optional[str] = Field(default=None, description="Hex color for username display")
flair: Optional[str] = Field(default=None, description="Small text/emoji beside name")
is_early_adopter: bool = Field(default=False, description="First 100 users get special perks")
is_supporter: bool = Field(default=False, description="Donated/supported the platform")
joined_at: datetime = Field(default_factory=datetime.utcnow)
# Email verification
email_verified: bool = Field(default=False)
verification_token: Optional[str] = Field(default=None)
verification_token_expires: Optional[datetime] = Field(default=None)
# Password reset
reset_token: Optional[str] = Field(default=None)
reset_token_expires: Optional[datetime] = Field(default=None)
# Multi-identity support: A user can have multiple Profiles
profiles: List["Profile"] = Relationship(back_populates="user")
comments: List["Comment"] = Relationship(back_populates="user")
ratings: List["Rating"] = Relationship(back_populates="user")
reviews: List["Review"] = Relationship(back_populates="user")
attendances: List["Attendance"] = Relationship(back_populates="user")
badges: List["UserBadge"] = Relationship(back_populates="user")
preferences: Optional["UserPreferences"] = Relationship(back_populates="user", sa_relationship_kwargs={"uselist": False})
reports: List["Report"] = Relationship(back_populates="user")
notifications: List["Notification"] = Relationship(back_populates="user")
class Report(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
entity_type: str = Field(index=True) # comment, review, nickname
entity_id: int = Field(index=True)
reason: str
details: str = Field(default="")
status: str = Field(default="pending", index=True) # pending, resolved, dismissed
created_at: datetime = Field(default_factory=datetime.utcnow)
user: "User" = Relationship(back_populates="reports")
class Badge(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True)
description: str
icon: str = Field(description="Lucide icon name or image URL")
slug: str = Field(unique=True, index=True)
tier: str = Field(default="bronze", description="bronze, silver, gold, platinum, diamond")
category: str = Field(default="general", description="attendance, ratings, social, milestones")
xp_reward: int = Field(default=50, description="XP awarded when badge is earned")
class UserBadge(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
badge_id: int = Field(foreign_key="badge.id")
awarded_at: datetime = Field(default_factory=datetime.utcnow)
user: "User" = Relationship(back_populates="badges")
badge: "Badge" = Relationship()
class Review(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
blurb: str = Field(description="One-liner/pullquote")
content: str = Field(description="Full review text")
score: float = Field(ge=1.0, le=10.0)
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
venue_id: Optional[int] = Field(default=None, foreign_key="venue.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
performance_id: Optional[int] = Field(default=None, foreign_key="performance.id")
tour_id: Optional[int] = Field(default=None, foreign_key="tour.id")
year: Optional[int] = Field(default=None, description="For reviewing a specific year")
created_at: datetime = Field(default_factory=datetime.utcnow)
user: "User" = Relationship(back_populates="reviews")
class UserPreferences(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", unique=True)
wiki_mode: bool = Field(default=False, description="Disable social features")
show_ratings: bool = Field(default=True)
show_comments: bool = Field(default=True)
user: User = Relationship(back_populates="preferences")
class Profile(SQLModel, table=True):
"""A user's identity within a specific context or global"""
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
username: str = Field(index=True)
display_name: Optional[str] = Field(default=None)
user: User = Relationship(back_populates="profiles")
# --- Groups ---
class Group(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
description: Optional[str] = None
privacy: str = Field(default="public") # public, private
created_by: int = Field(foreign_key="user.id")
created_at: datetime = Field(default_factory=datetime.utcnow)
members: List["GroupMember"] = Relationship(back_populates="group")
posts: List["GroupPost"] = Relationship(back_populates="group")
class GroupMember(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
group_id: int = Field(foreign_key="group.id")
user_id: int = Field(foreign_key="user.id")
role: str = Field(default="member") # member, admin
joined_at: datetime = Field(default_factory=datetime.utcnow)
group: Group = Relationship(back_populates="members")
user: User = Relationship()
class GroupPost(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
group_id: int = Field(foreign_key="group.id")
user_id: int = Field(foreign_key="user.id")
content: str
created_at: datetime = Field(default_factory=datetime.utcnow)
group: Group = Relationship(back_populates="posts")
user: User = Relationship()
class Notification(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
type: str = Field(description="reply, mention, system")
title: str
message: str
link: Optional[str] = None
is_read: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
user: User = Relationship(back_populates="notifications")
class Reaction(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
entity_type: str = Field(index=True) # "review", "comment"
entity_id: int = Field(index=True)
emoji: str # "❤️", "🔥", etc.
created_at: datetime = Field(default_factory=datetime.utcnow)
user: User = Relationship()
class ChaseSong(SQLModel, table=True):
"""Songs a user wants to see live (hasn't seen performed yet or wants to see again)"""
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id", index=True)
song_id: int = Field(foreign_key="song.id", index=True)
priority: int = Field(default=1, description="1=high, 2=medium, 3=low")
notes: Optional[str] = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
caught_at: Optional[datetime] = Field(default=None, description="When they finally saw it")
caught_show_id: Optional[int] = Field(default=None, foreign_key="show.id")
user: User = Relationship()
song: "Song" = Relationship()