Sauvegarde des vues #95
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
L'idée est que dans chaque tableau de données (/tableau, /titulaire, /acheteur) il soit possible de sauvegarder des vues pour chacun de ces tableaux. Une vue = sélection de colonnes, filtres et tris. Elle est matérialisée via une URL qui stocke toutes ces informations en paramètres. Chaque vue est enregistrée pour son tableau.
Ces vue ont un nom personnalisable au moment de leur création, via une modale.
En terme d'UI deux nouveaux boutons sont ajoutés : "Sauvegarder la vue" et, si l'utilisateur a des vues sauvegardées, un menu déroulant avec les vues sauvegardées et disponibles pour ce tableau. Cliquer sur une vue l'applique au tableau.
Cette fonctionnalité est réservée aux abonné·es.
Sauvegarde des vues du Tableau
Date : 2026-06-29
Statut : Design validé
Issue : #95
Contexte
La page
/tableaupermet déjà de filtrer, trier et choisir les colonnes desmarchés. Ces réglages sont matérialisés dans l'URL via trois paramètres
(
filtres,tris,colonnes) :sync_url_and_reset_buttonles produit (bouton« Partager la vue ») et
restore_view_from_urlles restaure à l'ouverture d'uneURL 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é à
/tableauuniquement. C'est le seul des troisqui gère aujourd'hui les paramètres d'URL et le partage ;
/titulaireet/acheteurne les supportent pas encore et sont hors périmètre.Objectif
Pour un·e abonné·e sur
/tableau:personnalisé, saisi dans une modale.
Pour un·e abonné·e dans l'espace compte :
/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
/tableausait déjà produire etrestaurer (
filtres+tris+colonnes). On ne réinvente rien :sync_url_and_reset_button, puis la stocker avec un nom./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 nerecharge pas la page.
src/pages/_compte_shell.py:current_user_has_subscription()— point unique de contrôle d'accès,respecte le drapeau
TOUS_ABONNES.SECTIONS— liste centralisée des sections de l'espace compte (chaque entréepeut exiger
require_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()audémarrage ; câblé dans
src/app.py(init_subscriptions(app.server)).via
flask_login.current_user(les callbacks Dash s'exécutent dans lecontexte de requête Flask).
Conception
1. Stockage — table
saved_viewsdansusers.sqliteNouveau module
src/saved_views/db.py, calqué sursrc/subscriptions/db.py.table_namevaut toujours'tableau'pour l'instant. La colonne réserve laplace pour
/titulaireet/acheteurplus tard, sans surcoût ni UIaujourd'hui.
queryest la query string telle qu'elle apparaît dans l'URL (par ex.filtres=...&tris=...&colonnes=...), produite et consommée exactement comme lefait le partage existant. Appliquer = naviguer vers
/tableau?<query>.UNIQUE (user_id, table_name, name)empêche les doublons de nom pour un·e mêmeutilisateur·ice.
Fonctions du module (toutes via
src.auth.db.get_conn()) :init_schema()executescript(SCHEMA)— idempotent (IF NOT EXISTS).list_views(user_id, table_name)name.upsert(user_id, table_name, name, query)ON CONFLICT(user_id, table_name, name) DO UPDATE→ écrasequeryetupdated_at. Enregistrer sous un nom existant met donc la vue à jour (pas d'erreur).rename(view_id, user_id, new_name)user_iddans leWHEREgarantit la propriété.delete(view_id, user_id)user_iddans leWHEREgarantit la propriété.get(view_id, user_id)init_schema()est appelé au démarrage danssrc/app.py, à côté deinit_subscriptions(app.server):2. UI sur
/tableau(abonné·es uniquement)Ajout d'un conteneur
saved-views-bardans latable-menuexistante detableau.py, masqué par défaut (style={"display": "none"}). Il contienttrois éléments :
dbc.DropdownMenu) — la liste des vues.Affichage conditionnel (gating)
Un callback rend la barre visible uniquement pour les abonné·es :
tableau_url.pathnameouhref).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 masquagecô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 ») :
filter_query,sort_by,hidden_columnsdu DataTable + valeur duchamp nom.
current_user_has_subscription(); si faux, ne fait rien(no-update).
sync_url_and_reset_button(réutiliser/extraire la logique commune dans unepetite fonction utilitaire pour éviter la duplication).
saved_views.db.upsert(current_user.id, "tableau", name, query).(« Vue « » enregistrée. »).
Appliquer
Callback qui remplit le menu déroulant :
après une sauvegarde.
list_views(current_user.id, "tableau").dbc.DropdownMenuItempar vue, sous forme de lien :href=f"/tableau?{view['query']}".libellé « Aucune vue enregistrée »).
Cliquer sur un item navigue vers
/tableau?<query>(sans rechargement, grâce àdcc.Location(refresh=False)), ce qui déclencherestore_view_from_urlexistant. Aucune nouvelle logique d'application n'est nécessaire.
3. Page de gestion
/compte/vuesSection dans
_compte_shell.pyAjouter une entrée à
SECTIONS: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.pyMême structure que
src/pages/compte_admin.py:Contenu (
_vues_section) :list_views(current_user.id, "tableau")) : pour chaquevue, son nom, sa date de création, un lien « Ouvrir » vers
/tableau?<query>, un bouton « Renommer » et un bouton « Supprimer »./tableau.Actions, via callbacks pattern-matching (ids du type
{"type": "vue-delete", "index": view_id}), contrôle de propriété paruser_iddans chaque appel DB :delete(view_id, current_user.id), puis rafraîchit la liste.rename(view_id, current_user.id, new_name), puis rafraîchit la liste.4. Sécurité
/tableauest cosmétique ; la garantieré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ètresd'URL ni le partage. La colonne
table_nameréserve la place pour les yétendre plus tard, sans UI ni callback dédiés aujourd'hui.
persistance localStorage de la DataTable.
avec le partage existant).
Tests
uv run pytestTests unitaires DB (
src/saved_views/db.py)upsertcrée une vue ;list_viewsla retourne.upsertavec un(user_id, table_name, name)existant écrasequeryetmet à jour
updated_at(pas de doublon, pas d'erreur).rename/deleten'affectent que les vues du bonuser_id(isolation entrecomptes).
(
ON DELETE CASCADE).Tests de gating
saved-views-barrenvoie un style masqué pour un·enon-abonné·e et visible pour un·e abonné·e (en s'appuyant sur
current_user_has_subscription())./compte/vuesredirige un·e non-abonné·e (comportementaccount_guard, déjàcouvert par le motif existant).