From ff56e4f1406013bfd783b223b875f111c8c6181d Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:07:48 -0800 Subject: [PATCH] feat: add Band Members (Musicians) feature - Sprint 4 --- backend/main.py | 3 +- backend/migrations/migrate_musicians.py | 69 +++++++ backend/models.py | 38 ++++ backend/routers/musicians.py | 138 +++++++++++++ frontend/app/admin/layout.tsx | 8 +- frontend/app/admin/musicians/page.tsx | 260 ++++++++++++++++++++++++ 6 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 backend/migrations/migrate_musicians.py create mode 100644 backend/routers/musicians.py create mode 100644 frontend/app/admin/musicians/page.tsx diff --git a/backend/main.py b/backend/main.py index 4f5624e..3faa1dc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI import os -from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos +from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians from fastapi.middleware.cors import CORSMiddleware @@ -42,6 +42,7 @@ app.include_router(admin.router) app.include_router(chase.router) app.include_router(gamification.router) app.include_router(videos.router) +app.include_router(musicians.router) # Optional features - can be disabled via env vars diff --git a/backend/migrations/migrate_musicians.py b/backend/migrations/migrate_musicians.py new file mode 100644 index 0000000..847fb58 --- /dev/null +++ b/backend/migrations/migrate_musicians.py @@ -0,0 +1,69 @@ +""" +Migration script to create Musician, BandMembership, and PerformanceGuest tables. +""" +from sqlmodel import Session, text +from database import engine + +def migrate_musicians(): + with Session(engine) as session: + print("Running Musicians Migration...") + + # Create Musician table + try: + session.exec(text(""" + CREATE TABLE IF NOT EXISTS musician ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + slug VARCHAR UNIQUE NOT NULL, + bio TEXT, + image_url VARCHAR, + primary_instrument VARCHAR, + notes TEXT + ) + """)) + session.exec(text("CREATE INDEX IF NOT EXISTS idx_musician_name ON musician(name)")) + session.exec(text("CREATE INDEX IF NOT EXISTS idx_musician_slug ON musician(slug)")) + print("Created musician table") + except Exception as e: + print(f"musician table error: {e}") + session.rollback() + + # Create BandMembership table + try: + session.exec(text(""" + CREATE TABLE IF NOT EXISTS bandmembership ( + id SERIAL PRIMARY KEY, + musician_id INTEGER NOT NULL REFERENCES musician(id), + artist_id INTEGER NOT NULL REFERENCES artist(id), + role VARCHAR, + start_date TIMESTAMP, + end_date TIMESTAMP, + notes TEXT + ) + """)) + print("Created bandmembership table") + except Exception as e: + print(f"bandmembership table error: {e}") + session.rollback() + + # Create PerformanceGuest table + try: + session.exec(text(""" + CREATE TABLE IF NOT EXISTS performanceguest ( + id SERIAL PRIMARY KEY, + performance_id INTEGER NOT NULL REFERENCES performance(id), + musician_id INTEGER NOT NULL REFERENCES musician(id), + instrument VARCHAR, + notes TEXT + ) + """)) + print("Created performanceguest table") + except Exception as e: + print(f"performanceguest table error: {e}") + session.rollback() + + session.commit() + print("Musicians Migration Complete!") + +if __name__ == "__main__": + migrate_musicians() diff --git a/backend/models.py b/backend/models.py index 1c93dd3..20cf777 100644 --- a/backend/models.py +++ b/backend/models.py @@ -97,6 +97,44 @@ class Artist(SQLModel, table=True): songs: List["Song"] = Relationship(back_populates="artist") +class Musician(SQLModel, table=True): + """Individual human musicians (for tracking sit-ins and band membership)""" + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + slug: str = Field(unique=True, index=True) + bio: Optional[str] = Field(default=None) + image_url: Optional[str] = Field(default=None) + primary_instrument: Optional[str] = Field(default=None) + notes: Optional[str] = Field(default=None) + + # Relationships + memberships: List["BandMembership"] = Relationship(back_populates="musician") + guest_appearances: List["PerformanceGuest"] = Relationship(back_populates="musician") + +class BandMembership(SQLModel, table=True): + """Link between Musician and Band/Artist with role and dates""" + id: Optional[int] = Field(default=None, primary_key=True) + musician_id: int = Field(foreign_key="musician.id") + artist_id: int = Field(foreign_key="artist.id", description="The band/group") + role: Optional[str] = Field(default=None, description="e.g., Keyboards, Rhythm Guitar") + start_date: Optional[datetime] = Field(default=None) + end_date: Optional[datetime] = Field(default=None, description="Null = current member") + notes: Optional[str] = Field(default=None) + + musician: Musician = Relationship(back_populates="memberships") + artist: Artist = Relationship() + +class PerformanceGuest(SQLModel, table=True): + """Link between Performance and Musician for sit-ins/guest appearances""" + id: Optional[int] = Field(default=None, primary_key=True) + performance_id: int = Field(foreign_key="performance.id") + musician_id: int = Field(foreign_key="musician.id") + instrument: Optional[str] = Field(default=None, description="What they played on this track") + notes: Optional[str] = Field(default=None) + + musician: Musician = Relationship(back_populates="guest_appearances") + + class Show(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) date: datetime = Field(index=True) diff --git a/backend/routers/musicians.py b/backend/routers/musicians.py new file mode 100644 index 0000000..c327249 --- /dev/null +++ b/backend/routers/musicians.py @@ -0,0 +1,138 @@ +""" +Musicians Router - API endpoints for managing musicians and band memberships. +""" +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select +from pydantic import BaseModel +from database import get_session +from models import Musician, BandMembership, PerformanceGuest, Artist, Performance +from slugify import generate_slug as slugify + +router = APIRouter(prefix="/musicians", tags=["musicians"]) + +# --- Schemas --- +class MusicianRead(BaseModel): + id: int + name: str + slug: str + bio: Optional[str] = None + image_url: Optional[str] = None + primary_instrument: Optional[str] = None + +class MusicianCreate(BaseModel): + name: str + bio: Optional[str] = None + image_url: Optional[str] = None + primary_instrument: Optional[str] = None + +class BandMembershipRead(BaseModel): + id: int + musician_id: int + artist_id: int + artist_name: Optional[str] = None + role: Optional[str] = None + start_date: Optional[str] = None + end_date: Optional[str] = None + +class GuestAppearanceRead(BaseModel): + id: int + performance_id: int + song_title: Optional[str] = None + show_date: Optional[str] = None + instrument: Optional[str] = None + +# --- Public Endpoints --- + +@router.get("", response_model=List[MusicianRead]) +def list_musicians( + search: Optional[str] = None, + limit: int = 50, + session: Session = Depends(get_session) +): + """List all musicians""" + query = select(Musician) + if search: + query = query.where(Musician.name.icontains(search)) + query = query.order_by(Musician.name).limit(limit) + return session.exec(query).all() + +@router.get("/{slug}") +def get_musician(slug: str, session: Session = Depends(get_session)): + """Get musician details with band memberships and guest appearances""" + musician = session.exec(select(Musician).where(Musician.slug == slug)).first() + if not musician: + raise HTTPException(status_code=404, detail="Musician not found") + + # Get band memberships + memberships = session.exec( + select(BandMembership).where(BandMembership.musician_id == musician.id) + ).all() + + bands = [] + for m in memberships: + artist = session.get(Artist, m.artist_id) + bands.append({ + "id": m.id, + "artist_id": m.artist_id, + "artist_name": artist.name if artist else None, + "artist_slug": artist.slug if artist else None, + "role": m.role, + "start_date": str(m.start_date) if m.start_date else None, + "end_date": str(m.end_date) if m.end_date else None, + }) + + # Get guest appearances + appearances = session.exec( + select(PerformanceGuest).where(PerformanceGuest.musician_id == musician.id) + ).all() + + guests = [] + for g in appearances: + perf = session.get(Performance, g.performance_id) + guests.append({ + "id": g.id, + "performance_id": g.performance_id, + "performance_slug": perf.slug if perf else None, + "instrument": g.instrument, + }) + + return { + "musician": { + "id": musician.id, + "name": musician.name, + "slug": musician.slug, + "bio": musician.bio, + "image_url": musician.image_url, + "primary_instrument": musician.primary_instrument, + }, + "bands": bands, + "guest_appearances": guests, + } + +# --- Admin Endpoints (for now, no auth check - can be added later) --- + +@router.post("", response_model=MusicianRead) +def create_musician( + musician_data: MusicianCreate, + session: Session = Depends(get_session) +): + """Create a new musician""" + slug = slugify(musician_data.name) + + # Check for existing + existing = session.exec(select(Musician).where(Musician.slug == slug)).first() + if existing: + raise HTTPException(status_code=400, detail="Musician with this name already exists") + + musician = Musician( + name=musician_data.name, + slug=slug, + bio=musician_data.bio, + image_url=musician_data.image_url, + primary_instrument=musician_data.primary_instrument, + ) + session.add(musician) + session.commit() + session.refresh(musician) + return musician diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index 2afa02e..1d0b625 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -3,8 +3,7 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { LayoutDashboard, MessageSquare, ShieldAlert, Users, Mic2, Calendar, Music2, MapPin } from "lucide-react" +import { LayoutDashboard, MessageSquare, ShieldAlert, Users, Mic2, Calendar, Music2, MapPin, UserCircle } from "lucide-react" export default function AdminLayout({ children, @@ -24,6 +23,11 @@ export default function AdminLayout({ href: "/admin/artists", icon: Mic2 }, + { + title: "Musicians", + href: "/admin/musicians", + icon: UserCircle + }, { title: "Shows", href: "/admin/shows", diff --git a/frontend/app/admin/musicians/page.tsx b/frontend/app/admin/musicians/page.tsx new file mode 100644 index 0000000..a09d349 --- /dev/null +++ b/frontend/app/admin/musicians/page.tsx @@ -0,0 +1,260 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import { useAuth } from "@/contexts/auth-context" +import { useRouter } from "next/navigation" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Search, Edit, Save, X, UserCircle, Plus } from "lucide-react" +import { getApiUrl } from "@/lib/api-config" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import Link from "next/link" + +interface Musician { + id: number + name: string + slug: string + bio: string | null + image_url: string | null + primary_instrument: string | null +} + +export default function AdminMusiciansPage() { + const { user, token } = useAuth() + const router = useRouter() + const [musicians, setMusicians] = useState([]) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [editingMusician, setEditingMusician] = useState(null) + const [isCreating, setIsCreating] = useState(false) + const [newMusician, setNewMusician] = useState({ name: "", bio: "", image_url: "", primary_instrument: "" }) + const [saving, setSaving] = useState(false) + + const fetchMusicians = useCallback(async () => { + if (!token) return + + try { + const res = await fetch(`${getApiUrl()}/musicians?limit=100`, { + headers: { Authorization: `Bearer ${token}` } + }) + if (res.ok) setMusicians(await res.json()) + } catch (e) { + console.error("Failed to fetch musicians", e) + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { + if (!user) { + router.push("/login") + return + } + if (user.role !== "admin") { + router.push("/") + return + } + fetchMusicians() + }, [user, router, fetchMusicians]) + + const createMusician = async () => { + if (!token || !newMusician.name) return + setSaving(true) + + try { + const res = await fetch(`${getApiUrl()}/musicians`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(newMusician) + }) + + if (res.ok) { + fetchMusicians() + setIsCreating(false) + setNewMusician({ name: "", bio: "", image_url: "", primary_instrument: "" }) + } + } catch (e) { + console.error("Failed to create musician", e) + } finally { + setSaving(false) + } + } + + const filteredMusicians = musicians.filter(m => + m.name.toLowerCase().includes(search.toLowerCase()) || + m.primary_instrument?.toLowerCase().includes(search.toLowerCase()) + ) + + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map(i =>
)} +
+
+ ) + } + + return ( +
+
+

+ + Musician Management +

+ +
+ +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + + + + + + + + + + + + + {filteredMusicians.map(musician => ( + + + + + + + ))} + +
MusicianInstrumentBioActions
+ + {musician.name} + + + {musician.primary_instrument || "—"} + + {musician.bio ? ( + Has Bio + ) : ( + No Bio + )} + + +
+ {filteredMusicians.length === 0 && ( +
+ No musicians found. Add some to get started! +
+ )} +
+
+ + {/* Create Dialog */} + + + + Add New Musician + +
+
+ + setNewMusician({ ...newMusician, name: e.target.value })} + /> +
+ +
+ + setNewMusician({ ...newMusician, primary_instrument: e.target.value })} + /> +
+ +
+ +