diff --git a/docs/superpowers/plans/2026-06-29-vues-sauvegardees.md b/docs/superpowers/plans/2026-06-29-vues-sauvegardees.md new file mode 100644 index 0000000..6b7ba26 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-vues-sauvegardees.md @@ -0,0 +1,990 @@ +# Sauvegarde des vues du Tableau — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Permettre aux abonné·es d'enregistrer des vues nommées (filtres + tris + colonnes) sur `/tableau`, de les appliquer en un clic, et de les gérer sur `/compte/vues`. + +**Architecture:** Une vue = un nom + la query string `filtres`/`tris`/`colonnes` que `/tableau` produit et restaure déjà. Stockage serveur dans `users.sqlite` (nouveau module `src/saved_views/`). La logique métier (construction de query, validation, builders d'UI) vit dans des fonctions pures testables ; les callbacks Dash ne font que les câbler. Réutilisation du `restore_view_from_url` existant pour appliquer une vue (navigation vers `/tableau?`). + +**Tech Stack:** Python, Dash 3.4, Dash Bootstrap Components, SQLite (`users.sqlite` via `src.auth.db.get_conn()`), Flask-Login (`current_user`), pytest. + +## Global Constraints + +- Imports internes toujours préfixés `src.` (ex. `src.saved_views.db`), jamais `saved_views.db`. +- Accès DB via `src.auth.db.get_conn()` (connexion thread-local sur `users.sqlite`). +- Contrôle d'abonnement via `src.pages._compte_shell.current_user_has_subscription()` (respecte `TOUS_ABONNES`). Ne jamais appeler `db.has_active_subscription()` directement pour le gating UI. +- Toute opération DB inclut `user_id` dans le `WHERE` (isolation entre comptes). +- Périmètre strict : `/tableau` uniquement. La colonne `table_name` vaut toujours `'tableau'`. +- Tests lancés avec `uv run pytest`. +- Format/lint : `prettier` (markdown) et `ruff` tournent en pre-commit ; committer du code déjà formaté. + +--- + +## Structure des fichiers + +| Fichier | Responsabilité | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/saved_views/__init__.py` (créer) | Marqueur de package. | +| `src/saved_views/db.py` (créer) | Schéma `saved_views` + CRUD (`init_schema`, `list_views`, `upsert`, `rename`, `delete`, `get`). | +| `src/saved_views/ui.py` (créer) | Fonctions **pures** d'UI/validation : `bar_style`, `clean_view_name`, `prepare_view_to_save`, `saved_views_items`, `views_table`. Aucune dépendance à un app Dash (pas de `register_page`/`callback`). | +| `src/utils/table.py` (modifier) | Ajouter `build_view_query()` ; refactorer `sync_url_and_reset_button` (dans `tableau.py`) pour l'utiliser. | +| `src/app.py` (modifier) | Appeler `saved_views.db.init_schema()` au démarrage. | +| `src/pages/tableau.py` (modifier) | Barre « vues » dans `table-menu` + 3 callbacks (visibilité, sauvegarde, remplissage du menu) qui câblent les helpers. | +| `src/pages/_compte_shell.py` (modifier) | Ajouter la section `vues` à `SECTIONS`. | +| `src/pages/compte_vues.py` (créer) | Page `/compte/vues` (liste/renommer/supprimer), gabarit `compte_admin.py`. | +| `tests/saved_views/__init__.py` (créer) | Package de tests. | +| `tests/saved_views/conftest.py` (créer) | Fixture `users_db_path` (copie de `tests/subscriptions/conftest.py`). | +| `tests/saved_views/test_db.py` (créer) | Tests unitaires du CRUD. | +| `tests/saved_views/test_ui.py` (créer) | Tests des fonctions pures d'UI/validation. | +| `tests/saved_views/test_build_view_query.py` (créer) | Tests de `build_view_query`. | + +--- + +## Task 1: Module DB `src/saved_views/db.py` + +**Files:** + +- Create: `src/saved_views/__init__.py` +- Create: `src/saved_views/db.py` +- Create: `tests/saved_views/__init__.py` +- Create: `tests/saved_views/conftest.py` +- Create: `tests/saved_views/test_db.py` +- Modify: `src/app.py` (après `init_subscriptions(app.server)`) + +**Interfaces:** + +- Consumes: `src.auth.db.get_conn()`, `src.auth.db.init_schema()`, `src.auth.db.create_user(email, password_hash) -> int`, `src.auth.db.delete_user(user_id)`. +- Produces: + + - `SCHEMA: str` + - `init_schema() -> None` + - `list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]` + - `upsert(user_id: int, table_name: str, name: str, query: str) -> None` + - `rename(view_id: int, user_id: int, new_name: str) -> None` + - `delete(view_id: int, user_id: int) -> None` + - `get(view_id: int, user_id: int) -> sqlite3.Row | None` + +- [ ] **Step 1: Créer le package et le fichier de tests vide** + +Créer `src/saved_views/__init__.py` (vide) et `tests/saved_views/__init__.py` (vide). + +Créer `tests/saved_views/conftest.py` (copie de la fixture de `tests/subscriptions/conftest.py`) : + +```python +import pytest + + +@pytest.fixture +def users_db_path(monkeypatch, tmp_path): + from src.auth.db import reset_conn_for_tests + + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + reset_conn_for_tests() + yield db_path + reset_conn_for_tests() +``` + +- [ ] **Step 2: Écrire les tests qui échouent** + +Créer `tests/saved_views/test_db.py` : + +```python +from src.auth import db as auth_db +from src.saved_views import db + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def test_init_schema_creates_table(users_db_path): + db.init_schema() + conn = auth_db.get_conn() + tables = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + assert "saved_views" in tables + + +def test_upsert_creates_and_lists(users_db_path): + db.init_schema() + uid = _make_user() + db.upsert(uid, "tableau", "Ma vue", "filtres=foo") + views = db.list_views(uid, "tableau") + assert len(views) == 1 + assert views[0]["name"] == "Ma vue" + assert views[0]["query"] == "filtres=foo" + + +def test_upsert_same_name_overwrites(users_db_path): + db.init_schema() + uid = _make_user() + db.upsert(uid, "tableau", "Ma vue", "filtres=foo") + db.upsert(uid, "tableau", "Ma vue", "filtres=bar") + views = db.list_views(uid, "tableau") + assert len(views) == 1 + assert views[0]["query"] == "filtres=bar" + + +def test_list_views_is_isolated_per_user(users_db_path): + db.init_schema() + uid1 = _make_user("a@ex.fr") + uid2 = _make_user("b@ex.fr") + db.upsert(uid1, "tableau", "Vue A", "filtres=a") + assert db.list_views(uid2, "tableau") == [] + + +def test_rename_only_affects_owner(users_db_path): + db.init_schema() + uid1 = _make_user("a@ex.fr") + uid2 = _make_user("b@ex.fr") + db.upsert(uid1, "tableau", "Vue A", "filtres=a") + view_id = db.list_views(uid1, "tableau")[0]["id"] + db.rename(view_id, uid2, "Pirate") # mauvais propriétaire → no-op + assert db.get(view_id, uid1)["name"] == "Vue A" + db.rename(view_id, uid1, "Vue B") + assert db.get(view_id, uid1)["name"] == "Vue B" + + +def test_delete_only_affects_owner(users_db_path): + db.init_schema() + uid1 = _make_user("a@ex.fr") + uid2 = _make_user("b@ex.fr") + db.upsert(uid1, "tableau", "Vue A", "filtres=a") + view_id = db.list_views(uid1, "tableau")[0]["id"] + db.delete(view_id, uid2) # mauvais propriétaire → no-op + assert db.get(view_id, uid1) is not None + db.delete(view_id, uid1) + assert db.get(view_id, uid1) is None + + +def test_views_deleted_on_user_cascade(users_db_path): + db.init_schema() + uid = _make_user() + db.upsert(uid, "tableau", "Vue A", "filtres=a") + auth_db.delete_user(uid) + assert db.list_views(uid, "tableau") == [] +``` + +- [ ] **Step 3: Lancer les tests, vérifier l'échec** + +Run: `uv run pytest tests/saved_views/test_db.py -v` +Expected: FAIL (ModuleNotFoundError: `src.saved_views.db`). + +- [ ] **Step 4: Écrire `src/saved_views/db.py`** + +```python +import sqlite3 +from datetime import datetime, timezone + +from src.auth.db import get_conn + +SCHEMA = """ +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); +""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def init_schema() -> None: + get_conn().executescript(SCHEMA) + + +def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]: + return ( + get_conn() + .execute( + "SELECT * FROM saved_views WHERE user_id = ? AND table_name = ? " + "ORDER BY name COLLATE NOCASE", + (user_id, table_name), + ) + .fetchall() + ) + + +def get(view_id: int, user_id: int) -> sqlite3.Row | None: + return ( + get_conn() + .execute( + "SELECT * FROM saved_views WHERE id = ? AND user_id = ?", + (view_id, user_id), + ) + .fetchone() + ) + + +def upsert(user_id: int, table_name: str, name: str, query: str) -> None: + now = _now() + get_conn().execute( + "INSERT INTO saved_views " + "(user_id, table_name, name, query, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT(user_id, table_name, name) DO UPDATE SET " + "query = excluded.query, updated_at = excluded.updated_at", + (user_id, table_name, name, query, now, now), + ) + + +def rename(view_id: int, user_id: int, new_name: str) -> None: + get_conn().execute( + "UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?", + (new_name, _now(), view_id, user_id), + ) + + +def delete(view_id: int, user_id: int) -> None: + get_conn().execute( + "DELETE FROM saved_views WHERE id = ? AND user_id = ?", + (view_id, user_id), + ) +``` + +- [ ] **Step 5: Lancer les tests, vérifier le succès** + +Run: `uv run pytest tests/saved_views/test_db.py -v` +Expected: PASS (7 tests). + +- [ ] **Step 6: Câbler `init_schema()` au démarrage dans `src/app.py`** + +Juste après la ligne `init_subscriptions(app.server)` (≈ ligne 106), ajouter : + +```python +from src.saved_views import db as saved_views_db # noqa: E402 + +saved_views_db.init_schema() +``` + +- [ ] **Step 7: Vérifier l'import de l'app** + +Run: `uv run python -c "import src.app"` +Expected: aucune erreur (sortie vide ou logs normaux). + +- [ ] **Step 8: Commit** + +```bash +git add src/saved_views/__init__.py src/saved_views/db.py src/app.py \ + tests/saved_views/__init__.py tests/saved_views/conftest.py \ + tests/saved_views/test_db.py +git commit -m "feat: table saved_views et CRUD #95" +``` + +--- + +## Task 2: Helper `build_view_query` dans `src/utils/table.py` + +Extrait la construction de query string aujourd'hui inline dans `sync_url_and_reset_button` (`tableau.py`), pour la réutiliser à la sauvegarde. + +**Files:** + +- Modify: `src/utils/table.py` (ajouter la fonction + imports si absents) +- Modify: `src/pages/tableau.py` (`sync_url_and_reset_button` utilise le helper) +- Create: `tests/saved_views/test_build_view_query.py` + +**Interfaces:** + +- Consumes: `src.utils.table.invert_columns(columns) -> list[str]`. +- Produces: `build_view_query(filter_query: str | None, sort_by: list | None, hidden_columns: list | None) -> str` — renvoie une query string (`urlencode`) avec les clés `filtres`, `tris` (JSON), `colonnes` (CSV des colonnes **visibles**). Renvoie `""` si aucun paramètre. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Créer `tests/saved_views/test_build_view_query.py` : + +```python +import urllib.parse + +from src.utils.table import build_view_query + + +def test_empty_inputs_give_empty_string(): + assert build_view_query(None, None, None) == "" + assert build_view_query("", [], []) == "" + + +def test_filter_only(): + q = build_view_query("{objet} icontains route", None, None) + params = urllib.parse.parse_qs(q) + assert params["filtres"] == ["{objet} icontains route"] + assert "tris" not in params + assert "colonnes" not in params + + +def test_sort_is_json_encoded(): + sort_by = [{"column_id": "montant", "direction": "desc"}] + q = build_view_query(None, sort_by, None) + params = urllib.parse.parse_qs(q) + import json + + assert json.loads(params["tris"][0]) == sort_by + + +def test_hidden_columns_become_visible_csv(): + # build_view_query reçoit les colonnes MASQUÉES et stocke les VISIBLES + q = build_view_query(None, None, ["objet"]) + params = urllib.parse.parse_qs(q) + visible = params["colonnes"][0].split(",") + assert "objet" not in visible + assert len(visible) > 0 +``` + +- [ ] **Step 2: Lancer les tests, vérifier l'échec** + +Run: `uv run pytest tests/saved_views/test_build_view_query.py -v` +Expected: FAIL (ImportError: `build_view_query`). + +- [ ] **Step 3: Ajouter `build_view_query` à `src/utils/table.py`** + +Vérifier que le haut du fichier contient `import json` et `import urllib.parse` ; les ajouter sinon. Puis ajouter, à la fin du fichier (après `invert_columns`) : + +```python +def build_view_query(filter_query, sort_by, hidden_columns) -> str: + """ + Construit la query string d'une vue Tableau (filtres + tris + colonnes), + identique à celle produite par le bouton « Partager la vue ». + + hidden_columns : colonnes masquées ; on stocke les colonnes visibles. + """ + params = {} + if filter_query: + params["filtres"] = filter_query + if sort_by: + params["tris"] = json.dumps(sort_by) + if hidden_columns: + params["colonnes"] = ",".join(invert_columns(hidden_columns)) + return urllib.parse.urlencode(params) +``` + +- [ ] **Step 4: Lancer les tests, vérifier le succès** + +Run: `uv run pytest tests/saved_views/test_build_view_query.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Refactorer `sync_url_and_reset_button` dans `src/pages/tableau.py`** + +Dans `src/pages/tableau.py`, importer le helper en haut (ajouter `build_view_query` à l'import existant depuis `src.utils.table`). + +Remplacer le corps de construction de l'URL (lignes ≈ 427-440, du `params = {}` jusqu'au `full_url = ...`) par : + +```python + query_string = build_view_query(filter_query, sort_by, hidden_columns) + full_url = f"{base_url}?{query_string}" if query_string else base_url +``` + +Supprimer l'import devenu inutile s'il n'est plus utilisé ailleurs (vérifier `json`/`urllib` restent utilisés par d'autres callbacks — ils le sont, ne pas les retirer). + +- [ ] **Step 6: Vérifier la non-régression du partage** + +Run: `uv run python -c "import src.app"` +Expected: aucune erreur. +Run: `uv run pytest tests/saved_views -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/utils/table.py src/pages/tableau.py tests/saved_views/test_build_view_query.py +git commit -m "refactor: extrait build_view_query et le réutilise dans le partage #95" +``` + +--- + +## Task 3: Fonctions pures d'UI/validation `src/saved_views/ui.py` + +**Files:** + +- Create: `src/saved_views/ui.py` +- Create: `tests/saved_views/test_ui.py` + +**Interfaces:** + +- Consumes: `dash_bootstrap_components as dbc`, `dash.html`. +- Produces: + + - `bar_style(has_subscription: bool) -> dict` — `{}` si abonné, `{"display": "none"}` sinon. + - `clean_view_name(name: str | None) -> str` — `name.strip()`, `""` si vide/None. + - `prepare_view_to_save(has_subscription: bool, name: str | None) -> tuple[str | None, str | None]` — renvoie `(clean_name, None)` si OK ; `(None, message)` si refus (non-abonné ou nom vide). + - `saved_views_items(views) -> list` — liste de `dbc.DropdownMenuItem` liens `href="/tableau?"` (un par vue). + - `views_table(views) -> html.Div` — bloc de gestion pour `/compte/vues` (un `html.Div` par vue avec id pattern-matching pour Ouvrir/Renommer/Supprimer), ou message d'état vide. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Créer `tests/saved_views/test_ui.py` : + +```python +from src.saved_views import ui + + +class _Row(dict): + """Imite un sqlite3.Row : accès par clé.""" + + +def _view(view_id, name, query): + return _Row(id=view_id, name=name, query=query) + + +def test_bar_style_hidden_for_non_subscriber(): + assert ui.bar_style(False) == {"display": "none"} + assert ui.bar_style(True) == {} + + +def test_clean_view_name_strips_and_empties(): + assert ui.clean_view_name(" Ma vue ") == "Ma vue" + assert ui.clean_view_name(" ") == "" + assert ui.clean_view_name(None) == "" + + +def test_prepare_refuses_non_subscriber(): + name, err = ui.prepare_view_to_save(False, "Ma vue") + assert name is None + assert err + + +def test_prepare_refuses_empty_name(): + name, err = ui.prepare_view_to_save(True, " ") + assert name is None + assert err + + +def test_prepare_accepts_valid(): + name, err = ui.prepare_view_to_save(True, " Ma vue ") + assert name == "Ma vue" + assert err is None + + +def test_saved_views_items_build_links(): + items = ui.saved_views_items( + [_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")] + ) + assert len(items) == 2 + assert items[0].href == "/tableau?filtres=a" + assert items[0].children == "Vue A" + + +def test_views_table_empty_state(): + out = ui.views_table([]) + # un Div non vide (message d'état) sans item de suppression + assert out is not None + + +def test_views_table_lists_views(): + out = ui.views_table([_view(1, "Vue A", "filtres=a")]) + text = str(out) + assert "Vue A" in text +``` + +- [ ] **Step 2: Lancer les tests, vérifier l'échec** + +Run: `uv run pytest tests/saved_views/test_ui.py -v` +Expected: FAIL (ModuleNotFoundError: `src.saved_views.ui`). + +- [ ] **Step 3: Écrire `src/saved_views/ui.py`** + +```python +import dash_bootstrap_components as dbc +from dash import html + + +def bar_style(has_subscription: bool) -> dict: + return {} if has_subscription else {"display": "none"} + + +def clean_view_name(name: str | None) -> str: + return (name or "").strip() + + +def prepare_view_to_save( + has_subscription: bool, name: str | None +) -> tuple[str | None, str | None]: + if not has_subscription: + return None, "Réservé aux abonné·es." + clean = clean_view_name(name) + if not clean: + return None, "Veuillez saisir un nom pour la vue." + return clean, None + + +def saved_views_items(views) -> list: + return [ + dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}") + for view in views + ] + + +def _view_row(view) -> html.Div: + view_id = view["id"] + return html.Div( + className="saved-view-row d-flex align-items-center gap-2 mb-2", + children=[ + html.Span(view["name"], className="flex-grow-1"), + dbc.Button( + "Ouvrir", + href=f"/tableau?{view['query']}", + color="link", + size="sm", + ), + dbc.Button( + "Renommer", + id={"type": "vue-rename-open", "index": view_id}, + color="secondary", + outline=True, + size="sm", + ), + dbc.Button( + "Supprimer", + id={"type": "vue-delete", "index": view_id}, + color="danger", + outline=True, + size="sm", + ), + ], + ) + + +def views_table(views) -> html.Div: + if not views: + return html.Div( + html.P( + "Vous n'avez pas encore de vue enregistrée. " + "Créez-en une depuis le Tableau, bouton « Sauvegarder la vue »." + ) + ) + return html.Div([_view_row(v) for v in views]) +``` + +- [ ] **Step 4: Lancer les tests, vérifier le succès** + +Run: `uv run pytest tests/saved_views/test_ui.py -v` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/saved_views/ui.py tests/saved_views/test_ui.py +git commit -m "feat: helpers UI/validation des vues sauvegardées #95" +``` + +--- + +## Task 4: Intégration sur `/tableau` + +Câble les helpers : barre « vues » (masquée par défaut), callback de visibilité (gating), callback de sauvegarde (avec modale), callback de remplissage du menu déroulant. + +**Files:** + +- Modify: `src/pages/tableau.py` + +**Interfaces:** + +- Consumes: `src.saved_views.db` (`upsert`, `list_views`), `src.saved_views.ui` (`bar_style`, `prepare_view_to_save`, `saved_views_items`), `src.utils.table.build_view_query`, `src.pages._compte_shell.current_user_has_subscription`, `flask_login.current_user`. +- Produces: composants d'id `saved-views-bar`, `btn-save-view`, `save-view-modal`, `save-view-name`, `btn-save-view-confirm`, `save-view-feedback`, `saved-views-menu`, `saved-views-refresh` (Store). + +- [ ] **Step 1: Ajouter les imports en haut de `src/pages/tableau.py`** + +```python +from flask_login import current_user + +from src.pages._compte_shell import current_user_has_subscription +from src.saved_views import db as saved_views_db +from src.saved_views import ui as saved_views_ui +``` + +(`build_view_query` a déjà été ajouté à l'import `src.utils.table` en Task 2.) + +- [ ] **Step 2: Ajouter la barre « vues » dans la `table-menu`** + +Dans le `children` de la `html.Div(className="table-menu", ...)` (≈ lignes 154-265), juste après le bouton « Choisir les colonnes » (id `tableau_columns_open`), insérer : + +```python + html.Div( + id="saved-views-bar", + style={"display": "none"}, + className="d-inline-flex align-items-center gap-2", + children=[ + dbc.Button( + "Sauvegarder la vue", + id="btn-save-view", + title="Enregistrer les filtres, tris et colonnes actuels sous un nom", + ), + dbc.DropdownMenu( + id="saved-views-menu", + label="Mes vues", + children=[], + className="d-inline-block", + ), + ], + ), + dcc.Store(id="saved-views-refresh"), + dbc.Modal( + id="save-view-modal", + is_open=False, + children=[ + dbc.ModalHeader(dbc.ModalTitle("Sauvegarder la vue")), + dbc.ModalBody( + [ + dbc.Label("Nom de la vue"), + dcc.Input( + id="save-view-name", + type="text", + className="form-control", + ), + html.Div(id="save-view-feedback", className="mt-2"), + ] + ), + dbc.ModalFooter( + dbc.Button( + "Enregistrer", + id="btn-save-view-confirm", + color="primary", + ) + ), + ], + ), +``` + +- [ ] **Step 3: Ajouter le callback de visibilité (gating)** + +À la fin de `src/pages/tableau.py`, ajouter : + +```python +@callback( + Output("saved-views-bar", "style"), + Input("tableau_url", "pathname"), +) +def toggle_saved_views_bar(_pathname): + return saved_views_ui.bar_style(current_user_has_subscription()) +``` + +- [ ] **Step 4: Ajouter le callback d'ouverture de la modale** + +```python +@callback( + Output("save-view-modal", "is_open"), + Input("btn-save-view", "n_clicks"), + Input("btn-save-view-confirm", "n_clicks"), + State("save-view-modal", "is_open"), + prevent_initial_call=True, +) +def toggle_save_view_modal(_open, _confirm, is_open): + return not is_open +``` + +- [ ] **Step 5: Ajouter le callback de sauvegarde (avec contrôle serveur)** + +```python +@callback( + Output("save-view-feedback", "children"), + Output("saved-views-refresh", "data"), + Input("btn-save-view-confirm", "n_clicks"), + State("save-view-name", "value"), + State("tableau_datatable", "filter_query"), + State("tableau_datatable", "sort_by"), + State("tableau_datatable", "hidden_columns"), + prevent_initial_call=True, +) +def save_view(_n, name, filter_query, sort_by, hidden_columns): + has_sub = current_user_has_subscription() + clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) + if error: + return html.Span(error, style={"color": "red"}), no_update + query = build_view_query(filter_query, sort_by, hidden_columns) + saved_views_db.upsert(current_user.id, "tableau", clean_name, query) + return ( + html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}), + clean_name, + ) +``` + +- [ ] **Step 6: Ajouter le callback de remplissage du menu déroulant** + +```python +@callback( + Output("saved-views-menu", "children"), + Input("tableau_url", "pathname"), + Input("saved-views-refresh", "data"), +) +def populate_saved_views_menu(_pathname, _refresh): + if not current_user_has_subscription(): + return [] + views = saved_views_db.list_views(current_user.id, "tableau") + return saved_views_ui.saved_views_items(views) +``` + +- [ ] **Step 7: Vérifier l'import et la non-régression** + +Run: `uv run python -c "import src.app"` +Expected: aucune erreur (pas d'erreur de callback dupliqué/composant manquant). +Run: `uv run pytest tests/saved_views -v` +Expected: PASS. + +- [ ] **Step 8: Vérification manuelle (smoke test)** + +Démarrer `uv run python run.py`, se connecter avec un compte abonné (ou `TOUS_ABONNES=true` dans `.env`), aller sur `/tableau` : + +- la barre « Sauvegarder la vue » + « Mes vues » est visible ; +- appliquer un filtre, cliquer « Sauvegarder la vue », saisir un nom, Enregistrer → confirmation verte ; +- ouvrir « Mes vues » → la vue apparaît ; cliquer dessus applique le filtre. +- Se déconnecter → la barre disparaît. + +- [ ] **Step 9: Commit** + +```bash +git add src/pages/tableau.py +git commit -m "feat: UI sauvegarde et application des vues sur /tableau #95" +``` + +--- + +## Task 5: Page de gestion `/compte/vues` + +**Files:** + +- Modify: `src/pages/_compte_shell.py` (ajout section `vues`) +- Create: `src/pages/compte_vues.py` +- Create: `tests/saved_views/test_compte_vues.py` + +**Interfaces:** + +- Consumes: `src.pages._compte_shell` (`account_guard`, `account_shell`, `SECTIONS`), `src.saved_views.db` (`list_views`, `rename`, `delete`), `src.saved_views.ui.views_table`, `flask_login.current_user`. +- Produces: page enregistrée sur `/compte/vues` ; section `vues` dans `SECTIONS`. + +- [ ] **Step 1: Écrire le test de la section (échoue)** + +Créer `tests/saved_views/test_compte_vues.py` : + +```python +from src.pages import _compte_shell as shell + + +def test_vues_section_is_gated_subscription(): + section = next(s for s in shell.SECTIONS if s["key"] == "vues") + assert section["href"] == "/compte/vues" + assert section["require_subscription"] is True + + +def test_vues_hidden_without_subscription(): + keys = {s["key"] for s in shell.visible_sections(has_subscription=False)} + assert "vues" not in keys + + +def test_vues_visible_with_subscription(): + keys = {s["key"] for s in shell.visible_sections(has_subscription=True)} + assert "vues" in keys +``` + +- [ ] **Step 2: Lancer le test, vérifier l'échec** + +Run: `uv run pytest tests/saved_views/test_compte_vues.py -v` +Expected: FAIL (`StopIteration` : section `vues` absente). + +- [ ] **Step 3: Ajouter la section dans `src/pages/_compte_shell.py`** + +Dans la liste `SECTIONS`, après l'entrée `filtres`, ajouter : + +```python + { + "key": "vues", + "label": "Mes vues", + "href": "/compte/vues", + "require_subscription": True, + }, +``` + +- [ ] **Step 4: Lancer le test, vérifier le succès** + +Run: `uv run pytest tests/saved_views/test_compte_vues.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 5: Créer la page `src/pages/compte_vues.py`** + +```python +import dash_bootstrap_components as dbc +from dash import ( + ALL, + Input, + Output, + State, + callback, + ctx, + html, + no_update, + register_page, +) +from flask_login import current_user + +from src.pages._compte_shell import account_guard, account_shell +from src.saved_views import db as saved_views_db +from src.saved_views import ui as saved_views_ui + +register_page( + __name__, + path="/compte/vues", + title="Mes vues | decp.info", + name="Mes vues", + description="Gérez vos vues enregistrées du tableau des marchés.", +) + + +def _content(): + views = saved_views_db.list_views(current_user.id, "tableau") + return html.Div( + [ + html.H2("Mes vues"), + html.P( + "Les vues que vous enregistrez depuis le Tableau apparaissent ici. " + "Cliquez sur « Ouvrir » pour appliquer une vue." + ), + html.Div(saved_views_ui.views_table(views), id="vues-list"), + ] + ) + + +def layout(**_): + guard = account_guard("/compte/vues", require_subscription=True) + if guard is not None: + return guard + return account_shell("vues", _content()) + + +@callback( + Output("vues-list", "children"), + Input({"type": "vue-delete", "index": ALL}, "n_clicks"), + prevent_initial_call=True, +) +def delete_view(n_clicks): + if not ctx.triggered_id or not any(n_clicks): + return no_update + saved_views_db.delete(ctx.triggered_id["index"], current_user.id) + views = saved_views_db.list_views(current_user.id, "tableau") + return saved_views_ui.views_table(views) +``` + +- [ ] **Step 6: Ajouter le renommage (modale partagée)** + +Ajouter en bas de `src/pages/compte_vues.py` une modale de renommage et ses callbacks. Compléter `_content()` pour inclure la modale : + +Dans `_content()`, ajouter à la liste des enfants (après `vues-list`) : + +```python + dbc.Modal( + id="vue-rename-modal", + is_open=False, + children=[ + dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")), + dbc.ModalBody( + dbc.Input(id="vue-rename-input", type="text"), + ), + dbc.ModalFooter( + dbc.Button("Renommer", id="vue-rename-confirm", color="primary") + ), + ], + ), + dbc.Input(id="vue-rename-id", type="hidden"), +``` + +Puis les callbacks : + +```python +@callback( + Output("vue-rename-modal", "is_open"), + Output("vue-rename-id", "value"), + Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"), + Input("vue-rename-confirm", "n_clicks"), + State("vue-rename-modal", "is_open"), + prevent_initial_call=True, +) +def toggle_rename_modal(_open, _confirm, is_open): + if isinstance(ctx.triggered_id, dict) and any(_open): + return True, str(ctx.triggered_id["index"]) + return False, no_update + + +@callback( + Output("vues-list", "children", allow_duplicate=True), + Input("vue-rename-confirm", "n_clicks"), + State("vue-rename-id", "value"), + State("vue-rename-input", "value"), + prevent_initial_call=True, +) +def rename_view(_n, view_id, new_name): + clean = saved_views_ui.clean_view_name(new_name) + if not view_id or not clean: + return no_update + saved_views_db.rename(int(view_id), current_user.id, clean) + views = saved_views_db.list_views(current_user.id, "tableau") + return saved_views_ui.views_table(views) +``` + +- [ ] **Step 7: Vérifier l'import et les tests** + +Run: `uv run python -c "import src.app"` +Expected: aucune erreur. +Run: `uv run pytest tests/saved_views -v` +Expected: PASS. + +- [ ] **Step 8: Vérification manuelle (smoke test)** + +Avec un compte abonné, aller sur `/compte/vues` : la liste des vues s'affiche ; « Supprimer » retire une vue ; « Renommer » ouvre la modale et met à jour le nom. Vérifier qu'un·e non-abonné·e est redirigé·e vers `/compte/abonnement`. + +- [ ] **Step 9: Commit** + +```bash +git add src/pages/_compte_shell.py src/pages/compte_vues.py tests/saved_views/test_compte_vues.py +git commit -m "feat: page /compte/vues (liste, renommer, supprimer) #95" +``` + +--- + +## Task 6: Vérification finale + +- [ ] **Step 1: Lancer toute la suite** + +Run: `uv run pytest` +Expected: PASS (les tests Selenium peuvent nécessiter Chrome ; au minimum `tests/saved_views`, `tests/test_compte_shell.py`, `tests/subscriptions` doivent passer). + +- [ ] **Step 2: Vérifier le formatage** + +Run: `uv run ruff format --check src/saved_views src/pages/compte_vues.py` +Expected: déjà formaté (sinon lancer `uv run ruff format` et committer). + +- [ ] **Step 3: Commit final si nécessaire** + +```bash +git add -A +git commit -m "chore: formatage vues sauvegardées #95" +``` + +--- + +## Self-Review + +**Couverture de la spec :** + +- Stockage `saved_views` dans `users.sqlite` → Task 1. ✓ +- Vue = nom + query (`filtres`/`tris`/`colonnes`), réutilise le mécanisme existant → Task 2 (`build_view_query`) + Task 4. ✓ +- Bouton « Sauvegarder la vue » + modale de nommage → Task 4. ✓ +- Menu déroulant des vues, application par navigation → Task 4 (`saved_views_items`, liens `/tableau?` + `restore_view_from_url` existant). ✓ +- Masquage pour non-abonné·es + contrôle serveur → Task 4 (`toggle_saved_views_bar`, `save_view` re-vérifie l'abonnement). ✓ +- Gestion `/compte/vues` (liste, renommer, supprimer), section gatée → Task 5. ✓ +- Isolation par `user_id`, cascade → Task 1 (tests). ✓ +- Hors périmètre titulaire/acheteur, colonne `table_name` réservée → respecté (`table_name="tableau"` partout). ✓ +- Tests DB + gating → Tasks 1, 3, 5. ✓ + +**Scan placeholders :** aucun TBD/TODO ; tout le code est fourni. + +**Cohérence des types/noms :** `build_view_query(filter_query, sort_by, hidden_columns)` cohérent Task 2↔4 ; `upsert(user_id, table_name, name, query)`, `list_views(user_id, table_name)`, `rename(view_id, user_id, new_name)`, `delete(view_id, user_id)` cohérents Task 1↔4↔5 ; `prepare_view_to_save(has_subscription, name) -> (clean_name, error)` cohérent Task 3↔4 ; ids des composants pattern-matching (`vue-delete`, `vue-rename-open`) cohérents `ui.py`↔`compte_vues.py`. diff --git a/docs/superpowers/specs/2026-06-29-vues-sauvegardees-design.md b/docs/superpowers/specs/2026-06-29-vues-sauvegardees-design.md new file mode 100644 index 0000000..590384c --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-vues-sauvegardees-design.md @@ -0,0 +1,274 @@ +# Sauvegarde des vues du Tableau + +**Date :** 2026-06-29 +**Statut :** Design validé +**Issue :** [#95](https://github.com/ColinMaudry/decp.info/issues/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 : + +3. **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?` ; `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`. + +```sql +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?`. +- `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)` : + +```python +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?` (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` : + +```python +{ + "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` : + +```python +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?`, 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 `/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). + +``` + +``` diff --git a/src/app.py b/src/app.py index 597c071..8802592 100644 --- a/src/app.py +++ b/src/app.py @@ -105,6 +105,10 @@ from src.subscriptions.setup import init_subscriptions # noqa: E402 init_subscriptions(app.server) +from src.saved_views import db as saved_views_db # noqa: E402 + +saved_views_db.init_schema() + # robots.txt @app.server.route("/robots.txt") diff --git a/src/pages/_compte_shell.py b/src/pages/_compte_shell.py index 7769bf9..46a6bd3 100644 --- a/src/pages/_compte_shell.py +++ b/src/pages/_compte_shell.py @@ -29,6 +29,12 @@ SECTIONS = [ "href": "/compte/filtres", "require_subscription": True, }, + { + "key": "vues", + "label": "Mes vues", + "href": "/compte/vues", + "require_subscription": True, + }, { "key": "siret", "label": "Mon SIRET", diff --git a/src/pages/compte_vues.py b/src/pages/compte_vues.py new file mode 100644 index 0000000..7cd23ce --- /dev/null +++ b/src/pages/compte_vues.py @@ -0,0 +1,106 @@ +import dash_bootstrap_components as dbc +from dash import ( + ALL, + Input, + Output, + State, + callback, + ctx, + html, + no_update, + register_page, +) +from flask_login import current_user + +from src.pages._compte_shell import account_guard, account_shell +from src.saved_views import db as saved_views_db +from src.saved_views import ui as saved_views_ui + +register_page( + __name__, + path="/compte/vues", + title="Mes vues | decp.info", + name="Mes vues", + description="Gérez vos vues enregistrées du tableau des marchés.", +) + + +def _content(): + views = saved_views_db.list_views(current_user.id, "tableau") + return html.Div( + [ + html.H2("Mes vues"), + html.P( + "Les vues que vous enregistrez depuis le Tableau apparaissent ici. " + "Cliquez sur « Ouvrir » pour appliquer une vue." + ), + html.Div(saved_views_ui.views_table(views), id="vues-list"), + dbc.Modal( + id="vue-rename-modal", + is_open=False, + children=[ + dbc.ModalHeader(dbc.ModalTitle("Renommer la vue")), + dbc.ModalBody( + dbc.Input(id="vue-rename-input", type="text"), + ), + dbc.ModalFooter( + dbc.Button("Renommer", id="vue-rename-confirm", color="primary") + ), + ], + ), + dbc.Input(id="vue-rename-id", type="hidden"), + ] + ) + + +def layout(**_): + guard = account_guard("/compte/vues", require_subscription=True) + if guard is not None: + return guard + return account_shell("vues", _content()) + + +@callback( + Output("vues-list", "children"), + Input({"type": "vue-delete", "index": ALL}, "n_clicks"), + prevent_initial_call=True, +) +def delete_view(n_clicks): + if not ctx.triggered_id or not any(n_clicks): + return no_update + saved_views_db.delete(ctx.triggered_id["index"], current_user.id) + views = saved_views_db.list_views(current_user.id, "tableau") + return saved_views_ui.views_table(views) + + +@callback( + Output("vue-rename-modal", "is_open"), + Output("vue-rename-id", "value"), + Input({"type": "vue-rename-open", "index": ALL}, "n_clicks"), + Input("vue-rename-confirm", "n_clicks"), + State("vue-rename-modal", "is_open"), + prevent_initial_call=True, +) +def toggle_rename_modal(_open, _confirm, is_open): + if isinstance(ctx.triggered_id, dict) and any(_open): + return True, str(ctx.triggered_id["index"]) + return False, no_update + + +@callback( + Output("vues-list", "children", allow_duplicate=True), + Input("vue-rename-confirm", "n_clicks"), + State("vue-rename-id", "value"), + State("vue-rename-input", "value"), + prevent_initial_call=True, +) +def rename_view(_n, view_id, new_name): + clean = saved_views_ui.clean_view_name(new_name) + if not view_id or not clean: + return no_update + try: + saved_views_db.rename(int(view_id), current_user.id, clean) + except Exception: + return no_update + views = saved_views_db.list_views(current_user.id, "tableau") + return saved_views_ui.views_table(views) diff --git a/src/pages/tableau.py b/src/pages/tableau.py index 56bdf00..6187949 100644 --- a/src/pages/tableau.py +++ b/src/pages/tableau.py @@ -18,13 +18,18 @@ from dash import ( no_update, register_page, ) +from flask_login import current_user from src.db import query_marches, schema from src.figures import DataTable, make_column_picker +from src.pages._compte_shell import current_user_has_subscription +from src.saved_views import db as saved_views_db +from src.saved_views import ui as saved_views_ui from src.utils import get_data_update_timestamp, logger from src.utils.seo import META_CONTENT from src.utils.table import ( COLUMNS, + build_view_query, filter_table_data, get_default_hidden_columns, invert_columns, @@ -245,6 +250,50 @@ layout = [ className="column_list", title="Choisir les colonnes à afficher et masquer", ), + html.Div( + id="saved-views-bar", + style={"display": "none"}, + className="d-inline-flex align-items-center gap-2", + children=[ + dbc.Button( + "Sauvegarder la vue", + id="btn-save-view", + title="Enregistrer les filtres, tris et colonnes actuels sous un nom", + ), + dbc.DropdownMenu( + id="saved-views-menu", + label="Mes vues", + children=[], + className="d-inline-block", + ), + ], + ), + dcc.Store(id="saved-views-refresh"), + dbc.Modal( + id="save-view-modal", + is_open=False, + children=[ + dbc.ModalHeader(dbc.ModalTitle("Sauvegarder la vue")), + dbc.ModalBody( + [ + dbc.Label("Nom de la vue"), + dcc.Input( + id="save-view-name", + type="text", + className="form-control", + ), + html.Div(id="save-view-feedback", className="mt-2"), + ] + ), + dbc.ModalFooter( + dbc.Button( + "Enregistrer", + id="btn-save-view-confirm", + color="primary", + ) + ), + ], + ), html.P("lignes", id="nb_rows"), html.Div(id="copy-container"), dcc.Input(id="share-url", readOnly=True, style={"display": "none"}), @@ -425,19 +474,7 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href): # Extract base URL (remove existing query params) base_url = href.split("?")[0] - params = {} - if filter_query: - params["filtres"] = filter_query - - if sort_by: - params["tris"] = json.dumps(sort_by) - - if hidden_columns: - table_columns = invert_columns(hidden_columns) - table_columns = ",".join(table_columns) - params["colonnes"] = table_columns - - query_string = urllib.parse.urlencode(params) + query_string = build_view_query(filter_query, sort_by, hidden_columns) full_url = f"{base_url}?{query_string}" if query_string else base_url copy_button = dcc.Clipboard( @@ -548,3 +585,57 @@ def toggle_tableau_columns(click_open, click_close, is_open): ) def reset_view(n_clicks): return "", [] + + +@callback( + Output("saved-views-bar", "style"), + Input("tableau_url", "pathname"), +) +def toggle_saved_views_bar(_pathname): + return saved_views_ui.bar_style(current_user_has_subscription()) + + +@callback( + Output("save-view-modal", "is_open"), + Input("btn-save-view", "n_clicks"), + prevent_initial_call=True, +) +def toggle_save_view_modal(_open): + return True + + +@callback( + Output("save-view-modal", "is_open", allow_duplicate=True), + Output("save-view-feedback", "children"), + Output("saved-views-refresh", "data"), + Input("btn-save-view-confirm", "n_clicks"), + State("save-view-name", "value"), + State("tableau_datatable", "filter_query"), + State("tableau_datatable", "sort_by"), + State("tableau_datatable", "hidden_columns"), + prevent_initial_call=True, +) +def save_view(_n, name, filter_query, sort_by, hidden_columns): + has_sub = current_user_has_subscription() + clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) + if error: + return True, html.Span(error, style={"color": "red"}), no_update + query = build_view_query(filter_query, sort_by, hidden_columns) + saved_views_db.upsert(current_user.id, "tableau", clean_name, query) + return ( + False, + html.Span(f"Vue « {clean_name} » enregistrée.", style={"color": "green"}), + clean_name, + ) + + +@callback( + Output("saved-views-menu", "children"), + Input("tableau_url", "pathname"), + Input("saved-views-refresh", "data"), +) +def populate_saved_views_menu(_pathname, _refresh): + if not current_user_has_subscription(): + return [] + views = saved_views_db.list_views(current_user.id, "tableau") + return saved_views_ui.saved_views_items(views) diff --git a/src/saved_views/__init__.py b/src/saved_views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/saved_views/db.py b/src/saved_views/db.py new file mode 100644 index 0000000..533fa8e --- /dev/null +++ b/src/saved_views/db.py @@ -0,0 +1,77 @@ +import sqlite3 +from datetime import datetime, timezone + +from src.auth.db import get_conn + +SCHEMA = """ +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); +""" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def init_schema() -> None: + get_conn().executescript(SCHEMA) + + +def list_views(user_id: int, table_name: str = "tableau") -> list[sqlite3.Row]: + return ( + get_conn() + .execute( + "SELECT * FROM saved_views WHERE user_id = ? AND table_name = ? " + "ORDER BY name COLLATE NOCASE", + (user_id, table_name), + ) + .fetchall() + ) + + +def get(view_id: int, user_id: int) -> sqlite3.Row | None: + return ( + get_conn() + .execute( + "SELECT * FROM saved_views WHERE id = ? AND user_id = ?", + (view_id, user_id), + ) + .fetchone() + ) + + +def upsert(user_id: int, table_name: str, name: str, query: str) -> None: + now = _now() + get_conn().execute( + "INSERT INTO saved_views " + "(user_id, table_name, name, query, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT(user_id, table_name, name) DO UPDATE SET " + "query = excluded.query, updated_at = excluded.updated_at", + (user_id, table_name, name, query, now, now), + ) + + +def rename(view_id: int, user_id: int, new_name: str) -> None: + get_conn().execute( + "UPDATE saved_views SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?", + (new_name, _now(), view_id, user_id), + ) + + +def delete(view_id: int, user_id: int) -> None: + get_conn().execute( + "DELETE FROM saved_views WHERE id = ? AND user_id = ?", + (view_id, user_id), + ) diff --git a/src/saved_views/ui.py b/src/saved_views/ui.py new file mode 100644 index 0000000..1b17f8b --- /dev/null +++ b/src/saved_views/ui.py @@ -0,0 +1,69 @@ +import dash_bootstrap_components as dbc +from dash import html + + +def bar_style(has_subscription: bool) -> dict: + return {} if has_subscription else {"display": "none"} + + +def clean_view_name(name: str | None) -> str: + return (name or "").strip() + + +def prepare_view_to_save( + has_subscription: bool, name: str | None +) -> tuple[str | None, str | None]: + if not has_subscription: + return None, "Réservé aux abonné·es." + clean = clean_view_name(name) + if not clean: + return None, "Veuillez saisir un nom pour la vue." + return clean, None + + +def saved_views_items(views) -> list: + return [ + dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}") + for view in views + ] + + +def _view_row(view) -> html.Div: + view_id = view["id"] + return html.Div( + className="saved-view-row d-flex align-items-center gap-2 mb-2", + children=[ + html.Span(view["name"], className="flex-grow-1"), + dbc.Button( + "Ouvrir", + href=f"/tableau?{view['query']}", + color="link", + size="sm", + ), + dbc.Button( + "Renommer", + id={"type": "vue-rename-open", "index": view_id}, + color="secondary", + outline=True, + size="sm", + ), + dbc.Button( + "Supprimer", + id={"type": "vue-delete", "index": view_id}, + color="danger", + outline=True, + size="sm", + ), + ], + ) + + +def views_table(views) -> html.Div: + if not views: + return html.Div( + html.P( + "Vous n'avez pas encore de vue enregistrée. " + "Créez-en une depuis le Tableau, bouton « Sauvegarder la vue »." + ) + ) + return html.Div([_view_row(v) for v in views]) diff --git a/src/utils/table.py b/src/utils/table.py index 1a96289..0298012 100644 --- a/src/utils/table.py +++ b/src/utils/table.py @@ -1,4 +1,6 @@ +import json import os +import urllib.parse import uuid import polars as pl @@ -557,6 +559,23 @@ def invert_columns(columns): return inverted_columns +def build_view_query(filter_query, sort_by, hidden_columns) -> str: + """ + Construit la query string d'une vue Tableau (filtres + tris + colonnes), + identique à celle produite par le bouton « Partager la vue ». + + hidden_columns : colonnes masquées ; on stocke les colonnes visibles. + """ + params = {} + if filter_query: + params["filtres"] = filter_query + if sort_by: + params["tris"] = json.dumps(sort_by) + if hidden_columns: + params["colonnes"] = ",".join(invert_columns(hidden_columns)) + return urllib.parse.urlencode(params) + + COLUMNS = schema.names() _EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI diff --git a/tests/saved_views/__init__.py b/tests/saved_views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/saved_views/conftest.py b/tests/saved_views/conftest.py new file mode 100644 index 0000000..ce77c3b --- /dev/null +++ b/tests/saved_views/conftest.py @@ -0,0 +1,12 @@ +import pytest + + +@pytest.fixture +def users_db_path(monkeypatch, tmp_path): + from src.auth.db import reset_conn_for_tests + + db_path = tmp_path / "users.test.sqlite" + monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + reset_conn_for_tests() + yield db_path + reset_conn_for_tests() diff --git a/tests/saved_views/test_build_view_query.py b/tests/saved_views/test_build_view_query.py new file mode 100644 index 0000000..78f59e5 --- /dev/null +++ b/tests/saved_views/test_build_view_query.py @@ -0,0 +1,34 @@ +import urllib.parse + +from src.utils.table import build_view_query + + +def test_empty_inputs_give_empty_string(): + assert build_view_query(None, None, None) == "" + assert build_view_query("", [], []) == "" + + +def test_filter_only(): + q = build_view_query("{objet} icontains route", None, None) + params = urllib.parse.parse_qs(q) + assert params["filtres"] == ["{objet} icontains route"] + assert "tris" not in params + assert "colonnes" not in params + + +def test_sort_is_json_encoded(): + sort_by = [{"column_id": "montant", "direction": "desc"}] + q = build_view_query(None, sort_by, None) + params = urllib.parse.parse_qs(q) + import json + + assert json.loads(params["tris"][0]) == sort_by + + +def test_hidden_columns_become_visible_csv(): + # build_view_query reçoit les colonnes MASQUÉES et stocke les VISIBLES + q = build_view_query(None, None, ["objet"]) + params = urllib.parse.parse_qs(q) + visible = params["colonnes"][0].split(",") + assert "objet" not in visible + assert len(visible) > 0 diff --git a/tests/saved_views/test_compte_vues.py b/tests/saved_views/test_compte_vues.py new file mode 100644 index 0000000..eaec535 --- /dev/null +++ b/tests/saved_views/test_compte_vues.py @@ -0,0 +1,17 @@ +from src.pages import _compte_shell as shell + + +def test_vues_section_is_gated_subscription(): + section = next(s for s in shell.SECTIONS if s["key"] == "vues") + assert section["href"] == "/compte/vues" + assert section["require_subscription"] is True + + +def test_vues_hidden_without_subscription(): + keys = {s["key"] for s in shell.visible_sections(has_subscription=False)} + assert "vues" not in keys + + +def test_vues_visible_with_subscription(): + keys = {s["key"] for s in shell.visible_sections(has_subscription=True)} + assert "vues" in keys diff --git a/tests/saved_views/test_db.py b/tests/saved_views/test_db.py new file mode 100644 index 0000000..8b71bca --- /dev/null +++ b/tests/saved_views/test_db.py @@ -0,0 +1,77 @@ +from src.auth import db as auth_db +from src.saved_views import db + + +def _make_user(email="u@ex.fr"): + auth_db.init_schema() + return auth_db.create_user(email, "hash") + + +def test_init_schema_creates_table(users_db_path): + db.init_schema() + conn = auth_db.get_conn() + tables = { + row[0] + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + assert "saved_views" in tables + + +def test_upsert_creates_and_lists(users_db_path): + db.init_schema() + uid = _make_user() + db.upsert(uid, "tableau", "Ma vue", "filtres=foo") + views = db.list_views(uid, "tableau") + assert len(views) == 1 + assert views[0]["name"] == "Ma vue" + assert views[0]["query"] == "filtres=foo" + + +def test_upsert_same_name_overwrites(users_db_path): + db.init_schema() + uid = _make_user() + db.upsert(uid, "tableau", "Ma vue", "filtres=foo") + db.upsert(uid, "tableau", "Ma vue", "filtres=bar") + views = db.list_views(uid, "tableau") + assert len(views) == 1 + assert views[0]["query"] == "filtres=bar" + + +def test_list_views_is_isolated_per_user(users_db_path): + db.init_schema() + uid1 = _make_user("a@ex.fr") + uid2 = _make_user("b@ex.fr") + db.upsert(uid1, "tableau", "Vue A", "filtres=a") + assert db.list_views(uid2, "tableau") == [] + + +def test_rename_only_affects_owner(users_db_path): + db.init_schema() + uid1 = _make_user("a@ex.fr") + uid2 = _make_user("b@ex.fr") + db.upsert(uid1, "tableau", "Vue A", "filtres=a") + view_id = db.list_views(uid1, "tableau")[0]["id"] + db.rename(view_id, uid2, "Pirate") # mauvais propriétaire → no-op + assert db.get(view_id, uid1)["name"] == "Vue A" + db.rename(view_id, uid1, "Vue B") + assert db.get(view_id, uid1)["name"] == "Vue B" + + +def test_delete_only_affects_owner(users_db_path): + db.init_schema() + uid1 = _make_user("a@ex.fr") + uid2 = _make_user("b@ex.fr") + db.upsert(uid1, "tableau", "Vue A", "filtres=a") + view_id = db.list_views(uid1, "tableau")[0]["id"] + db.delete(view_id, uid2) # mauvais propriétaire → no-op + assert db.get(view_id, uid1) is not None + db.delete(view_id, uid1) + assert db.get(view_id, uid1) is None + + +def test_views_deleted_on_user_cascade(users_db_path): + db.init_schema() + uid = _make_user() + db.upsert(uid, "tableau", "Vue A", "filtres=a") + auth_db.delete_user(uid) + assert db.list_views(uid, "tableau") == [] diff --git a/tests/saved_views/test_ui.py b/tests/saved_views/test_ui.py new file mode 100644 index 0000000..88a1678 --- /dev/null +++ b/tests/saved_views/test_ui.py @@ -0,0 +1,59 @@ +from src.saved_views import ui + + +class _Row(dict): + """Imite un sqlite3.Row : accès par clé.""" + + +def _view(view_id, name, query): + return _Row(id=view_id, name=name, query=query) + + +def test_bar_style_hidden_for_non_subscriber(): + assert ui.bar_style(False) == {"display": "none"} + assert ui.bar_style(True) == {} + + +def test_clean_view_name_strips_and_empties(): + assert ui.clean_view_name(" Ma vue ") == "Ma vue" + assert ui.clean_view_name(" ") == "" + assert ui.clean_view_name(None) == "" + + +def test_prepare_refuses_non_subscriber(): + name, err = ui.prepare_view_to_save(False, "Ma vue") + assert name is None + assert err + + +def test_prepare_refuses_empty_name(): + name, err = ui.prepare_view_to_save(True, " ") + assert name is None + assert err + + +def test_prepare_accepts_valid(): + name, err = ui.prepare_view_to_save(True, " Ma vue ") + assert name == "Ma vue" + assert err is None + + +def test_saved_views_items_build_links(): + items = ui.saved_views_items( + [_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")] + ) + assert len(items) == 2 + assert items[0].href == "/tableau?filtres=a" + assert items[0].children == "Vue A" + + +def test_views_table_empty_state(): + out = ui.views_table([]) + # un Div non vide (message d'état) sans item de suppression + assert out is not None + + +def test_views_table_lists_views(): + out = ui.views_table([_view(1, "Vue A", "filtres=a")]) + text = str(out) + assert "Vue A" in text