Merge branch '41-migration-ag-grid' into dev

This commit is contained in:
Colin Maudry
2026-07-10 16:06:01 +02:00
16 changed files with 2799 additions and 250 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
# Migration des tables vers Dash AG Grid — Design (Lot 1 : `tableau.py`)
- **Issues** : #41 (migration AG Grid), #97 (requêtes booléennes, abonnés), #112 (partage de vues abonnés par URL courte)
- **Date** : 2026-07-09
- **Décision d'archi de référence** : commentaire sur #41
## Contexte
Les tables de l'application reposent sur `dash_table.DataTable`, que Plotly abandonne au profit d'AG Grid. `colibre` a fortement personnalisé ses DataTable et les utilise sur 6 emplacements. La page vitrine `tableau.py` est la plus riche : paging / filtre / tri **server-side** sur DuckDB (~1,5 M lignes), partage de vue par URL, vues sauvegardées (abonnés), export Excel, sélecteur de colonnes, persistance, tooltips d'en-tête, liens dans les cellules.
Cette migration prépare une **version majeure** (pas de rétro-compatibilité) et doit rendre implémentable #97 (requêtes booléennes OR/AND/NOT + parenthèses, réservé aux abonnés).
## Objectifs
1. **Préserver les fonctionnalités existantes** de `tableau.py` en passant de `DataTable` à `dag.AgGrid`.
2. **Conserver l'apparence de base d'AG Grid** dans un premier temps — le portage de nos overrides CSS (polices Inter, largeurs de colonnes conditionnelles, tailles) est **reporté** au 2e temps.
3. **Poser le moteur de requête** (AST booléen → SQL DuckDB) comme socle canonique, pour que #97 soit une extension incrémentale.
## Non-objectifs (reportés)
- Portage des overrides CSS des DataTable (2e temps).
- Migration des autres pages : `acheteur.py`, `titulaire.py`, `observatoire.py`, `recherche.py`, `admin/liste.py`, `figures.make_table` (Lots 2 et 3).
- UI du champ de requête booléenne avancée #97 (le moteur AST est posé ici, l'UI vient ensuite).
- Partage de vue par URL courte `?vue=<user_id>_<nom>` (#112).
## Décisions d'architecture (rappel)
- **AG Grid = grille d'affichage.** En row model server-side, c'est notre callback Dash qui compile le filtre en SQL ; on ne dépend pas de la puissance de filtrage d'AG Grid.
- **Infinite Row Model** pour `tableau.py` (`rowModelType="infinite"`).
- **Modèle canonique = AST booléen** (`AND`/`OR`/`NOT` + groupement ; feuilles = `colonne op valeur`), compilé en SQL DuckDB paramétré.
- **Deux producteurs** alimentent le même AST : (1) filtres de colonne AG Grid (gratuit, comportement actuel préservé), (2) champ de requête texte inter-colonnes (#97, abonnés — UI reportée).
- **Pas de rétro-compat** : l'encodage riche de vue dans l'URL (`?filtres/tris/colonnes` en DSL DataTable) est **retiré**. Le partage passera par les vues sauvegardées (#112).
- **Pas d'AG Grid Enterprise.**
## Architecture cible (Lot 1)
### Flux de données
```
AG Grid (infinite)
│ getRowsRequest = {startRow, endRow, filterModel, sortModel}
callback Dash `get_rows_tableau`
│ filterModel ──► filtermodel_to_ast() ──► AST
│ AST ──► ast_to_sql() ──► (where_sql, params)
│ sortModel ──► sort_model_to_sql()
DuckDB (via _fetch_page_sql, réutilisé/adapté) ──► page + total
getRowsResponse = {rowData, rowCount}
```
### Moteur de requête — nouveau module `src/utils/query_ast.py`
Représentation canonique et compilateur, indépendants de l'UI :
- **Types AST** : nœuds `And(children)`, `Or(children)`, `Not(child)`, et feuille `Condition(column, operator, value)`.
- `ast_to_sql(node, schema) -> (where_sql, params)` : compile en SQL DuckDB **paramétré**. Valide chaque `column` contre `schema.names()` (jamais de concaténation de valeur utilisateur — même garantie que l'actuel `filter_query_to_sql`).
- Les **feuilles texte** réutilisent la logique de `tokenize_text_filter` (`src/utils/table_sql.py`) : insensible casse/accents, wildcards `*`, phrases `+`, multi-mots en `AND`. Les feuilles numériques/date réutilisent la logique de typage de `filter_query_to_sql`.
Deux traducteurs (producteurs) vers l'AST :
- `filtermodel_to_ast(filter_model, schema) -> node` : convertit le `filterModel` d'AG Grid (`agTextColumnFilter`, `agNumberColumnFilter`, `agDateColumnFilter`, avec `operator: AND/OR` + `condition1/condition2`) en AST. Colonnes combinées en `And`.
- _(reporté #97)_ `query_string_to_ast(text, schema) -> node` : parseur de la syntaxe FR `(béton OR ciment) AND brique AND NOT démolition`. Non implémenté au Lot 1, mais l'AST est prêt à le recevoir.
> On **retire** l'ancien DSL `{col} icontains valeur && …` de `tableau.py` : `filter_query_to_sql` et le JS `clean_filters` (`src/assets/dash_clientside.js`) ne sont plus utilisés par cette page. On les conserve tant que les autres pages (Lots 2/3) s'en servent, puis on les supprime au dernier lot.
### Composant grille — `src/figures.py`
Nouvelle fabrique `ag_grid(...)` (à côté de la classe `DataTable`, qui reste pour les pages non encore migrées) :
- `dag.AgGrid(rowModelType="infinite", ...)`.
- `columnDefs` dérivés de `schema` : `field`, `headerName`, `filter` par type (`agTextColumnFilter` / `agNumberColumnFilter` / `agDateColumnFilter`), `floatingFilter: True`, `headerTooltip` = définition de la colonne (remplace `tooltip_header`), `hide` selon les colonnes masquées.
- Cellules à liens (`marche` 🔍, `acheteur_nom`/`titulaire_nom` avec liens détail + 📊, `uid`, ressource) : `cellRenderer: "markdown"` + `dangerously_allow_code=True` sur la grille → le HTML `<a>` produit par `postprocess_page`/`add_links` se rend tel quel. `linkTarget: "_blank"` au besoin.
- **Scroll infini** (décidé) : pas de pagination numérotée. Grille à **hauteur fixe** (ex. `calc(100vh - …)`) avec scroll interne → **en-têtes toujours figés**, virtualisation des lignes (seules les lignes visibles + buffer sont rendues), chargement des blocs à la volée. Ne PAS utiliser `domLayout: "autoHeight"` (incompatible avec l'infinite row model).
- `dashGridOptions` : `cacheBlockSize` (taille de bloc serveur, ex. 100), `maxBlocksInCache`, `rowBuffer`, `infiniteInitialRowCount`. Optionnel : épingler à gauche les colonnes-clés (lien 🔍 marché, acheteur) via `pinned: "left"` pour rester visibles au scroll horizontal.
- **Apparence de base** : pas de thème custom au Lot 1 (thème AG Grid par défaut).
### Persistance
- `persistence=True`, `persistence_type="local"`, `persisted_props=["filterModel", "columnState"]` — remplace la persistance actuelle (`filter_query`, `sort_by`). Les tris et la visibilité des colonnes vivent dans `columnState`.
### Réécriture des callbacks `tableau.py`
- **Remplacé** : le callback `update_table` (Inputs `page_current/page_size/filter_query/sort_by`) devient `get_rows_tableau` (Input `getRowsRequest` → Output `getRowsResponse`).
- **Sélecteur de colonnes** : les callbacks colonnes pilotent désormais `columnDefs`/`columnState` (`hide`) au lieu de `hidden_columns`. `make_column_picker`, `get_default_hidden_columns`, `invert_columns` réutilisés.
- **Export Excel** (`download_data`) — chemin **DuckDB** (décidé) : recompile le `filterModel` courant (exposé en `State`) → AST → SQL, récupère les lignes filtrées/triées depuis DuckDB (colonnes masquées exclues), puis `write_styled_excel`. Un seul compilateur (AST→SQL), même chemin de données que la grille → pas de divergence filtre-affiché / filtre-exporté. Remplace l'actuel pipeline Polars (`filter_table_data`/`sort_table_data` sur `LazyFrame`).
- **nb_rows / hint téléchargement** : dérivés du `rowCount` et du total (seuil 65 000 lignes conservé).
- **Vues sauvegardées (abonnés)** : `saved_views` stocke désormais l'AST (JSON) + `columnState`, au lieu de la query DSL. `build_view_query` / `restore_view_from_url` remplacés par une sérialisation AST. Le _rappel_ de vue reste ; le _partage par URL riche_ est retiré.
- **Retiré** : `restore_view_from_url` (partie `?filtres/tris/colonnes`), `sync_url_and_reset_button` (URL riche), bouton « Partager la vue » (revient avec #112), `clean_filters` clientside.
- **Conservé** : mode d'emploi (à réécrire pour la nouvelle UX de filtres AG Grid), bouton Réinitialiser, `track_search`.
### Mode d'emploi
Le `dcc.Markdown` d'aide et les **liens d'exemple** codés en dur (qui encodent l'ancien DSL dans l'URL) sont **réécrits** pour décrire les filtres de colonne AG Grid. Les exemples « voirie < 40 k€ » / « clause sociale PME » sont retirés ou reformulés (plus d'URL riche).
## Dépendances
- Ajouter `dash-ag-grid` (non installé actuellement) : `uv add dash-ag-grid`. Version alignée sur Dash 3.4 (dash-ag-grid 35.x). Vérifier la compatibilité au moment de l'ajout.
## Cas limites & erreurs
- `getRowsRequest is None``no_update`.
- `filterModel` vide → AST vide → `where = TRUE`.
- Colonne inconnue dans un filtre → ignorée + `logger.warning` (parité avec l'actuel).
- Valeur numérique/date invalide → ignorée + warning.
- `rowCount` = 0 → la grille affiche « aucune ligne » (gérer le total à 0 sans casser la pagination).
- Sécurité : identifiants de colonnes validés contre le schéma, valeurs toujours paramétrées (jamais concaténées).
## Tests
- **Unitaires `query_ast.py`** : `ast_to_sql` (feuilles texte accent-insensitive, wildcard `*`, phrase `+`, numérique `=/>/<`, date ; `And/Or/Not` ; groupement). Réutiliser/adapter les cas de `tests/test_table.py` (ex. `test_filter_table_data_accent_insensitive`).
- **Unitaires `filtermodel_to_ast`** : chaque type de filtre AG Grid + `operator AND/OR` + `condition1/condition2`.
- **Parité SQL** : un `filterModel` simple doit produire le même résultat que l'ancien DSL équivalent (non-régression).
- **Intégration (Selenium/DashComposite)** : chargement de la grille, filtre de colonne, tri, pagination, sélecteur de colonnes, export Excel, persistance locale, vue sauvegardée (abonné).
- Suite complète `uv run pytest` uniquement en fin de lot.
## Décisions tranchées
- **Pagination** : scroll infini (grille à hauteur fixe, en-têtes figés, virtualisation).
- **Export Excel** : chemin DuckDB (AST → SQL), pipeline Polars retiré.
## Reporté au 2e temps / lots suivants
- Portage des overrides CSS (apparence).
- #97 : `query_string_to_ast` + champ de requête avancé (abonnés).
- #112 : partage de vue `?vue=<user_id>_<nom>`.
- Migration des Lots 2 (`acheteur`/`titulaire`/`observatoire`) et 3 (`recherche`/`admin`/`figures.make_table`), puis suppression de l'ancien DSL (`filter_query_to_sql`, `clean_filters`).
+1
View File
@@ -31,6 +31,7 @@ dependencies = [
"marshmallow>=3.20.0", "marshmallow>=3.20.0",
"boto3", "boto3",
"cryptography", "cryptography",
"dash-ag-grid>=35.2.0",
] ]
[dependency-groups] [dependency-groups]
+27
View File
@@ -2,6 +2,7 @@ import math
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal
import dash_ag_grid as dag
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import dash_leaflet as dl import dash_leaflet as dl
import dash_leaflet.express as dlx import dash_leaflet.express as dlx
@@ -1066,3 +1067,29 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
tooltip_header=tooltip, tooltip_header=tooltip,
filter_action="native" if filters else "none", filter_action="native" if filters else "none",
) )
def ag_grid(grid_id: str, column_defs: list[dict]) -> "dag.AgGrid":
"""Grille AG Grid server-side (infinite) pour la page Tableau.
Apparence de base d'AG Grid (aucun thème custom au Lot 1).
"""
return dag.AgGrid(
id=grid_id,
columnDefs=column_defs,
defaultColDef={"resizable": True, "minWidth": 120, "floatingFilter": True},
rowModelType="infinite",
dangerously_allow_code=True, # rend le HTML <a> des cellules liens
dashGridOptions={
"cacheBlockSize": 100,
"maxBlocksInCache": 10,
"rowBuffer": 0,
"infiniteInitialRowCount": 100,
"suppressCellFocus": True,
},
columnSize="responsiveSizeToFit",
style={"height": "70vh", "width": "100%"},
persistence=True,
persistence_type="local",
persisted_props=["filterModel", "columnState"],
)
+140 -239
View File
@@ -1,18 +1,16 @@
import json import json
import os import os
import urllib.parse
import uuid
from datetime import datetime from datetime import datetime
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
import polars as pl
from dash import ( from dash import (
ClientsideFunction, ALL,
Input, Input,
Output, Output,
State, State,
callback, callback,
clientside_callback, clientside_callback,
ctx,
dcc, dcc,
html, html,
no_update, no_update,
@@ -20,21 +18,23 @@ from dash import (
) )
from flask_login import current_user from flask_login import current_user
from src.db import query_marches, schema from src.db import schema
from src.figures import DataTable, make_column_picker from src.figures import ag_grid, make_column_picker
from src.pages._compte_shell import current_user_has_subscription 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 db as saved_views_db
from src.saved_views import ui as saved_views_ui from src.saved_views import ui as saved_views_ui
from src.utils import get_data_update_timestamp, logger from src.utils import get_data_update_timestamp, logger
from src.utils.grid import fetch_grid_page, grid_column_defs
from src.utils.query_ast import (
ast_from_dict,
ast_to_dict,
ast_to_filtermodel,
filtermodel_to_ast,
)
from src.utils.seo import META_CONTENT from src.utils.seo import META_CONTENT
from src.utils.table import ( from src.utils.table import (
COLUMNS, COLUMNS,
build_view_query,
filter_table_data,
get_default_hidden_columns, get_default_hidden_columns,
invert_columns,
prepare_table_data,
sort_table_data,
write_styled_excel, write_styled_excel,
) )
from src.utils.tracking import track_search from src.utils.tracking import track_search
@@ -64,17 +64,8 @@ register_page(
DATATABLE = html.Div( DATATABLE = html.Div(
className="marches_table", className="marches_table",
children=DataTable( children=ag_grid(
dtid="tableau_datatable", "tableau_grid", grid_column_defs(get_default_hidden_columns("tableau"))
persisted_props=["filter_query", "sort_by"],
persistence_type="local",
persistence=True,
page_size=20,
page_action="custom",
filter_action="custom",
sort_action="custom",
hidden_columns=[],
columns=[{"id": col, "name": col} for col in schema.names()],
), ),
) )
@@ -95,10 +86,6 @@ def _help_button_legend():
dbc.Button("Mes vues ▾", color="secondary", size="sm"), dbc.Button("Mes vues ▾", color="secondary", size="sm"),
"Rouvrir une vue que vous avez enregistrée (abonnés).", "Rouvrir une vue que vous avez enregistrée (abonnés).",
), ),
(
dbc.Button("Partager la vue", color="secondary", size="sm"),
"Copier l'adresse de la vue actuelle pour la partager ou la conserver.",
),
( (
dbc.Button("Télécharger (Excel)", color="secondary", size="sm"), dbc.Button("Télécharger (Excel)", color="secondary", size="sm"),
"Télécharger les données filtrées et triées au format Excel.", "Télécharger les données filtrées et triées au format Excel.",
@@ -136,9 +123,9 @@ def _help_button_legend():
layout = [ layout = [
dcc.Location(id="tableau_url", refresh=False), dcc.Location(id="tableau_url", refresh=False),
dcc.Store(id="filter-cleanup-trigger-tableau"),
dcc.Store(id="tableau-hidden-columns", storage_type="local"), dcc.Store(id="tableau-hidden-columns", storage_type="local"),
dcc.Store(id="tableau-table"), dcc.Store(id="tableau-table"),
dcc.Store(id="tableau-total"),
html.Script( html.Script(
type="application/ld+json", type="application/ld+json",
id="dataset_jsonld", id="dataset_jsonld",
@@ -201,7 +188,7 @@ layout = [
], ],
), ),
dcc.Markdown( dcc.Markdown(
f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin (exemples : [marchés de voirie < 40 k€ en 2025](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset), [marchés > 500 k€ avec clause sociale attribués à des PME à plus de 100 km dans le Var](/tableau?filtres=%7Btitulaire_categorie%7D+icontains+PME+%26%26+%7Btitulaire_distance%7D+i%3E+100+%26%26+%7Bmontant%7D+i%3E+500000+%26%26+%7Bacheteur_departement_code%7D+icontains+83+%26%26+%7BconsiderationsSociales%7D+icontains+clause&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2CconsiderationsSociales%2Ctitulaire_distance%2Cacheteur_departement_code%2Ctitulaire_categorie%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {len(schema.names())} en cliquant sur le bouton **Colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.", f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin. Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {len(schema.names())} en cliquant sur le bouton **Colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.",
style={"maxWidth": "1000px"}, style={"maxWidth": "1000px"},
), ),
html.Div( html.Div(
@@ -241,51 +228,45 @@ layout = [
Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée. Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée.
##### Appliquer des filtres ##### Filtrer les colonnes
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`. Chaque colonne a son propre filtre : saisissez une valeur dans le champ situé juste sous son en-tête (le filtre « flottant »), ou cliquez sur l'icône entonnoir dans l'en-tête pour ouvrir le filtre complet.
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est sensible ni à la casse (majuscules/minuscules), ni à l'accentuation. - Champs textuels : contient (par défaut), égal à, ne contient pas, commence par, se termine par...
- `rennes` => le texte contient "rennes" - Champs numériques (Durée en mois, Montant, nombre d'offres...) : égal à, supérieur à, inférieur à, entre (plage)...
- `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole" - Champs date (Date de notification...) : égal à, avant, après, entre (plage)...
- `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte
- `métropole+rennes` => le texte contient "metropole rennes" ou "métropole rennes", collé et dans cet ordre
- `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri"
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
- soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
- Champs date (Date de notification, ...) :
- `< 2024-01-31` pour "avant le 31 janvier 2024"
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022"
Vous pouvez filtrer plusieurs colonnes à la fois. Dans le filtre complet (icône entonnoir), vous pouvez combiner deux conditions sur la même colonne avec **ET** ou **OU**.
Vous pouvez filtrer plusieurs colonnes à la fois ; les filtres de colonnes différentes se cumulent toujours (ET).
##### Trier les données ##### Trier les données
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : Cliquez sur l'en-tête d'une colonne pour la trier. Chaque clic change le tri dans cet ordre :
1. tri croissant 1. tri croissant
2. tri décroissant 2. tri décroissant
3. pas de tri 3. pas de tri
Les tris sont appliqués dans l'ordre : la première colonne que vous triez a la priorité sur la seconde, qui triera uniquement au sein des groupes de valeurs de la première colonne. Pour trier sur plusieurs colonnes à la fois, maintenez la touche `Maj` (Shift) enfoncée en cliquant sur les en-têtes suivants : la première colonne triée a la priorité, la suivante ne départage qu'au sein des groupes de valeurs identiques de la précédente, et ainsi de suite.
##### Défilement
Le tableau charge les lignes au fur et à mesure que vous faites défiler la page, plutôt que par pages numérotées. Les en-têtes de colonnes (et leurs filtres) restent toujours visibles en haut du tableau pendant le défilement.
##### Afficher plus de colonnes ##### Afficher plus de colonnes
Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {len(schema.names())} colonnes, ce serait dommage de vous limiter ! Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {len(schema.names())} colonnes, ce serait dommage de vous limiter !
Pour afficher plus de colonnes, cliquez sur le bouton **Choisir les colonnes** et cochez les colonnes pour les afficher. Pour afficher plus de colonnes, cliquez sur le bouton **Colonnes** et cochez les colonnes à afficher.
##### Partager une vue ##### Vues sauvegardées (abonnés)
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur **Partager** pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres. Une vue est un ensemble de filtres, de tris et de colonnes affichées que vous avez appliqués. Si vous êtes abonné, le bouton **Sauvegarder la vue** vous permet d'enregistrer la configuration actuelle sous un nom, et le menu **Mes vues** de la rappeler d'un clic plus tard.
Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard.
##### Télécharger le résultat ##### Télécharger le résultat
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**. Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger (Excel)**.
##### Liens ##### Liens
@@ -375,8 +356,6 @@ layout = [
), ),
], ],
), ),
html.Div(id="copy-container"),
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
dbc.Button( dbc.Button(
"Télécharger (Excel)", "Télécharger (Excel)",
id="btn-download-data", id="btn-download-data",
@@ -434,196 +413,75 @@ layout = [
@callback( @callback(
Output("tableau_datatable", "data"), Output("tableau_grid", "getRowsResponse"),
Output("tableau_datatable", "columns"), Output("tableau-total", "data"),
Output("tableau_datatable", "tooltip_header"), Input("tableau_grid", "getRowsRequest"),
Output("tableau_datatable", "data_timestamp"),
Output("nb_rows", "children"),
Output("btn-download-data", "disabled"),
Output("btn-download-data", "children"),
Output("btn-download-data", "title"),
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
Output("download-hint", "children"),
Input("tableau_url", "href"),
Input("tableau_datatable", "page_current"),
Input("tableau_datatable", "page_size"),
Input("tableau_datatable", "filter_query"),
Input("tableau_datatable", "sort_by"),
State("tableau_datatable", "data_timestamp"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def update_table(href, page_current, page_size, filter_query, sort_by, data_timestamp): def get_rows_tableau(request):
result = list( if request is None:
prepare_table_data( return no_update, no_update
None, filter_model = request.get("filterModel") or None
data_timestamp, sort_model = request.get("sortModel") or None
filter_query, # AG Grid renvoie une nouvelle requête getRowsRequest pour chaque bloc de
page_current, # défilement infini, avec le même filterModel tant que le filtre ne change
page_size, # pas. Ne compter qu'une recherche par changement de filtre/tri (bloc 0),
sort_by, # pas une par bloc chargé au défilement.
"tableau", if filter_model and request.get("startRow", 0) == 0:
) track_search(json.dumps(filter_model), "tableau")
rows, total = fetch_grid_page(
filter_model,
sort_model,
request.get("startRow", 0),
request.get("endRow", 100),
) )
# Libellé court et constant ; la raison d'un éventuel blocage est affichée return {"rowData": rows, "rowCount": total}, total
# en clair dans la ligne d'infos (fiable cross-browser, contrairement à une
# infobulle sur bouton désactivé). index 5 = disabled, 6 = children, 7 = title.
result[6] = "Télécharger (Excel)" @callback(
download_blocked_too_many = result[5] and result[7] Output("nb_rows", "children"),
download_hint = ( Output("btn-download-data", "disabled"),
Output("download-hint", "children"),
Input("tableau-total", "data"),
)
def update_meta(total):
total = total or 0
too_many = total > 65000
hint = (
" · Filtrez sous 65 000 lignes pour activer le téléchargement" " · Filtrez sous 65 000 lignes pour activer le téléchargement"
if download_blocked_too_many if too_many
else "" else ""
) )
result.append(download_hint) return f"{total} lignes", too_many, hint
return tuple(result)
@callback( @callback(
Output("download-data", "data"), Output("download-data", "data"),
Input("btn-download-data", "n_clicks"), Input("btn-download-data", "n_clicks"),
State("tableau_datatable", "filter_query"), State("tableau_grid", "filterModel"),
State("tableau_datatable", "sort_by"), State("tableau_grid", "columnState"),
State("tableau_datatable", "hidden_columns"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list | None = None): def download_data(n_clicks, filter_model, column_state):
lff: pl.LazyFrame = query_marches().lazy() from src.utils.grid import export_dataframe
# Les colonnes masquées sont supprimées sort_model = [
if hidden_columns: {"colId": c["colId"], "sort": c["sort"]}
lff = lff.drop(hidden_columns) for c in (column_state or [])
if c.get("sort")
if filter_query: ]
track_search(filter_query, "tab download") hidden_columns = [c["colId"] for c in (column_state or []) if c.get("hide")]
lff = filter_table_data(lff, filter_query) if filter_model:
track_search(json.dumps(filter_model), "tab download")
if sort_by and len(sort_by) > 0: df = export_dataframe(filter_model, sort_model, hidden_columns)
lff = sort_table_data(lff, sort_by)
def to_bytes(buffer): def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer) write_styled_excel(df, buffer)
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S") date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx") return dcc.send_bytes(to_bytes, filename=f"decp_{date}.xlsx")
@callback(
Output("tableau_datatable", "filter_query"),
Output("tableau_datatable", "sort_by"),
Output("tableau-hidden-columns", "data"),
Output("tableau_url", "search"),
Output("filter-cleanup-trigger-tableau", "data"),
Input("tableau_url", "search"),
State("tableau_datatable", "filter_query"),
State("tableau_datatable", "sort_by"),
)
def restore_view_from_url(search, stored_filters, stored_sort):
if not search and not stored_filters:
return no_update, no_update, no_update, no_update, no_update
params = urllib.parse.parse_qs(search.lstrip("?")) if search else {}
logger.debug("params " + json.dumps(params, indent=2))
filter_query = no_update
sort_by = no_update
hidden_columns = no_update
trigger_cleanup = no_update
if "filtres" in params:
filter_query = params["filtres"][0]
trigger_cleanup = str(uuid.uuid4())
elif stored_filters:
filter_query = stored_filters
trigger_cleanup = str(uuid.uuid4())
if "tris" in params:
try:
sort_by = json.loads(params["tris"][0])
except json.JSONDecodeError:
pass
elif stored_sort:
sort_by = stored_sort
if "colonnes" in params:
table_columns = params["colonnes"][0].split(",")
verified_columns = [
column for column in table_columns if column in schema.names()
]
hidden_columns = invert_columns(verified_columns)
return filter_query, sort_by, hidden_columns, "", trigger_cleanup
# Pour nettoyer les icontains et i< des filtres
# voir aussi src/assets/dash_clientside.js
clientside_callback(
ClientsideFunction(
namespace="clientside",
function_name="clean_filters",
),
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
Input("filter-cleanup-trigger-tableau", "data"),
prevent_initial_call=True,
)
@callback(
Output("share-url", "value"),
Output("copy-container", "children"),
Input("tableau_datatable", "filter_query"),
Input("tableau_datatable", "sort_by"),
Input("tableau_datatable", "hidden_columns"),
State("tableau_url", "href"),
prevent_initial_call=True,
)
def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
if not href:
return no_update, no_update
# Extract base URL (remove existing query params)
base_url = href.split("?")[0]
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(
id="btn-copy-url",
target_id="share-url",
title="Copier l'URL de cette vue",
style={
"display": "inline-block",
"fontSize": 20,
"verticalAlign": "top",
"cursor": "pointer",
},
className="fa fa-link",
children=[
dbc.Button(
"Partager la vue",
color="secondary",
size="sm",
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
)
],
)
return full_url, copy_button
@callback(
Output("copy-container", "children", allow_duplicate=True),
Input("btn-copy-url", "n_clicks", allow_optional=True),
prevent_initial_call=True,
)
def show_confirmation(n_clicks):
if n_clicks:
return html.Span(
"Adresse de la vue copiée",
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
)
return no_update
@callback( @callback(
Output("tableau_help", "is_open"), Output("tableau_help", "is_open"),
[Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")], [Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")],
@@ -650,21 +508,18 @@ def update_hidden_columns_from_checkboxes(selected_columns):
@callback( @callback(
Output("tableau_datatable", "hidden_columns"), Output("tableau_grid", "columnDefs"),
Input( Input("tableau-hidden-columns", "data"),
"tableau-hidden-columns",
"data",
),
) )
def store_hidden_columns(hidden_columns): def apply_hidden_columns(hidden_columns):
if hidden_columns is None: if hidden_columns is None:
hidden_columns = get_default_hidden_columns("tableau") hidden_columns = get_default_hidden_columns("tableau")
return hidden_columns return grid_column_defs(hidden_columns)
@callback( @callback(
Output("tableau_column_list", "selected_rows"), Output("tableau_column_list", "selected_rows"),
Input("tableau_datatable", "hidden_columns"), Input("tableau-hidden-columns", "data"),
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
) )
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes): def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
@@ -688,13 +543,12 @@ def toggle_tableau_columns(click_open, click_close, is_open):
@callback( @callback(
Output("tableau_datatable", "filter_query", allow_duplicate=True), Output("tableau_grid", "filterModel", allow_duplicate=True),
Output("tableau_datatable", "sort_by", allow_duplicate=True),
Input("btn-tableau-reset", "n_clicks"), Input("btn-tableau-reset", "n_clicks"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def reset_view(n_clicks): def reset_view(n_clicks):
return "", [] return {}
@callback( @callback(
@@ -720,17 +574,20 @@ def toggle_save_view_modal(_open):
Output("saved-views-refresh", "data"), Output("saved-views-refresh", "data"),
Input("btn-save-view-confirm", "n_clicks"), Input("btn-save-view-confirm", "n_clicks"),
State("save-view-name", "value"), State("save-view-name", "value"),
State("tableau_datatable", "filter_query"), State("tableau_grid", "filterModel"),
State("tableau_datatable", "sort_by"), State("tableau_grid", "columnState"),
State("tableau_datatable", "hidden_columns"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def save_view(_n, name, filter_query, sort_by, hidden_columns): def save_view(_n, name, filter_model, column_state):
has_sub = current_user_has_subscription() has_sub = current_user_has_subscription()
clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name) clean_name, error = saved_views_ui.prepare_view_to_save(has_sub, name)
if error: if error:
return True, html.Span(error, style={"color": "red"}), no_update return True, html.Span(error, style={"color": "red"}), no_update
query = build_view_query(filter_query, sort_by, hidden_columns) # On stocke l'AST canonique (indépendant de l'UI), pas le filterModel brut
# d'AG Grid : cf. spec de conception, "l'AST (JSON) + columnState,
# indépendant de l'UI".
ast = filtermodel_to_ast(filter_model, schema)
query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state or []})
saved_views_db.upsert(current_user.id, "tableau", clean_name, query) saved_views_db.upsert(current_user.id, "tableau", clean_name, query)
return ( return (
False, False,
@@ -751,6 +608,50 @@ def populate_saved_views_menu(_pathname, _refresh):
return saved_views_ui.saved_views_items(views) return saved_views_ui.saved_views_items(views)
@callback(
Output("tableau_grid", "filterModel"),
Output("tableau_grid", "columnState"),
Output("tableau-hidden-columns", "data", allow_duplicate=True),
Input({"type": "saved-view-item", "index": ALL}, "n_clicks"),
State({"type": "saved-view-item", "index": ALL}, "id"),
prevent_initial_call=True,
)
def apply_saved_view(n_clicks, ids):
triggered = ctx.triggered_id
if not triggered or not any(n_clicks):
return no_update, no_update, no_update
row = saved_views_db.get(triggered["index"], current_user.id)
if not row:
return no_update, no_update, no_update
try:
view = json.loads(row["query"])
# L'AST canonique est stocké (pas le filterModel brut d'AG Grid) :
# cf. save_view. `ast_from_dict(None)` -> None et
# `ast_to_filtermodel(None, schema)` -> {} si la vue est d'un ancien
# format (sans clé "ast") : dégradation propre, la vue se rappelle
# sans filtre plutôt que de planter.
ast = ast_from_dict(view.get("ast"))
filter_model = ast_to_filtermodel(ast, schema)
column_state = view.get("columnState") or []
except (json.JSONDecodeError, TypeError, AttributeError):
# Vue enregistrée avant la migration vers AG Grid (Task 10) : row["query"]
# est encore une query string (ex. "filtres=a&tris=b"), pas du JSON. On
# échoue proprement plutôt que de planter le callback ; pas de
# migration automatique de l'ancien format.
logger.warning(
"Vue sauvegardée au format pré-migration, impossible de l'appliquer : "
f"id={row['id']!r} name={row['name']!r}"
)
return no_update, no_update, no_update
# tableau-hidden-columns pilote les cases à cocher du sélecteur de colonnes
# (update_checkboxes_from_hidden_columns) et la régénération des
# columnDefs (apply_hidden_columns) ; sans cette sortie, ce store restait
# désynchronisé du columnState rappelé (revue finale #41). Même extraction
# que download_data.
hidden_columns = [c["colId"] for c in column_state if c.get("hide")]
return filter_model, column_state, hidden_columns
@callback( @callback(
Output("overwrite-view-select", "options"), Output("overwrite-view-select", "options"),
Input("save-view-modal", "is_open"), Input("save-view-modal", "is_open"),
+9 -2
View File
@@ -23,7 +23,11 @@ def prepare_view_to_save(
def saved_views_items(views) -> list: def saved_views_items(views) -> list:
return [ return [
dbc.DropdownMenuItem(view["name"], href=f"/tableau?{view['query']}") dbc.DropdownMenuItem(
view["name"],
id={"type": "saved-view-item", "index": view["id"]},
n_clicks=0,
)
for view in views for view in views
] ]
@@ -35,8 +39,11 @@ def _view_row(view) -> html.Div:
children=[ children=[
html.Span(view["name"], className="flex-grow-1"), html.Span(view["name"], className="flex-grow-1"),
dbc.Button( dbc.Button(
# Limitation connue : la vue n'est pas appliquée automatiquement
# en arrivant sur /tableau depuis cette page. Le mécanisme de
# rappel multi-page reste à faire (voir tableau.py `apply_saved_view`).
"Ouvrir", "Ouvrir",
href=f"/tableau?{view['query']}", href="/tableau",
color="link", color="link",
size="sm", size="sm",
), ),
+124
View File
@@ -0,0 +1,124 @@
"""Datasource server-side pour AG Grid (infinite row model)."""
import polars as pl
from src.db import count_marches, query_marches, schema
from src.figures import DATA_SCHEMA
from src.utils.cache import cache
from src.utils.query_ast import ast_to_sql, filtermodel_to_ast, sort_model_to_sql
from src.utils.table import postprocess_page
@cache.memoize()
def _cached_count(where_sql: str, params: tuple) -> int:
"""Cache le COUNT(*) sur (where_sql, params).
AG Grid envoie une requête par bloc de défilement infini ; pour un même
filtre, tous les blocs partagent le même (where_sql, params) et donc le
même total — inutile de recompter un COUNT(*) sur ~1,5M lignes à chaque
bloc chargé (cf. `src.utils.table._fetch_page_sql`, même schéma).
"""
return count_marches(where_sql, params)
def fetch_grid_page(
filter_model,
sort_model,
start_row: int,
end_row: int,
base_where_sql: str = "TRUE",
base_params: tuple = (),
) -> tuple[list[dict], int]:
"""Renvoie (row_data, total_count) pour un bloc [start_row, end_row)."""
ast = filtermodel_to_ast(filter_model, schema)
filter_sql, filter_params = ast_to_sql(ast, schema)
where_sql = f"({base_where_sql}) AND ({filter_sql})"
params = [*base_params, *filter_params]
order_by = sort_model_to_sql(sort_model, schema) or None
total = _cached_count(where_sql, tuple(params))
limit = max(0, end_row - start_row)
page = query_marches(
where_sql=where_sql,
params=params,
order_by=order_by,
limit=limit,
offset=start_row,
)
page = postprocess_page(page)
return page.to_dicts(), total
def export_dataframe(filter_model, sort_model, hidden_columns) -> pl.DataFrame:
"""Renvoie les lignes filtrées/triées pour l'export Excel.
Colonnes masquées exclues, valeurs brutes (non post-traitées HTML).
"""
ast = filtermodel_to_ast(filter_model, schema)
filter_sql, params = ast_to_sql(ast, schema)
order_by = sort_model_to_sql(sort_model, schema) or None
visible = [c for c in schema.names() if c not in set(hidden_columns or [])]
return query_marches(
where_sql=filter_sql,
params=params,
columns=visible,
order_by=order_by,
)
_LINK_COLUMNS = {
"marche",
"uid",
"acheteur_id",
"acheteur_nom",
"titulaire_id",
"titulaire_nom",
"sourceFile",
}
def _filter_for(col_type) -> str:
if col_type.is_numeric():
return "agNumberColumnFilter"
if col_type == pl.Date:
return "agDateColumnFilter"
return "agTextColumnFilter"
def grid_column_defs(hidden_columns=None):
"""columnDefs dérivés du schéma DuckDB.
'marche' (colonne loupe ajoutée par postprocess_page) est placée en tête.
"""
hidden = set(hidden_columns or [])
defs = [
{
"field": "marche",
"headerName": "",
"cellRenderer": "markdown",
"filter": False,
"sortable": False,
"maxWidth": 60,
"pinned": "left",
}
]
for col in schema.names():
meta = DATA_SCHEMA.get(col, {})
col_type = schema[col]
col_def = {
"field": col,
"headerName": meta.get("title", col),
"filter": _filter_for(col_type),
"floatingFilter": True,
"sortable": True,
"hide": col in hidden,
}
if meta.get("description"):
col_def["headerTooltip"] = (
f"{meta.get('title', col)} ({col}) — {meta['description']}"
)
if col in _LINK_COLUMNS:
col_def["cellRenderer"] = "markdown"
defs.append(col_def)
return defs
+398
View File
@@ -0,0 +1,398 @@
"""Représentation canonique d'un filtre (AST booléen) et compilation en SQL DuckDB.
Ce module est indépendant de l'UI : plusieurs producteurs (filtres de colonne
AG Grid, futur champ de requête booléenne #97) construisent le même AST, compilé
ici en SQL paramétré. Les identifiants de colonnes sont validés contre le schéma ;
les valeurs passent toujours par le binding `?` (jamais concaténées).
"""
from dataclasses import dataclass
import polars as pl
from src.utils import logger
from src.utils.table_sql import sort_by_to_sql, tokenize_text_filter
@dataclass
class Condition:
column: str
operator: str
value: object = None
value2: object = None
@dataclass
class And:
children: list
@dataclass
class Or:
children: list
@dataclass
class Not:
child: object
Node = object # Condition | And | Or | Not | None
def ast_to_sql(node, schema: pl.Schema) -> tuple[str, list]:
"""Compile un AST en (where_sql, params). Nœud neutre -> ('TRUE', [])."""
if node is None:
return "TRUE", []
if isinstance(node, And):
return _join(node.children, "AND", schema)
if isinstance(node, Or):
return _join(node.children, "OR", schema)
if isinstance(node, Not):
sql, params = ast_to_sql(node.child, schema)
if sql == "TRUE":
return "TRUE", []
return f"NOT ({sql})", params
if isinstance(node, Condition):
return _condition_to_sql(node, schema)
logger.warning(f"Nœud AST inconnu ignoré : {node!r}")
return "TRUE", []
def _join(children, op: str, schema: pl.Schema) -> tuple[str, list]:
fragments: list[str] = []
params: list = []
for child in children:
sql, child_params = ast_to_sql(child, schema)
if sql == "TRUE":
continue
fragments.append(f"({sql})")
params.extend(child_params)
if not fragments:
return "TRUE", []
return f" {op} ".join(fragments), params
def _condition_to_sql(cond: Condition, schema: pl.Schema) -> tuple[str, list]:
col = cond.column
if col not in schema.names():
logger.warning(f"Colonne inconnue ignorée : {col!r}")
return "TRUE", []
col_type = schema[col]
quoted = f'"{col}"'
is_numeric = col_type.is_numeric()
col_is_date = col_type == pl.Date
if cond.operator == "blank":
if is_numeric or col_is_date:
return f"{quoted} IS NULL", []
return f"({quoted} IS NULL OR {quoted} = '')", []
if cond.operator == "notBlank":
if is_numeric or col_is_date:
return f"{quoted} IS NOT NULL", []
return f"({quoted} IS NOT NULL AND {quoted} <> '')", []
if is_numeric:
return _numeric_to_sql(cond, col_type, quoted)
# texte / date : traité comme texte (parité avec l'existant)
if cond.operator == "contains":
return tokenize_text_filter(col, str(cond.value), col_is_date)
if cond.operator == "notContains":
where, params = tokenize_text_filter(col, str(cond.value), col_is_date)
return f"NOT ({where})", params
target = f"CAST({quoted} AS VARCHAR)" if col_is_date else quoted
op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="}
if cond.operator in op_map:
return f"{quoted} IS NOT NULL AND {target} {op_map[cond.operator]} ?", [
str(cond.value)
]
if cond.operator == "range":
return f"{quoted} IS NOT NULL AND {target} BETWEEN ? AND ?", [
str(cond.value),
str(cond.value2),
]
if cond.operator == "startsWith":
return f"{quoted} ILIKE ?", [f"{cond.value}%"]
if cond.operator == "endsWith":
return f"{quoted} ILIKE ?", [f"%{cond.value}"]
logger.warning(f"Opérateur texte invalide : {cond.operator!r}")
return "TRUE", []
def _coerce_number(value, col_type):
try:
return int(value) if col_type.is_integer() else float(value)
except (TypeError, ValueError):
logger.warning(f"Valeur numérique invalide ignorée : {value!r}")
return None
def _numeric_to_sql(cond: Condition, col_type, quoted: str) -> tuple[str, list]:
op_map = {"eq": "=", "neq": "<>", "gt": ">", "gte": ">=", "lt": "<", "lte": "<="}
if cond.operator in op_map:
v = _coerce_number(cond.value, col_type)
if v is None:
return "TRUE", []
return f"{quoted} IS NOT NULL AND {quoted} {op_map[cond.operator]} ?", [v]
if cond.operator == "range":
v1 = _coerce_number(cond.value, col_type)
v2 = _coerce_number(cond.value2, col_type)
if v1 is None or v2 is None:
return "TRUE", []
return f"{quoted} BETWEEN ? AND ?", [v1, v2]
logger.warning(f"Opérateur numérique invalide : {cond.operator!r}")
return "TRUE", []
_TEXT_TYPE = {
"contains": "contains",
"notContains": "notContains",
"equals": "eq",
"notEqual": "neq",
"startsWith": "startsWith",
"endsWith": "endsWith",
"blank": "blank",
"notBlank": "notBlank",
}
_NUM_TYPE = {
"equals": "eq",
"notEqual": "neq",
"lessThan": "lt",
"lessThanOrEqual": "lte",
"greaterThan": "gt",
"greaterThanOrEqual": "gte",
"inRange": "range",
"blank": "blank",
"notBlank": "notBlank",
}
def _leaf(column: str, spec: dict):
"""Convertit une condition AG Grid unitaire en Condition."""
ftype = spec.get("filterType", "text")
ag_type = spec.get("type")
if ftype == "date":
op = _NUM_TYPE.get(ag_type)
if op == "range":
return Condition(column, "range", spec.get("dateFrom"), spec.get("dateTo"))
return Condition(column, op, spec.get("dateFrom")) if op else None
if ftype == "number":
op = _NUM_TYPE.get(ag_type)
if op == "range":
return Condition(column, "range", spec.get("filter"), spec.get("filterTo"))
return Condition(column, op, spec.get("filter")) if op else None
# texte
op = _TEXT_TYPE.get(ag_type)
return Condition(column, op, spec.get("filter")) if op else None
def filtermodel_to_ast(filter_model, schema):
"""Traduit un filterModel AG Grid en AST. Colonnes combinées en And."""
if not filter_model:
return None
children = []
for column, spec in filter_model.items():
if column not in schema.names():
logger.warning(f"Filtre sur colonne inconnue ignoré : {column!r}")
continue
if "operator" in spec: # deux conditions
c1 = _leaf(column, spec.get("condition1", {}))
c2 = _leaf(column, spec.get("condition2", {}))
parts = [c for c in (c1, c2) if c is not None]
if not parts:
continue
node = And(parts) if spec["operator"] == "AND" else Or(parts)
else:
node = _leaf(column, spec)
if node is None:
continue
children.append(node)
return And(children) if children else None
_TEXT_TYPE_INV = {v: k for k, v in _TEXT_TYPE.items()}
_NUM_TYPE_INV = {v: k for k, v in _NUM_TYPE.items()}
def _condition_to_filterspec(cond: Condition, schema: pl.Schema) -> dict | None:
"""Convertit une Condition en spec de filtre AG Grid unitaire (une colonne).
Inverse de `_leaf`. Renvoie None (avec warning) si la colonne est inconnue
ou si l'opérateur n'a pas d'équivalent AG Grid — ne devrait pas arriver
pour un AST produit par `filtermodel_to_ast`, mais on reste défensif.
"""
if cond.column not in schema.names():
logger.warning(
f"Colonne inconnue ignorée (ast_to_filtermodel) : {cond.column!r}"
)
return None
col_type = schema[cond.column]
if col_type.is_numeric():
filter_type = "number"
elif col_type == pl.Date:
filter_type = "date"
else:
filter_type = "text"
if filter_type in ("number", "date") and cond.operator == "range":
if filter_type == "number":
return {
"filterType": "number",
"type": "inRange",
"filter": cond.value,
"filterTo": cond.value2,
}
return {
"filterType": "date",
"type": "inRange",
"dateFrom": cond.value,
"dateTo": cond.value2,
}
if filter_type == "date":
ag_type = _NUM_TYPE_INV.get(cond.operator)
if ag_type is None:
logger.warning(
f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} "
f"(colonne {cond.column!r})"
)
return None
return {"filterType": "date", "type": ag_type, "dateFrom": cond.value}
if filter_type == "number":
ag_type = _NUM_TYPE_INV.get(cond.operator)
if ag_type is None:
logger.warning(
f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} "
f"(colonne {cond.column!r})"
)
return None
return {"filterType": "number", "type": ag_type, "filter": cond.value}
# texte
ag_type = _TEXT_TYPE_INV.get(cond.operator)
if ag_type is None:
logger.warning(
f"Opérateur sans équivalent AG Grid ignoré : {cond.operator!r} "
f"(colonne {cond.column!r})"
)
return None
return {"filterType": "text", "type": ag_type, "filter": cond.value}
def _child_to_filterspec(child, schema: pl.Schema):
"""Convertit un enfant du And de haut niveau en (colonne, spec filterModel).
Ne sait inverser que les deux formes produites par `filtermodel_to_ast` :
une Condition seule, ou un And/Or à 2 enfants portant sur la MÊME colonne
(filtre AG Grid natif à deux conditions). Toute autre forme (Not, And/Or
imbriqué plus profondément, colonnes différentes, plus de 2 enfants) est
ignorée avec un warning : un filterModel est par nature par-colonne et ne
peut représenter une expression booléenne arbitraire (cf. #97).
"""
if isinstance(child, Condition):
spec = _condition_to_filterspec(child, schema)
if spec is None:
return None
return child.column, spec
if isinstance(child, (And, Or)) and len(child.children) == 2:
c1, c2 = child.children
if (
isinstance(c1, Condition)
and isinstance(c2, Condition)
and c1.column == c2.column
):
spec1 = _condition_to_filterspec(c1, schema)
spec2 = _condition_to_filterspec(c2, schema)
if spec1 is None or spec2 is None:
return None
operator = "AND" if isinstance(child, And) else "OR"
return c1.column, {
"filterType": spec1["filterType"],
"operator": operator,
"condition1": spec1,
"condition2": spec2,
}
logger.warning(f"Nœud AST non représentable en filterModel, ignoré : {child!r}")
return None
def ast_to_filtermodel(node: Node, schema: pl.Schema) -> dict:
"""Traduit un AST en filterModel AG Grid. Inverse de `filtermodel_to_ast`.
Seules les formes que `filtermodel_to_ast` peut effectivement produire sont
garanties d'être inversées correctement (voir `_child_to_filterspec`).
"""
if node is None:
return {}
if isinstance(node, And) and not node.children:
return {}
# Le niveau supérieur est normalement un And multi-enfants (un enfant par
# colonne filtrée) ; on tolère aussi un nœud "nu" (une seule colonne).
children = node.children if isinstance(node, And) else [node]
filter_model: dict = {}
for child in children:
entry = _child_to_filterspec(child, schema)
if entry is None:
continue
column, spec = entry
filter_model[column] = spec
return filter_model
def sort_model_to_sql(sort_model: list | None, schema: pl.Schema) -> str:
"""Traduit un sortModel AG Grid en clause ORDER BY DuckDB (adapte à sort_by_to_sql)."""
if not sort_model:
return ""
sort_by = [
{"column_id": s.get("colId"), "direction": s.get("sort")} for s in sort_model
]
return sort_by_to_sql(sort_by, schema)
def ast_to_dict(node: Node) -> dict | None:
"""Sérialise un AST en dict JSON-compatible (pour persistance en base)."""
if node is None:
return None
if isinstance(node, Condition):
return {
"t": "cond",
"column": node.column,
"operator": node.operator,
"value": node.value,
"value2": node.value2,
}
if isinstance(node, And):
return {"t": "and", "children": [ast_to_dict(c) for c in node.children]}
if isinstance(node, Or):
return {"t": "or", "children": [ast_to_dict(c) for c in node.children]}
if isinstance(node, Not):
return {"t": "not", "child": ast_to_dict(node.child)}
return None
def ast_from_dict(data: dict | None) -> Node:
"""Reconstruit un AST à partir d'un dict produit par ast_to_dict."""
if data is None:
return None
t = data.get("t")
if t == "cond":
return Condition(
data["column"], data["operator"], data.get("value"), data.get("value2")
)
if t == "and":
return And([ast_from_dict(c) for c in data["children"]])
if t == "or":
return Or([ast_from_dict(c) for c in data["children"]])
if t == "not":
return Not(ast_from_dict(data["child"]))
return None
+120
View File
@@ -0,0 +1,120 @@
"""Régression revue finale #41 : apply_saved_view (callback qui RAPPELLE une vue
sauvegardée) ne doit pas planter si row["query"] est encore au format
pré-migration (query string, ex. "filtres=a&tris=b"), stocké par l'ancienne
build_view_query avant que Task 10 ne migre save_view vers du JSON.
Depuis le round 2 de la revue finale, le format JSON stocké est
{"ast": ..., "columnState": ...} (AST canonique, indépendant de l'UI) plutôt
que {"filterModel": ..., "columnState": ...} (filterModel brut d'AG Grid) :
cf. spec de conception. apply_saved_view doit aussi resynchroniser
tableau-hidden-columns à partir du columnState rappelé.
"""
import json
from unittest.mock import patch
import dash
import src.app # noqa: F401 # instancie l'app → register_page() des pages
from src.auth import db as auth_db
from src.pages import tableau
from src.saved_views import db as saved_views_db
from src.utils.query_ast import And, Condition, ast_to_dict
def _make_user(email="u@ex.fr"):
auth_db.init_schema()
return auth_db.create_user(email, "hash")
def _fake_user(user_id, authenticated=True):
user = type("U", (), {})()
user.is_authenticated = authenticated
user.id = user_id
return user
class _Ctx:
triggered_id = None
def test_apply_saved_view_old_format_returns_no_update(monkeypatch, users_db_path):
saved_views_db.init_schema()
uid = _make_user()
saved_views_db.upsert(uid, "tableau", "Vue historique", "filtres=a&tris=b")
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
_Ctx.triggered_id = {"type": "saved-view-item", "index": view_id}
monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, column_state, hidden_columns = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
assert filter_model is dash.no_update
assert column_state is dash.no_update
assert hidden_columns is dash.no_update
def test_apply_saved_view_new_format_returns_view(monkeypatch, users_db_path):
"""row["query"] au format post-round-2 : {"ast": ..., "columnState": ...},
AST canonique plutôt que filterModel brut d'AG Grid."""
saved_views_db.init_schema()
uid = _make_user()
ast = And([Condition("objet", "contains", "route")])
column_state = [
{"colId": "montant", "sort": "desc"},
{"colId": "acheteur_nom", "hide": True},
]
query = json.dumps({"ast": ast_to_dict(ast), "columnState": column_state})
saved_views_db.upsert(uid, "tableau", "Vue récente", query)
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
_Ctx.triggered_id = {"type": "saved-view-item", "index": view_id}
monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
assert filter_model == {
"objet": {"filterType": "text", "type": "contains", "filter": "route"}
}
assert returned_column_state == column_state
# tableau-hidden-columns doit être resynchronisé à partir du columnState
# rappelé (revue finale #41, round 2) : seules les colonnes avec hide=True.
assert hidden_columns == ["acheteur_nom"]
def test_apply_saved_view_missing_ast_key_degrades_gracefully(
monkeypatch, users_db_path
):
"""Vue stockée dans un format intermédiaire (sans clé "ast", ex. l'ancien
format {"filterModel": ..., "columnState": ...} produit avant le round 2) :
ast_from_dict(None) -> None, ast_to_filtermodel(None, schema) -> {} — la
vue se rappelle sans filtre plutôt que de planter le callback."""
saved_views_db.init_schema()
uid = _make_user()
column_state = [{"colId": "montant", "sort": "desc"}]
query = json.dumps(
{
"filterModel": {"objet": {"filterType": "text", "filter": "route"}},
"columnState": column_state,
}
)
saved_views_db.upsert(uid, "tableau", "Vue ancien format", query)
view_id = saved_views_db.list_views(uid, "tableau")[0]["id"]
_Ctx.triggered_id = {"type": "saved-view-item", "index": view_id}
monkeypatch.setattr(tableau, "ctx", _Ctx)
with patch.object(tableau, "current_user", _fake_user(uid)):
filter_model, returned_column_state, hidden_columns = tableau.apply_saved_view(
[1], [{"type": "saved-view-item", "index": view_id}]
)
assert filter_model == {}
assert returned_column_state == column_state
assert hidden_columns == []
+3 -2
View File
@@ -38,13 +38,14 @@ def test_prepare_accepts_valid():
assert err is None assert err is None
def test_saved_views_items_build_links(): def test_saved_views_items_build_clickable_entries():
items = ui.saved_views_items( items = ui.saved_views_items(
[_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")] [_view(1, "Vue A", "filtres=a"), _view(2, "Vue B", "tris=b")]
) )
assert len(items) == 2 assert len(items) == 2
assert items[0].href == "/tableau?filtres=a" assert items[0].id == {"type": "saved-view-item", "index": 1}
assert items[0].children == "Vue A" assert items[0].children == "Vue A"
assert items[1].id == {"type": "saved-view-item", "index": 2}
def test_views_table_empty_state(): def test_views_table_empty_state():
+121
View File
@@ -0,0 +1,121 @@
from unittest.mock import patch
import pytest
import src.app # noqa: F401 # instancie l'app → register_page() des pages
from src.pages.tableau import get_rows_tableau
from src.utils import grid as grid_module
from src.utils.grid import export_dataframe, fetch_grid_page, grid_column_defs
@pytest.fixture(scope="module")
def flask_app():
"""Minimal Flask app with SimpleCache so @cache.memoize() works in tests
(même pattern que tests/test_table.py)."""
from flask import Flask
from src.utils.cache import cache
app = Flask(__name__)
cache.init_app(app, config={"CACHE_TYPE": "SimpleCache"})
return app
@pytest.fixture(autouse=True)
def reset_cache(flask_app):
from src.utils.cache import cache
with flask_app.app_context():
try:
cache.clear()
except (RuntimeError, AttributeError):
pass
yield
def test_column_defs_have_field_and_filter():
defs = grid_column_defs(hidden_columns=[])
by_field = {d["field"]: d for d in defs}
assert "objet" in by_field
# filtre texte par défaut
assert by_field["objet"]["filter"] == "agTextColumnFilter"
# montant est numérique
assert by_field["montant"]["filter"] == "agNumberColumnFilter"
# headerTooltip présent (définition de colonne)
assert "headerTooltip" in by_field["objet"]
def test_column_defs_hidden_flag():
defs = grid_column_defs(hidden_columns=["objet"])
by_field = {d["field"]: d for d in defs}
assert by_field["objet"]["hide"] is True
def test_fetch_grid_page_returns_rows_and_count():
rows, total = fetch_grid_page(None, None, 0, 20)
assert isinstance(rows, list)
assert isinstance(total, int)
assert total >= len(rows)
if rows:
# postprocess_page ajoute une colonne 'marche' avec un lien
assert "marche" in rows[0]
def test_fetch_grid_page_filter_reduces_count():
_, total_all = fetch_grid_page(None, None, 0, 1)
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
rows, total_filtered = fetch_grid_page(fm, None, 0, 20)
assert total_filtered <= total_all
assert rows == [] and total_filtered == 0
def test_fetch_grid_page_offset_slicing():
rows, _ = fetch_grid_page(None, None, 0, 5)
assert len(rows) <= 5
def test_export_dataframe_excludes_hidden_columns():
df = export_dataframe(None, None, hidden_columns=["objet"])
assert "objet" not in df.columns
def test_export_dataframe_applies_filter():
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "zzzzzznope"}}
df = export_dataframe(fm, None, hidden_columns=[])
assert df.height == 0
def test_get_rows_tableau_tracks_search_once_per_filter_not_per_scroll_block():
"""Régression revue finale #41 : AG Grid envoie une getRowsRequest par bloc
de défilement infini, avec le même filterModel tant que le filtre ne
change pas. track_search ne doit être appelé qu'une fois par filtre (au
premier bloc, startRow == 0), pas une fois par bloc défilé."""
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
with patch("src.pages.tableau.track_search") as mocked:
get_rows_tableau({"filterModel": fm, "startRow": 0, "endRow": 100})
get_rows_tableau({"filterModel": fm, "startRow": 100, "endRow": 200})
get_rows_tableau({"filterModel": fm, "startRow": 200, "endRow": 300})
mocked.assert_called_once()
def test_fetch_grid_page_caches_count_across_scroll_blocks(flask_app, monkeypatch):
"""Régression revue finale #41 : count_marches ne doit être appelé qu'une
fois pour des blocs de défilement successifs partageant le même
where_sql/params (même filtre, start_row différent)."""
call_count = {"n": 0}
real_count_marches = grid_module.count_marches
def counting_count_marches(where_sql, params):
call_count["n"] += 1
return real_count_marches(where_sql, params)
monkeypatch.setattr(grid_module, "count_marches", counting_count_marches)
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "route"}}
with flask_app.app_context():
_, total1 = fetch_grid_page(fm, None, 0, 20)
_, total2 = fetch_grid_page(fm, None, 20, 40)
_, total3 = fetch_grid_page(fm, None, 40, 60)
assert call_count["n"] == 1
assert total1 == total2 == total3
+15 -4
View File
@@ -73,7 +73,13 @@ def test_002_filter_persistence(dash_duo: DashComposite):
) )
return _filter_input_in_view(dash_duo, filter_input_selector) return _filter_input_in_view(dash_duo, filter_input_selector)
for page in ["tableau", "acheteurs/123", "titulaires/345"]: # /tableau utilise désormais AG Grid (dash-ag-grid) au lieu de dash_table.DataTable ;
# la persistance de ses filtres/colonnes est couverte par persistence=True et
# persisted_props=["filterModel", "columnState"] configurés dans la fabrique
# ag_grid() de src/figures.py, et a été vérifiée manuellement via un navigateur
# réel (Task 12 du plan #41). Une couverture Selenium dédiée à AG Grid pourrait
# être ajoutée ultérieurement.
for page in ["acheteurs/123", "titulaires/345"]:
filter_input = open_page_and_check_filter_input() filter_input = open_page_and_check_filter_input()
filter_input.send_keys("11") # valeur quelconque, on teste la persistance filter_input.send_keys("11") # valeur quelconque, on teste la persistance
filter_input.send_keys(Keys.ENTER) filter_input.send_keys(Keys.ENTER)
@@ -99,7 +105,7 @@ def test_003_tableau_download(dash_duo: DashComposite):
print(app.server.name) print(app.server.name)
outputs = [ outputs = [
download_data(1, "", [], None), download_data(1, None, None),
download_acheteur_data(1, "/acheteurs/123", "2025", "ACHETEUR 1"), download_acheteur_data(1, "/acheteurs/123", "2025", "ACHETEUR 1"),
download_titulaire_data(1, "/titulaires/345", "2025", "TITULAIRE 1"), download_titulaire_data(1, "/titulaires/345", "2025", "TITULAIRE 1"),
] ]
@@ -347,13 +353,18 @@ def test_014_get_distance_histogram_all_nulls():
assert isinstance(result, dcc.Graph) assert isinstance(result, dcc.Graph)
def test_015_tableau_filter_date(dash_duo: DashComposite): def test_015_org_pages_filter_date(dash_duo: DashComposite):
from src.app import app from src.app import app
dash_duo.start_server(app) dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal(".logo > h1", "colibre", timeout=4) dash_duo.wait_for_text_to_equal(".logo > h1", "colibre", timeout=4)
for page in ["tableau", "acheteurs/123", "titulaires/345"]: # /tableau utilise désormais AG Grid ; le filtrage de sa colonne date est
# couvert par les tests unitaires de compilation SQL dans
# tests/test_query_ast.py (ex. test_date_range_uses_between) et a été
# vérifié manuellement (Task 12 du plan #41). Une couverture Selenium
# dédiée au filtre de date AG Grid pourrait être ajoutée ultérieurement.
for page in ["acheteurs/123", "titulaires/345"]:
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}") dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
filter_input = '.marches_table th[data-dash-column="dateNotification"] input' filter_input = '.marches_table th[data-dash-column="dateNotification"] input'
filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p' filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p'
+338
View File
@@ -0,0 +1,338 @@
import polars as pl
from src.utils.query_ast import (
And,
Condition,
Not,
Or,
ast_from_dict,
ast_to_dict,
ast_to_filtermodel,
ast_to_sql,
filtermodel_to_ast,
sort_model_to_sql,
)
SCHEMA = pl.Schema(
{
"acheteur_nom": pl.String,
"objet": pl.String,
"montant": pl.Float64,
"dureeMois": pl.Int64,
"dateNotification": pl.Date,
}
)
def _run(node):
"""Compile et retourne (sql, params)."""
return ast_to_sql(node, SCHEMA)
def test_none_is_true():
assert _run(None) == ("TRUE", [])
def test_empty_and_is_true():
assert _run(And([])) == ("TRUE", [])
def test_text_contains_uses_ilike_and_params():
sql, params = _run(Condition("objet", "contains", "voirie"))
assert "ILIKE ?" in sql
assert params == ["%voirie%"]
def test_text_contains_multiword_is_and():
sql, params = _run(Condition("objet", "contains", "metropole rennes"))
assert sql.count("ILIKE ?") == 2
assert params == ["%metropole%", "%rennes%"]
def test_text_contains_wildcard_and_phrase():
_, params = _run(Condition("objet", "contains", "distri* metropole+rennes"))
assert params == ["distri%", "%metropole rennes%"]
def test_text_notcontains_negates():
sql, params = _run(Condition("objet", "notContains", "construction"))
assert "NOT (" in sql
assert params == ["%construction%"]
def test_numeric_gt():
sql, params = _run(Condition("montant", "gt", 40000))
assert '"montant" > ?' in sql
assert params == [40000.0]
def test_numeric_eq_int_column():
sql, params = _run(Condition("dureeMois", "eq", "12"))
assert '"dureeMois" = ?' in sql
assert params == [12]
def test_numeric_range():
sql, params = _run(Condition("montant", "range", 100, 200))
assert params == [100.0, 200.0]
assert "BETWEEN" in sql or ("> ?" in sql and "< ?" in sql)
def test_numeric_invalid_value_is_true():
# valeur non numérique -> condition neutralisée (TRUE), pas d'exception
assert _run(Condition("montant", "gt", "abc")) == ("TRUE", [])
def test_date_gt_casts_varchar():
sql, params = _run(Condition("dateNotification", "gt", "2022"))
assert "VARCHAR" in sql
assert params == ["2022"]
def test_blank_and_notblank():
sql_b, _ = _run(Condition("objet", "blank"))
assert "IS NULL" in sql_b
sql_nb, _ = _run(Condition("objet", "notBlank"))
assert "IS NOT NULL" in sql_nb
def test_date_range_uses_between():
sql, params = _run(
Condition("dateNotification", "range", "2022-01-01", "2022-12-31")
)
assert "BETWEEN" in sql
assert params == ["2022-01-01", "2022-12-31"]
def test_text_range_uses_between():
sql, params = _run(Condition("acheteur_nom", "range", "a", "m"))
assert "BETWEEN" in sql
assert params == ["a", "m"]
def test_blank_on_numeric_column_no_empty_string():
sql, params = _run(Condition("montant", "blank"))
assert sql == '"montant" IS NULL'
assert params == []
def test_notblank_on_date_column_no_empty_string():
sql, params = _run(Condition("dateNotification", "notBlank"))
assert sql == '"dateNotification" IS NOT NULL'
assert params == []
def test_unknown_column_is_true():
assert _run(Condition("colonne_inexistante", "contains", "x")) == ("TRUE", [])
def test_and_or_not_grouping():
node = And(
[
Or(
[
Condition("objet", "contains", "beton"),
Condition("objet", "contains", "ciment"),
]
),
Not(Condition("objet", "contains", "demolition")),
]
)
sql, params = _run(node)
assert " OR " in sql and " AND " in sql and "NOT (" in sql
assert params == ["%beton%", "%ciment%", "%demolition%"]
def test_filtermodel_empty_is_none():
assert filtermodel_to_ast(None, SCHEMA) is None
assert filtermodel_to_ast({}, SCHEMA) is None
def test_filtermodel_text_contains():
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}}
_, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert params == ["%voirie%"]
def test_filtermodel_number_greaterthan():
fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}}
sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert '"montant"' in sql and params == [40000.0]
def test_filtermodel_number_inrange():
fm = {
"montant": {
"filterType": "number",
"type": "inRange",
"filter": 100,
"filterTo": 200,
}
}
_, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert params == [100.0, 200.0]
def test_filtermodel_date_uses_datefrom():
fm = {
"dateNotification": {
"filterType": "date",
"type": "greaterThan",
"dateFrom": "2022-01-01",
}
}
_, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert params == ["2022-01-01"]
def test_filtermodel_two_conditions_or():
fm = {
"objet": {
"filterType": "text",
"operator": "OR",
"condition1": {"filterType": "text", "type": "contains", "filter": "beton"},
"condition2": {
"filterType": "text",
"type": "contains",
"filter": "ciment",
},
}
}
sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert " OR " in sql and params == ["%beton%", "%ciment%"]
def test_filtermodel_multiple_columns_are_anded():
fm = {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"},
"montant": {"filterType": "number", "type": "greaterThan", "filter": 1000},
}
sql, params = ast_to_sql(filtermodel_to_ast(fm, SCHEMA), SCHEMA)
assert " AND " in sql and set(params) == {"%voirie%", 1000.0}
def test_sort_model_to_sql():
sm = [{"colId": "montant", "sort": "desc"}, {"colId": "dureeMois", "sort": "asc"}]
out = sort_model_to_sql(sm, SCHEMA)
assert out == '"montant" DESC NULLS LAST, "dureeMois" ASC NULLS LAST'
def test_sort_model_empty():
assert sort_model_to_sql(None, SCHEMA) == ""
assert sort_model_to_sql([], SCHEMA) == ""
def test_ast_dict_roundtrip():
node = And(
[
Or([Condition("objet", "contains", "beton")]),
Not(Condition("objet", "contains", "x")),
]
)
restored = ast_from_dict(ast_to_dict(node))
assert ast_to_sql(restored, SCHEMA) == ast_to_sql(node, SCHEMA)
def test_ast_dict_none():
assert ast_to_dict(None) is None
assert ast_from_dict(None) is None
def _roundtrip_sql(fm):
"""Compile fm -> ast -> filterModel -> ast à nouveau, renvoie (sql, params)
de la première et de la seconde compilation, pour vérifier l'équivalence
sémantique du round-trip (pas l'égalité dict-à-dict)."""
ast1 = filtermodel_to_ast(fm, SCHEMA)
rebuilt_fm = ast_to_filtermodel(ast1, SCHEMA)
ast2 = filtermodel_to_ast(rebuilt_fm, SCHEMA)
return ast_to_sql(ast1, SCHEMA), ast_to_sql(ast2, SCHEMA)
def test_ast_to_filtermodel_roundtrip_text_contains():
fm = {"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_number_greaterthan():
fm = {"montant": {"filterType": "number", "type": "greaterThan", "filter": 40000}}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_number_inrange():
fm = {
"montant": {
"filterType": "number",
"type": "inRange",
"filter": 100,
"filterTo": 200,
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_date_greaterthan():
fm = {
"dateNotification": {
"filterType": "date",
"type": "greaterThan",
"dateFrom": "2022-01-01",
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_two_conditions_or():
fm = {
"objet": {
"filterType": "text",
"operator": "OR",
"condition1": {"filterType": "text", "type": "contains", "filter": "beton"},
"condition2": {
"filterType": "text",
"type": "contains",
"filter": "ciment",
},
}
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_roundtrip_multiple_columns():
fm = {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"},
"montant": {"filterType": "number", "type": "greaterThan", "filter": 1000},
}
original, rebuilt = _roundtrip_sql(fm)
assert original == rebuilt
def test_ast_to_filtermodel_none_and_empty_and():
assert ast_to_filtermodel(None, SCHEMA) == {}
assert ast_to_filtermodel(And([]), SCHEMA) == {}
def test_ast_to_filtermodel_skips_not_with_warning():
node = And([Not(Condition("objet", "contains", "x"))])
assert ast_to_filtermodel(node, SCHEMA) == {}
def test_ast_to_filtermodel_skips_mismatched_columns_with_warning():
node = And(
[Or([Condition("objet", "contains", "a"), Condition("montant", "gt", 1)])]
)
assert ast_to_filtermodel(node, SCHEMA) == {}
def test_ast_to_filtermodel_bare_single_condition():
"""filtermodel_to_ast enveloppe toujours dans And, mais on tolère un nœud
non enveloppé (Condition seule) en entrée, défensivement."""
node = Condition("objet", "contains", "voirie")
fm = ast_to_filtermodel(node, SCHEMA)
assert fm == {
"objet": {"filterType": "text", "type": "contains", "filter": "voirie"}
}
+16
View File
@@ -0,0 +1,16 @@
from dash.testing.composite import DashComposite
def test_tableau_grid_loads_and_filters(dash_duo: DashComposite):
"""La page /tableau charge et affiche la grille AG Grid avec au moins une ligne."""
from src.app import app
dash_duo.start_server(app)
dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau")
# La grille AG Grid est présente
dash_duo.wait_for_element(".ag-root", timeout=8)
# Au moins une ligne rendue (row) après chargement du premier bloc
dash_duo.wait_for_element(".ag-center-cols-container .ag-row", timeout=8)
assert dash_duo.get_logs() == [] or all(
"SEVERE" not in log["level"] for log in dash_duo.get_logs()
)
+10 -3
View File
@@ -1,12 +1,19 @@
from dash.testing.composite import DashComposite from dash.testing.composite import DashComposite
def test_tableau_hscroll_bar_present(dash_duo: DashComposite): def test_marches_table_hscroll_bar_present(dash_duo: DashComposite):
"""La barre de défilement est injectée et le conteneur scroll horizontalement.""" """La barre de défilement est injectée et le conteneur scroll horizontalement.
Testé via /acheteurs/123 : depuis la migration de /tableau vers AG Grid
(qui gère son propre défilement horizontal nativement), cette page-ci ne
rend plus de dash_table.DataTable. .marches_table (et table_hscroll.js)
reste utilisé par acheteur.py, observatoire.py et titulaire.py, donc ce
comportement reste couvert via une de ces pages.
"""
from src.app import app from src.app import app
dash_duo.start_server(app) dash_duo.start_server(app)
dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau") dash_duo.wait_for_page(f"{dash_duo.server_url}/acheteurs/123")
dash_duo.wait_for_element(".marches_table", timeout=20) dash_duo.wait_for_element(".marches_table", timeout=20)
# Barre injectée par table_hscroll.js # Barre injectée par table_hscroll.js
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10) dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
Generated
+14
View File
@@ -590,6 +590,7 @@ dependencies = [
{ name = "brevo-python" }, { name = "brevo-python" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "dash", extra = ["compress"] }, { name = "dash", extra = ["compress"] },
{ name = "dash-ag-grid" },
{ name = "dash-bootstrap-components" }, { name = "dash-bootstrap-components" },
{ name = "dash-extensions", version = "1.0.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "dash-extensions", version = "1.0.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "dash-extensions", version = "2.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "dash-extensions", version = "2.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
@@ -636,6 +637,7 @@ requires-dist = [
{ name = "brevo-python", specifier = "==5.0.0rc1" }, { name = "brevo-python", specifier = "==5.0.0rc1" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "dash", extras = ["compress"], specifier = "==4.4.0" }, { name = "dash", extras = ["compress"], specifier = "==4.4.0" },
{ name = "dash-ag-grid", specifier = ">=35.2.0" },
{ name = "dash-bootstrap-components" }, { name = "dash-bootstrap-components" },
{ name = "dash-extensions" }, { name = "dash-extensions" },
{ name = "dash-leaflet" }, { name = "dash-leaflet" },
@@ -787,6 +789,18 @@ testing = [
{ name = "waitress" }, { name = "waitress" },
] ]
[[package]]
name = "dash-ag-grid"
version = "35.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/4d/259d21112a087a23ecd2f7dd651412755bf0547835805c38f3c77c971052/dash_ag_grid-35.2.0.tar.gz", hash = "sha256:507f5dccf7235bf1b9af2c59d4cd0f12205db03c3489f605948f13c72bcece4f", size = 5794514, upload-time = "2026-04-03T10:46:26.05Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/71/7f97793a3449ee5218f0fb820c03c15be11a7c6d0c61c37d38779b753d4e/dash_ag_grid-35.2.0-py3-none-any.whl", hash = "sha256:b8b33780faa7322101b0559658da2a301a58a3329ff6a2c45f1490f0c3719df9", size = 5836952, upload-time = "2026-04-03T10:46:23.957Z" },
]
[[package]] [[package]]
name = "dash-bootstrap-components" name = "dash-bootstrap-components"
version = "2.0.4" version = "2.0.4"