diff --git a/backend/models.py b/backend/models.py index c253f54..0f2ddb6 100644 --- a/backend/models.py +++ b/backend/models.py @@ -112,6 +112,24 @@ class Vertical(SQLModel, table=True): songs: List["Song"] = Relationship(back_populates="vertical") scenes: List["Scene"] = Relationship(back_populates="verticals", link_model=VerticalScene) +class VenueCanon(SQLModel, table=True): + """Canonical venue independent of band - enables cross-band venue linking (like SongCanon for songs)""" + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + slug: str = Field(unique=True, index=True) + city: str + state: Optional[str] = Field(default=None) + country: str = Field(default="USA") + latitude: Optional[float] = Field(default=None) + longitude: Optional[float] = Field(default=None) + capacity: Optional[int] = Field(default=None) + website_url: Optional[str] = Field(default=None) + notes: Optional[str] = Field(default=None) + + # All venue records that point to this canonical venue + venues: List["Venue"] = Relationship(back_populates="canon") + + class Venue(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(index=True) @@ -122,8 +140,13 @@ class Venue(SQLModel, table=True): capacity: Optional[int] = Field(default=None) notes: Optional[str] = Field(default=None) + # Link to canonical venue for cross-band deduplication + canon_id: Optional[int] = Field(default=None, foreign_key="venuecanon.id") + canon: Optional[VenueCanon] = Relationship(back_populates="venues") + 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) diff --git a/backend/routers/venues.py b/backend/routers/venues.py index 7ad0abd..aca78f0 100644 --- a/backend/routers/venues.py +++ b/backend/routers/venues.py @@ -40,3 +40,78 @@ def update_venue(venue_id: int, venue: VenueUpdate, session: Session = Depends(g session.commit() session.refresh(db_venue) return db_venue + + +@router.get("/{slug}/across-bands") +def get_venue_across_bands(slug: str, session: Session = Depends(get_session)): + """Get aggregated stats for a venue across all bands that have played there (via VenueCanon)""" + from models import VenueCanon, Show, Vertical + from sqlmodel import func + + # Find the venue by slug + venue = session.exec(select(Venue).where(Venue.slug == slug)).first() + if not venue: + raise HTTPException(status_code=404, detail="Venue not found") + + # If this venue has a canon_id, get all linked venues + linked_venues = [venue] + if venue.canon_id: + linked_venues = session.exec( + select(Venue).where(Venue.canon_id == venue.canon_id) + ).all() + + venue_ids = [v.id for v in linked_venues] + + # Get all shows at these venues + shows = session.exec( + select(Show) + .where(Show.venue_id.in_(venue_ids)) + .order_by(Show.date.desc()) + ).all() + + # Group by vertical/band + bands_stats = {} + for show in shows: + vertical = session.get(Vertical, show.vertical_id) + if vertical: + if vertical.id not in bands_stats: + bands_stats[vertical.id] = { + "vertical_id": vertical.id, + "vertical_name": vertical.name, + "vertical_slug": vertical.slug, + "show_count": 0, + "first_show": show.date, + "last_show": show.date, + "recent_shows": [] + } + bands_stats[vertical.id]["show_count"] += 1 + if show.date < bands_stats[vertical.id]["first_show"]: + bands_stats[vertical.id]["first_show"] = show.date + if show.date > bands_stats[vertical.id]["last_show"]: + bands_stats[vertical.id]["last_show"] = show.date + if len(bands_stats[vertical.id]["recent_shows"]) < 3: + bands_stats[vertical.id]["recent_shows"].append({ + "date": show.date.strftime("%Y-%m-%d") if show.date else None, + "slug": show.slug + }) + + # Format response + bands_list = sorted(bands_stats.values(), key=lambda x: x["show_count"], reverse=True) + for band in bands_list: + band["first_show"] = band["first_show"].strftime("%Y-%m-%d") if band["first_show"] else None + band["last_show"] = band["last_show"].strftime("%Y-%m-%d") if band["last_show"] else None + + return { + "venue": { + "id": venue.id, + "name": venue.name, + "slug": venue.slug, + "city": venue.city, + "state": venue.state, + "country": venue.country, + "capacity": venue.capacity, + }, + "total_shows": len(shows), + "bands_count": len(bands_list), + "bands": bands_list + }