feat: add Band Members (Musicians) feature - Sprint 4
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run

This commit is contained in:
fullsizemalt 2025-12-24 13:07:48 -08:00
parent 037d2aa463
commit ff56e4f140
6 changed files with 513 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Musician[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [editingMusician, setEditingMusician] = useState<Musician | null>(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 (
<div className="space-y-4">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<div className="grid gap-4">
{[1, 2, 3].map(i => <div key={i} className="h-20 bg-muted rounded animate-pulse" />)}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<UserCircle className="h-6 w-6" />
Musician Management
</h2>
<Button onClick={() => setIsCreating(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Musician
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search musicians..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Card>
<CardContent className="p-0">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-3 font-medium">Musician</th>
<th className="text-left p-3 font-medium">Instrument</th>
<th className="text-left p-3 font-medium">Bio</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredMusicians.map(musician => (
<tr key={musician.id} className="border-t">
<td className="p-3">
<Link href={`/musicians/${musician.slug}`} className="font-medium hover:underline">
{musician.name}
</Link>
</td>
<td className="p-3 text-muted-foreground">
{musician.primary_instrument || "—"}
</td>
<td className="p-3">
{musician.bio ? (
<Badge variant="outline" className="text-green-600 border-green-600">Has Bio</Badge>
) : (
<Badge variant="outline" className="text-yellow-600 border-yellow-600">No Bio</Badge>
)}
</td>
<td className="p-3 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingMusician(musician)}
>
<Edit className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
{filteredMusicians.length === 0 && (
<div className="p-8 text-center text-muted-foreground">
No musicians found. Add some to get started!
</div>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={isCreating} onOpenChange={setIsCreating}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add New Musician</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Name *</Label>
<Input
placeholder="e.g., Jeff Chimenti"
value={newMusician.name}
onChange={(e) => setNewMusician({ ...newMusician, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Primary Instrument</Label>
<Input
placeholder="e.g., Keyboards"
value={newMusician.primary_instrument}
onChange={(e) => setNewMusician({ ...newMusician, primary_instrument: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Bio</Label>
<Textarea
placeholder="Brief biography..."
value={newMusician.bio}
onChange={(e) => setNewMusician({ ...newMusician, bio: e.target.value })}
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Image URL</Label>
<Input
placeholder="https://..."
value={newMusician.image_url}
onChange={(e) => setNewMusician({ ...newMusician, image_url: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreating(false)}>
Cancel
</Button>
<Button onClick={createMusician} disabled={saving || !newMusician.name}>
<Save className="h-4 w-4 mr-2" />
{saving ? "Creating..." : "Create Musician"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog (placeholder - can be enhanced) */}
<Dialog open={!!editingMusician} onOpenChange={() => setEditingMusician(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit: {editingMusician?.name}</DialogTitle>
</DialogHeader>
<div className="py-4 text-muted-foreground">
Edit functionality coming soon. For now, view the musician profile.
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingMusician(null)}>
<X className="h-4 w-4 mr-2" />
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}