Merge vues sauvegardées #95

This commit is contained in:
Colin Maudry
2026-06-29 16:54:11 +02:00
16 changed files with 1848 additions and 13 deletions
@@ -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?<query>`).
**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?<query>"` (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?<query>` + `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`.
@@ -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?<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`.
```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?<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)` :
```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 « <nom> » 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` :
```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?<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 `/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).
```
```
+4
View File
@@ -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")
+6
View File
@@ -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",
+106
View File
@@ -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)
+104 -13
View File
@@ -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)
View File
+77
View File
@@ -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),
)
+69
View File
@@ -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])
+19
View File
@@ -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
View File
+12
View File
@@ -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()
@@ -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
+17
View File
@@ -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
+77
View File
@@ -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") == []
+59
View File
@@ -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