elmeg-demo/backend/routers/admin.py
fullsizemalt d276cdbd76
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
feat: add Admin Interface for Artists management
2025-12-24 12:34:43 -08:00

605 lines
17 KiB
Python

"""
Admin Router - Protected endpoints for admin users only.
User management, content CRUD, platform stats.
"""
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func
from pydantic import BaseModel
from database import get_session
from models import User, Profile, Show, Song, Venue, Tour, Rating, Comment, Review, Attendance, Artist
from dependencies import RoleChecker
from auth import get_password_hash
router = APIRouter(prefix="/admin", tags=["admin"])
# Only admins can access these endpoints
allow_admin = RoleChecker(["admin"])
# ============ SCHEMAS ============
class UserUpdate(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
email_verified: Optional[bool] = None
class UserListItem(BaseModel):
id: int
email: str
role: str
is_active: bool
email_verified: bool
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class ShowCreate(BaseModel):
date: datetime
vertical_id: int
venue_id: Optional[int] = None
tour_id: Optional[int] = None
notes: Optional[str] = None
bandcamp_link: Optional[str] = None
nugs_link: Optional[str] = None
youtube_link: Optional[str] = None
class ShowUpdate(BaseModel):
date: Optional[datetime] = None
venue_id: Optional[int] = None
tour_id: Optional[int] = None
notes: Optional[str] = None
bandcamp_link: Optional[str] = None
nugs_link: Optional[str] = None
youtube_link: Optional[str] = None
class SongCreate(BaseModel):
title: str
vertical_id: int
original_artist: Optional[str] = None
notes: Optional[str] = None
youtube_link: Optional[str] = None
class SongUpdate(BaseModel):
title: Optional[str] = None
original_artist: Optional[str] = None
notes: Optional[str] = None
youtube_link: Optional[str] = None
class VenueCreate(BaseModel):
name: str
city: str
state: Optional[str] = None
country: str = "USA"
capacity: Optional[int] = None
class VenueUpdate(BaseModel):
name: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
country: Optional[str] = None
capacity: Optional[int] = None
class TourCreate(BaseModel):
name: str
vertical_id: int
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class TourUpdate(BaseModel):
name: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class ArtistUpdate(BaseModel):
bio: Optional[str] = None
image_url: Optional[str] = None
notes: Optional[str] = None
class PlatformStats(BaseModel):
total_users: int
verified_users: int
total_shows: int
total_songs: int
total_venues: int
total_ratings: int
total_reviews: int
total_comments: int
# ============ STATS ============
@router.get("/stats", response_model=PlatformStats)
def get_platform_stats(
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Get platform-wide statistics"""
return PlatformStats(
total_users=session.exec(select(func.count(User.id))).one(),
verified_users=session.exec(select(func.count(User.id)).where(User.email_verified == True)).one(),
total_shows=session.exec(select(func.count(Show.id))).one(),
total_songs=session.exec(select(func.count(Song.id))).one(),
total_venues=session.exec(select(func.count(Venue.id))).one(),
total_ratings=session.exec(select(func.count(Rating.id))).one(),
total_reviews=session.exec(select(func.count(Review.id))).one(),
total_comments=session.exec(select(func.count(Comment.id))).one(),
)
# ============ USERS ============
@router.get("/users")
def list_users(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
role: Optional[str] = None,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""List all users with optional filtering"""
query = select(User)
if search:
query = query.where(User.email.contains(search))
if role:
query = query.where(User.role == role)
query = query.offset(skip).limit(limit)
users = session.exec(query).all()
# Get profiles for usernames
result = []
for user in users:
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
result.append({
"id": user.id,
"email": user.email,
"username": profile.username if profile else None,
"role": user.role,
"is_active": user.is_active,
"email_verified": user.email_verified,
})
return result
@router.get("/users/{user_id}")
def get_user(
user_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Get user details with activity stats"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
profile = session.exec(select(Profile).where(Profile.user_id == user.id)).first()
return {
"id": user.id,
"email": user.email,
"username": profile.username if profile else None,
"role": user.role,
"is_active": user.is_active,
"email_verified": user.email_verified,
"bio": user.bio,
"stats": {
"ratings": session.exec(select(func.count(Rating.id)).where(Rating.user_id == user.id)).one(),
"reviews": session.exec(select(func.count(Review.id)).where(Review.user_id == user.id)).one(),
"comments": session.exec(select(func.count(Comment.id)).where(Comment.user_id == user.id)).one(),
"attendances": session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user.id)).one(),
}
}
@router.patch("/users/{user_id}")
def update_user(
user_id: int,
update: UserUpdate,
session: Session = Depends(get_session),
admin: User = Depends(allow_admin)
):
"""Update user role, status, or verification"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Prevent admin from demoting themselves
if user.id == admin.id and update.role and update.role != "admin":
raise HTTPException(status_code=400, detail="Cannot demote yourself")
if update.role is not None:
user.role = update.role
if update.is_active is not None:
user.is_active = update.is_active
if update.email_verified is not None:
user.email_verified = update.email_verified
session.add(user)
session.commit()
session.refresh(user)
return {"message": "User updated", "user_id": user.id}
# ============ SHOWS ============
@router.post("/shows")
def create_show(
show_data: ShowCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new show"""
show = Show(**show_data.model_dump())
session.add(show)
session.commit()
session.refresh(show)
return show
@router.patch("/shows/{show_id}")
def update_show(
show_id: int,
update: ShowUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update show details"""
show = session.get(Show, show_id)
if not show:
raise HTTPException(status_code=404, detail="Show not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(show, key, value)
session.add(show)
session.commit()
session.refresh(show)
return show
@router.delete("/shows/{show_id}")
def delete_show(
show_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a show"""
show = session.get(Show, show_id)
if not show:
raise HTTPException(status_code=404, detail="Show not found")
session.delete(show)
session.commit()
return {"message": "Show deleted", "show_id": show_id}
# ============ SONGS ============
@router.post("/songs")
def create_song(
song_data: SongCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new song"""
song = Song(**song_data.model_dump())
session.add(song)
session.commit()
session.refresh(song)
return song
@router.patch("/songs/{song_id}")
def update_song(
song_id: int,
update: SongUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update song details"""
song = session.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(song, key, value)
session.add(song)
session.commit()
session.refresh(song)
return song
@router.delete("/songs/{song_id}")
def delete_song(
song_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a song"""
song = session.get(Song, song_id)
if not song:
raise HTTPException(status_code=404, detail="Song not found")
session.delete(song)
session.commit()
return {"message": "Song deleted", "song_id": song_id}
# ============ VENUES ============
@router.post("/venues")
def create_venue(
venue_data: VenueCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new venue"""
venue = Venue(**venue_data.model_dump())
session.add(venue)
session.commit()
session.refresh(venue)
return venue
@router.patch("/venues/{venue_id}")
def update_venue(
venue_id: int,
update: VenueUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update venue details"""
venue = session.get(Venue, venue_id)
if not venue:
raise HTTPException(status_code=404, detail="Venue not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(venue, key, value)
session.add(venue)
session.commit()
session.refresh(venue)
return venue
@router.delete("/venues/{venue_id}")
def delete_venue(
venue_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a venue"""
venue = session.get(Venue, venue_id)
if not venue:
raise HTTPException(status_code=404, detail="Venue not found")
session.delete(venue)
session.commit()
return {"message": "Venue deleted", "venue_id": venue_id}
# ============ TOURS ============
@router.post("/tours")
def create_tour(
tour_data: TourCreate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Create a new tour"""
tour = Tour(**tour_data.model_dump())
session.add(tour)
session.commit()
session.refresh(tour)
return tour
@router.patch("/tours/{tour_id}")
def update_tour(
tour_id: int,
update: TourUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update tour details"""
tour = session.get(Tour, tour_id)
if not tour:
raise HTTPException(status_code=404, detail="Tour not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(tour, key, value)
session.add(tour)
session.commit()
session.refresh(tour)
return tour
@router.delete("/tours/{tour_id}")
def delete_tour(
tour_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Delete a tour"""
tour = session.get(Tour, tour_id)
if not tour:
raise HTTPException(status_code=404, detail="Tour not found")
session.delete(tour)
session.commit()
return {"message": "Tour deleted", "tour_id": tour_id}
# ============ ARTISTS ============
@router.get("/artists")
def list_artists(
skip: int = 0,
limit: int = 50,
search: Optional[str] = None,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""List all artists with optional search"""
query = select(Artist)
if search:
query = query.where(Artist.name.icontains(search))
query = query.order_by(Artist.name).offset(skip).limit(limit)
artists = session.exec(query).all()
return [
{
"id": a.id,
"name": a.name,
"slug": a.slug,
"bio": a.bio,
"image_url": a.image_url,
"notes": a.notes,
}
for a in artists
]
@router.patch("/artists/{artist_id}")
def update_artist(
artist_id: int,
update: ArtistUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update artist details (bio, image, notes)"""
artist = session.get(Artist, artist_id)
if not artist:
raise HTTPException(status_code=404, detail="Artist not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(artist, key, value)
session.add(artist)
session.commit()
session.refresh(artist)
return artist
# ============ PERFORMANCES ============
from models import Performance
class PerformanceUpdate(BaseModel):
notes: Optional[str] = None
youtube_link: Optional[str] = None
bandcamp_link: Optional[str] = None
nugs_link: Optional[str] = None
track_url: Optional[str] = None
@router.patch("/performances/{performance_id}")
def update_performance(
performance_id: int,
update: PerformanceUpdate,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Update performance links and notes"""
performance = session.get(Performance, performance_id)
if not performance:
raise HTTPException(status_code=404, detail="Performance not found")
for key, value in update.model_dump(exclude_unset=True).items():
setattr(performance, key, value)
session.add(performance)
session.commit()
session.refresh(performance)
return performance
@router.get("/performances/{performance_id}")
def get_performance(
performance_id: int,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Get performance details for admin"""
performance = session.get(Performance, performance_id)
if not performance:
raise HTTPException(status_code=404, detail="Performance not found")
return {
"id": performance.id,
"slug": performance.slug,
"show_id": performance.show_id,
"song_id": performance.song_id,
"position": performance.position,
"set_name": performance.set_name,
"notes": performance.notes,
"youtube_link": performance.youtube_link,
"bandcamp_link": performance.bandcamp_link,
"nugs_link": performance.nugs_link,
"track_url": performance.track_url,
}
class BulkLinksImport(BaseModel):
links: List[dict] # {"show_id": 1, "platform": "nugs", "url": "..."} or {"performance_id": 1, ...}
@router.post("/import/external-links")
def bulk_import_links(
data: BulkLinksImport,
session: Session = Depends(get_session),
_: User = Depends(allow_admin)
):
"""Bulk import external links for shows and performances"""
updated_shows = 0
updated_performances = 0
errors = []
for item in data.links:
platform = item.get("platform", "").lower()
url = item.get("url")
if not platform or not url:
errors.append({"item": item, "error": "Missing platform or url"})
continue
field_name = f"{platform}_link"
if "show_id" in item:
show = session.get(Show, item["show_id"])
if show and hasattr(show, field_name):
setattr(show, field_name, url)
session.add(show)
updated_shows += 1
else:
errors.append({"item": item, "error": "Show not found or invalid platform"})
elif "performance_id" in item:
perf = session.get(Performance, item["performance_id"])
if perf and hasattr(perf, field_name):
setattr(perf, field_name, url)
session.add(perf)
updated_performances += 1
else:
errors.append({"item": item, "error": "Performance not found or invalid platform"})
session.commit()
return {
"updated_shows": updated_shows,
"updated_performances": updated_performances,
"errors": errors
}