feat: add Band Members (Musicians) feature - Sprint 4
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
This commit is contained in:
parent
037d2aa463
commit
ff56e4f140
6 changed files with 513 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
69
backend/migrations/migrate_musicians.py
Normal file
69
backend/migrations/migrate_musicians.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
138
backend/routers/musicians.py
Normal file
138
backend/routers/musicians.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
260
frontend/app/admin/musicians/page.tsx
Normal file
260
frontend/app/admin/musicians/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue