Pivot avant merge : au lieu de pages dédiées par cas d'usage, une seule page /admin avec sélecteur de table + édition de cellule DataTable, plus facile à faire évoluer au fil des besoins de support.
9.4 KiB
Panneau admin — éditeur générique de tables (/admin)
Date : 2026-07-03
Statut : design validé
Remplace : 2026-07-03-admin-ui-design.md (pages dédiées liste/détail/journal + formulaire de
changement de statut) — abandonné avant merge sur main au profit de ce design.
Contexte
Le design précédent (pages /admin, /admin/user/<id>, /admin/journal + un
formulaire dédié pour changer un statut d'abonnement) a été entièrement
implémenté et revu (9 tâches, revue finale "ready to merge"), mais jamais
mergé sur main. Avant la fusion, il est apparu qu'ajouter une page dédiée
à chaque nouveau besoin de support serait trop lent à faire évoluer. dash_table.DataTable
supporte l'édition de cellule (editable=True), ce qui permet une approche
plus générique : une seule page /admin avec un menu déroulant pour choisir
la table SQLite à afficher, filtrer nativement, et éditer directement les
cellules.
Portée
Trois tables du schéma users.sqlite sont éditables : users (hors
password_hash, totalement exclue), subscriptions, subscriber_state.
Une quatrième table, admin_actions (journal d'audit), est consultable dans
le même sélecteur mais en lecture seule. Toute autre table du schéma
(email_verification_tokens, password_reset_tokens, oauth_identities,
saved_views, feature_votes) est hors périmètre — pas dans le sélecteur.
Architecture
Fichiers
src/pages/admin/liste.py(réécrit) : page unique/admin— menu déroulant de sélection de table +dash_table.DataTableunique, editable,filter_action="native",sort_action="native",page_action="native",page_size=20.- Supprimés :
src/pages/admin/detail.py,src/pages/admin/journal.py,src/admin/routes.py(le blueprint Flask du formulaire de changement de statut — plus de formulaire, l'édition passe par un callback Dash). src/pages/admin/_shell.pysimplifié :admin_nav()supprimé (une seule page, plus de navigation entre sous-pages) ;not_admin()conservé à l'identique.src/admin/guard.py(is_admin()) : inchangé, réutilisé tel quel.src/admin/db.py(log_action,list_actions) : inchangé, réutilisé pour l'audit des éditions de cellule.- Nouveau
src/admin/tables.py: registre des tables autorisées et fonction générique d'écriture.
Registre des tables (src/admin/tables.py)
@dataclass
class TableConfig:
label: str
columns: list[str] # colonnes affichées, dans l'ordre
editable_columns: set[str] # sous-ensemble de columns
pk: str
column_types: dict[str, type] # int | float | str, pour les colonnes éditables
dropdowns: dict[str, list[str]] # colonne -> valeurs autorisées (optionnel)
target_user_id: Callable[[dict], int | None] # dérive le user_id à loguer depuis une ligne
| Table | Colonnes affichées | Éditables | Contraintes |
|---|---|---|---|
users |
id, email, email_verified, siret, pending_email, created_at, updated_at (password_hash exclue) |
email, email_verified, siret, pending_email | email_verified : dropdown 0/1 |
subscriptions |
id, user_id, frisbii_customer_handle, frisbii_subscription_handle, plan, prix_ht, status, current_period_end, created_at, updated_at | plan, prix_ht, status, current_period_end | status : dropdown SUBSCRIPTION_STATUSES ; plan : dropdown clés de PLANS |
subscriber_state |
user_id, trial_used, votes_balance, votes_last_credited_at, updated_at | trial_used, votes_balance, votes_last_credited_at | trial_used : dropdown 0/1 |
admin_actions |
id, admin_email, action, target_user_id, details, created_at | (aucune) | — |
Règles fixes, non contournables par la config : la clé primaire et les
colonnes created_at/updated_at ne sont jamais dans editable_columns.
Les handles Frisbii (frisbii_customer_handle, frisbii_subscription_handle)
et user_id (FK) sont explicitement exclus de l'édition — modifier un
handle désynchroniserait silencieusement l'état réel côté Frisbii sans rien
signaler ; modifier user_id casserait le rattachement à l'utilisateur.
target_user_id par table : users → row["id"], subscriptions →
row["user_id"], subscriber_state → row["user_id"] (sa propre PK),
admin_actions → non applicable (table en lecture seule, jamais loguée).
Flux d'édition
- Callback de sélection de table —
Input("admin-table-select", "value")→Output("admin-table", "data"/"columns"/"dropdown_conditional"). ChargeSELECT * FROM <table>où<table>est validé contre les clés deTABLESavant toute requête — jamais interpolé tel quel depuis l'UI, donc aucune table hors liste blanche n'est accessible même via un payload de callback forgé. - Callback d'édition —
Input("admin-table", "data"),State("admin-table", "data_previous"),State("admin-table-select", "value"). Diff ligne par ligne pour localiser la cellule modifiée.- Revalidation côté serveur : le callback ignore tout changement sur
une colonne absente de
editable_columns, même si elle est présente dans le payload — le flageditablede la DataTable est une aide visuelle côté client, pas une garantie de sécurité. Seul un admin authentifié (is_admin()déjà en tête de page) peut atteindre ce code. - Coercition de type avant écriture : SQLite étant faiblement typé,
écrire sans validation permettrait à du texte non convertible de finir
silencieusement dans une colonne
REAL/INTEGER. Chaque colonne éditable a un type attendu (column_types) ; une valeur qui ne convertit pas proprement est rejetée : la cellule revient à son ancienne valeur, une alerte s'affiche, rien n'est écrit. - Pour les colonnes à
dropdowns, la valeur est aussi vérifiée contre la liste autorisée avant écriture (défense en profondeur, en plus du rendu en dropdown côté UI). - Écriture via
set_cell(table: str, pk_value, column: str, value) -> Nonedanssrc/admin/tables.py—UPDATE <table> SET <column> = ? WHERE <pk> = ?. - Audit :
log_action(current_user.email, f"edit_{table}", target_user_id, f"{column}: {old} → {new}").
- Revalidation côté serveur : le callback ignore tout changement sur
une colonne absente de
Gestion des erreurs
- Table hors liste blanche (requête forgée) : aucune donnée renvoyée, pas de crash.
- Coercition de type échouée : cellule restaurée à l'ancienne valeur +
dbc.Alertd'erreur, rien n'est écrit en base. UPDATEtouchant 0 ligne (ligne supprimée entre-temps) : alerte d'erreur, pas d'exception non gérée.
Tests
tests/admin/test_tables.py(nouveau, remplacetests/admin/test_routes.py) : table hors liste blanche rejetée, colonne non éditable rejetée même présente dans le payload, coercition de type (succès et échec) par type de colonne, écriture réussie relue en base,admin_actionsloguée avec le bontarget_user_idpar table.- Les callbacks Dash (sélection de table, édition de cellule) sont de simples fonctions Python décorées — testables en les import-appelant directement avec des données factices, sans dispatch Dash ni serveur Flask.
tests/admin/test_guard.py: inchangé (guard non affecté par ce pivot).tests/admin/test_pages.py: les tests anonyme/non-admin → 404 sont conservés à l'identique (page toujours gardée paris_admin()). Le test de flux complet est réécrit : login admin réel →/admin→ sélection de "subscriptions" dans le menu déroulant → édition de la cellulestatusd'une ligne → vérification de la nouvelle valeur affichée et d'une ligne dansadmin_actions(consultable via le même sélecteur). Même contrainte de nettoyage explicite detests/users.test.sqlite(fichier committé, partagé pour toute la session de tests) qu'auparavant.- Supprimés :
tests/admin/test_routes.pyet toute couverture spécifique àdetail.py/journal.py.
Hors périmètre
- Toute table hors de la liste blanche (
email_verification_tokens,password_reset_tokens,oauth_identities,saved_views,feature_votes) — pas dans le sélecteur, pas éditable. password_hash: totalement exclue de l'affichage deusers(ni lecture ni édition).- Ajout/suppression de lignes depuis l'éditeur — édition de cellule seulement, pas de création/suppression.
- Plusieurs administrateurs (
ADMIN_EMAILunique, inchangé du design précédent). - Pagination de
admin_actionsau-delà delist_actions(limit=200)(inchangé du design précédent).