From f8d1e60519719a94c2e7993129983b3cf4cfc54b Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 3 Jul 2026 10:20:17 +0200 Subject: [PATCH] feat(admin): add /admin/user/ detail page --- src/pages/admin/detail.py | 201 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/pages/admin/detail.py diff --git a/src/pages/admin/detail.py b/src/pages/admin/detail.py new file mode 100644 index 0000000..f463427 --- /dev/null +++ b/src/pages/admin/detail.py @@ -0,0 +1,201 @@ +import dash_bootstrap_components as dbc +from dash import dcc, html, register_page + +from src.admin.guard import is_admin +from src.auth.db import get_user_by_id +from src.pages.admin._shell import admin_nav, not_admin +from src.subscriptions.db import ( + SUBSCRIPTION_STATUSES, + get_current, + get_subscriber_state, + list_by_user, +) + +register_page( + __name__, + path_template="/admin/user/", + title="Détail utilisateur | colibre", + name="Détail utilisateur", + description="Panneau d'administration interne.", +) + +ERROR_MESSAGES = {"invalid_status": "Statut invalide ou abonnement introuvable."} +SUCCESS_MESSAGES = {"status_changed": "Statut de l'abonnement mis à jour."} + + +def _parse_user_id(user_id): + try: + return int(user_id) + except (TypeError, ValueError): + return None + + +def _not_found(): + return dbc.Container( + [ + html.H2("Panneau admin"), + admin_nav("liste"), + dbc.Alert("Utilisateur introuvable.", color="warning"), + ], + fluid=True, + className="py-4", + ) + + +def _csrf(): + return dcc.Input( + type="hidden", + id={"type": "csrf-input", "index": "admin-status"}, + name="csrf_token", + ) + + +def _account_section(user): + return html.Div( + [ + html.H4("Compte"), + html.P([html.Strong("Email : "), user["email"]]), + html.P( + [ + html.Strong("Vérifié : "), + "oui" if user["email_verified"] else "non", + ] + ), + html.P([html.Strong("SIRET : "), user["siret"] or "—"]), + html.P([html.Strong("Créé le : "), user["created_at"]]), + ] + ) + + +def _state_section(user_id): + state = get_subscriber_state(user_id) + return html.Div( + [ + html.H4("État abonné", className="mt-4"), + html.P( + [ + html.Strong("Solde de votes : "), + str(state["votes_balance"] if state else 0), + ] + ), + html.P( + [ + html.Strong("Essai utilisé : "), + "oui" if state and state["trial_used"] else "non", + ] + ), + ] + ) + + +def _history_section(user_id, current_id): + rows = [] + for sub in list_by_user(user_id): + badge = ( + dbc.Badge("actuel", color="primary", className="ms-2") + if sub["id"] == current_id + else None + ) + rows.append( + html.Tr( + [ + html.Td([sub["plan"] or "—", badge]), + html.Td(sub["status"] or "—"), + html.Td(sub["prix_ht"] if sub["prix_ht"] is not None else "—"), + html.Td(sub["current_period_end"] or "—"), + html.Td(sub["created_at"]), + ] + ) + ) + return html.Div( + [ + html.H4("Historique des abonnements", className="mt-4"), + dbc.Table( + [ + html.Thead( + html.Tr( + [ + html.Th(h) + for h in ( + "Plan", + "Statut", + "Prix HT", + "Fin de période", + "Créé le", + ) + ] + ) + ), + html.Tbody(rows), + ], + bordered=True, + size="sm", + ), + ] + ) + + +def _status_form(user_id, current): + if current is None: + return html.Div() + return html.Div( + [ + html.H4("Changer le statut de l'abonnement courant", className="mt-4"), + html.Form( + method="POST", + action="/admin/actions/subscription-status", + children=[ + _csrf(), + dcc.Input(type="hidden", name="user_id", value=str(user_id)), + dcc.Input( + type="hidden", name="subscription_id", value=str(current["id"]) + ), + dbc.Select( + id="admin-status-select", + name="status", + options=[ + {"label": s, "value": s} for s in SUBSCRIPTION_STATUSES + ], + value=current["status"], + className="mb-3", + style={"maxWidth": "300px"}, + ), + dbc.Button( + "Mettre à jour le statut", type="submit", color="primary" + ), + ], + ), + ] + ) + + +def layout(user_id=None, error=None, status_changed=None, **_): + if not is_admin(): + return not_admin() + + uid = _parse_user_id(user_id) + user = get_user_by_id(uid) if uid is not None else None + if user is None: + return _not_found() + + alerts = [] + if error in ERROR_MESSAGES: + alerts.append(dbc.Alert(ERROR_MESSAGES[error], color="danger")) + if status_changed == "1": + alerts.append(dbc.Alert(SUCCESS_MESSAGES["status_changed"], color="success")) + + current = get_current(user["id"]) + + return dbc.Container( + [ + html.H2("Panneau admin"), + admin_nav("liste"), + *alerts, + _account_section(user), + _state_section(user["id"]), + _history_section(user["id"], current["id"] if current else None), + _status_form(user["id"], current), + ], + fluid=True, + className="py-4", + )