From 2da2a5bb2753ba2b207a1613e87242b7537a4307 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Apr 2026 22:44:33 +0000 Subject: [PATCH] feat: panel admin - gestion utilisateurs (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route /api/admin avec middleware require_admin - Liste utilisateurs avec statut, role, dates - Actions: activer/desactiver, promouvoir/rétrograder admin, supprimer - Dashboard stats (utilisateurs, téléchargements) - Template admin_panel.html avec table responsive - Champ is_admin ajoute au modele User - Migration automatique colonne is_admin - Protection: impossible de modifier son propre compte Closes #16 --- app/database.py | 8 ++ app/models/auth.py | 1 + app/routers/__init__.py | 2 + app/routers/router_admin.py | 165 ++++++++++++++++++++++++++ main.py | 2 + templates/components/admin_panel.html | 102 ++++++++++++++++ templates/index.html | 10 +- 7 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 app/routers/router_admin.py create mode 100644 templates/components/admin_panel.html diff --git a/app/database.py b/app/database.py index cc382cd..a11cbe0 100644 --- a/app/database.py +++ b/app/database.py @@ -48,6 +48,14 @@ def _ensure_columns(engine): 'download_dir': 'TEXT DEFAULT "downloads"', } + # Add is_admin to users table if missing + if 'users' in inspector.get_table_names(): + user_cols = {col['name'] for col in inspector.get_columns('users')} + if 'is_admin' not in user_cols: + with engine.connect() as conn: + conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0')) + conn.commit() + with engine.connect() as conn: for col_name, col_def in new_columns.items(): if col_name not in existing: diff --git a/app/models/auth.py b/app/models/auth.py index 75e3d93..d884e61 100644 --- a/app/models/auth.py +++ b/app/models/auth.py @@ -12,6 +12,7 @@ class UserBase(SQLModel): email: Optional[str] = Field(default=None, index=True) full_name: Optional[str] = None is_active: bool = Field(default=True) + is_admin: bool = Field(default=False) class UserTable(UserBase, table=True): diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 1cc67a9..f4abe0d 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -13,6 +13,7 @@ from app.routers.router_player import router as player_router from .router_static import router as static_router from .router_root import router as root_router from .router_settings import router as settings_router +from .router_admin import router as admin_router __all__ = [ "auth_router", @@ -26,5 +27,6 @@ __all__ = [ "static_router", "root_router", "settings_router", + "admin_router", ] diff --git a/app/routers/router_admin.py b/app/routers/router_admin.py new file mode 100644 index 0000000..8c8a2f5 --- /dev/null +++ b/app/routers/router_admin.py @@ -0,0 +1,165 @@ +""" +Admin panel routes for Ohm Stream Downloader API. +""" + +from datetime import datetime +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.templating import Jinja2Templates +from sqlmodel import Session, select + +from app.database import get_session, engine +from app.models.auth import User, UserTable +from app.routers.router_auth import get_current_user_from_token + +router = APIRouter(prefix="/api/admin", tags=["admin"]) +templates = Jinja2Templates(directory="templates") + + +async def require_admin(current_user: User = Depends(get_current_user_from_token)) -> User: + """Dependency that requires the current user to be an admin.""" + if not current_user.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + return current_user + + +@router.get("/users") +async def list_users( + current_user: User = Depends(require_admin), +): + """List all users (admin only)""" + with Session(engine) as session: + statement = select(UserTable) + users = session.exec(statement).all() + return { + "users": [ + { + "id": u.id, + "username": u.username, + "email": u.email, + "full_name": u.full_name, + "is_active": u.is_active, + "is_admin": u.is_admin, + "created_at": u.created_at.isoformat() if u.created_at else None, + "last_login": u.last_login.isoformat() if u.last_login else None, + } + for u in users + ], + "total": len(users), + } + + +@router.get("/stats") +async def get_admin_stats( + current_user: User = Depends(require_admin), +): + """Get admin dashboard statistics""" + from app.download_manager import DownloadManager + from main import download_manager + + with Session(engine) as session: + total_users = len(session.exec(select(UserTable)).all()) + active_users = len(session.exec(select(UserTable).where(UserTable.is_active == True)).all()) + admin_users = len(session.exec(select(UserTable).where(UserTable.is_admin == True)).all()) + + tasks = download_manager.get_all_tasks() + total_downloads = len(tasks) + completed_downloads = len([t for t in tasks if t.status == "completed"]) + active_downloads = len([t for t in tasks if t.status in ("downloading", "pending")]) + + return { + "users": { + "total": total_users, + "active": active_users, + "admins": admin_users, + }, + "downloads": { + "total": total_downloads, + "completed": completed_downloads, + "active": active_downloads, + }, + } + + +@router.put("/users/{user_id}/toggle-active") +async def toggle_user_active( + user_id: str, + response: Response, + current_user: User = Depends(require_admin), +): + """Activate or deactivate a user""" + with Session(engine) as session: + user = session.exec(select(UserTable).where(UserTable.id == user_id)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot modify your own account") + user.is_active = not user.is_active + session.add(user) + session.commit() + status = "active" if user.is_active else "inactive" + response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {user.username} is now {status}", "type": "success"}}}}' + return {"id": user_id, "is_active": user.is_active} + + +@router.put("/users/{user_id}/toggle-admin") +async def toggle_user_admin( + user_id: str, + response: Response, + current_user: User = Depends(require_admin), +): + """Promote or demote a user to/from admin""" + with Session(engine) as session: + user = session.exec(select(UserTable).where(UserTable.id == user_id)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot modify your own admin status") + user.is_admin = not user.is_admin + session.add(user) + session.commit() + role = "admin" if user.is_admin else "user" + response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{user.username} is now {role}", "type": "success"}}}}' + return {"id": user_id, "is_admin": user.is_admin} + + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: str, + response: Response, + current_user: User = Depends(require_admin), +): + """Delete a user""" + with Session(engine) as session: + user = session.exec(select(UserTable).where(UserTable.id == user_id)).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + username = user.username + session.delete(user) + session.commit() + response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {username} deleted", "type": "info"}}}}' + return {"deleted": user_id} + + +@router.get("/ui") +async def get_admin_ui( + request: Request, + current_user: Optional[User] = Depends(get_current_user_from_token), +): + """Get admin panel UI""" + if current_user is None or not current_user.is_admin: + from app.routers.router_auth import get_optional_user + return templates.TemplateResponse( + "components/login_prompt.html", {"request": request} + ) + + with Session(engine) as session: + users = session.exec(select(UserTable)).all() + + return templates.TemplateResponse( + "components/admin_panel.html", + {"request": request, "users": users, "current_user": current_user}, + ) diff --git a/main.py b/main.py index 2d6148f..d1cdf11 100644 --- a/main.py +++ b/main.py @@ -144,6 +144,7 @@ from app.routers import ( static_router, root_router, settings_router, + admin_router, ) @@ -159,6 +160,7 @@ app.include_router(sonarr_router) app.include_router(player_router) app.include_router(static_router) app.include_router(settings_router) +app.include_router(admin_router) if __name__ == "__main__": diff --git a/templates/components/admin_panel.html b/templates/components/admin_panel.html new file mode 100644 index 0000000..857093a --- /dev/null +++ b/templates/components/admin_panel.html @@ -0,0 +1,102 @@ +
+
+

Administration

+
+ + +
+
+
{{ users|length }}
+
Utilisateurs
+
+
+
{{ users|selectattr('is_active')|list|length }}
+
Actifs
+
+
+
{{ users|selectattr('is_admin')|list|length }}
+
Admins
+
+
+ + +
+
+

Gestion des utilisateurs

+
+ + {% if users %} +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
UtilisateurEmailStatutRoleDerniere connexionInscriptionActions
+
{{ user.username }}
+ {% if user.full_name %} +
{{ user.full_name }}
+ {% endif %} +
{{ user.email or '-' }} + + {% if user.is_active %}Actif{% else %}Inactif{% endif %} + + + + {% if user.is_admin %}Admin{% else %}User{% endif %} + + + {{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else '-' }} + + {{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '-' }} + + {% if user.id != current_user.id %} + + + + {% else %} + Vous + {% endif %} +
+
+ {% else %} +
Aucun utilisateur
+ {% endif %} +
+
diff --git a/templates/index.html b/templates/index.html index ee40a6d..c6a81d7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -139,7 +139,15 @@
-
Chargement des paramètres... +
Chargement des parametres... +
+
+
+ +
+
+
+
Chargement du panel admin...