""" 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 } # ============ EMAIL & NOTIFICATIONS ============ @router.post("/send-weekly-digest") def trigger_weekly_digest( session: Session = Depends(get_session), _: User = Depends(allow_admin) ): """Manually trigger weekly digest emails (runs automatically every Sunday 9am UTC)""" from services.weekly_digest import send_weekly_digests try: send_weekly_digests() return {"message": "Weekly digest emails sent successfully"} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to send digest: {str(e)}") @router.post("/test-email") def test_email( to_email: str, session: Session = Depends(get_session), _: User = Depends(allow_admin) ): """Send a test email to verify email configuration""" from services.email_service import email_service subject = "Elmeg Test Email" html_content = """
This is a test email from Elmeg to verify email configuration.
If you received this, SMTP is working correctly!
""" text_content = "This is a test email from Elmeg. If you received this, SMTP is working correctly!" success = email_service.send_email(to_email, subject, html_content, text_content) if success: return {"message": f"Test email sent to {to_email}"} else: raise HTTPException(status_code=500, detail="Failed to send test email")