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
This commit is contained in:
fullsizemalt 2025-12-21 18:46:40 -08:00
parent 2e4e0b811d
commit 3edbcdeb64
6 changed files with 398 additions and 9 deletions

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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(

View file

@ -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

134
backend/slugify.py Normal file
View file

@ -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}"