Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Sauvegarde des vues du Tableau
Date : 2026-06-29 Statut : Design validé Issue : #95
Contexte
La page /tableau permet déjà de filtrer, trier et choisir les colonnes des
marchés. Ces réglages sont matérialisés dans l'URL via trois paramètres
(filtres, tris, colonnes) : sync_url_and_reset_button les produit (bouton
« Partager la vue ») et restore_view_from_url les restaure à l'ouverture d'une
URL ainsi formée.
L'issue #95 demande d'aller plus loin : permettre aux utilisateur·ices de sauvegarder des vues nommées et de les ré-appliquer en un clic, sans avoir à manipuler ou conserver des URL.
L'issue mentionnait les trois tableaux (/tableau, /titulaire, /acheteur).
Le périmètre a été resserré à /tableau uniquement. C'est le seul des trois
qui gère aujourd'hui les paramètres d'URL et le partage ; /titulaire et
/acheteur ne les supportent pas encore et sont hors périmètre.
Objectif
Pour un·e abonné·e sur /tableau :
- Sauvegarder la vue courante (filtres + tris + colonnes) sous un nom personnalisé, saisi dans une modale.
- Appliquer une vue sauvegardée en la choisissant dans un menu déroulant.
Pour un·e abonné·e dans l'espace compte :
- Gérer ses vues sur une nouvelle page
/compte/vues: lister, renommer, supprimer.
Les non-abonné·es ne voient aucun de ces contrôles, et toute opération d'écriture est refusée côté serveur.
Principe
Une vue = un nom + la query string que /tableau sait déjà produire et
restaurer (filtres + tris + colonnes). On ne réinvente rien :
- Sauvegarder = construire la query string comme le fait déjà
sync_url_and_reset_button, puis la stocker avec un nom. - Appliquer = naviguer vers
/tableau?<query>;restore_view_from_urlexistant fait le reste.
Architecture existante (rappel)
src/pages/tableau.py:sync_url_and_reset_button— construit la query string à partir defilter_query,sort_by,hidden_columns(viainvert_columns).restore_view_from_url— réagit àtableau_url.search, appliquefiltres/tris/colonnesau DataTable.dcc.Location(id="tableau_url", refresh=False)— la navigation interne ne recharge pas la page.
src/pages/_compte_shell.py:current_user_has_subscription()— point unique de contrôle d'accès, respecte le drapeauTOUS_ABONNES.SECTIONS— liste centralisée des sections de l'espace compte (chaque entrée peut exigerrequire_subscription: True).account_guard(path, require_subscription)— protège une page compte (redirige vers/connexionou/compte/abonnement).account_shell(active, contenu)— gabarit (barre latérale + contenu).
src/subscriptions/db.py— modèle de référence pour un module DB surusers.sqlite: constanteSCHEMA,init_schema(), fonctions CRUD viasrc.auth.db.get_conn().src/subscriptions/setup.py::init_subscriptionsappelledb.init_schema()au démarrage ; câblé danssrc/app.py(init_subscriptions(app.server)).- L'identité de l'utilisateur·ice connecté·e est disponible dans les callbacks
via
flask_login.current_user(les callbacks Dash s'exécutent dans le contexte de requête Flask).
Conception
1. Stockage — table saved_views dans users.sqlite
Nouveau module src/saved_views/db.py, calqué sur src/subscriptions/db.py.
CREATE TABLE IF NOT EXISTS saved_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
table_name TEXT NOT NULL DEFAULT 'tableau',
name TEXT NOT NULL,
query TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE (user_id, table_name, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_views_user
ON saved_views(user_id, table_name);
table_namevaut toujours'tableau'pour l'instant. La colonne réserve la place pour/titulaireet/acheteurplus tard, sans surcoût ni UI aujourd'hui.queryest la query string telle qu'elle apparaît dans l'URL (par ex.filtres=...&tris=...&colonnes=...), produite et consommée exactement comme le fait le partage existant. Appliquer = naviguer vers/tableau?<query>.UNIQUE (user_id, table_name, name)empêche les doublons de nom pour un·e même utilisateur·ice.
Fonctions du module (toutes via src.auth.db.get_conn()) :
| Fonction | Rôle |
|---|---|
init_schema() |
executescript(SCHEMA) — idempotent (IF NOT EXISTS). |
list_views(user_id, table_name) |
Vues de l'utilisateur·ice pour ce tableau, triées par name. |
upsert(user_id, table_name, name, query) |
Insert, ou ON CONFLICT(user_id, table_name, name) DO UPDATE → écrase query et updated_at. Enregistrer sous un nom existant met donc la vue à jour (pas d'erreur). |
rename(view_id, user_id, new_name) |
Renomme ; user_id dans le WHERE garantit la propriété. |
delete(view_id, user_id) |
Supprime ; user_id dans le WHERE garantit la propriété. |
get(view_id, user_id) |
Une vue, contrôle de propriété. |
init_schema() est appelé au démarrage dans src/app.py, à côté de
init_subscriptions(app.server) :
from src.saved_views import db as saved_views_db
saved_views_db.init_schema()
2. UI sur /tableau (abonné·es uniquement)
Ajout d'un conteneur saved-views-bar dans la table-menu existante de
tableau.py, masqué par défaut (style={"display": "none"}). Il contient
trois éléments :
- Bouton « Sauvegarder la vue » — ouvre la modale de nommage.
- Modale de sauvegarde — un champ texte (nom) + bouton « Enregistrer ».
- Menu déroulant « Mes vues » (
dbc.DropdownMenu) — la liste des vues.
Affichage conditionnel (gating)
Un callback rend la barre visible uniquement pour les abonné·es :
- Déclencheur : chargement de la page (Input sur
tableau_url.pathnameouhref). - Logique :
style = {}sicurrent_user_has_subscription(), sinon{"display": "none"}.
Les composants restent présents dans le DOM (cachés), donc leurs callbacks sont
toujours valides — pas besoin de suppress_callback_exceptions. Le masquage
côté client ne suffit pas à lui seul : toute écriture est re-contrôlée côté
serveur (voir ci-dessous).
Sauvegarder
Callback de la modale (clic sur « Enregistrer ») :
- States :
filter_query,sort_by,hidden_columnsdu DataTable + valeur du champ nom. - Re-vérifie
current_user_has_subscription(); si faux, ne fait rien (no-update). - Construit la query string de la même manière que
sync_url_and_reset_button(réutiliser/extraire la logique commune dans une petite fonction utilitaire pour éviter la duplication). - Appelle
saved_views.db.upsert(current_user.id, "tableau", name, query). - Ferme la modale et rafraîchit le menu déroulant ; affiche une confirmation (« Vue « » enregistrée. »).
- Nom vide → message d'erreur inline dans la modale, pas d'enregistrement.
Appliquer
Callback qui remplit le menu déroulant :
- Déclencheurs : chargement de la page et signal de rafraîchissement émis après une sauvegarde.
- Récupère
list_views(current_user.id, "tableau"). - Rend un
dbc.DropdownMenuItempar vue, sous forme de lien :href=f"/tableau?{view['query']}". - Si la liste est vide, le menu n'est pas affiché (ou est désactivé avec un libellé « Aucune vue enregistrée »).
Cliquer sur un item navigue vers /tableau?<query> (sans rechargement, grâce à
dcc.Location(refresh=False)), ce qui déclenche restore_view_from_url
existant. Aucune nouvelle logique d'application n'est nécessaire.
3. Page de gestion /compte/vues
Section dans _compte_shell.py
Ajouter une entrée à SECTIONS :
{
"key": "vues",
"label": "Mes vues",
"href": "/compte/vues",
"require_subscription": True,
},
Cela rend automatiquement le lien visible dans la navigation de l'espace compte
pour les abonné·es (via visible_sections) et active la protection d'accès.
Page src/pages/compte_vues.py
Même structure que src/pages/compte_admin.py :
def layout(**_):
guard = account_guard("/compte/vues", require_subscription=True)
if guard is not None:
return guard
contenu = _vues_section()
return account_shell("vues", contenu)
Contenu (_vues_section) :
- Titre « Mes vues » + courte explication.
- Liste des vues (
list_views(current_user.id, "tableau")) : pour chaque vue, son nom, sa date de création, un lien « Ouvrir » vers/tableau?<query>, un bouton « Renommer » et un bouton « Supprimer ». - État vide : message invitant à créer une vue depuis
/tableau.
Actions, via callbacks pattern-matching (ids du type
{"type": "vue-delete", "index": view_id}), contrôle de propriété par
user_id dans chaque appel DB :
- Supprimer →
delete(view_id, current_user.id), puis rafraîchit la liste. - Renommer → champ de saisie (inline ou petite modale) →
rename(view_id, current_user.id, new_name), puis rafraîchit la liste.
4. Sécurité
- Le masquage des contrôles sur
/tableauest cosmétique ; la garantie réelle est le contrôle serveur dans chaque callback d'écriture (current_user_has_subscription()) et la présence deuser_iddans tous lesWHEREdes opérations DB (lecture comme écriture). /compte/vuesest protégée paraccount_guard(..., require_subscription=True)comme les autres sections réservées.
Hors périmètre
/titulaireet/acheteur: ces pages ne gèrent pas encore les paramètres d'URL ni le partage. La colonnetable_nameréserve la place pour les y étendre plus tard, sans UI ni callback dédiés aujourd'hui.- Aucune modification du partage d'URL existant (« Partager la vue ») ni de la persistance localStorage de la DataTable.
- Pas de partage d'une vue sauvegardée entre comptes, ni de vues publiques.
- La taille de page et la page courante ne font pas partie d'une vue (cohérent avec le partage existant).
Tests
uv run pytest
Tests unitaires DB (src/saved_views/db.py)
upsertcrée une vue ;list_viewsla retourne.upsertavec un(user_id, table_name, name)existant écrasequeryet met à jourupdated_at(pas de doublon, pas d'erreur).rename/deleten'affectent que les vues du bonuser_id(isolation entre comptes).- La suppression d'un·e utilisateur·ice supprime ses vues en cascade
(
ON DELETE CASCADE).
Tests de gating
- Le callback d'affichage de
saved-views-barrenvoie un style masqué pour un·e non-abonné·e et visible pour un·e abonné·e (en s'appuyant surcurrent_user_has_subscription()). - Le callback de sauvegarde refuse l'écriture (no-update) sans abonnement.
/compte/vuesredirige un·e non-abonné·e (comportementaccount_guard, déjà couvert par le motif existant).