From 76c1096f4ca3d65ec6c9dc554569defef6f70b38 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 13 Jul 2026 09:58:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(entity=5Fgrid):=20fonctions=20pures=20de?= =?UTF-8?q?=20grille=20scop=C3=A9e=20acheteur/titulaire=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/entity_grid.py | 118 ++++++++++++++++++++++++++++++++++++++ tests/test_entity_grid.py | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 src/utils/entity_grid.py create mode 100644 tests/test_entity_grid.py diff --git a/src/utils/entity_grid.py b/src/utils/entity_grid.py new file mode 100644 index 0000000..b24e2b6 --- /dev/null +++ b/src/utils/entity_grid.py @@ -0,0 +1,118 @@ +"""Logique de grille AG Grid scopée à une entité (acheteur/titulaire). + +Factorise ce qui est commun aux pages acheteur.py et titulaire.py : +- scope SQL (WHERE acheteur_id/titulaire_id + année), +- datasource server-side (réutilise fetch_grid_page du Lot 1), +- export Excel filtré (réutilise export_dataframe), +- columnDefs + réapplication de la disposition persistée, +- fabrique de grille à id pattern-matching (persistance filterModel par fiche). + +Les callbacks Dash sont enregistrés par register_entity_grid_callbacks() +(cf. module séparé de wiring) ; ici, seules des fonctions pures testables. +""" + +from dash import no_update + +from src.figures import ( + AG_GRID_LOCALE_FR, # noqa: F401 (réexport pratique) + ag_grid, +) +from src.utils.grid import ( + apply_persisted_layout, + export_dataframe, + fetch_grid_page, + grid_column_defs, +) + +_TOUTES = "Toutes les années" + + +def grid_type(org_type: str) -> str: + """Valeur de la clé `type` de l'id pattern-matching de la grille.""" + return f"{org_type}-grid" + + +def entity_scope(org_type: str, entity_id: str, year) -> tuple[str, list]: + """WHERE SQL + params liés scopant les requêtes à cette entité (+ année). + + Reproduit _acheteur_scope/_titulaire_scope des pages, unifié par org_type. + """ + if org_type == "titulaire": + where_sql = "titulaire_id = ? AND titulaire_typeIdentifiant = 'SIRET'" + else: + where_sql = "acheteur_id = ?" + params: list = [entity_id] + if year and year != _TOUTES: + where_sql += ' AND YEAR("dateNotification") = ?' + params.append(int(year)) + return where_sql, params + + +def _sort_model_from_column_state(column_state) -> list: + """Extrait le sortModel AG Grid d'un columnState (comme tableau.download_data).""" + return [ + {"colId": c["colId"], "sort": c["sort"]} + for c in (column_state or []) + if c.get("sort") + ] + + +def entity_grid_column_defs(hidden_columns, column_state) -> list[dict]: + """columnDefs du schéma DECP + disposition (largeur/ordre) persistée.""" + defs = grid_column_defs(hidden_columns) + return apply_persisted_layout(defs, column_state) + + +def fetch_entity_page(org_type, entity_id, year, request) -> tuple: + """Datasource server-side scopé pour la grille entité. + + Renvoie ({"rowData": ..., "rowCount": total}, total, total_unique). + """ + if request is None: + return no_update, no_update, no_update + base_where_sql, base_params = entity_scope(org_type, entity_id, year) + rows, total, total_unique = fetch_grid_page( + request.get("filterModel") or None, + request.get("sortModel") or None, + request.get("startRow", 0), + request.get("endRow", 100), + base_where_sql=base_where_sql, + base_params=tuple(base_params), + ) + return {"rowData": rows, "rowCount": total}, total, total_unique + + +def export_entity_dataframe(org_type, entity_id, year, filter_model, column_state): + """DataFrame filtré/trié (état courant de la grille) pour l'export Excel.""" + base_where_sql, base_params = entity_scope(org_type, entity_id, year) + sort_model = _sort_model_from_column_state(column_state) + hidden_columns = [c["colId"] for c in (column_state or []) if c.get("hide")] + return export_dataframe( + filter_model, + sort_model, + hidden_columns, + base_where_sql=base_where_sql, + base_params=tuple(base_params), + ) + + +def clear_sort(column_state) -> list[dict]: + """columnState avec le tri effacé (sort/sortIndex à None), largeur/ordre/ + épinglage préservés. Approche #47-safe de tableau.reset_view.""" + return [{**col, "sort": None, "sortIndex": None} for col in (column_state or [])] + + +def build_entity_grid(org_type, entity_id, year, hidden_columns, column_state): + """Grille AG Grid à id pattern-matching, scopée à (entité, année). + + L'id inclut entity_id + year → une entrée localStorage de filterModel par + fiche/année (persistance native scopée). columnState n'est PAS persisté + nativement (géré par un store global partagé) : persisted_props=["filterModel"]. + """ + grid_id = { + "type": grid_type(org_type), + "entity_id": entity_id, + "year": year or _TOUTES, + } + defs = entity_grid_column_defs(hidden_columns, column_state) + return ag_grid(grid_id, defs, persisted_props=["filterModel"]) diff --git a/tests/test_entity_grid.py b/tests/test_entity_grid.py new file mode 100644 index 0000000..c152455 --- /dev/null +++ b/tests/test_entity_grid.py @@ -0,0 +1,107 @@ +import dash_ag_grid as dag + +import src.app # noqa: F401 # instancie l'app → schema chargé +from src.db import query_marches +from src.utils.entity_grid import ( + build_entity_grid, + clear_sort, + entity_grid_column_defs, + entity_scope, + export_entity_dataframe, + fetch_entity_page, + grid_type, +) + + +def _an_acheteur_id(): + return query_marches("TRUE", (), columns=["acheteur_id"])["acheteur_id"][0] + + +def test_entity_scope_acheteur(): + where, params = entity_scope("acheteur", "S123", None) + assert where == "acheteur_id = ?" + assert params == ["S123"] + + +def test_entity_scope_titulaire_requires_siret(): + where, params = entity_scope("titulaire", "S123", None) + assert "titulaire_id = ?" in where + assert "titulaire_typeIdentifiant = 'SIRET'" in where + assert params == ["S123"] + + +def test_entity_scope_with_year_adds_bound_param(): + where, params = entity_scope("acheteur", "S123", "2025") + assert 'YEAR("dateNotification") = ?' in where + assert params == ["S123", 2025] + + +def test_entity_scope_toutes_les_annees_no_year_filter(): + where, params = entity_scope("acheteur", "S123", "Toutes les années") + assert "YEAR" not in where + assert params == ["S123"] + + +def test_fetch_entity_page_is_scoped(): + ach = _an_acheteur_id() + request = {"startRow": 0, "endRow": 50, "filterModel": None, "sortModel": None} + response, total, total_unique = fetch_entity_page("acheteur", ach, None, request) + assert total > 0 + assert total_unique > 0 + assert response["rowCount"] == total + assert len(response["rowData"]) <= 50 + # Toutes les lignes appartiennent à l'acheteur scopé. + assert all(str(r["acheteur_id"]).find(str(ach)) != -1 for r in response["rowData"]) + + +def test_fetch_entity_page_none_request(): + from dash import no_update + + out = fetch_entity_page("acheteur", "S123", None, None) + assert out == (no_update, no_update, no_update) + + +def test_export_entity_dataframe_scoped(): + ach = _an_acheteur_id() + df = export_entity_dataframe("acheteur", ach, None, None, None) + assert df.height > 0 + assert set(df["acheteur_id"].to_list()) == {ach} + + +def test_clear_sort_preserves_width_and_order(): + column_state = [ + {"colId": "objet", "width": 400, "sort": "asc", "sortIndex": 0}, + {"colId": "montant", "width": 150, "sort": "desc", "sortIndex": 1}, + ] + cleared = clear_sort(column_state) + assert all(c["sort"] is None and c["sortIndex"] is None for c in cleared) + assert cleared[0]["width"] == 400 and cleared[0]["colId"] == "objet" + assert cleared[1]["width"] == 150 + + +def test_clear_sort_empty(): + assert clear_sort(None) == [] + + +def test_entity_grid_column_defs_applies_persisted_layout(): + defs = entity_grid_column_defs( + hidden_columns=[], column_state=[{"colId": "montant", "width": 999}] + ) + by_field = {d["field"]: d for d in defs} + assert by_field["montant"]["width"] == 999 + + +def test_build_entity_grid_has_pattern_matching_id_and_filter_only_persistence(): + grid = build_entity_grid("acheteur", "S123", "Toutes les années", [], None) + assert isinstance(grid, dag.AgGrid) + assert grid.id == { + "type": "acheteur-grid", + "entity_id": "S123", + "year": "Toutes les années", + } + assert list(grid.persisted_props) == ["filterModel"] + + +def test_grid_type(): + assert grid_type("acheteur") == "acheteur-grid" + assert grid_type("titulaire") == "titulaire-grid"