From 6bec76535cfb240cd78a85bb94aa9cee380549cd Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Fri, 3 Jul 2026 12:27:04 +0200 Subject: [PATCH] =?UTF-8?q?Remplacer=20le=20design=20du=20panneau=20admin?= =?UTF-8?q?=20par=20un=20=C3=A9diteur=20g=C3=A9n=C3=A9rique=20de=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../2026-07-03-admin-table-editor-design.md | 155 ++++++++++++++++++ .../specs/2026-07-03-admin-ui-design.md | 4 +- 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-07-03-admin-table-editor-design.md diff --git a/docs/superpowers/specs/2026-07-03-admin-table-editor-design.md b/docs/superpowers/specs/2026-07-03-admin-table-editor-design.md new file mode 100644 index 0000000..acfd17b --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-admin-table-editor-design.md @@ -0,0 +1,155 @@ +# Panneau admin — éditeur générique de tables (`/admin`) + +Date : 2026-07-03 +Statut : design validé +Remplace : [2026-07-03-admin-ui-design.md](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/`, `/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.DataTable` unique, 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.py`** simplifié : `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`) + +```python +@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 + +1. **Callback de sélection de table** — `Input("admin-table-select", "value")` → `Output("admin-table", "data"/"columns"/"dropdown_conditional")`. + Charge `SELECT * FROM ` où `
` est validé contre les clés de + `TABLES` avant 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é. +2. **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 flag `editable` de 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) -> None` + dans `src/admin/tables.py` — `UPDATE
SET = ? WHERE = ?`. + - Audit : `log_action(current_user.email, f"edit_{table}", target_user_id, f"{column}: {old} → {new}")`. + +## 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.Alert` d'erreur, rien n'est écrit en base. +- `UPDATE` touchant 0 ligne (ligne supprimée entre-temps) : alerte d'erreur, + pas d'exception non gérée. + +## Tests + +- **`tests/admin/test_tables.py`** (nouveau, remplace `tests/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_actions` loguée avec le + bon `target_user_id` par 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 par `is_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 cellule `status` + d'une ligne → vérification de la nouvelle valeur affichée et d'une ligne + dans `admin_actions` (consultable via le même sélecteur). Même contrainte + de nettoyage explicite de `tests/users.test.sqlite` (fichier committé, + partagé pour toute la session de tests) qu'auparavant. +- Supprimés : `tests/admin/test_routes.py` et 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 de `users` (ni lecture + ni édition). +- Ajout/suppression de lignes depuis l'éditeur — édition de cellule + seulement, pas de création/suppression. +- Plusieurs administrateurs (`ADMIN_EMAIL` unique, inchangé du design + précédent). +- Pagination de `admin_actions` au-delà de `list_actions(limit=200)` + (inchangé du design précédent). diff --git a/docs/superpowers/specs/2026-07-03-admin-ui-design.md b/docs/superpowers/specs/2026-07-03-admin-ui-design.md index 0ce7ce9..a2afd7c 100644 --- a/docs/superpowers/specs/2026-07-03-admin-ui-design.md +++ b/docs/superpowers/specs/2026-07-03-admin-ui-design.md @@ -1,7 +1,9 @@ # Panneau admin interne (`/admin`) Date : 2026-07-03 -Statut : design validé +Statut : **remplacé** — voir [2026-07-03-admin-table-editor-design.md](2026-07-03-admin-table-editor-design.md). +Implémenté (9 tâches, revue finale approuvée) mais jamais mergé sur `main` ; +abandonné avant fusion au profit d'un éditeur de tables générique. ## Contexte