feat: Restore performances, enable HeadyVersion ratings, update branding
- Updated import script to fix pagination and add idempotency - Added performances list to Song Detail API and schemas - Activated backend rating logic for performances - Updated Landing Page branding - Implemented frontend performance list display
This commit is contained in:
parent
b837d7654f
commit
65bb05d9b0
7 changed files with 202 additions and 43 deletions
|
|
@ -34,7 +34,7 @@ DEMO_USERS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
def fetch_json(endpoint, params=None):
|
def fetch_json(endpoint, params=None):
|
||||||
"""Fetch JSON from El Goose API with error handling"""
|
"""Fetch JSON from El Goose API (Single Page)"""
|
||||||
url = f"{BASE_URL}/{endpoint}.json"
|
url = f"{BASE_URL}/{endpoint}.json"
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, params=params)
|
response = requests.get(url, params=params)
|
||||||
|
|
@ -50,11 +50,45 @@ def fetch_json(endpoint, params=None):
|
||||||
print(f"❌ Failed to fetch {endpoint}: {e}")
|
print(f"❌ Failed to fetch {endpoint}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def fetch_all_json(endpoint, params=None):
|
||||||
|
"""Fetch ALL data from El Goose API using pagination"""
|
||||||
|
all_data = []
|
||||||
|
page = 1
|
||||||
|
params = params.copy() if params else {}
|
||||||
|
|
||||||
|
print(f" Fetching {endpoint} pages...", end="", flush=True)
|
||||||
|
while True:
|
||||||
|
params['page'] = page
|
||||||
|
data = fetch_json(endpoint, params)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
all_data.extend(data)
|
||||||
|
print(f" {page}", end="", flush=True)
|
||||||
|
# If less than 50 results (typical page size might be larger, but if small, likely last page)
|
||||||
|
# Actually API returns empty list [] if page out of range usually.
|
||||||
|
# So 'if not data' handles it.
|
||||||
|
# Safety break if too many pages
|
||||||
|
if page > 100:
|
||||||
|
print(" (Safety limit reached)", end="")
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
print(" Done.")
|
||||||
|
return all_data
|
||||||
|
|
||||||
def create_users(session):
|
def create_users(session):
|
||||||
"""Create demo user personas"""
|
"""Create demo user personas"""
|
||||||
print("\n📝 Creating user personas...")
|
print("\n📝 Creating user personas...")
|
||||||
users = []
|
users = []
|
||||||
for user_data in DEMO_USERS:
|
for user_data in DEMO_USERS:
|
||||||
|
# Check existing
|
||||||
|
existing = session.exec(
|
||||||
|
select(User).where(User.email == user_data["email"])
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
users.append(existing)
|
||||||
|
continue
|
||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
email=user_data["email"],
|
email=user_data["email"],
|
||||||
hashed_password=pwd_context.hash("demo123"),
|
hashed_password=pwd_context.hash("demo123"),
|
||||||
|
|
@ -76,13 +110,13 @@ def create_users(session):
|
||||||
users.append(user)
|
users.append(user)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
print(f"✓ Created {len(users)} users")
|
print(f"✓ Created/Found {len(users)} users")
|
||||||
return users
|
return users
|
||||||
|
|
||||||
def import_venues(session):
|
def import_venues(session):
|
||||||
"""Import all venues"""
|
"""Import all venues"""
|
||||||
print("\n🏛️ Importing venues...")
|
print("\n🏛️ Importing venues...")
|
||||||
venues_data = fetch_json("venues")
|
venues_data = fetch_all_json("venues")
|
||||||
if not venues_data:
|
if not venues_data:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
@ -113,7 +147,7 @@ def import_venues(session):
|
||||||
def import_songs(session, vertical_id):
|
def import_songs(session, vertical_id):
|
||||||
"""Import all songs"""
|
"""Import all songs"""
|
||||||
print("\n🎵 Importing songs...")
|
print("\n🎵 Importing songs...")
|
||||||
songs_data = fetch_json("songs")
|
songs_data = fetch_all_json("songs")
|
||||||
if not songs_data:
|
if not songs_data:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
@ -148,12 +182,13 @@ def import_shows(session, vertical_id, venue_map):
|
||||||
"""Import all Goose shows"""
|
"""Import all Goose shows"""
|
||||||
print("\n🎤 Importing shows...")
|
print("\n🎤 Importing shows...")
|
||||||
params = {"artist": ARTIST_ID}
|
params = {"artist": ARTIST_ID}
|
||||||
shows_data = fetch_json("shows", params)
|
shows_data = fetch_all_json("shows", params)
|
||||||
|
|
||||||
if not shows_data:
|
if not shows_data:
|
||||||
|
# Fallback: fetch all shows and filter
|
||||||
# Fallback: fetch all shows and filter
|
# Fallback: fetch all shows and filter
|
||||||
print(" Fetching all shows and filtering for Goose...")
|
print(" Fetching all shows and filtering for Goose...")
|
||||||
shows_data = fetch_json("shows")
|
shows_data = fetch_all_json("shows")
|
||||||
shows_data = [s for s in (shows_data or []) if s.get('artist_id') == ARTIST_ID]
|
shows_data = [s for s in (shows_data or []) if s.get('artist_id') == ARTIST_ID]
|
||||||
|
|
||||||
if not shows_data:
|
if not shows_data:
|
||||||
|
|
@ -186,6 +221,18 @@ def import_shows(session, vertical_id, venue_map):
|
||||||
|
|
||||||
# Create show
|
# Create show
|
||||||
show_date = datetime.strptime(s['showdate'], '%Y-%m-%d')
|
show_date = datetime.strptime(s['showdate'], '%Y-%m-%d')
|
||||||
|
|
||||||
|
# Check existing show
|
||||||
|
existing_show = session.exec(
|
||||||
|
select(Show).where(
|
||||||
|
Show.date == show_date,
|
||||||
|
Show.venue_id == venue_map.get(s['venue_id'])
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_show:
|
||||||
|
show_map[s['show_id']] = existing_show.id
|
||||||
|
else:
|
||||||
show = Show(
|
show = Show(
|
||||||
date=show_date,
|
date=show_date,
|
||||||
vertical_id=vertical_id,
|
vertical_id=vertical_id,
|
||||||
|
|
@ -209,7 +256,7 @@ def import_setlists(session, show_map, song_map):
|
||||||
print("\n📋 Importing setlists...")
|
print("\n📋 Importing setlists...")
|
||||||
|
|
||||||
# Fetch all setlists (this gets all performances across all shows)
|
# Fetch all setlists (this gets all performances across all shows)
|
||||||
setlists_data = fetch_json("setlists")
|
setlists_data = fetch_all_json("setlists")
|
||||||
if not setlists_data:
|
if not setlists_data:
|
||||||
print("❌ No setlist data found")
|
print("❌ No setlist data found")
|
||||||
return
|
return
|
||||||
|
|
@ -229,6 +276,16 @@ def import_setlists(session, show_map, song_map):
|
||||||
if not our_show_id or not our_song_id:
|
if not our_show_id or not our_song_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check existing performance
|
||||||
|
existing_perf = session.exec(
|
||||||
|
select(Performance).where(
|
||||||
|
Performance.show_id == our_show_id,
|
||||||
|
Performance.song_id == our_song_id,
|
||||||
|
Performance.position == perf_data.get('position', 0)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing_perf:
|
||||||
perf = Performance(
|
perf = Performance(
|
||||||
show_id=our_show_id,
|
show_id=our_show_id,
|
||||||
song_id=our_song_id,
|
song_id=our_song_id,
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ class Rating(SQLModel, table=True):
|
||||||
|
|
||||||
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
|
show_id: Optional[int] = Field(default=None, foreign_key="show.id")
|
||||||
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
|
song_id: Optional[int] = Field(default=None, foreign_key="song.id")
|
||||||
|
performance_id: Optional[int] = Field(default=None, foreign_key="performance.id")
|
||||||
|
|
||||||
user: "User" = Relationship(back_populates="ratings")
|
user: "User" = Relationship(back_populates="ratings")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,10 @@ def create_rating(
|
||||||
query = query.where(Rating.show_id == rating.show_id)
|
query = query.where(Rating.show_id == rating.show_id)
|
||||||
elif rating.song_id:
|
elif rating.song_id:
|
||||||
query = query.where(Rating.song_id == rating.song_id)
|
query = query.where(Rating.song_id == rating.song_id)
|
||||||
|
elif rating.performance_id:
|
||||||
|
query = query.where(Rating.performance_id == rating.performance_id)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Must rate a show or song")
|
raise HTTPException(status_code=400, detail="Must rate a show, song, or performance")
|
||||||
|
|
||||||
existing_rating = session.exec(query).first()
|
existing_rating = session.exec(query).first()
|
||||||
if existing_rating:
|
if existing_rating:
|
||||||
|
|
@ -85,6 +87,7 @@ def create_rating(
|
||||||
def get_average_rating(
|
def get_average_rating(
|
||||||
show_id: Optional[int] = None,
|
show_id: Optional[int] = None,
|
||||||
song_id: Optional[int] = None,
|
song_id: Optional[int] = None,
|
||||||
|
performance_id: Optional[int] = None,
|
||||||
session: Session = Depends(get_session)
|
session: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
query = select(func.avg(Rating.score))
|
query = select(func.avg(Rating.score))
|
||||||
|
|
@ -92,8 +95,10 @@ def get_average_rating(
|
||||||
query = query.where(Rating.show_id == show_id)
|
query = query.where(Rating.show_id == show_id)
|
||||||
elif song_id:
|
elif song_id:
|
||||||
query = query.where(Rating.song_id == song_id)
|
query = query.where(Rating.song_id == song_id)
|
||||||
|
elif performance_id:
|
||||||
|
query = query.where(Rating.performance_id == performance_id)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Must specify show_id or song_id")
|
raise HTTPException(status_code=400, detail="Must specify show_id, song_id, or performance_id")
|
||||||
|
|
||||||
avg = session.exec(query).first()
|
avg = session.exec(query).first()
|
||||||
return float(avg) if avg else 0.0
|
return float(avg) if avg else 0.0
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from database import get_session
|
from database import get_session
|
||||||
|
|
@ -38,12 +39,66 @@ def read_song(song_id: int, session: Session = Depends(get_session)):
|
||||||
.where(EntityTag.entity_id == song_id)
|
.where(EntityTag.entity_id == song_id)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
# Fetch performances
|
||||||
|
# We join Show to ensure we can order by date
|
||||||
|
from models import Show, Performance, Rating
|
||||||
|
from sqlmodel import func
|
||||||
|
# We need PerformanceReadWithShow from schemas
|
||||||
|
from schemas import PerformanceReadWithShow
|
||||||
|
|
||||||
|
perfs = session.exec(
|
||||||
|
select(Performance)
|
||||||
|
.join(Show)
|
||||||
|
.where(Performance.song_id == song_id)
|
||||||
|
.order_by(Show.date.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Calculate ratings
|
||||||
|
perf_ids = [p.id for p in perfs]
|
||||||
|
rating_stats = {}
|
||||||
|
if perf_ids:
|
||||||
|
results = session.exec(
|
||||||
|
select(Rating.performance_id, func.avg(Rating.score), func.count(Rating.id))
|
||||||
|
.where(Rating.performance_id.in_(perf_ids))
|
||||||
|
.group_by(Rating.performance_id)
|
||||||
|
).all()
|
||||||
|
for r in results:
|
||||||
|
rating_stats[r[0]] = {"avg": float(r[1]) if r[1] else 0.0, "count": r[2]}
|
||||||
|
|
||||||
|
perf_dtos = []
|
||||||
|
for p in perfs:
|
||||||
|
# Lazy load show/venue (could be optimized)
|
||||||
|
venue_name = "Unknown"
|
||||||
|
venue_city = ""
|
||||||
|
venue_state = ""
|
||||||
|
show_date = datetime.now()
|
||||||
|
|
||||||
|
if p.show:
|
||||||
|
show_date = p.show.date
|
||||||
|
if p.show.venue:
|
||||||
|
venue_name = p.show.venue.name
|
||||||
|
venue_city = p.show.venue.city
|
||||||
|
venue_state = p.show.venue.state
|
||||||
|
|
||||||
|
stats = rating_stats.get(p.id, {"avg": 0.0, "count": 0})
|
||||||
|
|
||||||
|
perf_dtos.append(PerformanceReadWithShow(
|
||||||
|
**p.model_dump(),
|
||||||
|
show_date=show_date,
|
||||||
|
venue_name=venue_name,
|
||||||
|
venue_city=venue_city,
|
||||||
|
venue_state=venue_state,
|
||||||
|
avg_rating=stats["avg"],
|
||||||
|
total_reviews=stats["count"]
|
||||||
|
))
|
||||||
|
|
||||||
# Merge song data with stats
|
# Merge song data with stats
|
||||||
song_with_stats = SongReadWithStats(
|
song_with_stats = SongReadWithStats(
|
||||||
**song.model_dump(),
|
**song.model_dump(),
|
||||||
**stats
|
**stats
|
||||||
)
|
)
|
||||||
song_with_stats.tags = tags
|
song_with_stats.tags = tags
|
||||||
|
song_with_stats.performances = perf_dtos
|
||||||
return song_with_stats
|
return song_with_stats
|
||||||
|
|
||||||
@router.patch("/{song_id}", response_model=SongRead)
|
@router.patch("/{song_id}", response_model=SongRead)
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,7 @@ class SongRead(SongBase):
|
||||||
id: int
|
id: int
|
||||||
tags: List["TagRead"] = []
|
tags: List["TagRead"] = []
|
||||||
|
|
||||||
class SongReadWithStats(SongRead):
|
|
||||||
times_played: int
|
|
||||||
gap: int
|
|
||||||
last_played: Optional[datetime] = None
|
|
||||||
|
|
||||||
class SongUpdate(SQLModel):
|
class SongUpdate(SQLModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
|
@ -92,6 +89,20 @@ class PerformanceRead(PerformanceBase):
|
||||||
song: Optional["SongRead"] = None
|
song: Optional["SongRead"] = None
|
||||||
nicknames: List["PerformanceNicknameRead"] = []
|
nicknames: List["PerformanceNicknameRead"] = []
|
||||||
|
|
||||||
|
class PerformanceReadWithShow(PerformanceRead):
|
||||||
|
show_date: datetime
|
||||||
|
venue_name: str
|
||||||
|
venue_city: str
|
||||||
|
venue_state: Optional[str] = None
|
||||||
|
avg_rating: Optional[float] = 0.0
|
||||||
|
total_reviews: Optional[int] = 0
|
||||||
|
|
||||||
|
class SongReadWithStats(SongRead):
|
||||||
|
times_played: int
|
||||||
|
gap: int
|
||||||
|
last_played: Optional[datetime] = None
|
||||||
|
performances: List[PerformanceReadWithShow] = []
|
||||||
|
|
||||||
class PerformanceDetailRead(PerformanceRead):
|
class PerformanceDetailRead(PerformanceRead):
|
||||||
show: Optional["ShowRead"] = None
|
show: Optional["ShowRead"] = None
|
||||||
previous_performance_id: Optional[int] = None
|
previous_performance_id: Optional[int] = None
|
||||||
|
|
@ -210,6 +221,7 @@ class RatingBase(SQLModel):
|
||||||
score: int
|
score: int
|
||||||
show_id: Optional[int] = None
|
show_id: Optional[int] = None
|
||||||
song_id: Optional[int] = None
|
song_id: Optional[int] = None
|
||||||
|
performance_id: Optional[int] = None
|
||||||
|
|
||||||
class RatingCreate(RatingBase):
|
class RatingCreate(RatingBase):
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ export default function Home() {
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="flex flex-col items-center gap-4 py-12 text-center md:py-20 bg-gradient-to-b from-background to-accent/20 rounded-3xl border">
|
<section className="flex flex-col items-center gap-4 py-12 text-center md:py-20 bg-gradient-to-b from-background to-accent/20 rounded-3xl border">
|
||||||
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
|
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
|
||||||
Elmeg Demo
|
Elmeg Archive
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-[600px] text-lg text-muted-foreground">
|
<p className="max-w-[600px] text-lg text-muted-foreground">
|
||||||
Explore the "vibey" data we just generated.
|
The ultimate community archive for Goose history.
|
||||||
<br />
|
<br />
|
||||||
36 Personas. Wiki-linked Reviews. Interconnected Data.
|
Discover shows, rate performances, and connect with fans.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Link href="/leaderboards">
|
<Link href="/leaderboards">
|
||||||
|
|
@ -60,7 +60,7 @@ export default function Home() {
|
||||||
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
||||||
<Users className="h-8 w-8 mb-2 text-purple-500" />
|
<Users className="h-8 w-8 mb-2 text-purple-500" />
|
||||||
<h3 className="font-bold">Community</h3>
|
<h3 className="font-bold">Community</h3>
|
||||||
<p className="text-sm text-muted-foreground">Meet the 36 personas</p>
|
<p className="text-sm text-muted-foreground">Join the conversation</p>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
|
||||||
<Trophy className="h-8 w-8 mb-2 text-yellow-500" />
|
<Trophy className="h-8 w-8 mb-2 text-yellow-500" />
|
||||||
|
|
|
||||||
|
|
@ -94,11 +94,40 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
|
||||||
<CardTitle>Performance History</CardTitle>
|
<CardTitle>Performance History</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-muted-foreground text-sm">Performance history coming soon...</p>
|
{song.performances && song.performances.length > 0 ? (
|
||||||
{/*
|
<div className="space-y-4">
|
||||||
We need to fetch performances list here.
|
{song.performances.map((perf: any) => (
|
||||||
For now, we leave a placeholder.
|
<div key={perf.id} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
|
||||||
*/}
|
<div className="space-y-1">
|
||||||
|
<Link href={`/shows/${perf.show_id}`} className="font-medium hover:underline text-primary">
|
||||||
|
{new Date(perf.show_date).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</Link>
|
||||||
|
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
{perf.venue_name}, {perf.venue_city} {perf.venue_state}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{perf.notes && (
|
||||||
|
<p className="text-sm italic text-muted-foreground/80">"{perf.notes}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{/* Placeholder for Rating UI */}
|
||||||
|
<span className="text-xs font-semibold bg-secondary px-2 py-1 rounded-full text-secondary-foreground">
|
||||||
|
{perf.avg_rating > 0 ? perf.avg_rating.toFixed(1) : "Unrated"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">No performances recorded.</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue