From c16fe559e0b1e58a22a32c9d525ca7671f995605 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sun, 21 Dec 2025 13:50:52 -0800 Subject: [PATCH] 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 --- backend/main.py | 3 +- backend/routers/admin.py | 434 ++++++++++++++++++++++++++++++++++++ frontend/app/admin/page.tsx | 424 +++++++++++++++++++++++++++++++++-- 3 files changed, 838 insertions(+), 23 deletions(-) create mode 100644 backend/routers/admin.py diff --git a/backend/main.py b/backend/main.py index fea8a2b..7b690b6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ from fastapi import FastAPI -from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats +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 from fastapi.middleware.cors import CORSMiddleware @@ -34,6 +34,7 @@ app.include_router(notifications.router) app.include_router(feed.router) app.include_router(leaderboards.router) app.include_router(stats.router) +app.include_router(admin.router) @app.get("/") def read_root(): diff --git a/backend/routers/admin.py b/backend/routers/admin.py new file mode 100644 index 0000000..dc45d92 --- /dev/null +++ b/backend/routers/admin.py @@ -0,0 +1,434 @@ +""" +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} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 92a8ab0..caf6120 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -1,30 +1,410 @@ "use client" +import { useEffect, useState } from "react" +import { useAuth } from "@/contexts/auth-context" +import { useRouter } from "next/navigation" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { + Users, Music2, MapPin, Calendar, BarChart3, + Shield, ShieldCheck, Search, Check, X, Edit +} from "lucide-react" +import { getApiUrl } from "@/lib/api-config" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" -export default function AdminDashboard() { - return ( -
Select a category from the sidebar to manage content.
+ ) + } + + if (!user || user.role !== "admin") { + return null + } + + return ( +{stats.total_users}
+Total Users
+{stats.verified_users}
+Verified
+{stats.total_shows}
+Shows
+{stats.total_songs}
+Songs
+| User | +Role | +Status | +Verified | +Actions | +
|---|---|---|---|---|
|
+
+
+ {u.username || "No username"} +{u.email} + |
+
+ |
+
+ {u.is_active ? (
+ |
+
+ {u.email_verified ? (
+ |
+ + + | +
+ {stats?.total_shows || 0} shows in database +
+ ++ {stats?.total_songs || 0} songs in database +
+ ++ {stats?.total_venues || 0} venues in database +
+ +{stats?.total_ratings || 0} ratings
+{stats?.total_reviews || 0} reviews
+{stats?.total_comments || 0} comments
+