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