docs: plan tuile considérations sociales/environnementales
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)}` où `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 acheteur–titulaire ».
|
||||
|
||||
- [ ] **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.
|
||||
Reference in New Issue
Block a user