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:
parent
2e4e0b811d
commit
3edbcdeb64
6 changed files with 398 additions and 9 deletions
224
backend/migrations/add_slugs.py
Normal file
224
backend/migrations/add_slugs.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
134
backend/slugify.py
Normal 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}"
|
||||
Loading…
Add table
Reference in a new issue