Files
colibre/docs/superpowers/specs/2026-06-29-vues-sauvegardees-design.md
Colin Maudry 9d4280f69d Ajoute spec sauvegarde des vues #95
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:57:44 +02:00

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 :

  1. Sauvegarder la vue courante (filtres + tris + colonnes) sous un nom personnalisé, saisi dans une modale.
  2. Appliquer une vue sauvegardée en la choisissant dans un menu déroulant.

Pour un·e abonné·e dans l'espace compte :

  1. 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_url existant fait le reste.

Architecture existante (rappel)

  • src/pages/tableau.py :
    • sync_url_and_reset_button — construit la query string à partir de filter_query, sort_by, hidden_columns (via invert_columns).
    • restore_view_from_url — réagit à tableau_url.search, applique filtres/tris/colonnes au 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 drapeau TOUS_ABONNES.
    • SECTIONS — liste centralisée des sections de l'espace compte (chaque entrée peut exiger require_subscription: True).
    • account_guard(path, require_subscription) — protège une page compte (redirige vers /connexion ou /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 sur users.sqlite : constante SCHEMA, init_schema(), fonctions CRUD via src.auth.db.get_conn().
  • src/subscriptions/setup.py::init_subscriptions appelle db.init_schema() au démarrage ; câblé dans src/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_name vaut toujours 'tableau' pour l'instant. La colonne réserve la place pour /titulaire et /acheteur plus tard, sans surcoût ni UI aujourd'hui.
  • query est 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 :

  1. Bouton « Sauvegarder la vue » — ouvre la modale de nommage.
  2. Modale de sauvegarde — un champ texte (nom) + bouton « Enregistrer ».
  3. 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.pathname ou href).
  • Logique : style = {} si current_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_columns du 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.DropdownMenuItem par 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 :

  • Supprimerdelete(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 /tableau est cosmétique ; la garantie réelle est le contrôle serveur dans chaque callback d'écriture (current_user_has_subscription()) et la présence de user_id dans tous les WHERE des opérations DB (lecture comme écriture).
  • /compte/vues est protégée par account_guard(..., require_subscription=True) comme les autres sections réservées.

Hors périmètre

  • /titulaire et /acheteur : ces pages ne gèrent pas encore les paramètres d'URL ni le partage. La colonne table_name ré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)

  • upsert crée une vue ; list_views la retourne.
  • upsert avec un (user_id, table_name, name) existant écrase query et met à jour updated_at (pas de doublon, pas d'erreur).
  • rename / delete n'affectent que les vues du bon user_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-bar renvoie un style masqué pour un·e non-abonné·e et visible pour un·e abonné·e (en s'appuyant sur current_user_has_subscription()).
  • Le callback de sauvegarde refuse l'écriture (no-update) sans abonnement.
  • /compte/vues redirige un·e non-abonné·e (comportement account_guard, déjà couvert par le motif existant).