From 3edbcdeb644a70e1b4e46c52c31dbb6fd13edbb7 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sun, 21 Dec 2025 18:46:40 -0800 Subject: [PATCH] feat: Add slug support for all entities - Add slug fields to Song, Venue, Show, Tour, Performance models - Update routers to support lookup by slug or ID - Create slugify.py utility for generating URL-safe slugs - Add migration script to generate slugs for existing data - Performance slugs use songslug-YYYY-MM-DD format --- backend/migrations/add_slugs.py | 224 ++++++++++++++++++++++++++++++++ backend/models.py | 5 + backend/routers/performances.py | 17 ++- backend/routers/songs.py | 15 ++- backend/routers/venues.py | 12 +- backend/slugify.py | 134 +++++++++++++++++++ 6 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 backend/migrations/add_slugs.py create mode 100644 backend/slugify.py diff --git a/backend/migrations/add_slugs.py b/backend/migrations/add_slugs.py new file mode 100644 index 0000000..98544cb --- /dev/null +++ b/backend/migrations/add_slugs.py @@ -0,0 +1,224 @@ +""" +Migration script to add slug columns and generate slugs for existing data +""" +import os +import sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from sqlmodel import create_engine, Session, select, text +from slugify import generate_slug, generate_show_slug, generate_performance_slug + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://elmeg:elmeg@localhost/elmeg") +engine = create_engine(DATABASE_URL) + +def add_slug_columns(): + """Add slug columns to tables if they don't exist""" + with engine.connect() as conn: + # Add slug to song + conn.execute(text("ALTER TABLE song ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_song_slug ON song(slug)")) + + # Add slug to venue + conn.execute(text("ALTER TABLE venue ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_venue_slug ON venue(slug)")) + + # Add slug to show + conn.execute(text("ALTER TABLE show ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_show_slug ON show(slug)")) + + # Add slug to tour + conn.execute(text("ALTER TABLE tour ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_tour_slug ON tour(slug)")) + + # Add slug to performance + conn.execute(text("ALTER TABLE performance ADD COLUMN IF NOT EXISTS slug VARCHAR UNIQUE")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_performance_slug ON performance(slug)")) + + conn.commit() + print("✓ Slug columns added") + +def generate_song_slugs(): + """Generate slugs for all songs""" + with Session(engine) as session: + # Get all songs without slugs + result = session.exec(text("SELECT id, title FROM song WHERE slug IS NULL")) + songs = result.fetchall() + + existing_slugs = set() + # Get existing slugs + existing = session.exec(text("SELECT slug FROM song WHERE slug IS NOT NULL")) + for row in existing.fetchall(): + existing_slugs.add(row[0]) + + count = 0 + for song_id, title in songs: + base_slug = generate_slug(title, 50) + slug = base_slug + counter = 2 + + while slug in existing_slugs: + slug = f"{base_slug}-{counter}" + counter += 1 + + existing_slugs.add(slug) + session.execute( + text("UPDATE song SET slug = :slug WHERE id = :id"), + {"slug": slug, "id": song_id} + ) + count += 1 + + session.commit() + print(f"✓ Generated slugs for {count} songs") + +def generate_venue_slugs(): + """Generate slugs for all venues""" + with Session(engine) as session: + result = session.exec(text("SELECT id, name, city FROM venue WHERE slug IS NULL")) + venues = result.fetchall() + + existing_slugs = set() + existing = session.exec(text("SELECT slug FROM venue WHERE slug IS NOT NULL")) + for row in existing.fetchall(): + existing_slugs.add(row[0]) + + count = 0 + for venue_id, name, city in venues: + # Include city to help disambiguate + base_slug = generate_slug(f"{name} {city}", 60) + slug = base_slug + counter = 2 + + while slug in existing_slugs: + slug = f"{base_slug}-{counter}" + counter += 1 + + existing_slugs.add(slug) + session.execute( + text("UPDATE venue SET slug = :slug WHERE id = :id"), + {"slug": slug, "id": venue_id} + ) + count += 1 + + session.commit() + print(f"✓ Generated slugs for {count} venues") + +def generate_show_slugs(): + """Generate slugs for all shows""" + with Session(engine) as session: + result = session.exec(text(""" + SELECT s.id, s.date, v.name + FROM show s + LEFT JOIN venue v ON s.venue_id = v.id + WHERE s.slug IS NULL + """)) + shows = result.fetchall() + + existing_slugs = set() + existing = session.exec(text("SELECT slug FROM show WHERE slug IS NOT NULL")) + for row in existing.fetchall(): + existing_slugs.add(row[0]) + + count = 0 + for show_id, date, venue_name in shows: + date_str = date.strftime("%Y-%m-%d") if date else "unknown" + venue_slug = generate_slug(venue_name or "unknown", 25) + base_slug = f"{date_str}-{venue_slug}" + slug = base_slug + counter = 2 + + while slug in existing_slugs: + slug = f"{base_slug}-{counter}" + counter += 1 + + existing_slugs.add(slug) + session.execute( + text("UPDATE show SET slug = :slug WHERE id = :id"), + {"slug": slug, "id": show_id} + ) + count += 1 + + session.commit() + print(f"✓ Generated slugs for {count} shows") + +def generate_tour_slugs(): + """Generate slugs for all tours""" + with Session(engine) as session: + result = session.exec(text("SELECT id, name FROM tour WHERE slug IS NULL")) + tours = result.fetchall() + + existing_slugs = set() + existing = session.exec(text("SELECT slug FROM tour WHERE slug IS NOT NULL")) + for row in existing.fetchall(): + existing_slugs.add(row[0]) + + count = 0 + for tour_id, name in tours: + base_slug = generate_slug(name, 50) + slug = base_slug + counter = 2 + + while slug in existing_slugs: + slug = f"{base_slug}-{counter}" + counter += 1 + + existing_slugs.add(slug) + session.execute( + text("UPDATE tour SET slug = :slug WHERE id = :id"), + {"slug": slug, "id": tour_id} + ) + count += 1 + + session.commit() + print(f"✓ Generated slugs for {count} tours") + +def generate_performance_slugs(): + """Generate slugs for all performances (songslug-date format)""" + with Session(engine) as session: + result = session.exec(text(""" + SELECT p.id, s.slug as song_slug, sh.date + FROM performance p + JOIN song s ON p.song_id = s.id + JOIN show sh ON p.show_id = sh.id + WHERE p.slug IS NULL + """)) + performances = result.fetchall() + + existing_slugs = set() + existing = session.exec(text("SELECT slug FROM performance WHERE slug IS NOT NULL")) + for row in existing.fetchall(): + existing_slugs.add(row[0]) + + count = 0 + for perf_id, song_slug, date in performances: + date_str = date.strftime("%Y-%m-%d") if date else "unknown" + base_slug = f"{song_slug}-{date_str}" + slug = base_slug + counter = 2 + + # Handle multiple performances of same song in same show + while slug in existing_slugs: + slug = f"{base_slug}-{counter}" + counter += 1 + + existing_slugs.add(slug) + session.execute( + text("UPDATE performance SET slug = :slug WHERE id = :id"), + {"slug": slug, "id": perf_id} + ) + count += 1 + + session.commit() + print(f"✓ Generated slugs for {count} performances") + +def run_migration(): + print("=== Running Slug Migration ===") + add_slug_columns() + generate_song_slugs() + generate_venue_slugs() + generate_tour_slugs() + generate_show_slugs() + generate_performance_slugs() # Must run after song slugs + print("=== Migration Complete ===") + +if __name__ == "__main__": + run_migration() diff --git a/backend/models.py b/backend/models.py index d22f62e..628d8fd 100644 --- a/backend/models.py +++ b/backend/models.py @@ -6,6 +6,7 @@ from datetime import datetime 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") @@ -64,6 +65,7 @@ class Vertical(SQLModel, table=True): 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 @@ -75,6 +77,7 @@ class Venue(SQLModel, table=True): 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) @@ -90,6 +93,7 @@ class Artist(SQLModel, table=True): 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") @@ -109,6 +113,7 @@ class Show(SQLModel, table=True): 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) diff --git a/backend/routers/performances.py b/backend/routers/performances.py index 000c98a..6bceff5 100644 --- a/backend/routers/performances.py +++ b/backend/routers/performances.py @@ -8,12 +8,23 @@ from auth import get_current_user router = APIRouter(prefix="/performances", tags=["performances"]) -@router.get("/{performance_id}", response_model=PerformanceDetailRead) -def read_performance(performance_id: int, session: Session = Depends(get_session)): - performance = session.get(Performance, performance_id) +@router.get("/{performance_id_or_slug}", response_model=PerformanceDetailRead) +def read_performance(performance_id_or_slug: str, session: Session = Depends(get_session)): + performance = None + if performance_id_or_slug.isdigit(): + performance = session.get(Performance, int(performance_id_or_slug)) + + if not performance: + # Try slug lookup + performance = session.exec( + select(Performance).where(Performance.slug == performance_id_or_slug) + ).first() + if not performance: raise HTTPException(status_code=404, detail="Performance not found") + performance_id = performance.id # Use actual ID for lookups + # --- Calculate Stats & Navigation --- from sqlmodel import select, func, desc from models import Show diff --git a/backend/routers/songs.py b/backend/routers/songs.py index 3d12213..81edaaa 100644 --- a/backend/routers/songs.py +++ b/backend/routers/songs.py @@ -24,12 +24,21 @@ def read_songs(offset: int = 0, limit: int = Query(default=100, le=100), session from services.stats import get_song_stats -@router.get("/{song_id}", response_model=SongReadWithStats) -def read_song(song_id: int, session: Session = Depends(get_session)): - song = session.get(Song, song_id) +@router.get("/{song_id_or_slug}", response_model=SongReadWithStats) +def read_song(song_id_or_slug: str, session: Session = Depends(get_session)): + # Try to parse as int (ID), otherwise treat as slug + song = None + if song_id_or_slug.isdigit(): + song = session.get(Song, int(song_id_or_slug)) + + if not song: + # Try slug lookup + song = session.exec(select(Song).where(Song.slug == song_id_or_slug)).first() + if not song: raise HTTPException(status_code=404, detail="Song not found") + song_id = song.id # Use actual ID for lookups stats = get_song_stats(session, song_id) tags = session.exec( diff --git a/backend/routers/venues.py b/backend/routers/venues.py index b53ec33..ed0d8d2 100644 --- a/backend/routers/venues.py +++ b/backend/routers/venues.py @@ -21,9 +21,15 @@ def read_venues(offset: int = 0, limit: int = Query(default=100, le=100), sessio venues = session.exec(select(Venue).offset(offset).limit(limit)).all() return venues -@router.get("/{venue_id}", response_model=VenueRead) -def read_venue(venue_id: int, session: Session = Depends(get_session)): - venue = session.get(Venue, venue_id) +@router.get("/{venue_id_or_slug}", response_model=VenueRead) +def read_venue(venue_id_or_slug: str, session: Session = Depends(get_session)): + venue = None + if venue_id_or_slug.isdigit(): + venue = session.get(Venue, int(venue_id_or_slug)) + + if not venue: + venue = session.exec(select(Venue).where(Venue.slug == venue_id_or_slug)).first() + if not venue: raise HTTPException(status_code=404, detail="Venue not found") return venue diff --git a/backend/slugify.py b/backend/slugify.py new file mode 100644 index 0000000..e560b10 --- /dev/null +++ b/backend/slugify.py @@ -0,0 +1,134 @@ +""" +Slug generation utilities +""" +import re +from typing import Optional + +def generate_slug(text: str, max_length: int = 50) -> str: + """ + Generate a URL-safe slug from text. + + Examples: + "Tweezer Reprise" -> "tweezer-reprise" + "You Enjoy Myself" -> "you-enjoy-myself" + "The Gorge Amphitheatre" -> "the-gorge-amphitheatre" + """ + if not text: + return "" + + # Convert to lowercase + slug = text.lower() + + # Replace common special characters + replacements = { + "'": "", + "'": "", + '"': "", + "&": "and", + "+": "and", + "@": "at", + "#": "", + "$": "", + "%": "", + "!": "", + "?": "", + ".": "", + ",": "", + ":": "", + ";": "", + "/": "-", + "\\": "-", + "(": "", + ")": "", + "[": "", + "]": "", + "{": "", + "}": "", + "<": "", + ">": "", + "—": "-", + "–": "-", + "...": "", + } + + for old, new in replacements.items(): + slug = slug.replace(old, new) + + # Replace any non-alphanumeric characters with dashes + slug = re.sub(r'[^a-z0-9]+', '-', slug) + + # Remove leading/trailing dashes and collapse multiple dashes + slug = re.sub(r'-+', '-', slug).strip('-') + + # Truncate to max length (at word boundary if possible) + if len(slug) > max_length: + slug = slug[:max_length] + # Try to cut at last dash to avoid partial words + last_dash = slug.rfind('-') + if last_dash > max_length // 2: + slug = slug[:last_dash] + + return slug + + +def generate_unique_slug( + base_text: str, + existing_slugs: list[str], + max_length: int = 50 +) -> str: + """ + Generate a unique slug, appending numbers if necessary. + + Examples: + "Tweezer" with existing ["tweezer"] -> "tweezer-2" + "Tweezer" with existing ["tweezer", "tweezer-2"] -> "tweezer-3" + """ + base_slug = generate_slug(base_text, max_length - 4) # Leave room for "-999" + + if base_slug not in existing_slugs: + return base_slug + + # Find next available number + counter = 2 + while f"{base_slug}-{counter}" in existing_slugs: + counter += 1 + + return f"{base_slug}-{counter}" + + +def generate_show_slug(date_str: str, venue_name: str) -> str: + """ + Generate a slug for a show based on date and venue. + + Examples: + "2024-12-31", "Madison Square Garden" -> "2024-12-31-msg" + "2024-07-04", "The Gorge Amphitheatre" -> "2024-07-04-the-gorge" + """ + # Common venue abbreviations + abbreviations = { + "madison square garden": "msg", + "red rocks amphitheatre": "red-rocks", + "the gorge amphitheatre": "the-gorge", + "alpine valley music theatre": "alpine", + "dicks sporting goods park": "dicks", + "mgm grand garden arena": "mgm", + "saratoga performing arts center": "spac", + } + + venue_slug = abbreviations.get(venue_name.lower()) + if not venue_slug: + # Take first 2-3 words of venue name + venue_slug = generate_slug(venue_name, 25) + + return f"{date_str}-{venue_slug}" + + +def generate_performance_slug(song_title: str, show_date: str) -> str: + """ + Generate a slug for a specific performance. + + Examples: + "Tweezer", "2024-12-31" -> "tweezer-2024-12-31" + """ + song_slug = generate_slug(song_title, 30) + return f"{song_slug}-{show_date}"