Merge vues sauvegardées #95
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user