feat(admin): replace dedicated pages with a generic table editor at /admin

This commit is contained in:
Colin Maudry
2026-07-03 14:19:01 +02:00
parent cf23863a30
commit d229b58f3b
9 changed files with 99 additions and 594 deletions
-42
View File
@@ -1,42 +0,0 @@
from flask import Blueprint, abort, redirect, request
from flask_login import current_user
from src.admin.db import log_action
from src.admin.guard import is_admin
from src.subscriptions.db import SUBSCRIPTION_STATUSES, get_current, set_status
admin_bp = Blueprint("admin", __name__, url_prefix="/admin/actions")
@admin_bp.before_request
def _require_admin():
if not is_admin():
abort(404)
@admin_bp.route("/subscription-status", methods=["POST"])
def subscription_status():
user_id = request.form.get("user_id", type=int)
subscription_id = request.form.get("subscription_id", type=int)
status = request.form.get("status", "")
if user_id is None or subscription_id is None:
abort(400)
current = get_current(user_id)
if (
status not in SUBSCRIPTION_STATUSES
or current is None
or current["id"] != subscription_id
):
return redirect(f"/admin/user/{user_id}?error=invalid_status")
old_status = current["status"]
set_status(subscription_id, status)
log_action(
current_user.email,
"subscription_status_change",
user_id,
f"{old_status}{status}",
)
return redirect(f"/admin/user/{user_id}?status_changed=1")
-4
View File
@@ -48,10 +48,6 @@ def init_auth(app: Flask) -> None:
app.register_blueprint(auth_bp)
from src.admin.routes import admin_bp
app.register_blueprint(admin_bp)
init_oauth(app)
_csrf = CSRFProtect(app)
-12
View File
@@ -1,4 +1,3 @@
import dash_bootstrap_components as dbc
from dash import html
@@ -6,14 +5,3 @@ def not_admin():
return html.Div(
html.H2("404", id="admin-404-heading"), className="py-5 text-center"
)
def admin_nav(active: str):
return dbc.Nav(
[
dbc.NavLink("Utilisateurs", href="/admin", active=(active == "liste")),
dbc.NavLink("Journal", href="/admin/journal", active=(active == "journal")),
],
pills=True,
class_name="mb-4",
)
-203
View File
@@ -1,203 +0,0 @@
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/<user_id>",
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["frisbii_subscription_handle"] 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",
"Handle Frisbii",
"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",
)
-58
View File
@@ -1,58 +0,0 @@
import dash_bootstrap_components as dbc
from dash import dash_table, html, register_page
from src.admin.db import list_actions
from src.admin.guard import is_admin
from src.pages.admin._shell import admin_nav, not_admin
register_page(
__name__,
path="/admin/journal",
title="Journal admin | colibre",
name="Journal admin",
description="Panneau d'administration interne.",
)
def _rows():
rows = []
for action in list_actions():
target = action["target_user_id"]
rows.append(
{
"date": action["created_at"],
"admin": action["admin_email"],
"action": action["action"],
"user": f"[{target}](/admin/user/{target})" if target else "",
"détails": action["details"] or "",
}
)
return rows
def layout(**_):
if not is_admin():
return not_admin()
return dbc.Container(
[
html.H2("Journal des actions admin"),
admin_nav("journal"),
dash_table.DataTable(
id="admin-journal-table",
columns=[
{"name": "Date", "id": "date"},
{"name": "Admin", "id": "admin"},
{"name": "Action", "id": "action"},
{"name": "User", "id": "user", "presentation": "markdown"},
{"name": "Détails", "id": "détails"},
],
data=_rows(),
sort_action="native",
page_action="native",
page_size=20,
markdown_options={"link_target": "_self"},
),
],
fluid=True,
className="py-4",
)
+99 -31
View File
@@ -1,10 +1,21 @@
import dash_bootstrap_components as dbc
from dash import dash_table, html, register_page
from dash import (
Input,
Output,
State,
callback,
ctx,
dash_table,
html,
no_update,
register_page,
)
from flask_login import current_user
from src.admin.db import log_action
from src.admin.guard import is_admin
from src.auth.db import list_users
from src.pages.admin._shell import admin_nav, not_admin
from src.subscriptions.db import get_current
from src.admin.tables import TABLES, find_changed_cell, get_rows, set_cell
from src.pages.admin._shell import not_admin
register_page(
__name__,
@@ -14,22 +25,28 @@ register_page(
description="Panneau d'administration interne.",
)
DEFAULT_TABLE = "users"
def _rows():
rows = []
for user in list_users():
sub = get_current(user["id"])
rows.append(
{
"email": user["email"],
"vérifié": "oui" if user["email_verified"] else "non",
"plan": sub["plan"] if sub else "",
"statut": sub["status"] if sub else "",
"créé le": user["created_at"],
"voir": f"[Voir](/admin/user/{user['id']})",
}
)
return rows
def _columns_for(table: str):
cfg = TABLES[table]
return [
{
"name": col,
"id": col,
"editable": col in cfg.editable_columns,
**({"presentation": "dropdown"} if col in cfg.dropdowns else {}),
}
for col in cfg.columns
]
def _dropdown_for(table: str):
cfg = TABLES[table]
return {
col: {"options": [{"label": v, "value": v} for v in values]}
for col, values in cfg.dropdowns.items()
}
def layout(**_):
@@ -38,25 +55,76 @@ def layout(**_):
return dbc.Container(
[
html.H2("Panneau admin"),
admin_nav("liste"),
html.Div(id="admin-alerts"),
dbc.Select(
id="admin-table-select",
options=[{"label": name, "value": name} for name in TABLES],
value=DEFAULT_TABLE,
className="mb-3",
style={"maxWidth": "300px"},
),
dash_table.DataTable(
id="admin-users-table",
columns=[
{"name": "Email", "id": "email"},
{"name": "Vérifié", "id": "vérifié"},
{"name": "Plan", "id": "plan"},
{"name": "Statut", "id": "statut"},
{"name": "Créé le", "id": "créé le"},
{"name": "", "id": "voir", "presentation": "markdown"},
],
data=_rows(),
id="admin-table",
columns=_columns_for(DEFAULT_TABLE),
data=get_rows(DEFAULT_TABLE),
dropdown=_dropdown_for(DEFAULT_TABLE),
editable=True,
filter_action="native",
sort_action="native",
page_action="native",
page_size=20,
markdown_options={"link_target": "_self"},
),
],
fluid=True,
className="py-4",
)
@callback(
Output("admin-table", "data"),
Output("admin-table", "columns"),
Output("admin-table", "dropdown"),
Output("admin-alerts", "children"),
Input("admin-table-select", "value"),
Input("admin-table", "data"),
State("admin-table", "data_previous"),
prevent_initial_call=True,
)
def _update_table(selected_table, data, data_previous):
if ctx.triggered_id == "admin-table-select":
return (
get_rows(selected_table),
_columns_for(selected_table),
_dropdown_for(selected_table),
None,
)
change = find_changed_cell(data, data_previous)
if change is None:
return no_update, no_update, no_update, None
row_index, column, old_value, new_value = change
pk_value = data[row_index][TABLES[selected_table].pk]
try:
set_cell(selected_table, pk_value, column, new_value)
except ValueError as exc:
return (
no_update,
no_update,
no_update,
dbc.Alert(str(exc), color="danger", dismissable=True),
)
target_user_id = TABLES[selected_table].target_user_id(data[row_index])
log_action(
current_user.email,
f"edit_{selected_table}",
target_user_id,
f"{column}: {old_value!r}{new_value!r}",
)
return (
no_update,
no_update,
no_update,
dbc.Alert("Modification enregistrée.", color="success", dismissable=True),
)