Merge branch 'dev' into feature/73_compte_utilisateur

This commit is contained in:
Colin Maudry
2026-06-24 03:01:43 +02:00
76 changed files with 15560 additions and 1888 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,951 @@
# Observatoire — filtrage natif DuckDB — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer le filtrage Polars sur LazyFrame dans `prepare_dashboard_data` par un requêtage natif DuckDB, pour ne matérialiser que le sous-ensemble utile au lieu de l'intégralité de la table `decp` (~1,5 M lignes).
**Architecture:** Nouveau helper pur `dashboard_filters_to_sql(**filter_params) -> (where_sql, params)` dans `src/utils/table_sql.py` (modèle de `filter_query_to_sql`). `prepare_dashboard_data` devient une fonction fine qui appelle `query_marches(where_sql, params)` et retourne une `pl.DataFrame`. Les 3 appelants dans `src/pages/observatoire.py` sont adaptés à la nouvelle signature.
**Tech Stack:** Python 3.12, Polars, DuckDB, Dash, pytest.
**Spec:** `docs/superpowers/specs/2026-04-22-observatoire-duckdb-filters-design.md`.
---
## File Structure
**À créer :**
- `tests/test_dashboard_filters_to_sql.py` — tests unitaires du nouveau helper SQL (cas vide + cas par filtre).
- `tests/test_prepare_dashboard_data.py` — test d'intégration léger (appel DuckDB réel sur `tests/test.parquet`).
**À modifier :**
- `src/utils/table_sql.py` — ajouter `dashboard_filters_to_sql` + import `datetime`/`timedelta`.
- `src/utils/data.py` — réécrire `prepare_dashboard_data` (signature et implémentation), ajouter `query_marches` aux imports `from src.db`.
- `src/pages/observatoire.py` — adapter 3 sites d'appel (lignes ~668, ~791, ~882) ; retirer `query_marches` de l'import `from src.db` (plus utilisé).
- `tests/test_main.py` — supprimer `test_010_observatoire_montant_filter` (migré en test unitaire du helper).
---
## Task 1: Tests unitaires — cas par défaut + filtre année
**Files:**
- Create: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Write the failing tests**
Create `tests/test_dashboard_filters_to_sql.py`:
```python
from datetime import datetime, timedelta
from src.utils.table_sql import dashboard_filters_to_sql
def test_no_filters_uses_default_365_day_window():
where_sql, params = dashboard_filters_to_sql()
assert where_sql == '"dateNotification" > ?'
assert len(params) == 1
assert isinstance(params[0], datetime)
expected = datetime.now() - timedelta(days=365)
assert abs((params[0] - expected).total_seconds()) < 2
def test_year_filter_overrides_default_window():
where_sql, params = dashboard_filters_to_sql(dashboard_year="2025")
assert where_sql == 'YEAR("dateNotification") = ?'
assert params == [2025]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: FAIL with `ImportError: cannot import name 'dashboard_filters_to_sql'`.
- [ ] **Step 3: Implement the helper**
Add to the top of `src/utils/table_sql.py` (below existing imports):
```python
from datetime import datetime, timedelta
```
Append this function at the end of `src/utils/table_sql.py`:
```python
def dashboard_filters_to_sql(
dashboard_year=None,
dashboard_acheteur_id=None,
dashboard_acheteur_categorie=None,
dashboard_acheteur_departement_code=None,
dashboard_titulaire_id=None,
dashboard_titulaire_categorie=None,
dashboard_titulaire_departement_code=None,
dashboard_marche_type=None,
dashboard_marche_objet=None,
dashboard_marche_code_cpv=None,
dashboard_marche_considerations_sociales=None,
dashboard_marche_considerations_environnementales=None,
dashboard_marche_techniques=None,
dashboard_marche_innovant=None,
dashboard_marche_sous_traitance_declaree=None,
dashboard_montant_min=None,
dashboard_montant_max=None,
) -> tuple[str, list]:
"""Traduit les filtres du tableau de bord en (where_clause, params) DuckDB."""
clauses: list[str] = []
params: list = []
if dashboard_year:
clauses.append('YEAR("dateNotification") = ?')
params.append(int(dashboard_year))
else:
clauses.append('"dateNotification" > ?')
params.append(datetime.now() - timedelta(days=365))
return " AND ".join(clauses), params
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): squelette de dashboard_filters_to_sql (#72)"
```
---
## Task 2: Filtres d'égalité simples (catégorie, type, innovant, sous-traitance)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_marche_type_equality():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_type="Marché",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?'
assert params == [2025, "Marché"]
def test_innovant_value_all_is_skipped():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_innovant="all",
)
assert where_sql == 'YEAR("dateNotification") = ?'
assert params == [2025]
def test_innovant_value_oui_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_innovant="oui",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?'
assert params == [2025, "oui"]
def test_sous_traitance_value_non_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_sous_traitance_declaree="non",
)
assert (
where_sql
== 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?'
)
assert params == [2025, "non"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 4 new tests FAIL (missing clauses).
- [ ] **Step 3: Extend the helper**
Insert the following block in `dashboard_filters_to_sql`, **after** the `if dashboard_year / else` block and **before** `return " AND ".join(clauses), params`:
```python
if dashboard_marche_type:
clauses.append('"type" = ?')
params.append(dashboard_marche_type)
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
clauses.append('"marcheInnovant" = ?')
params.append(dashboard_marche_innovant)
if (
dashboard_marche_sous_traitance_declaree
and dashboard_marche_sous_traitance_declaree != "all"
):
clauses.append('"sousTraitanceDeclaree" = ?')
params.append(dashboard_marche_sous_traitance_declaree)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (6 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres d'égalité simples dans dashboard_filters_to_sql (#72)"
```
---
## Task 3: Filtres LIKE/ILIKE (ids, objet, cpv)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_acheteur_id_uses_like_wildcards():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_id="12345678900010",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
assert params == [2025, "%12345678900010%"]
def test_titulaire_id_uses_like_wildcards():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_id="999",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
assert params == [2025, "%999%"]
def test_marche_objet_uses_case_insensitive_ilike():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_objet="travaux",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "objet" ILIKE ?'
assert params == [2025, "%travaux%"]
def test_code_cpv_uses_prefix_like():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_code_cpv="4521",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?'
assert params == [2025, "4521%"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 4 new tests FAIL.
- [ ] **Step 3: Extend the helper**
Insert the following block, **just after** the year/default block and **before** the `if dashboard_marche_type` block:
```python
if dashboard_acheteur_id:
clauses.append('"acheteur_id" LIKE ?')
params.append(f"%{dashboard_acheteur_id}%")
if dashboard_titulaire_id:
clauses.append('"titulaire_id" LIKE ?')
params.append(f"%{dashboard_titulaire_id}%")
```
Insert in the "marché" block, **after** `dashboard_marche_type` and **before** `dashboard_marche_innovant`:
```python
if dashboard_marche_objet:
clauses.append('"objet" ILIKE ?')
params.append(f"%{dashboard_marche_objet}%")
if dashboard_marche_code_cpv:
clauses.append('"codeCPV" LIKE ?')
params.append(f"{dashboard_marche_code_cpv}%")
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (10 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres LIKE/ILIKE dans dashboard_filters_to_sql (#72)"
```
---
## Task 4: Filtre IN (départements) + skip conditionnel par ID
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_acheteur_departement_multiple_uses_in_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_departement_code=["75", "92", "93"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
'AND "acheteur_departement_code" IN (?, ?, ?)'
)
assert params == [2025, "75", "92", "93"]
def test_acheteur_categorie_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_categorie="Commune",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?'
assert params == [2025, "Commune"]
def test_titulaire_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_categorie="PME",
dashboard_titulaire_departement_code=["35"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
'AND "titulaire_categorie" = ? '
'AND "titulaire_departement_code" IN (?)'
)
assert params == [2025, "PME", "35"]
def test_acheteur_id_present_skips_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_id="123",
dashboard_acheteur_categorie="Commune",
dashboard_acheteur_departement_code=["75"],
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
assert params == [2025, "%123%"]
def test_titulaire_id_present_skips_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_id="999",
dashboard_titulaire_categorie="PME",
dashboard_titulaire_departement_code=["35"],
)
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
assert params == [2025, "%999%"]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 5 new tests FAIL.
- [ ] **Step 3: Refactor the helper with conditional skip**
Replace the two simple `if dashboard_acheteur_id` / `if dashboard_titulaire_id` blocks added in Task 3 with the nested form:
```python
if dashboard_acheteur_id:
clauses.append('"acheteur_id" LIKE ?')
params.append(f"%{dashboard_acheteur_id}%")
else:
if dashboard_acheteur_categorie:
clauses.append('"acheteur_categorie" = ?')
params.append(dashboard_acheteur_categorie)
if dashboard_acheteur_departement_code:
placeholders = ", ".join(["?"] * len(dashboard_acheteur_departement_code))
clauses.append(f'"acheteur_departement_code" IN ({placeholders})')
params.extend(dashboard_acheteur_departement_code)
if dashboard_titulaire_id:
clauses.append('"titulaire_id" LIKE ?')
params.append(f"%{dashboard_titulaire_id}%")
else:
if dashboard_titulaire_categorie:
clauses.append('"titulaire_categorie" = ?')
params.append(dashboard_titulaire_categorie)
if dashboard_titulaire_departement_code:
placeholders = ", ".join(
["?"] * len(dashboard_titulaire_departement_code)
)
clauses.append(f'"titulaire_departement_code" IN ({placeholders})')
params.extend(dashboard_titulaire_departement_code)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (15 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): IN départements et skip conditionnel par ID (#72)"
```
---
## Task 5: Filtre liste (techniques, considérations sociales/environnementales)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_marche_techniques_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_techniques=["Enchère", "Accord-cadre"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Enchère", "Accord-cadre"]]
def test_considerations_sociales_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_considerations_sociales=["Clause sociale"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Clause sociale"]]
def test_considerations_environnementales_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_considerations_environnementales=["Clause env."],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Clause env."]]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 3 new tests FAIL.
- [ ] **Step 3: Extend the helper**
Insert the following block in `dashboard_filters_to_sql`, **after** the `dashboard_marche_sous_traitance_declaree` block and **before** `return " AND ".join(clauses), params`:
```python
if dashboard_marche_techniques:
clauses.append(
"list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_techniques))
if dashboard_marche_considerations_sociales:
clauses.append(
"list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_considerations_sociales))
if dashboard_marche_considerations_environnementales:
clauses.append(
"list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
)
params.append(list(dashboard_marche_considerations_environnementales))
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (18 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres liste via list_has_any (#72)"
```
---
## Task 6: Filtres montant min/max (incluant 0)
**Files:**
- Modify: `tests/test_dashboard_filters_to_sql.py`
- Modify: `src/utils/table_sql.py`
- [ ] **Step 1: Add failing tests**
Append to `tests/test_dashboard_filters_to_sql.py`:
```python
def test_montant_min_only():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=1000,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
assert params == [2025, 1000]
def test_montant_max_only():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_max=500,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?'
assert params == [2025, 500]
def test_montant_zero_is_a_valid_lower_bound():
# 0 est falsy mais reste un filtre valide (distinct de None)
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=0,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
assert params == [2025, 0]
def test_montant_min_and_max_combined():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=100,
dashboard_montant_max=1000,
)
assert where_sql == (
'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?'
)
assert params == [2025, 100, 1000]
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: 4 new tests FAIL.
- [ ] **Step 3: Extend the helper**
Insert at the very end of `dashboard_filters_to_sql`, **just before** `return " AND ".join(clauses), params`:
```python
if dashboard_montant_min is not None:
clauses.append('"montant" >= ?')
params.append(dashboard_montant_min)
if dashboard_montant_max is not None:
clauses.append('"montant" <= ?')
params.append(dashboard_montant_max)
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (22 tests total).
- [ ] **Step 5: Commit**
```bash
rtk pre-commit run --files tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git add tests/test_dashboard_filters_to_sql.py src/utils/table_sql.py
rtk git commit -m "feat(observatoire): filtres montant min/max (#72)"
```
---
## Task 7: Réécriture de `prepare_dashboard_data`
**Files:**
- Modify: `src/utils/data.py`
- Modify: `tests/test_main.py` (supprimer `test_010_observatoire_montant_filter`)
- [ ] **Step 1: Remove the obsolete Polars-based test**
Delete the function `test_010_observatoire_montant_filter` from `tests/test_main.py` (lines ~218-256). La couverture du filtre montant est déjà assurée par les tests unitaires `test_montant_*` de la Task 6.
- [ ] **Step 2: Rewrite `prepare_dashboard_data`**
Replace the entire `prepare_dashboard_data` function in `src/utils/data.py` (lines ~86-194) with:
```python
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
"""Exécute la requête DuckDB filtrée pour le tableau de bord.
Retourne une pl.DataFrame matérialisée uniquement pour le sous-ensemble
correspondant aux filtres. Les appelants qui ont besoin d'une LazyFrame
appellent `.lazy()` sur le résultat.
"""
from src.utils.table_sql import dashboard_filters_to_sql
where_sql, params = dashboard_filters_to_sql(**filter_params)
return query_marches(where_sql=where_sql, params=params)
```
Update the import at the top of `src/utils/data.py`:
```python
from src.db import get_cursor, query_marches, schema
```
Remove the now-unused import in `src/utils/data.py`:
```python
from datetime import datetime, timedelta
```
(Si `datetime` n'est plus référencé dans `data.py` hors de `prepare_dashboard_data`, sinon garder.)
**Vérification rapide à effectuer avant de supprimer `datetime`/`timedelta`** :
```bash
rtk grep -n "datetime\|timedelta" src/utils/data.py
```
Si d'autres occurrences existent, conserver les imports.
- [ ] **Step 3: Run the full test suite**
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py tests/test_main.py -v -k "not selenium and not dash_duo"`
Ou, si filter n'est pas pratique :
Run: `rtk pytest tests/test_dashboard_filters_to_sql.py -v`
Expected: PASS (22 tests).
- [ ] **Step 4: Commit**
```bash
rtk pre-commit run --files src/utils/data.py tests/test_main.py
rtk git add src/utils/data.py tests/test_main.py
rtk git commit -m "refactor(observatoire): prepare_dashboard_data utilise DuckDB (#72)"
```
---
## Task 8: Adaptation des 3 appelants dans `observatoire.py`
**Files:**
- Modify: `src/pages/observatoire.py`
- [ ] **Step 1: Update `_compute_dashboard_children`**
Remplacer dans `src/pages/observatoire.py` (autour des lignes 660-670) :
```python
@cache.memoize()
def _compute_dashboard_children(filter_params_normalized: tuple):
logger.debug("Cache miss — computing dashboard")
filter_params = {
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
}
lff: pl.LazyFrame = query_marches().lazy()
lff = prepare_dashboard_data(lff=lff, **filter_params)
dff = lff.collect(engine="streaming")
```
Par :
```python
@cache.memoize()
def _compute_dashboard_children(filter_params_normalized: tuple):
logger.debug("Cache miss — computing dashboard")
filter_params = {
k: (list(v) if isinstance(v, tuple) else v) for k, v in filter_params_normalized
}
dff = prepare_dashboard_data(**filter_params)
lff = dff.lazy()
```
Le reste de la fonction (à partir de `df_per_uid = ...`) est inchangé.
- [ ] **Step 2: Update `download_observatoire`**
Remplacer dans `src/pages/observatoire.py` (autour des lignes 789-800) :
```python
def download_observatoire(_n_clicks, filter_params, hidden_columns):
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
if hidden_columns:
lff = lff.drop(hidden_columns)
def to_bytes(buffer):
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
```
Par :
```python
def download_observatoire(_n_clicks, filter_params, hidden_columns):
dff = prepare_dashboard_data(**(filter_params or {}))
if hidden_columns:
dff = dff.drop(hidden_columns)
def to_bytes(buffer):
dff.write_excel(buffer, worksheet="DECP")
date = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
return dcc.send_bytes(to_bytes, filename=f"decp_observatoire_{date}.xlsx")
```
- [ ] **Step 3: Update `populate_preview_table`**
Remplacer dans `src/pages/observatoire.py` (autour des lignes 879-892) :
```python
if not is_open:
return (no_update,) * 9
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
return prepare_table_data(
lff,
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"observatoire-preview",
)
```
Par :
```python
if not is_open:
return (no_update,) * 9
dff = prepare_dashboard_data(**(filter_params or {}))
return prepare_table_data(
dff.lazy(),
data_timestamp,
filter_query,
page_current,
page_size,
sort_by,
"observatoire-preview",
)
```
- [ ] **Step 4: Remove unused `query_marches` import**
Dans `src/pages/observatoire.py`, ligne ~19 :
```python
from src.db import query_marches, schema
```
Devient :
```python
from src.db import schema
```
Vérifier avant de committer :
```bash
rtk grep -n "query_marches" src/pages/observatoire.py
```
Expected: aucun résultat (ou uniquement des commentaires).
- [ ] **Step 5: Smoke test**
Démarrer l'app et naviguer sur `/observatoire`, vérifier à la main que :
- Les cartes s'affichent.
- Un filtre année se propage.
- Un filtre acheteur par SIRET partiel fonctionne.
- Un filtre département (multi-valeur) fonctionne.
- Un filtre montant_min fonctionne.
- Le bouton « Télécharger au format Excel » génère un fichier non vide.
- Le bouton « Voir les données » ouvre l'offcanvas et peuple la table.
Run: `python run.py`
Expected: app démarre sans erreur ; les filtres se comportent comme avant.
- [ ] **Step 6: Commit**
```bash
rtk pre-commit run --files src/pages/observatoire.py
rtk git add src/pages/observatoire.py
rtk git commit -m "refactor(observatoire): appelants utilisent la nouvelle signature (#72)"
```
---
## Task 9: Test d'intégration — `prepare_dashboard_data` sur `tests/test.parquet`
**Files:**
- Create: `tests/test_prepare_dashboard_data.py`
- [ ] **Step 1: Write the failing test**
Le but : vérifier que la fonction s'exécute réellement contre DuckDB, retourne une `pl.DataFrame`, et applique bien les filtres simples. `conftest.py` construit `tests/test.parquet` avec un jeu de données d'une ligne : acheteur_id `123`, acheteur_departement_code `75`, dateNotification `2025-01-01`, montant `10`.
Create `tests/test_prepare_dashboard_data.py`:
```python
import polars as pl
def test_returns_dataframe_with_year_filter():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(dashboard_year="2025")
assert isinstance(dff, pl.DataFrame)
assert dff.height == 1
def test_year_mismatch_returns_empty():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(dashboard_year="2024")
assert isinstance(dff, pl.DataFrame)
assert dff.height == 0
def test_acheteur_id_partial_match():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(
dashboard_year="2025",
dashboard_acheteur_id="12",
)
assert dff.height == 1
def test_departement_in_clause():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(
dashboard_year="2025",
dashboard_acheteur_departement_code=["75", "92"],
)
assert dff.height == 1
def test_montant_min_above_value_excludes_row():
from src.utils.data import prepare_dashboard_data
dff = prepare_dashboard_data(
dashboard_year="2025",
dashboard_montant_min=1000,
)
assert dff.height == 0
```
- [ ] **Step 2: Run the test**
Run: `rtk pytest tests/test_prepare_dashboard_data.py -v`
Expected: PASS (5 tests).
- [ ] **Step 3: Commit**
```bash
rtk pre-commit run --files tests/test_prepare_dashboard_data.py
rtk git add tests/test_prepare_dashboard_data.py
rtk git commit -m "test(observatoire): intégration DuckDB pour prepare_dashboard_data (#72)"
```
---
## Task 10: Vérification finale
**Files:** (aucune modification)
- [ ] **Step 1: Run the full test suite**
Run: `rtk pytest -v`
Expected: tous les tests unitaires passent. Les tests Selenium peuvent échouer si Chrome n'est pas disponible — ce n'est pas bloquant s'ils étaient déjà rouges avant.
- [ ] **Step 2: Check for leftover references**
Run: `rtk grep -rn "prepare_dashboard_data(lff" src/ tests/`
Expected: aucun résultat (plus d'appels avec l'ancienne signature).
Run: `rtk grep -rn "query_marches().lazy()" src/`
Expected: aucun résultat (ou uniquement dans `src/utils/table.py:prepare_table_data` pour le fallback).
- [ ] **Step 3: Confirm `datetime`/`timedelta` in data.py if needed**
Run: `rtk grep -n "datetime\|timedelta" src/utils/data.py`
Si aucune occurrence hors imports, vérifier que les imports inutiles ont bien été retirés dans Task 7.
- [ ] **Step 4: Manual timing sanity check (optionnel)**
Si possible, comparer informellement le temps de `_compute_dashboard_children` sur un filtre sélectif (ex. un département) avant/après. Pas de benchmark formel attendu.
- [ ] **Step 5: Push (manuel, à l'initiative de l'utilisateur)**
Conformément aux consignes projet, ne jamais `git push`. Laisser l'utilisateur pousser la branche `feature/72_observatoire_duckdb_filters` et ouvrir la PR.
@@ -0,0 +1,108 @@
# Plan: Ajouter des cartes de localisation aux pages acheteur et titulaire
## Date: 2026-04-28
## Statut: Approuvé
## Objectif: Ajouter des cartes interactives montrant la localisation des organisations sur les pages acheteur et titulaire
## Contexte
- Les pages acheteur et titulaire ont déjà des placeholders pour les cartes (`acheteur_map` et `titulaire_map`)
- La fonction `point_on_map()` existe déjà dans `src/figures.py` mais utilise un centrage fixe sur la France
- Les données de localisation proviennent de l'API Annuaire des Entreprises
- Les codes départementaux sont disponibles et plus fiables que les coordonnées pour la détection de région
## Exigences
### 1. Carte interactive
- **Localisation**: Colonne de droite dans la section d'informations sur l'organisation
- **Taille**: 400px de largeur × 300px de hauteur (fixe)
- **Contenu**: Carte centrée sur la France ou le département d'outre-mer approprié avec un point rouge à l'emplacement de l'organisation
- **Niveau de zoom**: Approprié pour montrer l'Hexagone ou le département d'outre-mer spécifique
- **Style**: Fond de carte clair avec point rouge visible
- **Interactivité**: Carte zoomable et déplaçable (pas de configuration statique)
### 2. Sources de données
- Utiliser les colonnes `acheteur_latitude` et `acheteur_longitude` pour les pages acheteur
- Utiliser les colonnes `titulaire_latitude` et `titulaire_longitude` pour les pages titulaire
- Utiliser les codes départementaux (`acheteur_departement_code`, `titulaire_departement_code`) pour la détection de région
- Solution de repli: Si les coordonnées ou codes départementaux sont manquants ou invalides, afficher une div vide
### 3. Détection de région
- **Départements métropolitains**: Codes à 2 caractères (ex: "75" pour Paris) → Carte Hexagone
- **Départements d'outre-mer**:
- "971" → Guadeloupe
- "972" → Martinique
- "973" → Guyane
- "974" → La Réunion
- "976" → Mayotte
- **Code département manquant**: Retourner une div vide (pas de détection basée sur les coordonnées)
### 4. Gestion des erreurs
- Coordonnées invalides → div vide
- Code département manquant → div vide
- Échec de l'API Annuaire → div vide (comportement existant)
- Format de code département invalide → div vide
## Implémentation
### Fichiers à modifier
#### 1. `src/figures.py` - Améliorer la fonction `point_on_map()`
**Ligne 178-209**: Remplacer la fonction existante par une version améliorée avec:
- Détection de région basée sur les codes départementaux
- Configuration de carte interactive (zoomable)
- Point plus grand (size=15)
- Commentaires en français
#### 2. `src/pages/acheteur.py` - Mettre à jour le callback
**Ligne 249-297**: Modifier `update_acheteur_infos()` pour:
- Extraire le code département du code postal
- Passer le code département à `point_on_map()`
- Ajouter des commentaires en français
#### 3. `src/pages/titulaire.py` - Mettre à jour le callback
**Ligne 259-297**: Modifier `update_titulaire_infos()` pour:
- Extraire le code département du code postal
- Passer le code département à `point_on_map()`
- Ajouter des commentaires en français
## Plan de Test
### Cas de test prioritaires
1. **Organisation métropolitaine**: Code département "75" (Paris) → Carte Hexagone
2. **Organisation à La Réunion**: Code département "974" → Carte centrée sur La Réunion
3. **Code département manquant**: Retourne une div vide
4. **Coordonnées invalides**: Retourne une div vide
5. **Interactivité**: Vérifier zoom et déplacement
### Critères d'acceptation
- [ ] Cartes fonctionnelles avec codes départementaux valides
- [ ] Div vide pour codes manquants/invalides
- [ ] Cartes correctement centrées et zoomées
- [ ] Interactivité (zoom et déplacement)
- [ ] Point de localisation visible (size=15)
## Approbation
Plan approuvé avec spécifications:
- Réutiliser et améliorer `point_on_map`
- Retourner div vide sans code département
- Point légèrement plus grand
- Cartes zoomables
- Utiliser codes départementaux pour détection de région
- Commentaires en français
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,699 @@
# Page `/etapes` — « Quelles données pour quelles étapes et quels seuils ? » — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Créer une page statique `/etapes` qui affiche un graphique HTML/CSS montrant quelles données (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP) sont publiées à chaque étape de la passation d'un marché public et à partir de quel seuil réglementaire.
**Architecture:** Une nouvelle page Dash auto-enregistrée (`src/pages/etapes.py`) qui expose un `layout` composé uniquement de `html.Div`/`dcc.Markdown` (aucun callback, aucune donnée dynamique). La page rend **deux représentations des mêmes données** basculées par media query : sur desktop/tablette, un graphique en grille CSS (1 colonne de libellés + 5 colonnes de seuils) où chaque publication est une barre positionnée en pourcentage ; sur mobile portrait (< 768 px), une liste verticale par étape. Le style vit dans `src/assets/css/style.css` (auto-chargé par Dash). L'URL est ajoutée au sitemap mais pas à la navbar.
**Tech Stack:** Python 3, Dash 3.4 (pages API), CSS (grille + positionnement absolu), Flask (route sitemap existante).
---
## Contexte pour l'engineer (à lire avant de commencer)
- decp.info est une app Dash multi-pages. Chaque page est un module dans `src/pages/` qui appelle `register_page(...)` au niveau du module et expose une variable `layout`. Dash découvre ces pages automatiquement grâce à `use_pages=True` (voir `src/app.py:30`).
- **Imports** : toujours importer les modules de l'app avec le préfixe `src.` (ex. `from src.utils.seo import META_CONTENT`).
- La navbar (`src/app.py:170-182`) est construite à partir d'une **liste blanche de noms** : `["Recherche", "À propos", "Tableau", "Observatoire"]`. Une page dont le `name` n'est pas dans cette liste **n'apparaît pas** dans la navbar. On ne touche donc PAS à la navbar.
- Le sitemap (`src/app.py:70-86`) est une **liste d'URLs codée en dur**. Il faut y ajouter `/etapes`.
- Le CSS personnalisé est dans `src/assets/css/style.css` (Dash charge automatiquement tout ce qui est dans `src/assets/`). On y ajoute les règles du graphique.
- **Pré-requis commit** : ce dépôt utilise `pre-commit` (prettier, ruff). Les hooks ne tournent que si le virtualenv est activé. Avant chaque `git commit`, faire `source .venv/bin/activate` dans la même commande shell. Prettier peut reformater les fichiers Markdown/CSS : si un commit échoue parce que des fichiers ont été modifiés par un hook, refaire `git add` puis `git commit`.
- **Référence visuelle** : la maquette validée est `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`. Le code HTML/CSS ci-dessous en est la transposition.
- Ce projet n'a **pas** de test automatisé pour cette page (contenu 100 % statique). La vérification est manuelle via `python run.py`. Les tâches ci-dessous remplacent donc le cycle TDD par des vérifications de rendu explicites.
---
## File Structure
- **Create** `src/pages/etapes.py` — la page : `register_page(...)` + `layout`. Contient `build_chart()` (graphique grille desktop), `build_mobile()` (liste verticale mobile, alimentée par la structure `STAGES_MOBILE`) et `build_legend()`, pour garder le `layout` lisible. Responsabilité unique : décrire la page `/etapes`.
- **Modify** `src/app.py` — ajouter `"/etapes"` à la liste `pages` de la fonction `sitemap()`.
- **Modify** `src/assets/css/style.css` — ajouter un bloc de règles préfixées `.etapes-*` : graphique en grille, liste mobile `.etapes-m-*`, et media query de bascule à 768 px.
---
## Task 1 : Squelette de la page `/etapes`
**Files:**
- Create: `src/pages/etapes.py`
- [ ] **Step 1: Créer le fichier avec l'enregistrement de page et un layout minimal**
Créer `src/pages/etapes.py` avec exactement ce contenu (le graphique sera ajouté en Task 2) :
```python
from dash import dcc, html, register_page
from src.utils.seo import META_CONTENT
NAME = "Quelles données pour quelles étapes et quels seuils ?"
register_page(
__name__,
path="/etapes",
title=f"{NAME} | decp.info",
name="Étapes et données",
description=(
"À chaque étape d'un marché public (programmation, publicité, "
"attribution), quelles données sont publiées et à partir de quel "
"seuil : DECP, BOAMP, JOUE, journaux d'annonces légales, Approch."
),
image_url=META_CONTENT["image_url"],
)
layout = html.Div(
className="container",
children=[
html.H2(NAME),
dcc.Markdown(
"Un marché public passe par plusieurs étapes. À chacune, des "
"données peuvent être publiées — selon le montant du marché et "
"des obligations réglementaires. Ce graphique situe les "
"principales publications de données par **étape** (de haut en "
"bas) et par **seuil** (de gauche à droite, en euros hors taxes)."
),
# Le graphique sera inséré ici en Task 2
dcc.Markdown(
"**À noter :** l'axe horizontal n'est pas linéaire — les seuils "
"sont espacés régulièrement pour rester lisibles. Les étapes "
"*Contrat* et *Paiement* n'ont aujourd'hui aucune donnée publiée "
"en open data.",
className="etapes-note",
),
],
)
```
- [ ] **Step 2: Lancer l'app et vérifier que la page se charge**
Run :
```bash
source .venv/bin/activate && python run.py
```
Puis ouvrir `http://127.0.0.1:8050/etapes` dans un navigateur.
Expected : la page affiche le titre « Quelles données pour quelles étapes et quels seuils ? », le paragraphe d'intro et la note, avec le bandeau de navigation en haut. Aucune erreur dans la console du serveur. Arrêter le serveur (Ctrl-C).
- [ ] **Step 3: Vérifier l'absence dans la navbar**
Sur n'importe quelle page, vérifier visuellement que « Étapes et données » **n'apparaît pas** dans la barre de navigation (la liste blanche `src/app.py:181` ne la contient pas).
Expected : la navbar montre uniquement Recherche / Tableau / Observatoire / À propos.
- [ ] **Step 4: Commit**
```bash
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes"
```
(Si le commit échoue car un hook a reformaté le fichier : refaire `git add src/pages/etapes.py && git commit -m "feat(etapes): squelette de la page /etapes"`.)
---
## Task 2 : Le graphique HTML/CSS
**Files:**
- Modify: `src/pages/etapes.py`
Le graphique est une grille de 6 colonnes : 1 colonne de libellés d'étape (150 px) + 5 colonnes de seuils égales. L'en-tête X et chaque ligne d'étape occupent les colonnes 2 → 6 (`grid-column: 2 / -1`). À l'intérieur d'une ligne, les barres sont positionnées en `position:absolute` avec `left`/`right` en pourcentage, où chaque segment de seuil = 20 % de la largeur :
- Segment 1 (0 € → 40 k€) : 0 % 20 %
- Segment 2 (40 k€ → 90 k€) : 20 % 40 %
- Segment 3 (90 k€ → 140/216 k€) : 40 % 60 %
- Segment 4 (140/216 k€ → 5,404 M€) : 60 % 80 %
- Segment 5 (≥ 5,404 M€) : 80 % 100 %
Une barre qui « commence à 40 k€ et va jusqu'à l'infini » s'écrit donc `left:20%; right:2%` (les `2%` de marge évitent de coller au bord). Une barre qui remplit la case 90 k€ → seuil formalisé s'écrit `left:40%; right:40%`.
- [ ] **Step 1: Ajouter la fonction `build_chart()` au-dessus de `layout`**
Dans `src/pages/etapes.py`, insérer cette fonction entre le bloc `register_page(...)` et la définition de `layout` :
```python
def _lane(*bars):
"""Une ligne d'étape : fond segmenté en 5 + barres positionnées."""
return html.Div(
className="etapes-lane",
children=[
html.Div(
className="etapes-segs",
children=[html.Div() for _ in range(5)],
),
*bars,
],
)
def _bar(label, color, style):
base = {"backgroundColor": color}
base.update(style)
return html.Div(label, className="etapes-bar", style=base)
def build_chart():
return html.Div(
className="etapes-chart-scroll",
children=html.Div(
className="etapes-chart",
children=[
# En-tête : coin vide + 5 marqueurs de seuils
html.Div(className="etapes-corner"),
html.Div(
className="etapes-xhead",
children=[
html.Div("0 €", className="etapes-xcell"),
html.Div(
[html.Strong("40 000 €"), "seuil DECP"],
className="etapes-xcell",
),
html.Div(
[html.Strong("90 000 €"), "publicité"],
className="etapes-xcell",
),
html.Div(
[html.Strong("140 k€ / 216 k€"), "seuils formalisés (UE)"],
className="etapes-xcell",
),
html.Div(
[html.Strong("5,404 M€"), "travaux (UE)"],
className="etapes-xcell",
),
],
),
# Programmation
html.Div("Programmation", className="etapes-stage"),
_lane(
_bar(
"Approch — sourcing / préinformation (non réglementaire)",
"#7c5cff",
{"left": "2%", "right": "2%"},
),
),
# Publicité (appel d'offres)
html.Div(
["Publicité ", html.Small("(appel d'offres)")],
className="etapes-stage",
),
_lane(
_bar(
"Journaux d'annonces légales",
"#f79009",
{"left": "40%", "right": "40%", "top": "6px", "height": "20px"},
),
_bar(
"BOAMP",
"#1570ef",
{"left": "40%", "right": "2%", "top": "28px", "height": "20px"},
),
_bar(
"JOUE — avis de marché",
"#0e9384",
{"left": "60%", "right": "2%", "top": "6px", "height": "20px"},
),
),
# Attribution
html.Div("Attribution", className="etapes-stage"),
_lane(
_bar(
"DECP — données essentielles",
"#12b76a",
{"left": "20%", "right": "2%", "top": "6px", "height": "20px"},
),
_bar(
"JOUE — avis d'attribution",
"#0e9384",
{"left": "60%", "right": "2%", "top": "28px", "height": "20px"},
),
),
# Contrat (vide)
html.Div("Contrat", className="etapes-stage"),
html.Div(
"— aucune donnée publiée aujourd'hui —",
className="etapes-lane etapes-empty",
),
# Paiement (vide)
html.Div("Paiement", className="etapes-stage"),
html.Div(
"— aucune donnée publiée aujourd'hui —",
className="etapes-lane etapes-empty",
),
],
),
)
def build_legend():
items = [
("Approch", "#7c5cff"),
("Journaux d'annonces légales", "#f79009"),
("BOAMP", "#1570ef"),
("JOUE", "#0e9384"),
("DECP", "#12b76a"),
]
return html.Div(
className="etapes-legend",
children=[
html.Span(
[
html.I(style={"backgroundColor": color}),
label,
]
)
for label, color in items
],
)
```
- [ ] **Step 2: Insérer le graphique et la légende dans `layout`**
Dans `layout`, remplacer la ligne de commentaire `# Le graphique sera inséré ici en Task 2` par :
```python
build_chart(),
build_legend(),
```
- [ ] **Step 3: Lancer l'app et vérifier le rendu**
Run :
```bash
source .venv/bin/activate && python run.py
```
Ouvrir `http://127.0.0.1:8050/etapes`.
Expected (comparer à la maquette `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`) :
- En-tête X : `0 € · 40 000 € (seuil DECP) · 90 000 € (publicité) · 140 k€/216 k€ (seuils formalisés UE) · 5,404 M€ (travaux UE)`.
- Lignes de haut en bas : Programmation (barre Approch pleine largeur), Publicité (Journaux d'annonces légales + BOAMP + JOUE), Attribution (DECP + JOUE), Contrat (vide), Paiement (vide).
- La barre « Journaux d'annonces légales » occupe la case 90 k€ → seuil formalisé ; DECP démarre à 40 k€ ; JOUE et BOAMP démarrent aux bons segments.
- La légende sous le graphique liste les 5 publications avec leurs couleurs.
À ce stade le style brut (couleurs des barres) doit déjà être visible car appliqué inline ; la mise en page de la grille sera finalisée en Task 4. Si la grille n'est pas encore correcte (colonnes non alignées), c'est attendu — continuer. Arrêter le serveur.
- [ ] **Step 4: Commit**
```bash
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): graphique données par étape et par seuil"
```
(Si échec dû à un hook : refaire `git add` puis `git commit`.)
---
## Task 3 : Vue mobile (liste verticale par étape)
**Files:**
- Modify: `src/pages/etapes.py`
Sur écran portrait étroit, le graphique en grille n'est pas lisible (vue d'ensemble perdue). On ajoute une **liste verticale par étape** qui décrit les mêmes données en texte. Le basculement entre les deux rendus se fera en CSS (Task 4). Pour éviter la duplication, les publications de chaque étape sont décrites dans une structure de données Python consommée par le rendu mobile.
- [ ] **Step 1: Ajouter la structure de données et `build_mobile()`**
Dans `src/pages/etapes.py`, ajouter ce bloc juste avant la fonction `build_legend()` :
```python
# Données par étape, partagées par la vue mobile.
# Chaque item : (libellé, couleur, plage de seuils en texte).
STAGES_MOBILE = [
(
"Programmation",
[
("Approch", "#7c5cff", "tous montants — publication non réglementaire"),
],
),
(
"Publicité (appel d'offres)",
[
("Journaux d'annonces légales", "#f79009", "de 90 000 € au seuil formalisé"),
("BOAMP", "#1570ef", "à partir de 90 000 €"),
(
"JOUE — avis de marché",
"#0e9384",
"à partir des seuils formalisés (140 k€ / 216 k€)",
),
],
),
(
"Attribution",
[
("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"),
("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"),
],
),
("Contrat", []),
("Paiement", []),
]
def build_mobile():
blocks = []
for stage, items in STAGES_MOBILE:
if items:
children = [
html.Div(
[
html.I(style={"backgroundColor": color}),
html.Span(label, className="etapes-m-label"),
html.Span(seuil, className="etapes-m-seuil"),
],
className="etapes-m-item",
)
for label, color, seuil in items
]
else:
children = [
html.Div(
"aucune donnée publiée aujourd'hui",
className="etapes-m-item etapes-m-empty",
)
]
blocks.append(
html.Div(
[html.H4(stage, className="etapes-m-stage"), *children],
className="etapes-m-block",
)
)
return html.Div(blocks, className="etapes-mobile")
```
- [ ] **Step 2: Insérer `build_mobile()` dans `layout`**
Dans `layout`, la ligne `build_chart(),` (insérée en Task 2) est suivie de `build_mobile(),`, soit :
```python
build_chart(),
build_mobile(),
build_legend(),
```
- [ ] **Step 3: Lancer l'app et vérifier (rendu brut, avant CSS de bascule)**
Run :
```bash
source .venv/bin/activate && python run.py
```
Ouvrir `http://127.0.0.1:8050/etapes`. À ce stade les deux rendus s'affichent l'un sous l'autre (la bascule CSS arrive en Task 4) : sous le graphique, la liste affiche Programmation (Approch…), Publicité (3 publications), Attribution (2 publications), puis Contrat et Paiement avec « aucune donnée publiée aujourd'hui ». C'est attendu. Arrêter le serveur.
- [ ] **Step 4: Commit**
```bash
source .venv/bin/activate && git add src/pages/etapes.py && git commit -m "feat(etapes): vue mobile liste par étape"
```
(Si échec dû à un hook : refaire `git add` puis `git commit`.)
---
## Task 4 : CSS du graphique + bascule mobile
**Files:**
- Modify: `src/assets/css/style.css`
- [ ] **Step 1: Ajouter le bloc CSS à la fin de `src/assets/css/style.css`**
Ajouter à la fin du fichier :
```css
/* ===== Page /etapes : graphique données par étape et par seuil ===== */
.etapes-chart-scroll {
overflow-x: auto;
margin: 1rem 0;
}
.etapes-chart {
min-width: 720px;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 8px;
overflow: hidden;
font-size: 13px;
display: grid;
grid-template-columns: 150px repeat(5, 1fr);
}
.etapes-corner {
border-bottom: 2px solid #344054;
}
.etapes-xhead {
grid-column: 2 / -1;
display: grid;
grid-template-columns: repeat(5, 1fr);
border-bottom: 2px solid #344054;
}
.etapes-xcell {
text-align: center;
padding: 6px 2px;
font-size: 11px;
color: #475467;
border-left: 1px dashed #d0d5dd;
}
.etapes-xcell strong {
display: block;
color: #101828;
font-size: 12px;
}
.etapes-stage {
padding: 14px 10px;
font-weight: 600;
color: #101828;
border-bottom: 1px solid #eaecf0;
display: flex;
align-items: center;
}
.etapes-stage small {
font-weight: 400;
color: #667085;
}
.etapes-lane {
grid-column: 2 / -1;
position: relative;
border-bottom: 1px solid #eaecf0;
min-height: 52px;
}
.etapes-segs {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
}
.etapes-segs > div {
border-left: 1px dashed #eaecf0;
}
.etapes-bar {
position: absolute;
top: 9px;
height: 32px;
border-radius: 6px;
color: #fff;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
padding: 0 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
white-space: nowrap;
overflow: hidden;
}
.etapes-empty {
color: #98a2b3;
font-style: italic;
padding: 14px;
display: flex;
align-items: center;
}
.etapes-legend {
margin-top: 14px;
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
}
.etapes-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.etapes-legend i {
width: 14px;
height: 14px;
border-radius: 3px;
display: inline-block;
}
.etapes-note {
margin-top: 8px;
color: #667085;
font-size: 13px;
}
/* --- Vue mobile (liste par étape) : masquée par défaut --- */
.etapes-mobile {
display: none;
margin: 1rem 0;
}
.etapes-m-block {
border: 1px solid #d0d5dd;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.etapes-m-stage {
margin: 0;
padding: 10px 12px;
background: #f9fafb;
border-bottom: 1px solid #eaecf0;
font-size: 15px;
color: #101828;
}
.etapes-m-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #f2f4f7;
font-size: 13px;
}
.etapes-m-item:last-child {
border-bottom: none;
}
.etapes-m-item i {
width: 12px;
height: 12px;
border-radius: 3px;
flex: 0 0 auto;
position: relative;
top: 2px;
}
.etapes-m-label {
font-weight: 600;
color: #101828;
}
.etapes-m-seuil {
color: #667085;
}
.etapes-m-empty {
color: #98a2b3;
font-style: italic;
}
/* --- Bascule desktop / mobile au point de rupture 768 px --- */
@media (max-width: 768px) {
.etapes-chart-scroll,
.etapes-legend {
display: none;
}
.etapes-mobile {
display: block;
}
}
```
- [ ] **Step 2: Lancer l'app et vérifier le rendu final**
Run :
```bash
source .venv/bin/activate && python run.py
```
Ouvrir `http://127.0.0.1:8050/etapes` en grand écran (≥ 768 px).
Expected : le graphique est identique à la maquette v3 — colonnes alignées, en-tête X avec ligne de séparation foncée, barres colorées bien positionnées dans chaque segment, lignes Contrat/Paiement grisées en italique, légende sous le graphique. La **liste mobile est masquée** (le graphique seul est visible).
- [ ] **Step 3: Vérifier la bascule responsive**
Dans le navigateur, ouvrir les devtools et passer en mode mobile portrait (largeur < 768 px, ex. iPhone SE 375 px). Tester aussi une largeur intermédiaire (~800 px).
Expected :
- À largeur intermédiaire (~800 px, ≥ 768) : le **graphique** s'affiche, défilable horizontalement (`overflow-x:auto` + `min-width:720px`), barres non écrasées ; liste mobile masquée.
- En portrait (< 768 px) : le graphique **et la légende disparaissent**, remplacés par la **liste verticale par étape** — chaque étape est un bloc avec son titre, et chaque publication a sa pastille de couleur, son nom et sa plage de seuils en texte. Aucun défilement horizontal nécessaire. Contrat/Paiement affichent « aucune donnée publiée aujourd'hui » en italique.
Arrêter le serveur.
- [ ] **Step 4: Commit**
```bash
source .venv/bin/activate && git add src/assets/css/style.css && git commit -m "feat(etapes): styles du graphique étapes/seuils"
```
(Si échec dû à un hook prettier : refaire `git add` puis `git commit`.)
---
## Task 5 : Référencement de la page dans le sitemap
**Files:**
- Modify: `src/app.py` (fonction `sitemap()`, ~ligne 73)
- [ ] **Step 1: Ajouter `/etapes` à la liste des URLs du sitemap**
Dans `src/app.py`, dans la fonction `sitemap()`, modifier la liste `pages` :
```python
pages = [
"/",
"/observatoire",
"/tableau",
"/a-propos",
"/etapes",
]
```
- [ ] **Step 2: Vérifier le sitemap**
Run :
```bash
source .venv/bin/activate && python run.py
```
Ouvrir `http://127.0.0.1:8050/sitemap.xml`.
Expected : le XML contient désormais une entrée `<loc>https://decp.info/etapes</loc>`. Arrêter le serveur.
- [ ] **Step 3: Commit**
```bash
source .venv/bin/activate && git add src/app.py && git commit -m "feat(etapes): référencement de /etapes dans le sitemap"
```
---
## Vérification finale (checklist de la spec)
- [ ] `/etapes` affiche le graphique fidèle à la maquette v3, avec le bandeau de navigation global en haut.
- [ ] La page est **absente** de la navbar.
- [ ] `/sitemap.xml` **contient** `/etapes`.
- [ ] Sur fenêtre intermédiaire (≥ 768 px), le graphique défile horizontalement sans s'écraser.
- [ ] Sur écran portrait étroit (< 768 px), le graphique est masqué et remplacé par la liste verticale par étape, lisible sans défilement horizontal.
- [ ] Titre H2 de la page = « Quelles données pour quelles étapes et quels seuils ? ».
- [ ] `name` de la page = « Étapes et données ».
@@ -0,0 +1,665 @@
# Bootstrap résilient des données et du schéma — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rendre le démarrage de decp.info résilient aux ressources externes KO (parquet, schéma, stats), pour que l'API partagée ne tombe plus à cause d'un déploiement défaillant.
**Architecture:** Trois invariants. (1) Le DuckDB est réutilisé si la reconstruction échoue. (2) Le schéma suit la chaîne `URL → cache → RuntimeError`, le dernier schéma distant fonctionnel étant persisté localement. (3) Les chargements de pages au boot ne lèvent jamais sur une ressource externe KO. Une tâche préalable répare le baseline de tests laissé rouge par le merge de `main`.
**Tech Stack:** Python, Polars, DuckDB, httpx, flask-caching, pytest (+ monkeypatch).
**Spec de référence:** `docs/superpowers/specs/2026-06-12-bootstrap-resilient-donnees-schema-design.md`
---
## Structure des fichiers
| Fichier | Responsabilité | Action |
| ------------------------------------- | ---------------------------------------- | ----------------------------- |
| `tests/test_db.py` | Tests bootstrap DuckDB | Modifier (réparer + ajouter) |
| `tests/conftest.py` | Setup déterministe des tests | Modifier (seed schéma) |
| `tests/schema.fixture.json` | Schéma complet figé pour tests (offline) | Créer (commité) |
| `tests/test_schema.py` | Tests résolution schéma | Créer |
| `tests/test_page_loads.py` | Tests chargements best-effort (C/D) | Créer |
| `src/utils/data.py` | Résolution schéma | Modifier |
| `src/db.py` | Bootstrap DuckDB | Modifier (`_ensure_database`) |
| `src/utils/__init__.py` | Helper date MAJ best-effort | Modifier (ajout fonction) |
| `src/pages/tableau.py` | Date MAJ au niveau module | Modifier |
| `src/figures.py` | `get_sources_tables` | Modifier |
| `.template.env`, `.env`, `.gitignore` | Config | Modifier |
---
## Task 1 : Réparer le baseline de tests `test_db.py`
Le merge de `main` a changé `build_database(db_path)` (1 arg, parquet lu via env) et memoïsé `get_last_modified` (besoin du contexte d'app). Trois corrections pour repartir au vert. **Aucune logique applicative ne change ici.**
**Files:**
- Modify: `tests/test_db.py`
- [ ] **Step 1 : Corriger la fixture `built_db` (signature `build_database`)**
Dans `tests/test_db.py`, remplacer :
```python
from src.db import build_database
build_database(db_path, parquet_path)
return db_path
```
par :
```python
from src.db import build_database
build_database(db_path)
return db_path
```
(L'env `DATA_FILE_PARQUET_PATH` est déjà posé juste au-dessus dans la fixture.)
- [ ] **Step 2 : Corriger `test_concurrent_build_serialized`**
Ajouter `monkeypatch` à la signature et poser l'env du parquet ; corriger l'appel `build_database`.
Remplacer la ligne de signature :
```python
def test_concurrent_build_serialized(tmp_path):
```
par :
```python
def test_concurrent_build_serialized(tmp_path, monkeypatch):
```
Juste après `df.write_parquet(parquet_path)`, ajouter :
```python
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
```
Et dans `worker()`, remplacer :
```python
if db.should_rebuild(db_path, parquet_path):
db.build_database(db_path, parquet_path)
```
par :
```python
if db.should_rebuild(db_path, parquet_path):
db.build_database(db_path)
```
- [ ] **Step 3 : Corriger les 2 tests prod de `should_rebuild` (memoïsation)**
`should_rebuild` non-dev appelle `get_last_modified`, memoïsé et inutilisable hors contexte d'app en test. On le monkeypatche pour tester la logique de comparaison de dates.
Dans `test_should_rebuild_prod_when_parquet_newer`, juste avant l'`assert`, ajouter :
```python
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
```
Dans `test_should_not_rebuild_prod_when_parquet_older`, juste avant l'`assert`, ajouter la même ligne :
```python
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
```
- [ ] **Step 4 : Lancer les tests, vérifier le vert**
Run: `rtk proxy python -m pytest tests/test_db.py -q`
Expected: tous PASS (plus aucun `TypeError`/`AttributeError`).
- [ ] **Step 5 : Commit**
```bash
git add tests/test_db.py
git commit -m "test: réparer le baseline test_db cassé par le merge (#78)"
```
---
## Task 2 : Schéma résilient — chaîne `URL → cache → RuntimeError`
Réécrire `get_data_schema` avec helpers robustes et persistance du dernier schéma distant fonctionnel. Supprimer `DATA_SCHEMA_LOCAL` au profit de `DATA_SCHEMA_CACHE`.
**Files:**
- Create: `tests/schema.fixture.json`, `tests/test_schema.py`
- Modify: `tests/conftest.py`, `src/utils/data.py`, `.template.env`, `.env`, `.gitignore`
- [ ] **Step 1 : Créer le fixture schéma complet (offline, déterministe)**
Run:
```bash
cp ../decp-processing/dist/schema.json tests/schema.fixture.json
test -s tests/schema.fixture.json && python -c "import json;assert 'fields' in json.load(open('tests/schema.fixture.json'))" && echo OK
```
Expected: `OK` (le fixture contient bien une clé `fields`).
- [ ] **Step 2 : Rendre la résolution du schéma déterministe en test (conftest)**
Dans `tests/conftest.py`, ajouter `import json` en tête (avec les autres imports) puis, au niveau module **avant** toute logique existante (juste après la ligne `_DB_PATH = Path(...)`), ajouter :
```python
# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un
# fixture commité et on désactive la récupération distante.
_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json"))
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE)
os.environ.pop("DATA_SCHEMA_PATH", None)
```
- [ ] **Step 3 : Écrire les tests schéma (échouent d'abord)**
Créer `tests/test_schema.py` :
```python
import json
import httpx
import pytest
from src.utils import data as data_mod
VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]}
class FakeResp:
def __init__(self, payload, ok=True, bad_json=False):
self._payload = payload
self._ok = ok
self._bad_json = bad_json
def raise_for_status(self):
if not self._ok:
raise httpx.HTTPError("boom")
return self
def json(self):
if self._bad_json:
raise json.JSONDecodeError("bad", "", 0)
return self._payload
def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
result = data_mod.get_data_schema()
assert "uid" in result
assert json.loads(cache.read_text())["fields"][0]["name"] == "uid"
def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False))
assert "uid" in data_mod.get_data_schema()
def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1}))
assert "uid" in data_mod.get_data_schema()
def test_no_url_uses_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
assert "uid" in data_mod.get_data_schema()
def test_no_source_raises(tmp_path, monkeypatch):
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json"))
with pytest.raises(RuntimeError):
data_mod.get_data_schema()
def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch):
# parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé
cache = tmp_path / "nodir" / "schema.cache.json"
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
assert "uid" in data_mod.get_data_schema()
```
- [ ] **Step 4 : Lancer les tests, vérifier l'échec**
Run: `rtk proxy python -m pytest tests/test_schema.py -q`
Expected: FAIL (les helpers/comportements n'existent pas encore ; `get_data_schema` actuel plante différemment).
- [ ] **Step 5 : Réécrire `get_data_schema` + helpers**
Dans `src/utils/data.py`, remplacer entièrement la fonction `get_data_schema` (actuellement lignes ~68-95) par :
```python
def _validate_schema(raw) -> dict | None:
if isinstance(raw, dict) and isinstance(raw.get("fields"), list) and raw["fields"]:
return raw
return None
def _fetch_remote_schema(url: str | None) -> dict | None:
if not url:
return None
try:
raw = get(url, follow_redirects=True).raise_for_status().json()
except (httpx.HTTPError, json.JSONDecodeError) as e:
logger.error(f"Schéma distant indisponible ({url}) : {e}")
return None
return _validate_schema(raw)
def _load_schema_file(path: str) -> dict | None:
if not path or not os.path.exists(path):
return None
try:
with open(path) as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.error(f"Schéma local illisible ({path}) : {e}")
return None
return _validate_schema(raw)
def _persist_schema_cache(raw: dict, path: str) -> None:
if not path:
return
try:
tmp = f"{path}.tmp"
with open(tmp, "w") as f:
json.dump(raw, f)
os.replace(tmp, path)
except OSError as e:
logger.warning(f"Écriture du cache schéma échouée ({path}) : {e}")
def get_data_schema() -> dict:
cache_path = os.getenv("DATA_SCHEMA_CACHE", "./schema.cache.json")
raw = _fetch_remote_schema(os.getenv("DATA_SCHEMA_PATH"))
if raw is not None:
_persist_schema_cache(raw, cache_path)
else:
raw = _load_schema_file(cache_path)
if raw is None:
raise RuntimeError("Aucun schéma disponible (ni distant ni cache).")
return OrderedDict((c["name"], c) for c in raw["fields"])
```
Vérifier que les imports en tête de `src/utils/data.py` couvrent : `json`, `os`, `OrderedDict`, `httpx`, `get` (déjà présents : `from httpx import HTTPError, get`). `HTTPError` peut devenir inutilisé — voir Step 7.
- [ ] **Step 6 : Lancer les tests, vérifier le vert**
Run: `rtk proxy python -m pytest tests/test_schema.py -q`
Expected: 6 PASS.
- [ ] **Step 7 : Nettoyer imports + config + gitignore**
Dans `src/utils/data.py`, si `HTTPError` n'est plus utilisé, remplacer `from httpx import HTTPError, get` par `from httpx import get` (garder `import httpx`). Vérifier avec :
Run: `rtk proxy python -m ruff check src/utils/data.py`
Expected: pas d'erreur F401.
Dans `.gitignore`, ajouter sous la ligne `**/decp.duckdb` :
```
**/schema.cache.json
```
Dans `.template.env`, supprimer la ligne `DATA_SCHEMA_PATH_LOCAL=...` et ajouter :
```
DATA_SCHEMA_CACHE=./schema.cache.json
```
Dans `.env` (local, non versionné), supprimer la ligne `DATA_SCHEMA_LOCAL=...` et ajouter `DATA_SCHEMA_CACHE=./schema.cache.json`.
- [ ] **Step 8 : Commit**
```bash
git add tests/schema.fixture.json tests/test_schema.py tests/conftest.py src/utils/data.py .template.env .gitignore
git commit -m "feat: schéma résilient URL→cache + suppression DATA_SCHEMA_LOCAL (#78)"
```
---
## Task 3 : Bootstrap DuckDB résilient
Ajouter le garde-fou `try/except` dans `_ensure_database` : réutiliser le DuckDB existant si la reconstruction échoue ; ne lever qu'en cold start.
**Files:**
- Modify: `src/db.py:117-128` (`_ensure_database`)
- Test: `tests/test_db.py`
- [ ] **Step 1 : Écrire les tests (échouent d'abord)**
Ajouter à la fin de `tests/test_db.py` :
```python
def _raise(*args, **kwargs):
raise RuntimeError("boom")
def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb"
dbf.write_bytes(b"existing")
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
monkeypatch.setattr(db, "should_rebuild", _raise)
result = db._ensure_database() # ne doit pas lever
assert result == dbf
assert dbf.read_bytes() == b"existing"
def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb"
dbf.write_bytes(b"existing")
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
monkeypatch.setattr(db, "build_database", _raise)
db._ensure_database() # ne doit pas lever
assert dbf.read_bytes() == b"existing"
def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb" # n'existe pas
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
monkeypatch.setattr(db, "build_database", _raise)
with pytest.raises(RuntimeError):
db._ensure_database()
def test_ensure_database_builds_when_needed(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb"
dbf.write_bytes(b"old")
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x")
called = {}
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p))
db._ensure_database()
assert called.get("built") == dbf
```
Ajouter `import pytest` en tête de `tests/test_db.py` s'il n'y est pas déjà (il y est).
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run: `rtk proxy python -m pytest tests/test_db.py -q -k ensure_database`
Expected: FAIL (le `try/except` n'existe pas ; les exceptions remontent).
- [ ] **Step 3 : Implémenter le garde-fou**
Dans `src/db.py`, remplacer `_ensure_database` (lignes ~117-128) par :
```python
def _ensure_database() -> Path:
db_path = Path(os.getenv("DUCKDB_PATH", "./decp.duckdb"))
parquet_path = os.getenv("DATA_FILE_PARQUET_PATH", "")
lock_path = db_path.with_suffix(".duckdb.lock")
db_exists = db_path.exists()
with open(lock_path, "w") as lock_fd:
fcntl.flock(lock_fd, fcntl.LOCK_EX)
try:
if should_rebuild(db_path, parquet_path):
build_database(db_path)
else:
logger.debug("Base de données déjà disponible et à jour.")
except Exception as e:
if db_exists:
logger.error(
f"Bootstrap données KO ({e}). "
f"Réutilisation du DuckDB existant : {db_path}"
)
else:
logger.critical("Aucune base DuckDB et reconstruction impossible.")
raise
return db_path
```
- [ ] **Step 4 : Lancer, vérifier le vert**
Run: `rtk proxy python -m pytest tests/test_db.py -q`
Expected: tous PASS (anciens + 4 nouveaux).
- [ ] **Step 5 : Commit**
```bash
git add src/db.py tests/test_db.py
git commit -m "feat: réutiliser le DuckDB existant si le bootstrap échoue (#78)"
```
---
## Task 4 : Chargements de pages best-effort (C + D)
`tableau.py` (date MAJ via `get_last_modified`) et `a-propos.py` (stats via `get_sources_tables`) chargent au boot et peuvent tuer le démarrage. On les rend best-effort.
**Files:**
- Create: `tests/test_page_loads.py`
- Modify: `src/utils/__init__.py`, `src/pages/tableau.py`, `src/figures.py`
- [ ] **Step 1 : Écrire les tests (échouent d'abord)**
Créer `tests/test_page_loads.py` :
```python
import os
def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch):
import src.utils as u
from src.utils import get_data_update_timestamp
def boom(*a, **k):
raise RuntimeError("net down")
monkeypatch.setattr(u, "get_last_modified", boom)
fb = tmp_path / "decp.duckdb"
fb.write_bytes(b"x")
assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb))
def test_update_timestamp_none_when_all_fail(monkeypatch):
import src.utils as u
from src.utils import get_data_update_timestamp
def boom(*a, **k):
raise RuntimeError("net down")
monkeypatch.setattr(u, "get_last_modified", boom)
assert get_data_update_timestamp("http://x", None) is None
def test_update_timestamp_nominal(monkeypatch):
import src.utils as u
from src.utils import get_data_update_timestamp
monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0)
assert get_data_update_timestamp("http://x", None) == 123.0
def test_sources_tables_none_path():
from src.figures import get_sources_tables
div = get_sources_tables(None)
assert "indisponible" in str(div.children).lower()
def test_sources_tables_missing_file():
from src.figures import get_sources_tables
div = get_sources_tables("/does/not/exist.csv")
assert "indisponible" in str(div.children).lower()
def test_sources_tables_valid_csv(tmp_path):
from dash import dash_table
from src.figures import get_sources_tables
csv = tmp_path / "s.csv"
csv.write_text(
"nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n"
"Source A,Org A,5,2,XA,http://a,1\n"
)
div = get_sources_tables(str(csv))
assert isinstance(div.children, dash_table.DataTable)
```
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run: `rtk proxy python -m pytest tests/test_page_loads.py -q`
Expected: FAIL (`get_data_update_timestamp` n'existe pas ; `get_sources_tables(None)` plante).
- [ ] **Step 3 : Ajouter `get_data_update_timestamp` dans `src/utils/__init__.py`**
À la fin de `src/utils/__init__.py`, ajouter :
```python
def get_data_update_timestamp(
parquet_path: str, fallback_path: str | None = None
) -> float | None:
"""Date de MAJ des données, best-effort, sans jamais lever (usage au boot)."""
try:
return get_last_modified(parquet_path)
except Exception as e:
logger.warning(f"Date de mise à jour des données indisponible ({e})")
if fallback_path:
try:
return os.path.getmtime(fallback_path)
except OSError:
pass
return None
```
(`os` et `logger` sont déjà disponibles dans ce module.)
- [ ] **Step 4 : Utiliser le helper dans `tableau.py`**
Dans `src/pages/tableau.py`, remplacer l'import ligne 24 :
```python
from src.utils import get_last_modified, logger
```
par :
```python
from src.utils import get_data_update_timestamp, logger
```
Et remplacer les lignes 36-38 :
```python
update_date_timestamp = get_last_modified(os.getenv("DATA_FILE_PARQUET_PATH", ""))
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
```
par :
```python
update_date_timestamp = get_data_update_timestamp(
os.getenv("DATA_FILE_PARQUET_PATH", ""),
os.getenv("DUCKDB_PATH", "./decp.duckdb"),
)
if update_date_timestamp is not None:
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
else:
update_date = "date inconnue"
update_date_iso = ""
```
- [ ] **Step 5 : Élargir `get_sources_tables` dans `src/figures.py`**
Dans `src/figures.py` (`get_sources_tables`, ~lignes 122-125), remplacer :
```python
try:
dff = pl.read_csv(source_path)
except (URLError, HTTPError):
return html.Div("Erreur de connexion")
```
par :
```python
try:
if not source_path:
raise ValueError("SOURCE_STATS_CSV_PATH non défini")
dff = pl.read_csv(source_path)
except Exception as e:
logger.warning(f"Sources de données indisponibles ({e})")
return html.Div("Sources de données momentanément indisponibles.")
```
Si `URLError`/`HTTPError` (import ligne 3 `from urllib.error import HTTPError, URLError`) ne sont plus utilisés ailleurs dans le fichier, supprimer cet import.
Run: `rtk proxy python -m ruff check src/figures.py`
Expected: pas d'erreur F401.
- [ ] **Step 6 : Lancer, vérifier le vert**
Run: `rtk proxy python -m pytest tests/test_page_loads.py -q`
Expected: 6 PASS.
- [ ] **Step 7 : Smoke test — l'import des modules modifiés ne casse pas**
Run: `rtk proxy python -c "import src.figures, src.pages.tableau; print('import OK')"`
Expected: `import OK`.
(NB : `a-propos.py` a un tiret, non importable par nom — son correctif `get_sources_tables` est couvert par les tests unitaires du Step 6 et la page sera validée par la suite Selenium au Step 8.)
- [ ] **Step 8 : Suite complète**
Run: `rtk proxy python -m pytest -q`
Expected: vert (hors tests Selenium nécessitant Chrome, à lancer si l'environnement le permet).
- [ ] **Step 9 : Commit**
```bash
git add tests/test_page_loads.py src/utils/__init__.py src/pages/tableau.py src/figures.py
git commit -m "feat: chargements de pages best-effort au boot (tableau, sources) (#78)"
```
---
## Notes d'exécution
- **Dev hors-ligne 1er run :** « cache seul » supprime le fallback in-repo. Au tout premier démarrage sur une machine sans `schema.cache.json` ni réseau, le boot lèvera `RuntimeError`. En conditions normales (URL OK une fois, ou cache déjà présent) c'est transparent. Les tests sont rendus déterministes via `tests/schema.fixture.json` (Task 2).
- **CHANGELOG :** penser à ajouter une entrée (résilience bootstrap données/schéma) avant de finaliser la PR #78, si le projet le tient à jour.
- **`get_last_modified` reste memoïsé** : non modifié ici ; le cache FileSystem est vidé à chaque boot (`rmtree` dans `app.py`), donc pas de last-modified périmé entre déploiements.
@@ -0,0 +1,725 @@
# Parité API decp.info / tabular-api — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Atteindre la parité de l'API `/api/v1/data` de decp.info avec `tabular-api` (data.gouv.fr) sur les opérateurs manquants, et documenter chaque mot-clé dans le Swagger UI.
**Architecture:** Le parsing des filtres reste dans `src/api/filters.py` (`build_where`). On y ajoute l'opérateur `differs` et une nouvelle fonction `parse_aggregators` qui détecte les drapeaux d'agrégation et produit des fragments SQL. `src/db.py` gagne `aggregate_marches`. `src/api/routes.py` oriente vers le chemin agrégation ou le chemin normal, renomme le param réservé `count``count_results`, et documente les opérateurs.
**Tech Stack:** Flask + flask-smorest, DuckDB, Polars, pytest.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `src.api.filters`).
- Valeurs de filtre liées par paramètres `?` (jamais interpolées). Noms de colonnes validés contre `schema` avant interpolation.
- Spec de référence : `docs/superpowers/specs/2026-06-22-api-parite-datagouv-design.md`.
- Périmètre : `count_results`, `differs`, agrégation (`groupby`/`count`/`sum`/`avg`/`min`/`max`), doc. **Hors périmètre : `or`.**
- Lancer les tests via `rtk pytest` ; l'environnement de test définit `DEVELOPMENT=true` et `DATA_FILE_PARQUET_PATH=tests/test.parquet`.
- `tests/test.parquet` contient notamment les colonnes : `uid`, `montant` (Int64), `acheteur_departement_code`, `objet`, `dateNotification` (Date).
---
### Task 1 : Renommer le param réservé `count` → `count_results`
**Files:**
- Modify: `src/api/filters.py` (constante `RESERVED_PARAMS`)
- Modify: `src/api/routes.py` (lecture du param + doc swagger)
- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py`
**Interfaces:**
- Consumes: rien.
- Produces: le param réservé s'appelle désormais `count_results` ; `count` n'est plus réservé (libéré pour l'agrégation en Task 3).
- [ ] **Step 1 : Mettre à jour les tests existants**
Dans `tests/api/test_filters.py`, remplacer `("count", "false")` par `("count_results", "false")` dans `test_reserved_params_are_ignored` :
```python
def test_reserved_params_are_ignored():
where, params, order = build_where(
[
("page", "2"),
("page_size", "100"),
("columns", "uid"),
("count_results", "false"),
("uid__exact", "z"),
],
SCHEMA,
)
assert where == '"uid" = ?'
assert params == ["z"]
```
Dans `tests/api/test_endpoints_data.py`, renommer le test et l'URL :
```python
def test_data_count_results_false_omits_total(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?count_results=false", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert "total" not in body["meta"]
```
- [ ] **Step 2 : Lancer les tests, vérifier l'échec**
Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_count_results_false_omits_total tests/api/test_filters.py::test_reserved_params_are_ignored -v`
Expected: FAIL (`count_results` encore traité comme filtre inconnu → 400 / `FilterError`).
- [ ] **Step 3 : Modifier `RESERVED_PARAMS`**
Dans `src/api/filters.py` :
```python
RESERVED_PARAMS = {"page", "page_size", "columns", "count_results"}
```
- [ ] **Step 4 : Modifier la lecture dans la route**
Dans `src/api/routes.py`, fonction `data()`, remplacer :
```python
count = request.args.get("count", "true").lower() != "false"
```
par :
```python
count_results = request.args.get("count_results", "true").lower() != "false"
```
et plus bas remplacer `if count else None` par `if count_results else None`.
- [ ] **Step 5 : Mettre à jour la doc swagger du param**
Dans `src/api/routes.py`, dans `@bp.doc(parameters=[...])`, remplacer le bloc du paramètre `count` par :
```python
{
"name": "count_results",
"in": "query",
"schema": {"type": "string", "enum": ["true", "false"], "default": "true"},
"description": "Inclure le total (`COUNT(*)`) dans `meta`. Mettre `false` pour accélérer la requête. Ignoré en mode agrégation.",
},
```
Et dans le docstring de `data()`, remplacer la mention `count (true|false ...)` par `count_results (true|false ; mettre false pour économiser le COUNT(*))`.
- [ ] **Step 6 : Lancer les tests, vérifier le succès**
Run: `rtk pytest tests/api/test_endpoints_data.py tests/api/test_filters.py -v`
Expected: PASS (tous).
- [ ] **Step 7 : Commit**
```bash
git add src/api/filters.py src/api/routes.py tests/api/test_filters.py tests/api/test_endpoints_data.py
git commit -m "feat(api): renomme le param réservé count en count_results (#78)"
```
---
### Task 2 : Opérateur de filtre `differs`
**Files:**
- Modify: `src/api/filters.py` (`OPERATORS`, `build_where`)
- Test: `tests/api/test_filters.py`, `tests/api/test_endpoints_data.py`
**Interfaces:**
- Consumes: `build_where(args, schema) -> (where_sql, params, order_sql)` (existant).
- Produces: `col__differs=val` → fragment SQL `"col" IS DISTINCT FROM ?`.
- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)**
Dans `tests/api/test_filters.py` :
```python
def test_differs_filter():
where, params, _ = build_where([("uid__differs", "abc")], SCHEMA)
assert where == '"uid" IS DISTINCT FROM ?'
assert params == ["abc"]
def test_differs_filter_on_int():
where, params, _ = build_where([("annee__differs", "2020")], SCHEMA)
assert where == '"annee" IS DISTINCT FROM ?'
assert params == [2020]
```
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run: `rtk pytest tests/api/test_filters.py::test_differs_filter tests/api/test_filters.py::test_differs_filter_on_int -v`
Expected: FAIL (`FilterError: Opérateur inconnu : __differs`).
- [ ] **Step 3 : Ajouter `differs` à `OPERATORS`**
Dans `src/api/filters.py`, ajouter `"differs",` dans le set `OPERATORS` (après `"notcontains",`).
- [ ] **Step 4 : Ajouter la branche dans `build_where`**
Dans `src/api/filters.py`, dans `build_where`, après le bloc `elif op == "notcontains":` (lignes ~132-134), ajouter :
```python
elif op == "differs":
where_parts.append(f'"{col}" IS DISTINCT FROM ?')
params.append(v)
```
- [ ] **Step 5 : Lancer les tests unitaires, vérifier le succès**
Run: `rtk pytest tests/api/test_filters.py -k differs -v`
Expected: PASS.
- [ ] **Step 6 : Ajouter un test d'endpoint**
Dans `tests/api/test_endpoints_data.py` :
```python
def test_data_differs_excludes_value(api_client, valid_token_header):
client, _ = api_client
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
uid = base["data"][0]["uid"]
resp = client.get(f"/api/v1/data?uid__differs={uid}", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert all(row["uid"] != uid for row in body["data"])
```
- [ ] **Step 7 : Lancer, vérifier le succès**
Run: `rtk pytest tests/api/test_endpoints_data.py::test_data_differs_excludes_value -v`
Expected: PASS.
- [ ] **Step 8 : Commit**
```bash
git add src/api/filters.py tests/api/test_filters.py tests/api/test_endpoints_data.py
git commit -m "feat(api): ajoute l'opérateur de filtre differs (IS DISTINCT FROM) (#78)"
```
---
### Task 3 : Parsing des agrégateurs (`parse_aggregators`)
**Files:**
- Modify: `src/api/filters.py` (constantes `AGGREGATORS`/`AGG_SQL`, dataclass `AggregationSpec`, fonction `parse_aggregators`, skip dans `build_where`)
- Test: `tests/api/test_filters.py`
**Interfaces:**
- Consumes: `_split_key`, `FilterError` (existants).
- Produces:
- `AGGREGATORS: set[str]` = `{"groupby","count","sum","avg","min","max"}`.
- `@dataclass class AggregationSpec: select_sql: str; group_by_sql: str | None`.
- `parse_aggregators(args: list[tuple[str,str]], schema: pl.Schema) -> AggregationSpec | None``None` si aucun agrégateur.
- `build_where` ignore désormais les clés dont l'opérateur ∈ `AGGREGATORS`.
- [ ] **Step 1 : Écrire les tests unitaires (échec attendu)**
Dans `tests/api/test_filters.py`, ajouter l'import et les tests :
```python
from src.api.filters import AggregationSpec, parse_aggregators
def test_parse_aggregators_none_when_absent():
assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None
def test_parse_aggregators_groupby_and_count():
spec = parse_aggregators(
[("annee__groupby", ""), ("uid__count", "")], SCHEMA
)
assert isinstance(spec, AggregationSpec)
assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"'
assert spec.group_by_sql == '"annee"'
def test_parse_aggregators_multiple_aggregates():
spec = parse_aggregators(
[
("annee__groupby", ""),
("montant__sum", ""),
("montant__avg", ""),
("montant__min", ""),
("montant__max", ""),
],
SCHEMA,
)
assert spec.select_sql == (
'"annee", SUM("montant") AS "montant__sum", '
'AVG("montant") AS "montant__avg", '
'MIN("montant") AS "montant__min", '
'MAX("montant") AS "montant__max"'
)
assert spec.group_by_sql == '"annee"'
def test_parse_aggregators_global_without_groupby():
spec = parse_aggregators([("uid__count", "")], SCHEMA)
assert spec.select_sql == 'COUNT("uid") AS "uid__count"'
assert spec.group_by_sql is None
def test_parse_aggregators_unknown_column_raises():
with pytest.raises(FilterError):
parse_aggregators([("nope__count", "")], SCHEMA)
def test_build_where_ignores_aggregator_flags():
where, params, _ = build_where(
[("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")],
SCHEMA,
)
assert where == '"montant" >= ?'
assert params == [100.0]
```
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run: `rtk pytest tests/api/test_filters.py -k aggregator -v`
Expected: FAIL (`ImportError: cannot import name 'parse_aggregators'`).
- [ ] **Step 3 : Ajouter constantes + dataclass + fonction**
Dans `src/api/filters.py`, ajouter en haut l'import `from dataclasses import dataclass` (après les imports existants), puis après `RESERVED_PARAMS` :
```python
AGGREGATORS = {"groupby", "count", "sum", "avg", "min", "max"}
AGG_SQL = {"count": "COUNT", "sum": "SUM", "avg": "AVG", "min": "MIN", "max": "MAX"}
@dataclass
class AggregationSpec:
select_sql: str
group_by_sql: str | None
def parse_aggregators(
args: list[tuple[str, str]], schema: pl.Schema
) -> AggregationSpec | None:
"""Détecte les drapeaux d'agrégation (`col__groupby`, `col__count`, ...).
Retourne None si aucun agrégateur. Sinon, construit les fragments SQL
`select_sql` et `group_by_sql` (noms de colonnes validés contre le schéma).
"""
group_cols: list[str] = []
aggregates: list[tuple[str, str]] = [] # (operator, column)
has_agg = False
for key, _ in args:
parsed = _split_key(key)
if not parsed:
continue
col, op = parsed
if op not in AGGREGATORS:
continue
has_agg = True
if col not in schema:
raise FilterError(f"Colonne inconnue : {col!r}", field=key)
if op == "groupby":
group_cols.append(col)
else:
aggregates.append((op, col))
if not has_agg:
return None
select_parts = [f'"{c}"' for c in group_cols]
for op, col in aggregates:
select_parts.append(f'{AGG_SQL[op]}("{col}") AS "{col}__{op}"')
group_by_sql = ", ".join(f'"{c}"' for c in group_cols) if group_cols else None
return AggregationSpec(select_sql=", ".join(select_parts), group_by_sql=group_by_sql)
```
- [ ] **Step 4 : Faire ignorer les drapeaux d'agrégation par `build_where`**
Dans `src/api/filters.py`, dans `build_where`, juste après `col, op = parsed` (et avant `if op not in OPERATORS:`), ajouter :
```python
if op in AGGREGATORS:
continue
```
- [ ] **Step 5 : Lancer les tests, vérifier le succès**
Run: `rtk pytest tests/api/test_filters.py -v`
Expected: PASS (tous, anciens et nouveaux).
- [ ] **Step 6 : Commit**
```bash
git add src/api/filters.py tests/api/test_filters.py
git commit -m "feat(api): parsing des opérateurs d'agrégation (groupby/count/sum/avg/min/max) (#78)"
```
---
### Task 4 : `aggregate_marches` dans la couche DB
**Files:**
- Modify: `src/db.py` (nouvelle fonction `aggregate_marches`)
- Test: `tests/api/test_db_aggregate.py` (créer)
**Interfaces:**
- Consumes: `get_cursor()`, `logger` (existants dans `src/db.py`).
- Produces: `aggregate_marches(select_sql: str, where_sql: str = "TRUE", params: tuple | list = (), group_by: str | None = None, limit: int | None = None, offset: int | None = None) -> pl.DataFrame`.
- [ ] **Step 1 : Écrire le test (échec attendu)**
Créer `tests/api/test_db_aggregate.py` :
```python
import polars as pl
from src.db import aggregate_marches
def test_aggregate_groupby_count_returns_named_columns():
df = aggregate_marches(
select_sql='"acheteur_departement_code", COUNT("uid") AS "uid__count"',
group_by='"acheteur_departement_code"',
)
assert isinstance(df, pl.DataFrame)
assert df.columns == ["acheteur_departement_code", "uid__count"]
assert df["uid__count"].sum() > 0
def test_aggregate_global_without_groupby_returns_one_row():
df = aggregate_marches(select_sql='COUNT("uid") AS "uid__count"')
assert df.height == 1
assert df["uid__count"][0] > 0
```
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run: `rtk pytest tests/api/test_db_aggregate.py -v`
Expected: FAIL (`ImportError: cannot import name 'aggregate_marches'`).
- [ ] **Step 3 : Implémenter `aggregate_marches`**
Dans `src/db.py`, après `count_marches` (vers la ligne 186), ajouter :
```python
def aggregate_marches(
select_sql: str,
where_sql: str = "TRUE",
params: tuple | list = (),
group_by: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> pl.DataFrame:
"""SELECT agrégé paramétré contre la table decp.
`select_sql` et `group_by` sont des fragments SQL construits depuis des
noms de colonnes validés (jamais de valeur utilisateur libre). Les
valeurs de filtre passent par le binding `?` via `params`.
"""
sql = f"SELECT {select_sql} FROM decp WHERE {where_sql}"
if group_by:
sql += f" GROUP BY {group_by}"
if limit is not None:
sql += f" LIMIT {int(limit)}"
if offset is not None:
sql += f" OFFSET {int(offset)}"
logger.debug("aggregate_marches: " + sql.replace("?", "{}").format(*params))
return get_cursor().execute(sql, list(params)).pl()
```
- [ ] **Step 4 : Lancer, vérifier le succès**
Run: `rtk pytest tests/api/test_db_aggregate.py -v`
Expected: PASS.
- [ ] **Step 5 : Commit**
```bash
git add src/db.py tests/api/test_db_aggregate.py
git commit -m "feat(db): aggregate_marches pour les requêtes GROUP BY (#78)"
```
---
### Task 5 : Orchestration du mode agrégation dans la route
**Files:**
- Modify: `src/api/routes.py` (fonction `data()`)
- Test: `tests/api/test_endpoints_data.py`
**Interfaces:**
- Consumes: `parse_aggregators` (Task 3), `aggregate_marches` (Task 4), `build_where` (existant), `AggregationSpec`.
- Produces: l'endpoint `/api/v1/data` renvoie des lignes agrégées quand un opérateur d'agrégation est présent ; `meta` sans `total` ; `columns` + agrégation → 400.
- [ ] **Step 1 : Écrire les tests d'endpoint (échec attendu)**
Dans `tests/api/test_endpoints_data.py` :
```python
def test_data_aggregation_groupby_count(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?acheteur_departement_code__groupby&uid__count",
headers=valid_token_header,
)
assert resp.status_code == 200
body = resp.get_json()
assert body["data"], "agrégation vide ?"
for row in body["data"]:
assert set(row.keys()) == {"acheteur_departement_code", "uid__count"}
assert "total" not in body["meta"]
def test_data_aggregation_global_count(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?uid__count", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert len(body["data"]) == 1
assert "uid__count" in body["data"][0]
def test_data_aggregation_with_filter(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?acheteur_departement_code__groupby&uid__count&montant__greater=0",
headers=valid_token_header,
)
assert resp.status_code == 200
def test_data_aggregation_with_columns_returns_400(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?uid__count&columns=uid",
headers=valid_token_header,
)
assert resp.status_code == 400
```
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run: `rtk pytest tests/api/test_endpoints_data.py -k aggregation -v`
Expected: FAIL (les drapeaux d'agrégation sont ignorés → réponse non agrégée, clés inattendues / pas de 400).
- [ ] **Step 3 : Mettre à jour les imports de la route**
Dans `src/api/routes.py`, remplacer la ligne d'import des filtres :
```python
from src.api.filters import FilterError, build_where
```
par :
```python
from src.api.filters import FilterError, build_where, parse_aggregators
from src.db import aggregate_marches
```
(et conserver l'import existant `from src.db import count_marches, query_marches`).
- [ ] **Step 4 : Brancher le chemin agrégation dans `data()`**
Dans `src/api/routes.py`, fonction `data()`, remplacer le bloc qui va de `try:` (parsing `build_where`) jusqu'au `return {...}` final par :
```python
args = list(request.args.items(multi=True))
try:
agg = parse_aggregators(args, duckdb_schema)
where_sql, params, order_sql = build_where(args, duckdb_schema)
except FilterError as e:
abort(400, message=str(e), errors={"field": e.field})
if agg is not None:
if columns:
abort(
400,
message="`columns` ne peut pas être combiné avec une agrégation",
)
df = aggregate_marches(
select_sql=agg.select_sql,
where_sql=where_sql,
params=params,
group_by=agg.group_by_sql,
limit=page_size,
offset=(page - 1) * page_size,
)
df_ready = df.with_columns(cs.temporal().cast(pl.String))
return {
"data": df_ready.to_dicts(),
"meta": {"page": page, "page_size": page_size},
"links": _build_links(page, page_size, None),
}
df = query_marches(
where_sql=where_sql,
params=params,
columns=columns,
order_by=order_sql,
limit=page_size,
offset=(page - 1) * page_size,
)
# JSON ne sérialise pas date/datetime nativement → cast en string ISO
df_ready = df.with_columns(cs.temporal().cast(pl.String))
total = count_marches(where_sql, params) if count_results else None
meta = {"page": page, "page_size": page_size}
if total is not None:
meta["total"] = total
return {
"data": df_ready.to_dicts(),
"meta": meta,
"links": _build_links(page, page_size, total),
}
```
(Note : `count_results` provient de la Task 1 ; `columns`, `page`, `page_size` sont déjà calculés plus haut dans la fonction.)
- [ ] **Step 5 : Lancer les tests d'endpoint, vérifier le succès**
Run: `rtk pytest tests/api/test_endpoints_data.py -v`
Expected: PASS (tous).
- [ ] **Step 6 : Commit**
```bash
git add src/api/routes.py tests/api/test_endpoints_data.py
git commit -m "feat(api): mode agrégation sur /data (groupby + agrégats) (#78)"
```
---
### Task 6 : Documentation Swagger des mots-clés
**Files:**
- Modify: `src/api/routes.py` (bloc `@bp.doc` du param dynamique + docstring de `data()`)
- Test: `tests/api/test_openapi_doc.py` (créer)
**Interfaces:**
- Consumes: l'OpenAPI généré, servi sur `/api/v1/openapi.json`.
- Produces: la description du paramètre `<colonne>__<opérateur>` liste tous les opérateurs (filtres + agrégation) avec une définition d'une ligne chacun, et décrit le mode agrégation.
- [ ] **Step 1 : Écrire le test (échec attendu)**
Créer `tests/api/test_openapi_doc.py` :
```python
def test_openapi_documents_new_keywords(api_client):
client, _ = api_client
resp = client.get("/api/v1/openapi.json")
assert resp.status_code == 200
raw = resp.get_data(as_text=True)
for keyword in ["count_results", "differs", "groupby", "__sum", "__avg", "__min", "__max"]:
assert keyword in raw, f"{keyword} absent de la doc OpenAPI"
```
- [ ] **Step 2 : Lancer, vérifier l'échec**
Run: `rtk pytest tests/api/test_openapi_doc.py -v`
Expected: FAIL (`differs`, `groupby`, etc. absents de la description).
- [ ] **Step 3 : Étoffer la description du paramètre dynamique**
Dans `src/api/routes.py`, remplacer la `description` du paramètre `<colonne>__<opérateur>` par :
```python
"description": (
"Filtre ou agrégation dynamique : `<colonne>__<opérateur>` "
"(voir les colonnes via `/schema`).\n\n"
"**Filtres** (`<colonne>__<op>=<valeur>`) :\n"
"- `exact` : égal à la valeur\n"
"- `differs` : différent de la valeur (null-safe, `IS DISTINCT FROM`)\n"
"- `contains` / `notcontains` : contient / ne contient pas (LIKE)\n"
"- `in` / `notin` : dans / hors d'une liste séparée par des virgules\n"
"- `less` / `greater` : ≤ / ≥\n"
"- `strictly_less` / `strictly_greater` : < / >\n"
"- `isnull` / `isnotnull` : valeur nulle / non nulle (sans valeur)\n"
"- `sort` : tri, valeur `asc` ou `desc`\n\n"
"**Agrégation** (drapeaux sans valeur, ex. `acheteur_departement_code__groupby&montant__sum`) :\n"
"- `groupby` : regroupe sur la colonne\n"
"- `count`, `sum`, `avg`, `min`, `max` : agrège la colonne ; "
"la colonne de sortie est nommée `colonne__count`, `colonne__sum`, "
"`colonne__avg`, `colonne__min`, `colonne__max`\n\n"
"En mode agrégation, la réponse contient des lignes groupées, "
"`columns` est interdit et `meta` ne contient pas `total`.\n\n"
"Exemples : `acheteur_id__contains=VILLE`, `montant__greater=10000`, "
"`acheteur_departement_code__groupby&montant__sum`."
),
```
- [ ] **Step 4 : Mettre à jour le docstring de `data()`**
Dans `src/api/routes.py`, remplacer le docstring de `data()` par :
```python
"""Récupère des marchés publics filtrés, triés ou agrégés.
Filtres en query string : `<colonne>__<opérateur>=<valeur>`.
Opérateurs de filtre : exact, differs, contains, notcontains, in, notin,
less, greater, strictly_less, strictly_greater, isnull, isnotnull, sort.
Agrégation (drapeaux sans valeur) : `<colonne>__groupby`,
`<colonne>__count|sum|avg|min|max`. Les colonnes agrégées sont nommées
`<colonne>__<opérateur>`. `columns` est interdit avec une agrégation et
`meta` ne contient alors pas `total`.
Paramètres réservés : page (défaut 1), page_size (défaut 50, max 1000),
columns (csv), count_results (true|false ; mettre false pour économiser
le COUNT(*)).
Exemple d'agrégation :
`?acheteur_departement_code__groupby&uid__count&montant__sum`
"""
```
- [ ] **Step 5 : Lancer, vérifier le succès**
Run: `rtk pytest tests/api/test_openapi_doc.py -v`
Expected: PASS.
- [ ] **Step 6 : Vérifier la non-régression complète de l'API**
Run: `rtk pytest tests/api/ -v`
Expected: PASS (tous).
- [ ] **Step 7 : Commit**
```bash
git add src/api/routes.py tests/api/test_openapi_doc.py
git commit -m "docs(api): documente les opérateurs (filtres + agrégation) dans Swagger (#78)"
```
---
## Notes de vérification de référence (manuel, hors tests automatisés)
Après implémentation, vérifier que quelques requêtes d'agrégation renvoient
des valeurs cohérentes avec data.gouv.fr sur la même ressource DECP
(`22847056-61df-452d-837d-8b8ceadbfc52`), aux différences de fraîcheur près :
```
GET /api/v1/data?acheteur_departement_code__groupby&uid__count&montant__sum
```
à comparer à :
```
https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/data/?acheteur_departement_code__groupby&uid__count&montant__sum
```
@@ -0,0 +1,426 @@
# Tuile « Considérations sociales et environnementales » — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter dans `/observatoire`, juste après la tuile « Type d'achat », une tuile montrant via deux barres de progression la part des marchés filtrés comportant au moins une considération sociale (rouge) et au moins une considération environnementale (vert).
**Architecture:** Une fonction pure de calcul (`compute_considerations_stats`) dans `src/figures.py`, testée directement, alimente une fonction de rendu (`get_considerations_card_content`) qui produit un `html.Div` de deux `dbc.Progress`. La tuile est ajoutée dans `_compute_dashboard_children` via le `make_card` existant.
**Tech Stack:** Polars (LazyFrame), Dash / Dash Bootstrap Components (`dbc.Progress`), pytest.
## Global Constraints
- Imports internes toujours préfixés `src.` (ex. `from src.figures import ...`), jamais `figures` ou `utils`.
- Dédoublonnage **par `uid`** : un marché compté une fois (première valeur par `uid`).
- « Au moins une considération » = la valeur de colonne **contient** (insensible casse) `Clause`, `Critère` ou `Marché réservé`. Regex exacte : `(?i)Clause|Critère|Marché réservé`.
- Dénominateur = **tous** les marchés filtrés (uid distincts), y compris `Sans objet` et non renseignés.
- `pct = round(100 * numérateur / dénominateur)` ; si dénominateur = 0 → `pct = 0`.
- Couleurs issues de `px.colors.qualitative.Safe` : sociales = `rgb(204, 102, 119)` (index 1, rouge) ; environnementales = `rgb(17, 119, 51)` (index 3, vert).
- Robustesse : si une colonne `considerations*` est absente du schéma, son `(count, pct)` vaut `(0, 0)` sans exception.
---
### Task 1: Fonction de calcul `compute_considerations_stats`
**Files:**
- Modify: `src/figures.py` (ajouter la fonction après `get_dashboard_summary_table`, ~ ligne 729)
- Test: `tests/test_figures.py` (créer)
**Interfaces:**
- Consumes: rien (fonction pure prenant un `pl.LazyFrame`).
- Produces: `compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]` renvoyant `{"sociales": (count, pct), "environnementales": (count, pct)}``count` = nombre de marchés (uid distincts) avec au moins une considération et `pct` = pourcentage entier sur le total des uid distincts.
- [ ] **Step 1: Write the failing test**
Créer `tests/test_figures.py` :
```python
import polars as pl
def _make_lff(rows):
return pl.LazyFrame(rows)
def test_compute_considerations_stats_basic():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# uid u1 : social oui (Clause), env non (Sans objet)
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
# uid u2 : social non (Sans objet), env oui (Critère)
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
# uid u3 : social oui (Marché réservé compte), env null
{"uid": "u3", "considerationsSociales": "Marché réservé", "considerationsEnvironnementales": None},
# uid u4 : aucune considération
{"uid": "u4", "considerationsSociales": "Pas de considération sociale", "considerationsEnvironnementales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# 4 marchés au total. Social : u1, u3 -> 2/4 = 50%. Env : u2 -> 1/4 = 25%.
assert stats["sociales"] == (2, 50)
assert stats["environnementales"] == (1, 25)
def test_compute_considerations_stats_dedup_per_uid():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# uid u1 présent 2 fois (2 titulaires) -> compté une seule fois
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# 2 marchés distincts. Social : u1 -> 1/2 = 50%.
assert stats["sociales"] == (1, 50)
assert stats["environnementales"] == (0, 0)
def test_compute_considerations_stats_missing_column():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
{"uid": "u1", "considerationsSociales": "Clause sociale"},
{"uid": "u2", "considerationsSociales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# Colonne env absente -> (0, 0) sans exception. Social : 1/2 = 50%.
assert stats["sociales"] == (1, 50)
assert stats["environnementales"] == (0, 0)
def test_compute_considerations_stats_empty():
from src.figures import compute_considerations_stats
lff = pl.LazyFrame(
{
"uid": pl.Series([], dtype=pl.String),
"considerationsSociales": pl.Series([], dtype=pl.String),
"considerationsEnvironnementales": pl.Series([], dtype=pl.String),
}
)
stats = compute_considerations_stats(lff)
assert stats["sociales"] == (0, 0)
assert stats["environnementales"] == (0, 0)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_figures.py -v`
Expected: FAIL avec `ImportError: cannot import name 'compute_considerations_stats'`.
- [ ] **Step 3: Write minimal implementation**
Dans `src/figures.py`, ajouter après `get_dashboard_summary_table` (avant `make_card`) :
```python
CONSIDERATIONS_REGEX = r"(?i)Clause|Critère|Marché réservé"
CONSIDERATIONS_COLUMNS = {
"sociales": "considerationsSociales",
"environnementales": "considerationsEnvironnementales",
}
def compute_considerations_stats(lff: pl.LazyFrame) -> dict[str, tuple[int, int]]:
"""Part des marchés (uid distincts) ayant au moins une considération.
Renvoie {"sociales": (count, pct), "environnementales": (count, pct)}.
Dénominateur = tous les uid distincts. Colonne absente -> (0, 0).
"""
names = lff.collect_schema().names()
present = {
key: col for key, col in CONSIDERATIONS_COLUMNS.items() if col in names
}
stats = {key: (0, 0) for key in CONSIDERATIONS_COLUMNS}
if not present:
return stats
agg = (
lff.select(["uid"] + list(present.values()))
.group_by("uid")
.agg([pl.col(col).first() for col in present.values()])
.collect(engine="streaming")
)
total = agg.height
if total == 0:
return stats
for key, col in present.items():
count = agg.filter(
pl.col(col).str.contains(CONSIDERATIONS_REGEX)
).height
pct = round(100 * count / total)
stats[key] = (count, pct)
return stats
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_figures.py -v`
Expected: 4 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add src/figures.py tests/test_figures.py
git commit -m "feat(observatoire): calcul part marchés avec considération sociale/env
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 2: Rendu de la tuile `get_considerations_card_content`
**Files:**
- Modify: `src/figures.py` (ajouter après `compute_considerations_stats`)
- Test: `tests/test_figures.py` (ajouter au fichier de la Task 1)
**Interfaces:**
- Consumes: `compute_considerations_stats(lff) -> dict[str, tuple[int, int]]` (Task 1) ; `format_number` (déjà importé en tête de `src/figures.py` : `from src.utils.table import add_links, format_number, setup_table_columns`).
- Produces: `get_considerations_card_content(lff: pl.LazyFrame) -> html.Div` : un `html.Div` contenant deux blocs (sociales, environnementales), chacun avec un libellé, un `dbc.Progress` coloré rempli au pourcentage, et le nombre de marchés.
- [ ] **Step 1: Write the failing test**
Ajouter à `tests/test_figures.py` :
```python
def test_get_considerations_card_content_returns_two_progress_bars():
import dash_bootstrap_components as dbc
from dash import html
from src.figures import get_considerations_card_content
lff = pl.LazyFrame(
[
{"uid": "u1", "considerationsSociales": "Clause sociale", "considerationsEnvironnementales": "Sans objet"},
{"uid": "u2", "considerationsSociales": "Sans objet", "considerationsEnvironnementales": "Critère environnemental"},
]
)
div = get_considerations_card_content(lff)
assert isinstance(div, html.Div)
# Récupère récursivement tous les dbc.Progress
def find_progress(component, found):
children = getattr(component, "children", None)
if isinstance(component, dbc.Progress):
found.append(component)
if isinstance(children, (list, tuple)):
for c in children:
find_progress(c, found)
elif children is not None:
find_progress(children, found)
return found
bars = find_progress(div, [])
assert len(bars) == 2
# Sociales (rouge) : u1 -> 50%. Environnementales (vert) : u2 -> 50%.
social_bar, env_bar = bars[0], bars[1]
assert social_bar.value == 50
assert social_bar.label == "50 %"
assert social_bar.style["backgroundColor"] == "rgb(204, 102, 119)"
assert env_bar.value == 50
assert env_bar.label == "50 %"
assert env_bar.style["backgroundColor"] == "rgb(17, 119, 51)"
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_figures.py::test_get_considerations_card_content_returns_two_progress_bars -v`
Expected: FAIL avec `ImportError: cannot import name 'get_considerations_card_content'`.
- [ ] **Step 3: Write minimal implementation**
Dans `src/figures.py`, ajouter après `compute_considerations_stats` :
```python
CONSIDERATIONS_DISPLAY = [
# (clé, libellé, couleur Safe)
("sociales", "Sociales", "rgb(204, 102, 119)"),
("environnementales", "Environnementales", "rgb(17, 119, 51)"),
]
def get_considerations_card_content(lff: pl.LazyFrame) -> html.Div:
"""Deux barres de progression : part des marchés avec considération."""
stats = compute_considerations_stats(lff)
blocks = []
for key, label, color in CONSIDERATIONS_DISPLAY:
count, pct = stats[key]
blocks.append(
html.Div(
className="mb-3",
children=[
html.Div(
className="d-flex justify-content-between",
children=[
html.Span(label),
html.Span(
f"{format_number(count)} marchés",
className="text-muted",
),
],
),
dbc.Progress(
value=pct,
label=f"{pct} %",
style={"backgroundColor": color},
),
],
)
)
return html.Div(children=blocks)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `uv run pytest tests/test_figures.py -v`
Expected: tous les tests PASS (5 au total).
- [ ] **Step 5: Commit**
```bash
git add src/figures.py tests/test_figures.py
git commit -m "feat(observatoire): tuile considérations en barres de progression
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
### Task 3: Intégration dans l'Observatoire
**Files:**
- Modify: `src/pages/observatoire.py` (import ~ lignes 20-31 ; appel dans `_compute_dashboard_children` après le bloc « Type d'achat », ~ ligne 723)
**Interfaces:**
- Consumes: `get_considerations_card_content(lff) -> html.Div` (Task 2) ; `make_card` (déjà importé).
- Produces: une nouvelle `dbc.Col` (card) insérée dans la liste `cards` entre « Type d'achat » et « Distance acheteurtitulaire ».
- [ ] **Step 1: Ajouter l'import**
Dans `src/pages/observatoire.py`, dans le bloc `from src.figures import (...)` (lignes 20-31), ajouter `get_considerations_card_content` en respectant l'ordre alphabétique existant (après `get_barchart_sources`) :
```python
from src.figures import (
DataTable,
get_barchart_sources,
get_considerations_card_content,
get_dashboard_summary_table,
get_distance_histogram,
get_duplicate_matrix,
get_geographic_maps,
get_top_org_table,
make_card,
make_column_picker,
make_donut,
)
```
- [ ] **Step 2: Insérer la tuile après « Type d'achat »**
Dans `_compute_dashboard_children`, juste après le `cards.append(...)` du donut « Type d'achat » (qui se termine ligne ~723) et avant `distance_histogram = ...`, insérer :
```python
considerations_content = get_considerations_card_content(lff)
cards.append(
make_card(
title="Considérations sociales et environnementales",
subtitle="part des marchés concernés",
fig=considerations_content,
)
)
```
Le bloc résultant doit ressembler à :
```python
donut_marche_type = make_donut(lff, "type", per_uid=True, nulls="?")
cards.append(
make_card(
title="Type d'achat",
subtitle="en nombre de marchés attribués",
fig=donut_marche_type,
)
)
considerations_content = get_considerations_card_content(lff)
cards.append(
make_card(
title="Considérations sociales et environnementales",
subtitle="part des marchés concernés",
fig=considerations_content,
)
)
distance_histogram = get_distance_histogram(lff)
```
- [ ] **Step 3: Vérifier que la page se charge (test d'import/rendu)**
Run: `uv run pytest tests/test_page_loads.py -v`
Expected: PASS (aucune régression sur le chargement des pages). Si `tests/test_page_loads.py` ne couvre pas `/observatoire`, lancer en complément :
Run: `uv run python -c "import src.pages.observatoire"`
Expected: aucune erreur d'import.
- [ ] **Step 4: Lancer l'ensemble de la suite figures + observatoire**
Run: `uv run pytest tests/test_figures.py -v`
Expected: tous PASS.
- [ ] **Step 5: Commit**
```bash
git add src/pages/observatoire.py
git commit -m "feat(observatoire): afficher la tuile considérations après Type d'achat
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review
**Spec coverage :**
- Définition « au moins une considération » (regex incl. `Marché réservé`) → Task 1, `CONSIDERATIONS_REGEX`, tests `basic`.
- Dédoublonnage par `uid` → Task 1, test `dedup_per_uid`.
- Dénominateur = tous les marchés filtrés → Task 1 (`total = agg.height`), tests.
- Colonne absente → 0 % → Task 1, test `missing_column` ; cas vide → test `empty`.
- Deux barres `dbc.Progress`, couleurs Safe rouge/vert, labels `XX %` + `N marchés` → Task 2.
- Insertion après « Type d'achat », dimensions par défaut `make_card` → Task 3.
- Hors périmètre (pas de tooltip, pas de filtre) → respecté, rien d'ajouté.
**Placeholder scan :** aucun TODO/TBD ; tout le code est fourni.
**Type consistency :** `compute_considerations_stats` renvoie `dict[str, tuple[int, int]]` clés `sociales`/`environnementales`, consommé tel quel par `get_considerations_card_content` (Task 2) ; `get_considerations_card_content` renvoie `html.Div`, passé à `make_card(fig=...)` (Task 3). Cohérent.
@@ -0,0 +1,321 @@
# Excel Export Styling Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Améliorer les exports Excel de toutes les vues tableau : en-têtes stylisés (fond rouge `#b33821`, texte blanc, gras), largeurs de colonnes issues du `DataTable` commun, largeur minimale de 3.5 cm, et retour à la ligne automatique dans toutes les cellules.
**Architecture:** Une fonction utilitaire `write_styled_excel(df, buffer, worksheet)` centralisée dans `src/utils/table.py` crée le workbook xlsxwriter avec `default_format_properties: {text_wrap: True}`, puis délègue à `polars.DataFrame.write_excel` avec le `header_format` et les `column_widths` calculés. Les 6 callbacks de téléchargement appellent cette fonction à la place de `.write_excel()` direct.
**Tech Stack:** Python, Polars, xlsxwriter (déjà en dep), openpyxl (disponible, utilisé en test uniquement)
## Global Constraints
- Imports de modules internes toujours via `src.` (ex. `from src.utils.table import …`)
- `xlsxwriter` est en dep de prod — pas besoin de l'ajouter
- `openpyxl` est disponible (dep transitive) — pas besoin de l'ajouter à `pyproject.toml`
- Run tests : `uv run pytest` (pas `pytest` direct)
- Couleur primaire de l'app : `#b33821`
- Largeur minimale : 132 pixels (≈ 3.5 cm à 96 DPI)
---
## File Map
| Fichier | Action | Rôle |
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `src/utils/table.py` | Modifier | Ajouter `import xlsxwriter`, constantes `_EXCEL_*`, fonction `write_styled_excel` |
| `tests/test_excel.py` | Créer | Tests unitaires de `write_styled_excel` |
| `src/pages/tableau.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_data` |
| `src/pages/acheteur.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_acheteur_data` et `download_filtered_acheteur_data` |
| `src/pages/titulaire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_titulaire_data` et `download_filtered_titulaire_data` |
| `src/pages/observatoire.py` | Modifier | Importer et utiliser `write_styled_excel` dans `download_observatoire` |
---
### Task 1 : `write_styled_excel` dans `src/utils/table.py`
**Files:**
- Modify: `src/utils/table.py:1` (ajouter import), `src/utils/table.py:559` (fin de fichier)
- Create: `tests/test_excel.py`
**Interfaces:**
- Produces: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` — exporté publiquement depuis `src.utils.table`
- [ ] **Step 1 : Créer `tests/test_excel.py` avec les tests en échec**
```python
import io
import openpyxl
import polars as pl
import pytest
from src.utils.table import write_styled_excel
def test_write_styled_excel_header_bold_and_color():
df = pl.DataFrame({"objet": ["marché A"], "montant": [1000.0]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
cell = ws.cell(row=1, column=1)
assert cell.font.bold is True
assert cell.fill.fgColor.rgb == "FFB33821"
assert cell.font.color.rgb == "FFFFFFFF"
def test_write_styled_excel_data_cell_text_wrap():
df = pl.DataFrame({"objet": ["marché A"]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
cell = ws.cell(row=2, column=1)
assert cell.alignment.wrap_text is True
def test_write_styled_excel_known_column_wider_than_minimum():
# "objet" a une largeur explicite (350px) > minimum (132px)
# "autre" n'a pas de largeur explicite → reçoit le minimum
df = pl.DataFrame({"autre": ["x"], "objet": ["y"]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre"
width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet"
assert width_autre > 0
assert width_objet > width_autre
def test_write_styled_excel_custom_worksheet_name():
df = pl.DataFrame({"col": ["val"]})
buf = io.BytesIO()
write_styled_excel(df, buf, worksheet="2025")
buf.seek(0)
wb = openpyxl.load_workbook(buf)
assert "2025" in wb.sheetnames
```
- [ ] **Step 2 : Vérifier que les tests échouent**
```bash
uv run pytest tests/test_excel.py -v
```
Attendu : `ImportError` ou `AttributeError: module 'src.utils.table' has no attribute 'write_styled_excel'`
- [ ] **Step 3 : Ajouter `import xlsxwriter` dans `src/utils/table.py`**
Ligne 1 du fichier, après les imports existants. Le bloc imports actuel (lignes 114) devient :
```python
import os
import uuid
import polars as pl
import xlsxwriter
from dash import no_update
from polars import selectors as cs
from unidecode import unidecode
from src.db import count_marches, count_unique_marches, query_marches, schema
from src.utils import logger
from src.utils.cache import cache
from src.utils.data import DATA_SCHEMA
from src.utils.frontend import get_button_properties
from src.utils.tracking import track_search
```
- [ ] **Step 4 : Ajouter les constantes et `write_styled_excel` à la fin de `src/utils/table.py`**
Après la ligne `COLUMNS = schema.names()` (actuellement dernière ligne, 559) :
```python
_EXCEL_MIN_COLUMN_WIDTH = 132 # ≈ 3.5 cm à 96 DPI
_EXCEL_HEADER_FORMAT = {
"bold": True,
"bg_color": "#b33821",
"font_color": "white",
}
_EXCEL_COLUMN_WIDTHS = {
"objet": 350,
"acheteur_nom": 250,
"titulaire_nom": 250,
"acheteur_id": 160,
}
def write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None:
col_widths = {
col: max(_EXCEL_MIN_COLUMN_WIDTH, _EXCEL_COLUMN_WIDTHS.get(col, 0))
for col in df.columns
}
wb = xlsxwriter.Workbook(buffer, {"default_format_properties": {"text_wrap": True}})
ws = wb.add_worksheet(worksheet)
df.write_excel(
workbook=wb,
worksheet=ws,
header_format=_EXCEL_HEADER_FORMAT,
column_widths=col_widths,
)
wb.close()
```
- [ ] **Step 5 : Vérifier que les tests passent**
```bash
uv run pytest tests/test_excel.py -v
```
Attendu : 4 tests PASS
- [ ] **Step 6 : Commit**
```bash
git add src/utils/table.py tests/test_excel.py
git commit -m "feat: add write_styled_excel utility for styled Excel exports #83"
```
---
### Task 2 : Mettre à jour les 6 callbacks de téléchargement
**Files:**
- Modify: `src/pages/tableau.py:26-33,344-345`
- Modify: `src/pages/acheteur.py:30-37,399-402,441-442`
- Modify: `src/pages/titulaire.py:29-36,421-423,462-463`
- Modify: `src/pages/observatoire.py:43,813-814`
**Interfaces:**
- Consumes: `write_styled_excel(df: pl.DataFrame, buffer, worksheet: str = "DECP") -> None` depuis Task 1
- [ ] **Step 1 : Mettre à jour `src/pages/tableau.py`**
Ajouter `write_styled_excel` à l'import existant (lignes 2633) :
```python
from src.utils.table import (
COLUMNS,
filter_table_data,
get_default_hidden_columns,
invert_columns,
prepare_table_data,
sort_table_data,
write_styled_excel,
)
```
Remplacer le bloc `def to_bytes` dans `download_data` (lignes 344345) :
```python
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
- [ ] **Step 2 : Mettre à jour `src/pages/acheteur.py`**
Ajouter `write_styled_excel` à l'import existant (lignes 3037) :
```python
from src.utils.table import (
COLUMNS,
filter_table_data,
format_number,
get_default_hidden_columns,
prepare_table_data,
sort_table_data,
write_styled_excel,
)
```
Remplacer le bloc `def to_bytes` dans `download_acheteur_data` (lignes 399402) :
```python
def to_bytes(buffer):
write_styled_excel(
df_to_download,
buffer,
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
)
```
Remplacer le bloc `def to_bytes` dans `download_filtered_acheteur_data` (ligne 441442) :
```python
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
- [ ] **Step 3 : Mettre à jour `src/pages/titulaire.py`**
Ajouter `write_styled_excel` à l'import existant (lignes 2936) :
```python
from src.utils.table import (
COLUMNS,
filter_table_data,
format_number,
get_default_hidden_columns,
prepare_table_data,
sort_table_data,
write_styled_excel,
)
```
Remplacer le bloc `def to_bytes` dans `download_titulaire_data` (lignes 421423) :
```python
def to_bytes(buffer):
write_styled_excel(
df_to_download,
buffer,
worksheet="DECP" if annee in ["Toutes les années", None] else annee,
)
```
Remplacer le bloc `def to_bytes` dans `download_filtered_titulaire_data` (lignes 462463) :
```python
def to_bytes(buffer):
write_styled_excel(lff.collect(engine="streaming"), buffer)
```
- [ ] **Step 4 : Mettre à jour `src/pages/observatoire.py`**
Modifier l'import (ligne 43) :
```python
from src.utils.table import COLUMNS, get_default_hidden_columns, prepare_table_data, write_styled_excel
```
Remplacer le bloc `def to_bytes` dans `download_observatoire` (ligne 813814) :
```python
def to_bytes(buffer):
write_styled_excel(dff, buffer)
```
- [ ] **Step 5 : Vérifier les tests existants et les nouveaux**
```bash
uv run pytest tests/test_excel.py tests/test_main.py::test_003_tableau_download -v
```
Attendu : tous PASS (le test existant vérifie que le contenu base64 est non vide et que le nom de fichier commence par `decp_` — comportement inchangé)
- [ ] **Step 6 : Commit**
```bash
git add src/pages/tableau.py src/pages/acheteur.py src/pages/titulaire.py src/pages/observatoire.py
git commit -m "refactor: use write_styled_excel in all download callbacks #83"
```
@@ -0,0 +1,350 @@
# Défilement horizontal ergonomique des tableaux (#82) — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rendre le défilement horizontal des tableaux toujours accessible (barre miroir en haut, synchronisée) et garder les en-têtes de colonnes visibles pendant le défilement vertical de la page.
**Architecture:** Option B (sticky au niveau page). Le tableau reste dans le flux de la page. CSS rend les en-têtes `position: sticky; top: 0`. Une barre de défilement « miroir » est injectée par JS au-dessus de chaque tableau, sticky en haut, et synchronisée avec le conteneur scrollable du tableau. Aucun scroll vertical imbriqué.
**Tech Stack:** Dash 3.4 `dash_table.DataTable`, CSS (`src/assets/css/style.css`), JS vanilla auto-chargé depuis `src/assets/` (pattern existant : MutationObserver, cf. `dash_clientside.js`), tests Selenium (`dash[testing]` / `DashComposite`).
## Global Constraints
- Importer les modules de l'app avec le préfixe `src.` (ex. `src.figures`), jamais `figures`.
- UI en français.
- Cibler la classe partagée `marches_table` (présente sur les 4 pages : `/tableau`, `/acheteur`, `/titulaire`, `/observatoire`) — pas de duplication par page.
- Ne pas modifier la hauteur du tableau ni introduire de conteneur à hauteur fixe (option A explicitement écartée).
- Les commits référencent `#82`.
- Le pre-commit hook lance `prettier` (CSS/JS/MD) et `ruff` (Python) et **modifie les fichiers** : après un échec dû au reformatage, refaire `git add` puis recommiter.
- Terminer chaque message de commit par : `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
---
## File Structure
| Fichier | Responsabilité | Action |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| `src/assets/css/style.css` | En-têtes sticky + neutralisation overflow interne Dash + styles de la barre miroir | Modifier |
| `src/assets/table_hscroll.js` | Injecter la barre miroir au-dessus de chaque `.marches_table`, synchroniser le scroll, masquer si pas de débordement, recalculer au resize / re-render | Créer |
| `tests/test_tableau_hscroll.py` | Test Selenium : barre miroir présente + en-tête sticky sur `/tableau` | Créer |
| `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` | Consigner le verdict du spike (Task 1) | Modifier |
**Décision DOM clé :** la barre miroir est **injectée par JS** (et non ajoutée dans le markup Python), ce qui évite de toucher au markup des 4 pages et fonctionne quel que soit le wrapper. Le conteneur scrollable du tableau est l'élément Dash `.dash-spreadsheet-container` (ou son parent direct), confirmé au spike.
---
## Task 1 : Spike — vérifier le DOM réel et la technique sticky
**But :** lever l'inconnue principale (structure DOM de DataTable + compatibilité `position: sticky` des en-têtes avec un conteneur `overflow-x`). Produit un verdict écrit qui pilote les tâches suivantes.
**Files:**
- Modify: `docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md` (section « Verdict du spike »)
- [ ] **Step 1 : Lancer l'app**
```bash
source .venv/bin/activate
python run.py
```
Ouvrir `http://127.0.0.1:8050/tableau` dans le navigateur.
- [ ] **Step 2 : Inspecter le DOM du tableau**
Dans les DevTools, sur le tableau, relever et noter :
- l'élément qui porte la **largeur totale** du tableau (table `.cell-table`) et sa largeur (`scrollWidth`) vs. celle de son parent (`clientWidth`) → confirme le débordement ;
- l'élément qui (le cas échéant) porte déjà un `overflow`/`overflow-x` (regarder `.dash-spreadsheet-container`, `.dash-spreadsheet-inner`) via l'onglet _Computed_ ;
- le sélecteur exact de la ligne d'en-tête (attendu : `th.dash-header` dans un `tr`).
- [ ] **Step 3 : Tester l'hypothèse sticky en live**
Dans la console DevTools, appliquer à chaud :
```js
document
.querySelectorAll(
".marches_table .dash-spreadsheet-container, .marches_table .dash-spreadsheet-inner"
)
.forEach((e) => (e.style.overflow = "visible"));
document.querySelectorAll(".marches_table th.dash-header").forEach((e) => {
e.style.position = "sticky";
e.style.top = "0";
e.style.zIndex = "10";
e.style.background = "#fff";
});
```
Scroller verticalement la page → **les en-têtes restent-ils collés en haut ?**
- [ ] **Step 4 : Consigner le verdict**
Ajouter une section « Verdict du spike » à la spec, répondant à :
- sélecteur exact de l'en-tête et du conteneur scrollable ;
- l'astuce `overflow: visible` sur les conteneurs Dash suffit-elle à faire fonctionner le sticky page ? (attendu : oui) ;
- si **non** : noter que les en-têtes sticky devront être pilotés en JS (repositionnement au scroll) — le reste du plan reste valable, seul l'implémentation du sticky de la Task 2 change.
- [ ] **Step 5 : Commit**
```bash
git add docs/superpowers/specs/2026-06-23-tableaux-scroll-horizontal-sticky-design.md
git commit -m "docs: verdict spike DOM tableaux #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2 : En-têtes collants (CSS)
**But :** les en-têtes de colonnes restent visibles en haut de la fenêtre pendant le défilement vertical de la page.
**Files:**
- Modify: `src/assets/css/style.css` (ajouter après le bloc `.marches_table.stuck`, vers la ligne 360)
**Interfaces:**
- Consumes: sélecteurs confirmés au spike (Task 1) : `.marches_table th.dash-header`, conteneurs `.dash-spreadsheet-container` / `.dash-spreadsheet-inner`.
- Produces: classe `marches_table` dont les conteneurs Dash internes sont en `overflow: visible` et dont les en-têtes sont sticky — la Task 3 (JS) s'appuie dessus pour poser le conteneur scrollable et la barre miroir.
- [ ] **Step 1 : Écrire le CSS des en-têtes sticky**
Dans `src/assets/css/style.css`, ajouter :
```css
/* ===== Tableaux : en-têtes collants + scroll horizontal (#82) ===== */
/* Neutraliser l'overflow interne de Dash pour que le sticky se cale sur la page */
.marches_table .dash-spreadsheet-container,
.marches_table .dash-spreadsheet-inner {
overflow: visible !important;
}
/* En-têtes de colonnes collants en haut de la fenêtre */
.marches_table th.dash-header {
position: sticky;
top: 0;
z-index: 10;
background-color: #fff;
}
```
> Si le verdict du spike indique que le sticky CSS ne tient pas, remplacer ce bloc par le repositionnement JS décrit dans la spec et le porter en Task 3 ; documenter le choix dans le commit.
- [ ] **Step 2 : Vérifier dans le navigateur**
App lancée (`python run.py`), sur `/tableau` : scroller verticalement → l'en-tête reste figé en haut, sur fond opaque, au-dessus des lignes. Vérifier aussi `/acheteur` et `/observatoire`.
- [ ] **Step 3 : Commit**
```bash
git add src/assets/css/style.css
git commit -m "feat(tableaux): en-têtes de colonnes collants #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3 : Barre de défilement miroir (JS + CSS)
**But :** une barre de défilement horizontale toujours visible en haut du tableau, synchronisée avec le défilement horizontal du tableau, masquée si le tableau ne déborde pas.
**Files:**
- Create: `src/assets/table_hscroll.js`
- Modify: `src/assets/css/style.css` (styles de `.dt-hscroll`)
**Interfaces:**
- Consumes: les `.marches_table` rendues par Dash ; le conteneur scrollable interne (`.dash-spreadsheet-container`, confirmé au spike) dont on lit `scrollWidth`/`clientWidth` et qu'on pilote via `scrollLeft`.
- Produces: pour chaque `.marches_table`, un nœud `<div class="dt-hscroll">` inséré en première position, contenant `<div class="dt-hscroll-inner">` dont la largeur = largeur totale du tableau.
- [ ] **Step 1 : Écrire le CSS de la barre miroir**
Dans `src/assets/css/style.css`, à la suite du bloc de la Task 2 :
```css
/* Barre de défilement horizontale miroir, collée en haut */
.marches_table .dt-hscroll {
position: sticky;
top: 0;
z-index: 11; /* au-dessus des en-têtes sticky */
overflow-x: auto;
overflow-y: hidden;
height: 14px;
}
.marches_table .dt-hscroll-inner {
height: 1px;
}
.marches_table .dt-hscroll.is-hidden {
display: none;
}
```
- [ ] **Step 2 : Écrire le JS de synchronisation**
Créer `src/assets/table_hscroll.js` :
```js
// Barre de défilement horizontale miroir pour les tableaux (.marches_table) — #82
(function () {
"use strict";
// Renvoie le conteneur réellement scrollable horizontalement du tableau.
function getScrollEl(wrapper) {
return wrapper.querySelector(".dash-spreadsheet-container") || wrapper;
}
function setup(wrapper) {
if (wrapper.dataset.hscrollReady === "1") return;
const scrollEl = getScrollEl(wrapper);
if (!scrollEl) return;
const bar = document.createElement("div");
bar.className = "dt-hscroll is-hidden";
const inner = document.createElement("div");
inner.className = "dt-hscroll-inner";
bar.appendChild(inner);
wrapper.insertBefore(bar, wrapper.firstChild);
let syncing = false;
const onBar = () => {
if (syncing) return;
syncing = true;
scrollEl.scrollLeft = bar.scrollLeft;
syncing = false;
};
const onTable = () => {
if (syncing) return;
syncing = true;
bar.scrollLeft = scrollEl.scrollLeft;
syncing = false;
};
bar.addEventListener("scroll", onBar);
scrollEl.addEventListener("scroll", onTable);
const refresh = () => {
const total = scrollEl.scrollWidth;
const visible = scrollEl.clientWidth;
inner.style.width = total + "px";
bar.classList.toggle("is-hidden", total <= visible + 1);
bar.scrollLeft = scrollEl.scrollLeft;
};
// Recalcule quand le tableau change (pagination, tri, filtre, données).
const obs = new MutationObserver(() => refresh());
obs.observe(scrollEl, { childList: true, subtree: true, attributes: true });
window.addEventListener("resize", refresh);
wrapper.dataset.hscrollReady = "1";
refresh();
}
function scan() {
document.querySelectorAll(".marches_table").forEach(setup);
}
// Les tableaux apparaissent après le rendu Dash : observer le body.
const rootObs = new MutationObserver(() => scan());
rootObs.observe(document.body, { childList: true, subtree: true });
scan();
})();
```
- [ ] **Step 3 : Vérifier dans le navigateur**
App lancée, sur `/tableau` : une barre fine apparaît en haut du tableau ; la faire glisser déplace le tableau horizontalement, et inversement. Réduire la fenêtre / élargir → la barre apparaît/disparaît selon le débordement. Changer de page de pagination → la barre se recalcule. Vérifier que `/acheteur`, `/titulaire`, `/observatoire` (tableaux multiples) fonctionnent chacun indépendamment.
- [ ] **Step 4 : Commit**
```bash
git add src/assets/table_hscroll.js src/assets/css/style.css
git commit -m "feat(tableaux): barre de défilement horizontale miroir synchronisée #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 4 : Test Selenium de non-régression
**But :** garantir que la barre miroir est rendue et que les en-têtes sont sticky, et que rien ne casse le chargement de `/tableau`.
**Files:**
- Create: `tests/test_tableau_hscroll.py`
**Interfaces:**
- Consumes: éléments DOM produits par Task 2 (`th.dash-header` sticky) et Task 3 (`.marches_table .dt-hscroll`).
- [ ] **Step 1 : Écrire le test**
Créer `tests/test_tableau_hscroll.py` (s'aligner sur le style des tests existants dans `tests/`, notamment l'usage de `dash_duo`/`DashComposite` et l'import de l'app) :
```python
from selenium.webdriver.common.by import By
def test_tableau_hscroll_bar_present(dash_duo, start_app):
"""La barre miroir est injectée et l'en-tête est collant sur /tableau."""
dash_duo.wait_for_element(".marches_table", timeout=20)
# Barre miroir injectée par table_hscroll.js
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
header = dash_duo.find_element(".marches_table th.dash-header")
position = header.value_of_css_property("position")
assert position == "sticky"
```
> Adapter les fixtures (`dash_duo`, `start_app`, navigation initiale vers `/tableau`) à ce qui existe déjà dans `tests/` : reprendre le mécanisme d'amorçage utilisé par les autres tests (par ex. `tests/test_main.py`) plutôt que d'en inventer un.
- [ ] **Step 2 : Lancer le test et vérifier qu'il échoue si on retire le JS** (sanity)
```bash
rtk pytest tests/test_tableau_hscroll.py -v
```
Expected: PASS avec le JS/CSS en place.
- [ ] **Step 3 : Lancer la suite Selenium impactée**
```bash
rtk pytest tests/test_main.py -v
```
Expected: pas de régression (mêmes résultats qu'avant la branche).
- [ ] **Step 4 : Commit**
```bash
git add tests/test_tableau_hscroll.py
git commit -m "test(tableaux): barre miroir et en-tête sticky sur /tableau #82
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Self-Review
**Couverture spec :**
- En-têtes sticky → Task 2. ✓
- Barre miroir synchronisée en haut → Task 3. ✓
- Masquage si pas de débordement → Task 3 (`is-hidden`). ✓
- Recalcul pagination/tri/filtre/resize → Task 3 (MutationObserver + resize). ✓
- Plusieurs tableaux par page → Task 3 (`querySelectorAll` + `dataset.hscrollReady`). ✓
- Contrainte overflow CSS → Task 1 (spike) + Task 2 (`overflow: visible`). ✓
- Portée 4 pages via `marches_table` → ciblage CSS/JS global. ✓
- Validation navigateur + non-régression Selenium → Task 1/2/3 (manuel) + Task 4. ✓
**Placeholders :** les renvois « adapter aux fixtures existantes » de la Task 4 pointent vers `tests/test_main.py` comme référence concrète (les fixtures Selenium du projet ne sont pas réinventées ici à dessein) ; aucun TODO/TBD ailleurs.
**Cohérence des noms :** classes `dt-hscroll` / `dt-hscroll-inner` / `is-hidden` et flag `dataset.hscrollReady` utilisés de façon identique entre CSS (Task 3 step 1) et JS (Task 3 step 2). `marches_table` et `th.dash-header` cohérents entre Task 2 et Task 4.