elmeg-demo/backend/models.py
fullsizemalt 5ffb428bb8 feat: Add gamification system
Backend:
- Add XP, level, streak fields to User model
- Add tier, category, xp_reward fields to Badge model
- Create gamification service with XP, levels, streaks, badge checking
- Add gamification router with level progress, leaderboard endpoints
- Define 16 badge types across attendance, ratings, social, milestones

Frontend:
- LevelProgressCard component with XP bar and streak display
- XPLeaderboard component showing top users
- Integrate level progress into profile page

Slug System:
- All entities now support slug-based URLs
- Performances use songslug-YYYY-MM-DD format
2025-12-21 18:58:42 -08:00

335 lines
15 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")
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)
instrument: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None)
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)
vertical: Vertical = 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)
# 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)
# 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()