elmeg-demo/backend/routers/admin.py
fullsizemalt c16fe559e0 feat: Add Admin Panel (Phase 2)
Backend:
- admin.py router with user management CRUD
- Platform stats endpoint
- Shows/Songs/Venues/Tours CRUD
- Protected by RoleChecker (admin only)

Frontend:
- /admin dashboard with stats cards
- Users tab with search and edit dialog
- Content tab with entity counts
- Role/ban/verification management
2025-12-21 13:50:52 -08:00

434 lines
12 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
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 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}