Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c304f05d85 | |||
| 5341ca002e | |||
| ed32b0f66b | |||
| 4d3e8ac344 | |||
| ab74717679 | |||
| c9a97fe9d2 | |||
| b8db0cddf8 | |||
| 4c60fed0c2 | |||
| 070a91fed1 | |||
| e638ce45be | |||
| e55f3447db | |||
| 6cf213add5 | |||
| 80c27bf357 | |||
| 2e9b587613 | |||
| ad7e3e5b1f | |||
| 7d4df0e6ad | |||
| 75e5aeaf40 | |||
| b8af6a17a1 | |||
| 0c9666204b | |||
| cd468a837c | |||
| 8a2f7f620c | |||
| 3a73cdf4b9 | |||
| 62eb4d98f0 | |||
| 6fdec32b29 | |||
| 1447a9fcaf | |||
| 9d7f33905f | |||
| 755b8c13ab | |||
| f4b57dbe5c | |||
| 0322c20513 | |||
| edbdeaa370 | |||
| 25746b4869 | |||
| 1a5f049b1a | |||
| bd6a4ff266 | |||
| 1839928e69 | |||
| a6049b3244 | |||
| d8ee6e5b37 | |||
| b437decf5f | |||
| 10f24dec30 | |||
| 18b5488051 | |||
| 7d8f8a7c19 | |||
| 93777cce6d | |||
| dadbb0aeff | |||
| f7b7954ed2 | |||
| fc4d965b20 | |||
| a715140af0 | |||
| c45d4e0ea1 | |||
| 0777153c82 | |||
| 6e670c97c9 | |||
| 522c467702 | |||
| 74ae1fb008 | |||
| e3a0fba4df | |||
| a382370767 | |||
| 653999693c | |||
| aaf54eef91 | |||
| b996eb97cc | |||
| 3e89dacff9 | |||
| eb8d7abe0d | |||
| a484984e40 | |||
| bea160aa00 | |||
| 8e603d2806 | |||
| 1d833bb800 | |||
| f81c897342 | |||
| 3532a9c381 | |||
| b4956c34d1 | |||
| 72d4881796 | |||
| 1e67d329d0 | |||
| 4e25ff5c85 | |||
| cdb6a70f7a | |||
| 74213d3844 | |||
| 95c90e319e | |||
| abc6390174 | |||
| 6d22b7298a |
@@ -20,7 +20,7 @@ jobs:
|
||||
environment: ${{ github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up SSH key
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
DATA_FILE_PARQUET_PATH=https://www.data.gouv.fr/fr/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432
|
||||
DUCKDB_PATH=./decp.duckdb
|
||||
PORT=8050
|
||||
DEVELOPMENT=True
|
||||
SOURCE_STATS_CSV_PATH="https://www.data.gouv.fr/api/1/datasets/r/8ded94de-3b80-4840-a5bb-7faad1c9c234"
|
||||
@@ -8,6 +9,7 @@ ANNOUNCEMENTS=
|
||||
|
||||
# Chemin vers le schéma de données
|
||||
DATA_SCHEMA_PATH=https://www.data.gouv.fr/api/1/datasets/r/9a4144c0-ee44-4dec-bee5-bbef38191d9a
|
||||
DATA_SCHEMA_PATH_LOCAL=../schema.json
|
||||
|
||||
# Colonnes masquées par défaut
|
||||
DISPLAYED_COLUMNS="uid, acheteur_id, acheteur_nom, montant, objet, titulaire_nom, titulaire_id, dateNotification, dureeMois, acheteur_departement_code, sourceDataset"
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
##### 2.7.9 (9 juin 2026)
|
||||
|
||||
- Ajout d'une vue "étapes" (elle sera mieux intégrée dans le site à l'avenir)
|
||||
- Correction de petites erreurs qui polluent les logs
|
||||
|
||||
##### 2.7.8 (18 mai 2026)
|
||||
|
||||
- Récupération du schéma de données plus robuste, ne pas dépendre de data.gouv.fr
|
||||
|
||||
##### 2.7.7 (11 mai 2026)
|
||||
|
||||
- Suppression des mentions sur les profils d'acheteur. Omnikles/Safetender publie via l'API DUME et Klekoon ne publie pas, mais c'est peut-être pas le seul, donc je préfère supprimer et refaire un tour.
|
||||
|
||||
##### 2.7.6 (5 mai 2026)
|
||||
|
||||
- Correction du problème de filtre par date dans les tableaux
|
||||
- Retour des cartes dans les pages acheteur et titulaire
|
||||
- Possibilité de chercher un SIRET/SIREN avec des espaces dans les champs `SIRET acheteur` et `Identifiant titulaire`
|
||||
|
||||
##### 2.7.5 (24 avril 2026)
|
||||
|
||||
- Amélioration des permormances de l'observatoire
|
||||
- Possibilité dans observatoire (champ objet) et tableau (tous champs texte) de soit chercher des mots présents, soit une suite de mot précise (voir mode d'emploi dans Tableau)
|
||||
- Ajout d'une animation pendant le chargement de la prévisualisation des données de l'observatoire
|
||||
|
||||
##### 2.7.4 (22 avril 2026)
|
||||
|
||||
- Utilisation élargie de DuckDB au détriment de Polars => bien meilleure perf ([#72](https://github.com/ColinMaudry/decp.info/issues/72)
|
||||
|
||||
##### 2.7.3 (20 avril 2026)
|
||||
|
||||
- Mise en cache des vues tableau par ensemble de filtres et de tris
|
||||
|
||||
@@ -10,27 +10,38 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
### Setup
|
||||
|
||||
Setting up the virtual environment:
|
||||
|
||||
```bash
|
||||
cp template.env .env # then customize .env
|
||||
python -m venv .venv # s'il n'existe pas déjà
|
||||
source .venv/bin/activate
|
||||
rtk pip install -U pip > /dev/null 2>&1
|
||||
rtk pip install -e . --group=dev
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
```bash
|
||||
cp .template.env .env # then customize .env
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
uv run run.py # starts Dash with debug=True and hot reload
|
||||
python run.py # starts Dash app
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
uv run gunicorn app:server
|
||||
gunicorn app:server
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
rtk uv run pytest # run all tests (Selenium-based integration tests)
|
||||
rtk uv run pytest tests/test_main.py::test_001_logo_and_search # run a single test
|
||||
rtk pytest # run all tests (some are Selenium-based integration tests)
|
||||
rtk pytest tests/test_main.py::test_001_logo_and_search # run a single test
|
||||
```
|
||||
|
||||
Tests require a running Chrome/Chromium browser. They use `DashComposite` from `dash[testing]` with Selenium WebDriver.
|
||||
@@ -40,12 +51,12 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from `
|
||||
### Multi-page Dash app
|
||||
|
||||
- `src/app.py` — creates the Dash app instance, navbar, SEO endpoints (robots.txt, sitemap.xml), Matomo analytics
|
||||
- `src/pages/*.py` — each page registers itself with `@register_page()` and owns its own layout and callbacks
|
||||
- `src/pages/*.py` — each page registers itself with `@register_page()` and o.wns its own layout and callbacks
|
||||
- `run.py` — dev entry point; exports `server` (Flask) for gunicorn
|
||||
|
||||
### Module imports
|
||||
|
||||
- always import modules from the app starting with `src.` (e.g. `src.utils.`, `src.pages.recherche`, etc.)
|
||||
- always import modules from the app starting with `src.` (e.g. `src.utils.`, `src.pages.recherche`, etc.), NOT `utils.cache` or `pages.observatoire`.
|
||||
|
||||
### Key pages
|
||||
|
||||
@@ -60,9 +71,9 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from `
|
||||
|
||||
### Data layer
|
||||
|
||||
- Data is stored as **Parquet** and loaded with **Polars** (fast columnar operations)
|
||||
- Data is stored as **Parquet** at rest, possibly in DuckDB, loaded in DuckDB, served from DuckDB for big queries and manipulated with **Polars** for the remaining steps
|
||||
- Path set via `DATA_FILE_PARQUET_PATH` env var; tests use `tests/test.parquet`
|
||||
- `src/utils.py` — filtering helpers, search (`search_org`), link generation, geographic data loading
|
||||
- `src/util/*.py` — helpers shared by other modules, search (`search_org`), link generation, geographic data loading
|
||||
- `src/callbacks.py` — shared Dash callbacks (e.g. `get_top_org_table`)
|
||||
- `src/figures.py` — chart and map components (Plotly Express, Dash Leaflet with marker clustering)
|
||||
- a Parquet file with production data is located at `../decp-processing/decp_prod.parquet` (~ 1,5 million records)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# decp.info
|
||||
|
||||
> v2.7.3
|
||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
|
||||
=> [decp.info](https://decp.info)
|
||||
|
||||
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
|
||||
@@ -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,206 @@
|
||||
# Observatoire — filtrage natif DuckDB
|
||||
|
||||
## Contexte
|
||||
|
||||
La page `/observatoire` construit ses cartes, ses téléchargements et sa prévisualisation
|
||||
tabulaire à partir de la fonction `prepare_dashboard_data` (dans `src/utils/data.py`).
|
||||
Aujourd'hui, cette fonction prend une `pl.LazyFrame` — typiquement obtenue par
|
||||
`query_marches().lazy()` — et applique une série de filtres côté Polars.
|
||||
|
||||
`query_marches()` matérialise l'intégralité de la table `decp` (~1,5 M lignes) en
|
||||
DataFrame Polars, même lorsqu'un utilisateur applique des filtres restrictifs. Les
|
||||
filtres sont ensuite appliqués sur cet ensemble déjà matérialisé.
|
||||
|
||||
Le pattern utilisé par `_fetch_page_sql` (dans `src/utils/table.py`) montre comment
|
||||
déléguer le filtrage à DuckDB :
|
||||
|
||||
1. Un traducteur (`filter_query_to_sql`, dans `src/utils/table_sql.py`) transforme le
|
||||
DSL utilisateur en `(where_sql, params)`.
|
||||
2. `query_marches(where_sql=..., params=...)` ne matérialise que le sous-ensemble utile.
|
||||
|
||||
Ce spec décrit comment appliquer ce même pattern aux filtres de l'observatoire.
|
||||
|
||||
## Objectifs
|
||||
|
||||
- Réduire la consommation mémoire et le temps de chaque callback de l'observatoire
|
||||
en poussant le filtrage au niveau DuckDB.
|
||||
- Conserver strictement la sémantique des filtres actuels (pas de régression
|
||||
fonctionnelle).
|
||||
- Garder une frontière claire : un helper pur `dashboard_filters_to_sql` qui ne
|
||||
touche pas à la base, et une `prepare_dashboard_data` fine qui appelle DuckDB.
|
||||
|
||||
## Non-objectifs
|
||||
|
||||
- Pas de refonte de l'UI de filtres.
|
||||
- Pas d'optimisation ou de cache supplémentaire autour de
|
||||
`_compute_dashboard_children` (déjà `@cache.memoize()`).
|
||||
- Pas de changement du comportement par défaut (365 derniers jours quand aucune
|
||||
année n'est sélectionnée).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Nouveau helper — `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."""
|
||||
```
|
||||
|
||||
Fonction pure, sans accès à la base. Même signature que `prepare_dashboard_data`
|
||||
actuelle (hors `lff`). Retourne `("TRUE", [])` si aucun filtre n'est actif.
|
||||
|
||||
### Réécriture — `prepare_dashboard_data` (`src/utils/data.py`)
|
||||
|
||||
```python
|
||||
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
|
||||
where_sql, params = dashboard_filters_to_sql(**filter_params)
|
||||
return query_marches(where_sql=where_sql, params=params)
|
||||
```
|
||||
|
||||
- **Signature** : suppression du paramètre `lff`. Retour `pl.DataFrame` (et non plus
|
||||
`pl.LazyFrame`).
|
||||
- Les appelants qui ont besoin d'une LazyFrame appellent `.lazy()` sur le résultat.
|
||||
|
||||
### Appelants — `src/pages/observatoire.py`
|
||||
|
||||
Trois sites d'appel à adapter :
|
||||
|
||||
1. **`_compute_dashboard_children`** (ligne ~668) — on remplace
|
||||
|
||||
```python
|
||||
lff: pl.LazyFrame = query_marches().lazy()
|
||||
lff = prepare_dashboard_data(lff=lff, **filter_params)
|
||||
dff = lff.collect(engine="streaming")
|
||||
```
|
||||
|
||||
par
|
||||
|
||||
```python
|
||||
dff = prepare_dashboard_data(**filter_params)
|
||||
lff = dff.lazy()
|
||||
```
|
||||
|
||||
Les appels existants à `make_donut`, `get_distance_histogram`, `get_top_org_table`,
|
||||
`get_barchart_sources` continuent de recevoir `lff` ; `get_geographic_maps`
|
||||
continue de recevoir `dff`. `df_per_uid` est calculé à partir de `dff`.
|
||||
|
||||
2. **`download_observatoire`** (ligne ~791) —
|
||||
|
||||
```python
|
||||
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")
|
||||
```
|
||||
|
||||
3. **`populate_preview_table`** (ligne ~882) —
|
||||
```python
|
||||
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||
return prepare_table_data(
|
||||
dff.lazy(), # prepare_table_data accepte une LazyFrame
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
## Traduction des filtres
|
||||
|
||||
| Filtre | Actuel (Polars) | Cible (SQL DuckDB) |
|
||||
| --------------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `dashboard_year` (présent) | `dt.year() == int(year)` | `YEAR("dateNotification") = ?` |
|
||||
| `dashboard_year` (absent) — comportement par défaut | `> now - 365j` | `"dateNotification" > ?` (datetime calculé à l'appel) |
|
||||
| `dashboard_acheteur_id` | `str.contains(val)` | `"acheteur_id" LIKE ?` avec `%val%` |
|
||||
| `dashboard_acheteur_categorie` | `== val` (skip si acheteur_id présent) | `"acheteur_categorie" = ?` |
|
||||
| `dashboard_acheteur_departement_code` | `is_in(list)` (skip si acheteur_id présent) | `"acheteur_departement_code" IN (?, ?, ...)` |
|
||||
| `dashboard_titulaire_id` | idem acheteur | idem |
|
||||
| `dashboard_titulaire_categorie` | idem | idem |
|
||||
| `dashboard_titulaire_departement_code` | idem | idem |
|
||||
| `dashboard_marche_type` | `== val` | `"type" = ?` |
|
||||
| `dashboard_marche_objet` | `str.contains("(?i)val")` | `"objet" ILIKE ?` avec `%val%` |
|
||||
| `dashboard_marche_code_cpv` | `str.starts_with(val)` | `"codeCPV" LIKE ?` avec `val%` |
|
||||
| `dashboard_marche_techniques` | `str.split(", ").list.set_intersection(xs).list.len() > 0` | `list_has_any(string_split("techniques", ', '), ?::VARCHAR[])` |
|
||||
| `dashboard_marche_considerations_sociales` | idem | idem sur `"considerationsSociales"` |
|
||||
| `dashboard_marche_considerations_environnementales` | idem | idem sur `"considerationsEnvironnementales"` |
|
||||
| `dashboard_marche_innovant` (`"oui"`/`"non"`, sinon skip) | `== val` | `"marcheInnovant" = ?` |
|
||||
| `dashboard_marche_sous_traitance_declaree` | idem | `"sousTraitanceDeclaree" = ?` |
|
||||
| `dashboard_montant_min` | `>= val` | `"montant" >= ?` |
|
||||
| `dashboard_montant_max` | `<= val` | `"montant" <= ?` |
|
||||
|
||||
**Logique conditionnelle conservée** : si `dashboard_acheteur_id` est fourni, les filtres
|
||||
`categorie` et `departement_code` acheteur sont ignorés (même chose pour titulaire).
|
||||
|
||||
**Traitement des valeurs spéciales** :
|
||||
|
||||
- `dashboard_marche_innovant` / `dashboard_marche_sous_traitance_declaree` : valeur
|
||||
`"all"` ou falsy → aucun filtre ajouté.
|
||||
- `dashboard_year` : converti en `int` avant injection.
|
||||
- `dashboard_montant_min` / `_max` : `None` → aucun filtre (distinct de `0`, qui reste
|
||||
un filtre valide via `>=` ou `<=`).
|
||||
|
||||
**Sécurité SQL** : toutes les valeurs utilisateurs passent par DuckDB en paramètres liés
|
||||
(`?`). Seuls des noms de colonnes statiques (contrôlés par le code) sont injectés dans le
|
||||
fragment SQL via `f"..."`. Pas de différence avec le pattern existant de
|
||||
`filter_query_to_sql`.
|
||||
|
||||
## Tests
|
||||
|
||||
### Unitaires (nouveaux)
|
||||
|
||||
Nouveau fichier `tests/test_dashboard_filters_to_sql.py` :
|
||||
|
||||
- Cas vide → `("TRUE", [])`.
|
||||
- Un seul filtre simple (année, type, etc.) → fragment SQL et params attendus.
|
||||
- Filtre montant min/max (migration de l'actuel `test_010_observatoire_montant_filter`).
|
||||
- Filtre liste (techniques, considerationsSociales) → usage de `list_has_any`.
|
||||
- Filtre acheteur_id fourni → catégorie/département acheteur ignorés.
|
||||
- Filtre `"all"` / `None` sur innovant/sous_traitance → aucun fragment ajouté.
|
||||
- Comportement par défaut sans année → fragment `"dateNotification" > ?` avec un param
|
||||
datetime à ~365 j dans le passé (tolérance de quelques secondes).
|
||||
|
||||
### Intégration (nouveau, léger)
|
||||
|
||||
Un test qui appelle `prepare_dashboard_data` contre `tests/test.parquet` avec un ou
|
||||
deux filtres connus, vérifie le `height` et la bonne nature du retour (`pl.DataFrame`).
|
||||
|
||||
### Test Selenium existant
|
||||
|
||||
`test_009_observatoire_filter_persistence` et `test_008_observatoire_navigation_from_search`
|
||||
ne touchent pas à la signature ; ils doivent continuer à passer.
|
||||
|
||||
## Risques et migration
|
||||
|
||||
- **Risque sémantique** : la fonction Polars `str.contains` utilisée pour les IDs est
|
||||
un regex. Les utilisateurs attendent probablement un contains littéral sur un SIRET
|
||||
(14 chiffres). Le passage à `LIKE '%val%'` est neutre si la valeur ne contient pas de
|
||||
caractère spécial regex — ce qui est le cas pour des SIRET. **Hypothèse** acceptée :
|
||||
le contenu `dashboard_acheteur_id`/`dashboard_titulaire_id` est alphanumérique.
|
||||
- **Risque de drift du cache** : la date "365 derniers jours" n'est pas incluse dans
|
||||
la clé de cache de `_compute_dashboard_children`. C'est un comportement pré-existant
|
||||
; non traité par ce spec.
|
||||
- **Import circulaire** : `src/utils/data.py` importe déjà depuis `src/db.py`.
|
||||
`src/utils/table_sql.py` importe depuis `src/utils/table.py`. Pas de nouveau cycle.
|
||||
|
||||
## Succès
|
||||
|
||||
- Les 3 callbacks de l'observatoire restent fonctionnellement équivalents.
|
||||
- Les tests unitaires et d'intégration passent.
|
||||
- Une inspection manuelle confirme un temps d'exécution réduit sur un filtre
|
||||
sélectif (par ex. un département + une année).
|
||||
@@ -0,0 +1,122 @@
|
||||
# Page `/etapes` — « Quelles données pour quelles étapes et quels seuils ? »
|
||||
|
||||
Date : 2026-06-04
|
||||
Branche : `dev`
|
||||
|
||||
## Objectif
|
||||
|
||||
Créer une page pédagogique sur decp.info qui montre, sur un seul graphique, **quelles données sont publiées à chaque étape de la passation d'un marché public** et **à partir de quel seuil réglementaire** (en € HT).
|
||||
|
||||
La page aide à comprendre l'écosystème des publications de données de la commande publique et à situer les DECP (le cœur de decp.info) parmi les autres sources.
|
||||
|
||||
## Portée
|
||||
|
||||
- Une page dédiée à l'URL `/etapes`.
|
||||
- Layout standard (bandeau de navigation global affiché en haut, comme toutes les pages).
|
||||
- **Non listée** dans la navbar pour l'instant (on ne sait pas encore comment la lier depuis le reste de l'app — elle n'est pas secrète).
|
||||
- **Référencée** dans le sitemap pour le SEO.
|
||||
- Graphique en **HTML/CSS statique** (pas de Plotly, pas de SVG, pas d'interactivité).
|
||||
- Pas de test automatisé spécifique (contenu statique) ; vérification visuelle via `python run.py`.
|
||||
|
||||
Hors portée : tout lien entrant depuis la navbar ou d'autres pages, toute interactivité (survol, filtre), toute donnée dynamique.
|
||||
|
||||
## Le graphique
|
||||
|
||||
### Axes
|
||||
|
||||
- **Axe Y** (de haut en bas) — étapes de la passation :
|
||||
1. Programmation
|
||||
2. Publicité (appel d'offres)
|
||||
3. Attribution
|
||||
4. Contrat — _vide_ (« aucune donnée publiée aujourd'hui »)
|
||||
5. Paiement — _vide_ (« aucune donnée publiée aujourd'hui »)
|
||||
- **Axe X** — seuils réglementaires en € HT, **segmenté** (espacement égal entre seuils, pas linéaire, sinon tout serait écrasé entre 40 k€ et 5,4 M€). Marqueurs de colonnes :
|
||||
- `0 €`
|
||||
- `40 000 €` — seuil DECP
|
||||
- `90 000 €` — seuil de publicité
|
||||
- `140 000 € / 216 000 €` — seuils formalisés (UE)
|
||||
- `5 404 000 €` — travaux (UE)
|
||||
|
||||
### Barres (publications de données)
|
||||
|
||||
Chaque barre est une bande horizontale colorée, positionnée sur sa ligne d'étape et couvrant la plage de seuils où la publication s'applique.
|
||||
|
||||
| Publication | Étape(s) | Plage de seuils | Note |
|
||||
| ------------------------------- | ------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------- |
|
||||
| **Approch** | Programmation | toute la largeur | sourcing / préinformation, publication **non réglementaire** |
|
||||
| **Journaux d'annonces légales** | Publicité | 90 000 € → seuil formalisé | remplit exactement cette case |
|
||||
| **BOAMP** | Publicité | ≥ 90 000 € (jusqu'à l'infini) | au-delà des seuils UE, publicité obligatoire au BOAMP **et** au JOUE |
|
||||
| **JOUE** | Publicité (avis de marché) + Attribution (avis d'attribution) | ≥ seuils formalisés | deux barres, une par étape |
|
||||
| **DECP** | Attribution | ≥ 40 000 € (jusqu'à l'infini) | données essentielles de la commande publique |
|
||||
|
||||
### Légende
|
||||
|
||||
Sous le graphique : une pastille de couleur + le nom complet pour chaque publication (Approch, Journaux d'annonces légales, BOAMP, JOUE, DECP).
|
||||
|
||||
## Implémentation
|
||||
|
||||
### Nouveau fichier `src/pages/etapes.py`
|
||||
|
||||
Enregistrement de la page :
|
||||
|
||||
```python
|
||||
register_page(
|
||||
__name__,
|
||||
path="/etapes",
|
||||
title="Quelles données pour quelles étapes et quels seuils ? | 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"],
|
||||
)
|
||||
```
|
||||
|
||||
Le `name="Étapes et données"` n'est pas dans la liste blanche de la navbar (`src/app.py:181`), la page reste donc hors navigation tout en étant accessible.
|
||||
|
||||
`layout` = `html.Div(className="container", children=[...])` :
|
||||
|
||||
1. `html.H2("Quelles données pour quelles étapes et quels seuils ?")`
|
||||
2. Paragraphe d'intro (`dcc.Markdown`) expliquant ce que montre le graphique.
|
||||
3. Le graphique (composants `html.Div` reproduisant la maquette v3, barres positionnées en `left`/`right` en `%`).
|
||||
4. La légende.
|
||||
5. Note de bas (`dcc.Markdown`) : axe X segmenté (non linéaire) ; Contrat et Paiement sans données ouvertes à ce jour.
|
||||
|
||||
### Modification de `src/app.py`
|
||||
|
||||
Ajouter `"/etapes"` à la liste des URLs du sitemap (`sitemap()`, ~ligne 73) :
|
||||
|
||||
```python
|
||||
pages = [
|
||||
"/",
|
||||
"/observatoire",
|
||||
"/tableau",
|
||||
"/a-propos",
|
||||
"/etapes",
|
||||
]
|
||||
```
|
||||
|
||||
Aucune modification de la navbar.
|
||||
|
||||
### CSS
|
||||
|
||||
Bloc dédié dans `src/assets/css/` (fichier existant ou nouveau), avec classes préfixées (ex. `.etapes-chart`, `.etapes-lane`, `.etapes-bar`…) pour éviter toute collision.
|
||||
|
||||
### Responsive — deux rendus
|
||||
|
||||
Le graphique en grille n'est pas lisible sur écran portrait étroit (la vue d'ensemble est perdue). On rend donc **deux représentations des mêmes données**, basculées par media query (point de rupture ~768 px) :
|
||||
|
||||
- **Desktop / tablette (≥ 768 px)** : le graphique en grille (maquette v3), enveloppé dans un conteneur `overflow-x:auto` + `min-width` pour les écrans intermédiaires. Le rendu mobile est masqué.
|
||||
- **Mobile (< 768 px)** : le graphique est masqué et remplacé par une **liste verticale par étape**. Chaque étape est un bloc qui liste ses publications, chacune avec sa pastille de couleur, son nom, et sa **plage de seuils en texte** (ex. « DECP — à partir de 40 000 € »). Les étapes Contrat/Paiement affichent « aucune donnée publiée aujourd'hui ».
|
||||
|
||||
Pour éviter la duplication, les publications de chaque étape (libellé, couleur, texte de plage) sont décrites **une seule fois** dans une structure de données Python, consommée par le rendu mobile et la légende. Le graphique en grille garde son positionnement explicite (intrinsèquement spatial).
|
||||
|
||||
## Vérification
|
||||
|
||||
- `python run.py` puis ouvrir `/etapes` : le graphique s'affiche, fidèle à la maquette v3, avec le bandeau de navigation en haut.
|
||||
- `/etapes` **absente** de la navbar.
|
||||
- `/sitemap.xml` **contient** `/etapes`.
|
||||
- Sur fenêtre intermédiaire : défilement horizontal du graphique, pas d'écrasement.
|
||||
- Sur écran portrait étroit (< 768 px) : le graphique en grille est masqué, remplacé par la liste verticale par étape, lisible sans défilement horizontal.
|
||||
|
||||
## Référence
|
||||
|
||||
Maquette validée : `.superpowers/brainstorm/80498-1780599135/content/chart-concept-v3.html`.
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "decp.info"
|
||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||
version = "2.7.3"
|
||||
version = "2.7.9"
|
||||
requires-python = ">= 3.10"
|
||||
authors = [{ name = "Colin Maudry", email = "colin@colmo.tech" }]
|
||||
dependencies = [
|
||||
@@ -21,6 +21,7 @@ dependencies = [
|
||||
"duckdb",
|
||||
"flask-caching",
|
||||
"pyarrow>=23.0.1",
|
||||
"flask-cors>=6.0.2",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -40,6 +41,7 @@ testpaths = ["tests"]
|
||||
env = [
|
||||
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
|
||||
"DEVELOPMENT=true",
|
||||
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json",
|
||||
"REBUILD_DUCKDB=true",
|
||||
"DATA_SCHEMA_LOCAL=/home/colin/git/decp-processing/dist/schema.json",
|
||||
]
|
||||
addopts = "-p no:warnings"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from flask_cors import CORS
|
||||
|
||||
from src.app import app
|
||||
|
||||
# To use `gunicorn run:server` (prod)
|
||||
server = app.server
|
||||
CORS(server)
|
||||
|
||||
# To use `python run.py` (dev)
|
||||
if __name__ == "__main__":
|
||||
|
||||
+23
-9
@@ -2,10 +2,11 @@ import os
|
||||
from shutil import rmtree
|
||||
|
||||
import dash_bootstrap_components as dbc
|
||||
import pandas # noqa: F401 # eager import: avoid plotly's lazy-import race across Dash callback threads
|
||||
import tomllib
|
||||
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
|
||||
from dotenv import load_dotenv
|
||||
from flask import Response
|
||||
from flask import Flask, Response
|
||||
|
||||
from src.utils import DEVELOPMENT
|
||||
from src.utils.cache import cache
|
||||
@@ -26,12 +27,10 @@ META_TAGS = [
|
||||
if DEVELOPMENT:
|
||||
META_TAGS.append({"name": "robots", "content": "noindex"})
|
||||
|
||||
app: Dash = Dash(
|
||||
title="decp.info",
|
||||
use_pages=True,
|
||||
compress=True,
|
||||
meta_tags=META_TAGS,
|
||||
)
|
||||
# Le cache doit être initialisé AVANT la construction de Dash : `use_pages=True`
|
||||
# importe les modules de pages pendant l'instanciation, et certains appellent des
|
||||
# fonctions memoizées (@cache.memoize) dès l'import (ex. tableau.py).
|
||||
server = Flask(__name__)
|
||||
|
||||
cache_dir = os.getenv("CACHE_DIR", "/tmp/decp-cache")
|
||||
|
||||
@@ -39,7 +38,7 @@ if os.path.exists(cache_dir):
|
||||
rmtree(cache_dir)
|
||||
|
||||
cache.init_app(
|
||||
app.server,
|
||||
server,
|
||||
config={
|
||||
"CACHE_TYPE": "FileSystemCache",
|
||||
"CACHE_DIR": cache_dir,
|
||||
@@ -50,6 +49,14 @@ cache.init_app(
|
||||
},
|
||||
)
|
||||
|
||||
app: Dash = Dash(
|
||||
server=server,
|
||||
title="decp.info",
|
||||
use_pages=True,
|
||||
compress=True,
|
||||
meta_tags=META_TAGS,
|
||||
)
|
||||
|
||||
|
||||
# robots.txt
|
||||
@app.server.route("/robots.txt")
|
||||
@@ -68,6 +75,7 @@ def sitemap():
|
||||
"/observatoire",
|
||||
"/tableau",
|
||||
"/a-propos",
|
||||
"/etapes",
|
||||
]
|
||||
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
@@ -144,7 +152,13 @@ navbar = dbc.Navbar(
|
||||
style={"minWidth": "230px"},
|
||||
),
|
||||
dbc.Nav(
|
||||
children=[dcc.Markdown(os.getenv("ANNOUNCEMENTS"), id="announcements")],
|
||||
children=[
|
||||
dcc.Markdown(
|
||||
os.getenv("ANNOUNCEMENTS"),
|
||||
id="announcements",
|
||||
dangerously_allow_html=True,
|
||||
),
|
||||
],
|
||||
style={
|
||||
"maxWidth": "1200px",
|
||||
"display": "inline-block",
|
||||
|
||||
@@ -144,6 +144,10 @@ p.version > a {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
#announcements p {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.seeBorder {
|
||||
border: dotted 1px green;
|
||||
}
|
||||
@@ -542,3 +546,218 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
/* ===== 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;
|
||||
cursor: pointer;
|
||||
transition: filter 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.etapes-bar:hover {
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.etapes-empty {
|
||||
color: #98a2b3;
|
||||
font-style: italic;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #eaecf0;
|
||||
}
|
||||
|
||||
.etapes-m-stage {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 15px;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.etapes-m-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #1570ef;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.etapes-m-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.etapes-detail {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.etapes-detail:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- Bascule desktop / mobile au point de rupture 768 px --- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.etapes-chart-scroll {
|
||||
display: none;
|
||||
}
|
||||
.etapes-mobile {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,31 @@ import polars as pl
|
||||
import polars.selectors as cs
|
||||
from polars.exceptions import ComputeError
|
||||
|
||||
from src.utils import logger
|
||||
from src.utils import get_last_modified, logger
|
||||
|
||||
|
||||
def should_rebuild(db_path: Path, parquet_path: Path) -> bool:
|
||||
def should_rebuild(db_path: Path, parquet_path: str) -> bool:
|
||||
db_path = Path(db_path)
|
||||
parquet_path = Path(parquet_path)
|
||||
if not db_path.exists():
|
||||
return True
|
||||
dev = os.getenv("DEVELOPMENT", "False").lower() == "true"
|
||||
force = os.getenv("REBUILD_DUCKDB", "False").lower() == "true"
|
||||
if dev and not force:
|
||||
return False
|
||||
return parquet_path.stat().st_mtime > db_path.stat().st_mtime
|
||||
last_modified: float = get_last_modified(parquet_path)
|
||||
return last_modified > db_path.stat().st_mtime
|
||||
|
||||
|
||||
def _load_source_frame(parquet_path: Path) -> pl.DataFrame:
|
||||
def _load_source_frame() -> pl.DataFrame:
|
||||
"""Read the source parquet and apply the row-level transforms.
|
||||
|
||||
Kept here (not in utils.py) so src.db has no dependency on utils.
|
||||
Mirrors the behavior previously in utils.get_decp_data().
|
||||
"""
|
||||
|
||||
parquet_path: str = os.getenv("DATA_FILE_PARQUET_PATH", "")
|
||||
if not (parquet_path.startswith("http")):
|
||||
assert os.path.exists(parquet_path)
|
||||
try:
|
||||
lff: pl.LazyFrame = pl.scan_parquet(str(parquet_path))
|
||||
except ComputeError:
|
||||
@@ -58,20 +62,21 @@ def _load_source_frame(parquet_path: Path) -> pl.DataFrame:
|
||||
return lff.collect()
|
||||
|
||||
|
||||
def build_database(db_path: Path, parquet_path: Path) -> None:
|
||||
def build_database(db_path: Path) -> None:
|
||||
"""Build the DuckDB database atomically under an exclusive lock.
|
||||
|
||||
Caller MUST hold the fcntl.flock on the .lock file.
|
||||
"""
|
||||
db_path = Path(db_path)
|
||||
parquet_path = Path(parquet_path)
|
||||
tmp_path = db_path.with_suffix(".duckdb.tmp")
|
||||
staging_parquet = db_path.with_suffix(".staging.parquet")
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
|
||||
logger.info(f"Construction de la base DuckDB à partir de {parquet_path}...")
|
||||
frame = _load_source_frame(parquet_path)
|
||||
logger.info(
|
||||
f"Construction de la base DuckDB à partir de {os.getenv('DATA_FILE_PARQUET_PATH', '')}..."
|
||||
)
|
||||
frame = _load_source_frame()
|
||||
|
||||
# Write transformed frame as parquet so DuckDB can read it natively
|
||||
# (avoids pyarrow dependency for the Polars→DuckDB handoff)
|
||||
@@ -110,14 +115,14 @@ def build_database(db_path: Path, parquet_path: Path) -> None:
|
||||
|
||||
|
||||
def _ensure_database() -> Path:
|
||||
db_path = Path("./decp.duckdb")
|
||||
parquet_path = Path(os.getenv("DATA_FILE_PARQUET_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")
|
||||
|
||||
with open(lock_path, "w") as lock_fd:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
if should_rebuild(db_path, parquet_path):
|
||||
build_database(db_path, parquet_path)
|
||||
build_database(db_path)
|
||||
else:
|
||||
logger.debug("Base de données déjà disponible et à jour.")
|
||||
return db_path
|
||||
@@ -135,10 +140,11 @@ def get_cursor() -> duckdb.DuckDBPyConnection:
|
||||
|
||||
def query_marches(
|
||||
where_sql: str = "TRUE",
|
||||
params: tuple = (),
|
||||
params: tuple | list = (),
|
||||
columns: list[str] | None = None,
|
||||
order_by: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> pl.DataFrame:
|
||||
"""Run a parameterized SELECT against the decp table and return Polars.
|
||||
|
||||
@@ -152,4 +158,25 @@ def query_marches(
|
||||
sql += f" ORDER BY {order_by}"
|
||||
if limit is not None:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
if offset is not None:
|
||||
sql += f" OFFSET {int(offset)}"
|
||||
|
||||
logger.debug("query_marches: " + sql.replace("?", "{}").format(*params))
|
||||
|
||||
return get_cursor().execute(sql, list(params)).pl()
|
||||
|
||||
|
||||
def count_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
|
||||
"""Retourne le nombre de lignes correspondant à where_sql."""
|
||||
sql = f"SELECT COUNT(*) FROM decp WHERE {where_sql}"
|
||||
logger.debug("count_marches: " + sql.replace("?", "{}").format(*params))
|
||||
result = get_cursor().execute(sql, list(params)).fetchone()
|
||||
return int(result[0]) if result else 0
|
||||
|
||||
|
||||
def count_unique_marches(where_sql: str = "TRUE", params: tuple | list = ()) -> int:
|
||||
"""Retourne le nombre de uid distincts correspondant à where_sql."""
|
||||
sql = f"SELECT COUNT(DISTINCT uid) FROM decp WHERE {where_sql}"
|
||||
logger.debug("count_unique_marches: " + sql.replace("?", "{}").format(*params))
|
||||
result = get_cursor().execute(sql, list(params)).fetchone()
|
||||
return int(result[0]) if result else 0
|
||||
|
||||
+66
-21
@@ -11,8 +11,10 @@ import plotly.graph_objects as go
|
||||
import polars as pl
|
||||
from dash import dash_table, dcc, html
|
||||
from dash_extensions.javascript import Namespace
|
||||
from polars.exceptions import ColumnNotFoundError
|
||||
|
||||
from src.db import schema
|
||||
from src.utils import logger
|
||||
from src.utils.data import DATA_SCHEMA, DEPARTEMENTS_GEOJSON
|
||||
from src.utils.table import add_links, format_number, setup_table_columns
|
||||
|
||||
@@ -173,39 +175,78 @@ def get_sources_tables(source_path) -> html.Div:
|
||||
return html.Div(children=datatable)
|
||||
|
||||
|
||||
def point_on_map(lat, lon):
|
||||
def point_on_map(lat, lon, departement_code=None):
|
||||
"""Fonction améliorée utilisant les codes départementaux pour la détection de région.
|
||||
|
||||
Args:
|
||||
lat: Coordonnée de latitude
|
||||
lon: Coordonnée de longitude
|
||||
departement_code: Code du département (ex: '75', '971', etc.)
|
||||
|
||||
Returns:
|
||||
html.Div contenant la carte, ou div vide si invalide
|
||||
"""
|
||||
# Validation des coordonnées
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except (TypeError, ValueError):
|
||||
return html.Div() # Div vide pour les coordonnées invalides
|
||||
|
||||
# Create a scatter mapbox or choropleth map
|
||||
# Vérification que les coordonnées sont valides
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return html.Div()
|
||||
|
||||
# Si aucun code département n'est fourni, retourner une div vide
|
||||
if not departement_code:
|
||||
return html.Div()
|
||||
|
||||
# Détermination de la région en utilisant le code département
|
||||
# Logique identique à get_geographic_maps
|
||||
if departement_code in ["971", "972", "973", "974", "976"]:
|
||||
region_key = departement_code # Département d'outre-mer
|
||||
elif len(departement_code) == 2: # Département métropolitain
|
||||
region_key = "Hexagone"
|
||||
else:
|
||||
return html.Div() # Format de code département invalide
|
||||
|
||||
# Paramètres de carte par région (réutilisés de get_geographic_maps)
|
||||
regions = {
|
||||
"Hexagone": {"center": [46.6, 2.2], "zoom": 5},
|
||||
"971": {"center": [16.23, -61.55], "zoom": 9}, # Guadeloupe
|
||||
"972": {"center": [14.64, -61.02], "zoom": 10}, # Martinique
|
||||
"973": {"center": [3.93, -53.12], "zoom": 7}, # Guyane
|
||||
"974": {"center": [-21.11, 55.53], "zoom": 9}, # La Réunion
|
||||
"976": {"center": [-12.82, 45.16], "zoom": 10}, # Mayotte
|
||||
}
|
||||
|
||||
settings = regions.get(region_key, regions["Hexagone"])
|
||||
|
||||
# Création de la carte
|
||||
fig = px.scatter_map(
|
||||
lat=[lat], lon=[lon], height=300, width=400, color=[1], size=[1]
|
||||
lat=[lat],
|
||||
lon=[lon],
|
||||
height=300,
|
||||
# width=400,
|
||||
color=[1],
|
||||
zoom=settings["zoom"],
|
||||
)
|
||||
|
||||
fig.update_coloraxes(showscale=False)
|
||||
fig.update_traces(marker=dict(size=10))
|
||||
|
||||
# Set map style (you can use 'open-street-map', 'carto-positron', etc.)
|
||||
# Configuration de la carte (interactive - zoomable)
|
||||
fig.update_layout(
|
||||
mapbox_style="light", # Light, clean background
|
||||
map_style="light", # Fond de carte clair
|
||||
margin={"r": 0, "t": 0, "l": 0, "b": 0},
|
||||
mapbox_center={"lat": settings["center"][0], "lon": settings["center"][1]},
|
||||
mapbox_zoom=settings["zoom"],
|
||||
coloraxis_showscale=False,
|
||||
)
|
||||
|
||||
# Optionally, center the map on France
|
||||
fig.update_geos(
|
||||
center=dict(lat=46.603354, lon=1.888334), # Center of France
|
||||
lataxis_range=[41, 51.5], # Latitude range for France
|
||||
lonaxis_range=[-5, 10], # Longitude range for France
|
||||
return html.Div(
|
||||
dcc.Graph(figure=fig, config={"displayModeBar": False}),
|
||||
)
|
||||
|
||||
# But scatter_mapbox doesn't use geos, so better to control via zoom/center manually
|
||||
# Let's reset and use proper centering in scatter_mapbox instead:
|
||||
|
||||
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
|
||||
|
||||
graph = dcc.Graph(id="map", figure=fig)
|
||||
graph = html.Div(style={"width": "400px"})
|
||||
return graph
|
||||
|
||||
|
||||
class DataTable(dash_table.DataTable):
|
||||
def __init__(
|
||||
@@ -833,13 +874,17 @@ def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool =
|
||||
lff = lff.cast(pl.String)
|
||||
lff = lff.fill_null("")
|
||||
|
||||
try:
|
||||
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||
except ColumnNotFoundError:
|
||||
logger.warning(f"get_top_org_table: column not found. {lff.collect_schema()}")
|
||||
return html.Div()
|
||||
|
||||
if dff.height == 0:
|
||||
return html.Div()
|
||||
|
||||
columns, tooltip = setup_table_columns(
|
||||
dff, hideable=False, exclude=[f"{org_type}_id"], new_columns=["Attributions"]
|
||||
dff, hideable=False, exclude=[f"{org_type}_id"]
|
||||
)
|
||||
dff = add_links(dff)
|
||||
data = dff.to_dicts()
|
||||
|
||||
@@ -87,14 +87,7 @@ Vous pouvez consommer les données qui alimentent decp.info
|
||||
dcc.Markdown(
|
||||
"""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
|
||||
|
||||
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [ci-dessous](/a-propos#sources). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
|
||||
|
||||
- klekoon.fr (ils y travaillent)
|
||||
- safetender.com (Omnikles)
|
||||
|
||||
**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics.
|
||||
|
||||
Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""
|
||||
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [ci-dessous](/bin.usr-is-merged/)). Je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""
|
||||
),
|
||||
html.H4("Sources de données ", id="sources"),
|
||||
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
||||
|
||||
@@ -257,8 +257,15 @@ def update_acheteur_infos(url):
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
# Extraction du code département à partir du code postal
|
||||
code_postal = data_etablissement.get("code_postal", "")
|
||||
departement_code = code_postal[:2] if code_postal else None
|
||||
|
||||
# Création de la carte avec le code département pour un centrage approprié
|
||||
acheteur_map = point_on_map(
|
||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||
data_etablissement["latitude"],
|
||||
data_etablissement["longitude"],
|
||||
departement_code,
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
|
||||
@@ -2,6 +2,7 @@ import polars as pl
|
||||
from dash import Input, Output, callback, dcc, html, register_page
|
||||
|
||||
from src.db import get_cursor
|
||||
from src.utils import logger
|
||||
from src.utils.data import DF_ACHETEURS, DF_TITULAIRES
|
||||
|
||||
NAME = "Liste des marchés publics"
|
||||
@@ -27,9 +28,12 @@ def make_org_nom_verbe(org_type, org_id) -> tuple:
|
||||
|
||||
|
||||
def get_title(code, org_type, org_id):
|
||||
if org_type:
|
||||
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||
|
||||
return f"Marchés publics {verbe} par {org_nom} | decp.info"
|
||||
else:
|
||||
logger.warning(f"Pas de org_type pour org_id: {org_id}")
|
||||
return "Marchés publics | decp.info"
|
||||
|
||||
|
||||
def get_description(code, org_type, org_id):
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
from dash import Input, Output, State, callback, ctx, dcc, html, register_page
|
||||
|
||||
from src.utils.seo import META_CONTENT
|
||||
|
||||
NAME = "Quelles données pour quelles étapes et quels seuils dans les marchés publics ?"
|
||||
|
||||
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"],
|
||||
)
|
||||
|
||||
# Contenu des fiches — à rédiger en Markdown.
|
||||
# Clés barres : "bar-approch", "bar-jal", "bar-boamp", "bar-joue-marche",
|
||||
# "bar-decp", "bar-joue-attribution"
|
||||
# Clés étapes (mobile) : "stage-programmation", "stage-publicite",
|
||||
# "stage-attribution", "stage-contrat", "stage-paiement"
|
||||
ALL_CONTENT: dict[str, str | None] = {
|
||||
"bar-approch": None,
|
||||
"bar-jal": None,
|
||||
"bar-boamp": None,
|
||||
"bar-joue-marche": None,
|
||||
"bar-decp": None,
|
||||
"bar-joue-attribution": None,
|
||||
"stage-programmation": None,
|
||||
"stage-publicite": None,
|
||||
"stage-attribution": None,
|
||||
"stage-contrat": None,
|
||||
"stage-paiement": None,
|
||||
}
|
||||
|
||||
_BAR_IDS = [
|
||||
"bar-approch",
|
||||
"bar-jal",
|
||||
"bar-boamp",
|
||||
"bar-joue-marche",
|
||||
"bar-decp",
|
||||
"bar-joue-attribution",
|
||||
]
|
||||
_STAGE_IDS = [
|
||||
"stage-programmation",
|
||||
"stage-publicite",
|
||||
"stage-attribution",
|
||||
"stage-contrat",
|
||||
"stage-paiement",
|
||||
]
|
||||
|
||||
|
||||
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, bar_id=None):
|
||||
base = {"backgroundColor": color}
|
||||
base.update(style)
|
||||
props = {"className": "etapes-bar", "style": base}
|
||||
if bar_id is not None:
|
||||
props["id"] = bar_id
|
||||
props["n_clicks"] = 0
|
||||
return html.Div(label, **props)
|
||||
|
||||
|
||||
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%"},
|
||||
bar_id="bar-approch",
|
||||
),
|
||||
),
|
||||
# Publicité (appel d'offres)
|
||||
html.Div(["Publicité"], className="etapes-stage"),
|
||||
_lane(
|
||||
_bar(
|
||||
"JAL",
|
||||
"#f79009",
|
||||
{"left": "40%", "right": "40%", "top": "6px", "height": "20px"},
|
||||
bar_id="bar-jal",
|
||||
),
|
||||
_bar(
|
||||
"BOAMP",
|
||||
"#1570ef",
|
||||
{"left": "40%", "right": "2%", "top": "28px", "height": "20px"},
|
||||
bar_id="bar-boamp",
|
||||
),
|
||||
_bar(
|
||||
"JOUE — avis de marché",
|
||||
"#0e9384",
|
||||
{"left": "60%", "right": "2%", "top": "6px", "height": "20px"},
|
||||
bar_id="bar-joue-marche",
|
||||
),
|
||||
),
|
||||
# Attribution
|
||||
html.Div("Attribution", className="etapes-stage"),
|
||||
_lane(
|
||||
_bar(
|
||||
"DECP — données essentielles",
|
||||
"#12b76a",
|
||||
{"left": "20%", "right": "2%", "top": "6px", "height": "20px"},
|
||||
bar_id="bar-decp",
|
||||
),
|
||||
_bar(
|
||||
"JOUE — avis d'attribution",
|
||||
"#0e9384",
|
||||
{"left": "60%", "right": "2%", "top": "28px", "height": "20px"},
|
||||
bar_id="bar-joue-attribution",
|
||||
),
|
||||
),
|
||||
# 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",
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Données par étape, partagées par la vue mobile.
|
||||
# Chaque tuple : (libellé étape, id CSS, [(libellé, couleur, plage seuils)]).
|
||||
STAGES_MOBILE = [
|
||||
(
|
||||
"Programmation",
|
||||
"stage-programmation",
|
||||
[
|
||||
("Approch", "#7c5cff", "tous montants — publication non réglementaire"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Publicité (appel d'offres)",
|
||||
"stage-publicite",
|
||||
[
|
||||
("JAL", "#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",
|
||||
"stage-attribution",
|
||||
[
|
||||
("DECP — données essentielles", "#12b76a", "à partir de 40 000 €"),
|
||||
("JOUE — avis d'attribution", "#0e9384", "à partir des seuils formalisés"),
|
||||
],
|
||||
),
|
||||
("Contrat", "stage-contrat", []),
|
||||
("Paiement", "stage-paiement", []),
|
||||
]
|
||||
|
||||
|
||||
def build_mobile():
|
||||
blocks = []
|
||||
for stage, stage_id, 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.Div(
|
||||
[
|
||||
html.H4(stage, className="etapes-m-stage"),
|
||||
html.Button(
|
||||
"Voir fiche →",
|
||||
id=stage_id,
|
||||
n_clicks=0,
|
||||
className="etapes-m-link",
|
||||
),
|
||||
],
|
||||
className="etapes-m-header",
|
||||
),
|
||||
*children,
|
||||
],
|
||||
className="etapes-m-block",
|
||||
)
|
||||
)
|
||||
return html.Div(blocks, className="etapes-mobile")
|
||||
|
||||
|
||||
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)."
|
||||
),
|
||||
build_chart(),
|
||||
build_mobile(),
|
||||
dcc.Store(id="etapes-selected", data=None),
|
||||
html.Div(id="etapes-detail", className="etapes-detail"),
|
||||
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",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@callback(
|
||||
Output("etapes-detail", "children"),
|
||||
Output("etapes-selected", "data"),
|
||||
[Input(id_, "n_clicks") for id_ in _BAR_IDS + _STAGE_IDS],
|
||||
State("etapes-selected", "data"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def _show_detail(*args):
|
||||
current = args[-1]
|
||||
triggered = ctx.triggered_id
|
||||
if triggered == current:
|
||||
return None, None
|
||||
content = ALL_CONTENT.get(triggered)
|
||||
if content is None:
|
||||
return dcc.Markdown(f"*Fiche en cours de rédaction.* {triggered}"), triggered
|
||||
return dcc.Markdown(content), triggered
|
||||
+3
-2
@@ -109,7 +109,7 @@ def update_marche_info(marche, titulaires):
|
||||
column_object = DATA_SCHEMA.get(col)
|
||||
column_name = column_object.get("title") if column_object else col
|
||||
|
||||
if marche[col]:
|
||||
if marche and col in marche:
|
||||
if col == "acheteur_nom":
|
||||
value = html.A(
|
||||
href=f"/acheteurs/{marche['acheteur_id']}",
|
||||
@@ -134,6 +134,7 @@ def update_marche_info(marche, titulaires):
|
||||
"considerationsSociales",
|
||||
"considerationsEnvironnementales",
|
||||
]
|
||||
and col in marche
|
||||
and "," in marche[col]
|
||||
):
|
||||
col_values = marche[col].split(", ")
|
||||
@@ -243,7 +244,7 @@ def get_marche_jsonld(marche, titulaires) -> str:
|
||||
titulaire.get("titulaire_id"),
|
||||
org_name=titulaire.get("titulaire_nom"),
|
||||
org_type="titulaire",
|
||||
type_org_id=titulaire.get("titulaire_typeIdentifiant"),
|
||||
type_org_id=titulaire.get("titulaire_typeIdentifiant", "SIRET"),
|
||||
),
|
||||
"orderedItem": {
|
||||
"@type": type_order,
|
||||
|
||||
+21
-11
@@ -16,7 +16,7 @@ from dash import (
|
||||
register_page,
|
||||
)
|
||||
|
||||
from src.db import query_marches, schema
|
||||
from src.db import schema
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_barchart_sources,
|
||||
@@ -508,6 +508,11 @@ Alors, on fait comment ?
|
||||
size="xl",
|
||||
),
|
||||
# DataTable
|
||||
dcc.Loading(
|
||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||
id="loading-statistques",
|
||||
type="default",
|
||||
children=[
|
||||
html.Div(
|
||||
className="marches_table",
|
||||
children=DataTable(
|
||||
@@ -517,8 +522,12 @@ Alors, on fait comment ?
|
||||
sort_action="custom",
|
||||
filter_action="custom",
|
||||
hidden_columns=[],
|
||||
columns=[{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS],
|
||||
columns=[
|
||||
{"id": col, "name": col} for col in OBSERVATOIRE_COLUMNS
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -664,10 +673,8 @@ def _compute_dashboard_children(filter_params_normalized: tuple):
|
||||
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")
|
||||
dff = prepare_dashboard_data(**filter_params)
|
||||
lff = dff.lazy()
|
||||
|
||||
df_per_uid = (
|
||||
dff.select("uid", "montant").group_by("uid").agg(pl.col("montant").first())
|
||||
@@ -788,13 +795,13 @@ def update_dashboard_cards(*filter_values):
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def download_observatoire(_n_clicks, filter_params, hidden_columns):
|
||||
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
||||
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||
|
||||
if hidden_columns:
|
||||
lff = lff.drop(hidden_columns)
|
||||
dff = dff.drop(hidden_columns)
|
||||
|
||||
def to_bytes(buffer):
|
||||
lff.collect(engine="streaming").write_excel(buffer, worksheet="DECP")
|
||||
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")
|
||||
@@ -817,6 +824,9 @@ def toggle_montant_modal(n_triggers, _close):
|
||||
prevent_initial_call=False,
|
||||
)
|
||||
def add_organization_name_in_title(acheteur_id, titulaire_id):
|
||||
acheteur_id = acheteur_id.replace(" ", "") if acheteur_id else None
|
||||
titulaire_id = titulaire_id.replace(" ", "") if titulaire_id else None
|
||||
|
||||
def lookup_nom(df_org, id_col, nom_col, org_id):
|
||||
match = df_org.filter(pl.col(id_col) == org_id)
|
||||
return match[nom_col].item(0) if match.height >= 1 else None
|
||||
@@ -879,10 +889,10 @@ def populate_preview_table(
|
||||
if not is_open:
|
||||
return (no_update,) * 9
|
||||
|
||||
lff = prepare_dashboard_data(lff=query_marches().lazy(), **(filter_params or {}))
|
||||
dff = prepare_dashboard_data(**(filter_params or {}))
|
||||
|
||||
return prepare_table_data(
|
||||
lff,
|
||||
dff.lazy(),
|
||||
data_timestamp,
|
||||
filter_query,
|
||||
page_current,
|
||||
|
||||
+10
-9
@@ -21,7 +21,7 @@ from dash import (
|
||||
|
||||
from src.db import query_marches, schema
|
||||
from src.figures import DataTable, make_column_picker
|
||||
from src.utils import logger
|
||||
from src.utils import get_last_modified, logger
|
||||
from src.utils.seo import META_CONTENT
|
||||
from src.utils.table import (
|
||||
COLUMNS,
|
||||
@@ -33,7 +33,7 @@ from src.utils.table import (
|
||||
)
|
||||
from src.utils.tracking import track_search
|
||||
|
||||
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||
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()
|
||||
|
||||
@@ -163,18 +163,19 @@ layout = [
|
||||
|
||||
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
||||
|
||||
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules).
|
||||
- Exemple : `rennes` retourne "RENNES METROPOLE".
|
||||
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché, n'est pas sensible à la casse (majuscules/minuscules) et est sensbible à l'accentuation.
|
||||
- `rennes` => le texte contient "rennes"
|
||||
- `metro* *pole` => le texte contient un mot qui commence par "metro" et un mot qui finit par "pole"
|
||||
- `metropole rennes` => le texte contient les mots "metropole" et "rennes", n'importe où dans le texte
|
||||
- `metropole+rennes` => le texte contient "metropole rennes", collé et dans cet ordre
|
||||
- `metropole+rennes travaux distri*` => le texte contient "metropole rennes", "travaux" et un mot qui commence par "distri"
|
||||
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
|
||||
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
|
||||
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
|
||||
- soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
||||
- Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples :
|
||||
- Champs date (Date de notification, ...) :
|
||||
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
||||
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022".
|
||||
- Pour les champs textuels et les champs dates :
|
||||
- pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`)
|
||||
- pour chercher du texte qui **finit par** votre texte, entrez `*texte`
|
||||
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022"
|
||||
|
||||
Vous pouvez filtrer plusieurs colonnes à la fois.
|
||||
|
||||
|
||||
@@ -263,8 +263,15 @@ def update_titulaire_infos(url):
|
||||
if data_etablissement:
|
||||
data_etablissement = data_etablissement[0]
|
||||
|
||||
# Extraction du code département à partir du code postal
|
||||
code_postal = data_etablissement.get("code_postal", "")
|
||||
departement_code = code_postal[:2] if code_postal else None
|
||||
|
||||
# Création de la carte avec le code département pour un centrage approprié
|
||||
titulaire_map = point_on_map(
|
||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||
data_etablissement["latitude"],
|
||||
data_etablissement["longitude"],
|
||||
departement_code,
|
||||
)
|
||||
code_departement, nom_departement, nom_region = get_departement_region(
|
||||
data_etablissement["code_postal"]
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from src.utils.cache import cache
|
||||
|
||||
|
||||
@cache.memoize()
|
||||
def get_last_modified(parquet_path: str) -> float:
|
||||
logger.info("Récupération de la date de modification des données...")
|
||||
logging.getLogger("httpx").setLevel("WARNING")
|
||||
if parquet_path.startswith("http"):
|
||||
last_modified = httpx.head(
|
||||
url=parquet_path,
|
||||
follow_redirects=True,
|
||||
).headers["last-modified"]
|
||||
last_modified = datetime.strptime(last_modified, "%a, %d %b %Y %X %Z").strftime(
|
||||
"%s"
|
||||
)
|
||||
return float(last_modified)
|
||||
parquet_local_path = Path(parquet_path)
|
||||
return parquet_local_path.stat().st_mtime
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||
|
||||
+34
-120
@@ -2,18 +2,19 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import polars as pl
|
||||
from httpx import HTTPError, get
|
||||
|
||||
from src.db import get_cursor, schema
|
||||
from src.db import get_cursor, query_marches, schema
|
||||
from src.utils import logger
|
||||
|
||||
logging.getLogger("httpx").setLevel("WARNING")
|
||||
|
||||
|
||||
def get_annuaire_data(siret: str) -> dict:
|
||||
def get_annuaire_data(siret: str) -> dict | None:
|
||||
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||
try:
|
||||
response = get(url).raise_for_status()
|
||||
@@ -52,7 +53,8 @@ def get_departements_geojson() -> dict:
|
||||
return geojson
|
||||
|
||||
|
||||
def get_departement_region(code_postal):
|
||||
def get_departement_region(code_postal: str | None):
|
||||
if code_postal:
|
||||
if code_postal > "97000":
|
||||
code_departement = code_postal[:3]
|
||||
else:
|
||||
@@ -60,20 +62,30 @@ def get_departement_region(code_postal):
|
||||
nom_departement = DEPARTEMENTS[code_departement]["departement"]
|
||||
nom_region = DEPARTEMENTS[code_departement]["region"]
|
||||
return code_departement, nom_departement, nom_region
|
||||
return "", "", ""
|
||||
|
||||
|
||||
def get_data_schema() -> dict:
|
||||
# Récupération du schéma des données tabulaires
|
||||
path = os.getenv("DATA_SCHEMA_PATH")
|
||||
if path.startswith("http"):
|
||||
original_schema: dict = get(
|
||||
os.getenv("DATA_SCHEMA_PATH"), follow_redirects=True
|
||||
).json()
|
||||
elif os.path.exists(path):
|
||||
with open(path) as f:
|
||||
url = os.getenv("DATA_SCHEMA_PATH")
|
||||
local_path = Path(os.getenv("DATA_SCHEMA_LOCAL", ""))
|
||||
|
||||
original_schema = {}
|
||||
if url:
|
||||
try:
|
||||
original_schema: dict = get(url, follow_redirects=True).json()
|
||||
except (
|
||||
httpx.ReadTimeout,
|
||||
httpx.ReadError,
|
||||
httpx.ConnectError,
|
||||
httpx.ConnectTimeout,
|
||||
):
|
||||
logger.error(f"Erreur HTTP lors de la récupération du schéma ({url})")
|
||||
|
||||
if os.path.exists(local_path) and original_schema == {}:
|
||||
with open(local_path) as f:
|
||||
original_schema: dict = json.load(f)
|
||||
else:
|
||||
raise Exception(f"Chemin vers le schéma invalide: {path}")
|
||||
logger.info(f"Utilisation du schéma local ({local_path})")
|
||||
|
||||
new_schema = OrderedDict()
|
||||
|
||||
@@ -83,115 +95,17 @@ def get_data_schema() -> dict:
|
||||
return new_schema
|
||||
|
||||
|
||||
def prepare_dashboard_data(
|
||||
lff: pl.LazyFrame,
|
||||
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,
|
||||
) -> pl.LazyFrame:
|
||||
if dashboard_year:
|
||||
lff = lff.filter(pl.col("dateNotification").dt.year() == int(dashboard_year))
|
||||
else:
|
||||
lff = lff.filter(
|
||||
pl.col("dateNotification") > (datetime.now() - timedelta(days=365))
|
||||
)
|
||||
def prepare_dashboard_data(**filter_params) -> pl.DataFrame:
|
||||
"""Exécute la requête DuckDB filtrée pour le tableau de bord.
|
||||
|
||||
if dashboard_acheteur_id:
|
||||
lff = lff.filter(pl.col("acheteur_id").str.contains(dashboard_acheteur_id))
|
||||
else:
|
||||
if dashboard_acheteur_categorie:
|
||||
lff = lff.filter(
|
||||
pl.col("acheteur_categorie") == dashboard_acheteur_categorie
|
||||
)
|
||||
if dashboard_acheteur_departement_code:
|
||||
lff = lff.filter(
|
||||
pl.col("acheteur_departement_code").is_in(
|
||||
dashboard_acheteur_departement_code
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
if dashboard_titulaire_id:
|
||||
lff = lff.filter(pl.col("titulaire_id").str.contains(dashboard_titulaire_id))
|
||||
else:
|
||||
if dashboard_titulaire_categorie:
|
||||
lff = lff.filter(
|
||||
pl.col("titulaire_categorie") == dashboard_titulaire_categorie
|
||||
)
|
||||
if dashboard_titulaire_departement_code:
|
||||
lff = lff.filter(
|
||||
pl.col("titulaire_departement_code").is_in(
|
||||
dashboard_titulaire_departement_code
|
||||
)
|
||||
)
|
||||
|
||||
if dashboard_marche_type:
|
||||
lff = lff.filter(pl.col("type") == dashboard_marche_type)
|
||||
|
||||
if dashboard_marche_objet:
|
||||
lff = lff.filter(pl.col("objet").str.contains(f"(?i){dashboard_marche_objet}"))
|
||||
|
||||
if dashboard_marche_code_cpv:
|
||||
lff = lff.filter(pl.col("codeCPV").str.starts_with(dashboard_marche_code_cpv))
|
||||
|
||||
if dashboard_marche_innovant and dashboard_marche_innovant != "all":
|
||||
lff = lff.filter(pl.col("marcheInnovant") == dashboard_marche_innovant)
|
||||
|
||||
if (
|
||||
dashboard_marche_sous_traitance_declaree
|
||||
and dashboard_marche_sous_traitance_declaree != "all"
|
||||
):
|
||||
lff = lff.filter(
|
||||
pl.col("sousTraitanceDeclaree") == dashboard_marche_sous_traitance_declaree
|
||||
)
|
||||
|
||||
if dashboard_marche_techniques:
|
||||
lff = lff.filter(
|
||||
pl.col("techniques")
|
||||
.str.split(", ")
|
||||
.list.set_intersection(dashboard_marche_techniques)
|
||||
.list.len()
|
||||
> 0
|
||||
)
|
||||
|
||||
if dashboard_marche_considerations_sociales:
|
||||
lff = lff.filter(
|
||||
pl.col("considerationsSociales")
|
||||
.str.split(", ")
|
||||
.list.set_intersection(dashboard_marche_considerations_sociales)
|
||||
.list.len()
|
||||
> 0
|
||||
)
|
||||
|
||||
if dashboard_marche_considerations_environnementales:
|
||||
lff = lff.filter(
|
||||
pl.col("considerationsEnvironnementales")
|
||||
.str.split(", ")
|
||||
.list.set_intersection(dashboard_marche_considerations_environnementales)
|
||||
.list.len()
|
||||
> 0
|
||||
)
|
||||
|
||||
if dashboard_montant_min is not None:
|
||||
lff = lff.filter(pl.col("montant") >= dashboard_montant_min)
|
||||
|
||||
if dashboard_montant_max is not None:
|
||||
lff = lff.filter(pl.col("montant") <= dashboard_montant_max)
|
||||
|
||||
return lff
|
||||
where_sql, params = dashboard_filters_to_sql(**filter_params)
|
||||
return query_marches(where_sql=where_sql, params=params)
|
||||
|
||||
|
||||
def build_org_frame(org_type: str) -> pl.DataFrame:
|
||||
|
||||
@@ -7,6 +7,8 @@ def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dic
|
||||
address = None
|
||||
if type_org_id.lower() == "siret" and len(org_id) == 14:
|
||||
annuaire_data = get_annuaire_data(org_id)
|
||||
if not annuaire_data:
|
||||
return {}
|
||||
annuaire_address = annuaire_data["matching_etablissements"][0]
|
||||
code_postal = annuaire_address["code_postal"]
|
||||
commune = annuaire_address["libelle_commune"]
|
||||
|
||||
+65
-36
@@ -5,7 +5,7 @@ import polars as pl
|
||||
from dash import no_update
|
||||
from polars import selectors as cs
|
||||
|
||||
from src.db import query_marches, schema
|
||||
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
|
||||
@@ -154,6 +154,8 @@ def normalize_sort_by(sort_by) -> tuple:
|
||||
|
||||
|
||||
def format_number(number) -> str:
|
||||
if not number:
|
||||
return ""
|
||||
number = "{:,}".format(number).replace(",", " ")
|
||||
return number
|
||||
|
||||
@@ -376,32 +378,12 @@ def get_default_hidden_columns(page):
|
||||
return hidden_columns
|
||||
|
||||
|
||||
@cache.memoize()
|
||||
def _load_filter_sort_postprocess(filter_query, sort_by_key):
|
||||
logger.debug(
|
||||
f"Cache miss — recomputing for filter={filter_query!r} sort={sort_by_key!r}"
|
||||
)
|
||||
def postprocess_page(dff: pl.DataFrame) -> pl.DataFrame:
|
||||
"""Post-traitement à appliquer sur une page déjà paginée.
|
||||
|
||||
lff: pl.LazyFrame = query_marches().lazy()
|
||||
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query)
|
||||
|
||||
if sort_by_key:
|
||||
sort_by = [
|
||||
{"column_id": col, "direction": direction} for col, direction in sort_by_key
|
||||
]
|
||||
lff = sort_table_data(lff, sort_by)
|
||||
|
||||
dff = table_postprocess(lff)
|
||||
|
||||
return dff
|
||||
|
||||
|
||||
def table_postprocess(lff) -> pl.DataFrame:
|
||||
lff = lff.cast(pl.String)
|
||||
lff = lff.fill_null("")
|
||||
dff: pl.DataFrame = lff.collect()
|
||||
À appeler après la pagination.
|
||||
"""
|
||||
dff = dff.with_columns(pl.all().cast(pl.String).fill_null(""))
|
||||
dff = add_links(dff)
|
||||
if "sourceFile" in dff.columns:
|
||||
dff = add_resource_link(dff)
|
||||
@@ -410,6 +392,48 @@ def table_postprocess(lff) -> pl.DataFrame:
|
||||
return dff
|
||||
|
||||
|
||||
@cache.memoize()
|
||||
def _fetch_page_sql(
|
||||
filter_query: str | None,
|
||||
sort_by_key: tuple,
|
||||
page_current: int,
|
||||
page_size: int,
|
||||
) -> tuple[pl.DataFrame, int, int]:
|
||||
"""Chemin rapide : filtre/tri/pagine dans DuckDB, post-traite la page seule.
|
||||
|
||||
Retourne (page_dataframe_post_traitée, total_count, total_unique_count).
|
||||
"""
|
||||
# Import local pour éviter une dépendance circulaire
|
||||
# (src.utils.table_sql importe split_filter_part depuis src.utils.table).
|
||||
from src.utils.table_sql import filter_query_to_sql, sort_by_to_sql
|
||||
|
||||
logger.debug(
|
||||
f"Cache miss SQL — filter={filter_query!r} sort={sort_by_key!r} "
|
||||
f"page={page_current} size={page_size}"
|
||||
)
|
||||
|
||||
where_sql, params = filter_query_to_sql(filter_query or "", schema)
|
||||
|
||||
sort_by_dash = [
|
||||
{"column_id": col, "direction": direction} for col, direction in sort_by_key
|
||||
]
|
||||
order_by = sort_by_to_sql(sort_by_dash, schema) or None
|
||||
|
||||
total = count_marches(where_sql, params)
|
||||
total_unique = count_unique_marches(where_sql, params)
|
||||
|
||||
page = query_marches(
|
||||
where_sql=where_sql,
|
||||
params=params,
|
||||
order_by=order_by,
|
||||
limit=page_size,
|
||||
offset=page_current * page_size,
|
||||
)
|
||||
|
||||
page = postprocess_page(page)
|
||||
return page, total, total_unique
|
||||
|
||||
|
||||
def prepare_table_data(
|
||||
data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table
|
||||
):
|
||||
@@ -433,9 +457,13 @@ def prepare_table_data(
|
||||
trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4())
|
||||
|
||||
if data is None:
|
||||
# Probablement car il s'agit de la page Tableau
|
||||
sort_by_key = normalize_sort_by(sort_by)
|
||||
dff: pl.DataFrame = _load_filter_sort_postprocess(
|
||||
filter_query=filter_query, sort_by_key=sort_by_key
|
||||
dff, height, total_unique = _fetch_page_sql(
|
||||
filter_query=filter_query,
|
||||
sort_by_key=sort_by_key,
|
||||
page_current=page_current,
|
||||
page_size=page_size,
|
||||
)
|
||||
else:
|
||||
if isinstance(data, list):
|
||||
@@ -450,24 +478,25 @@ def prepare_table_data(
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query)
|
||||
|
||||
df_height = lff.select("uid").collect(engine="streaming")
|
||||
height = df_height.height
|
||||
total_unique = df_height["uid"].n_unique()
|
||||
|
||||
if sort_by and len(sort_by) > 0:
|
||||
lff = sort_table_data(lff, sort_by)
|
||||
|
||||
dff: pl.DataFrame = table_postprocess(lff)
|
||||
|
||||
height = dff.height
|
||||
start_row = page_current * page_size
|
||||
lff = lff.slice(start_row, page_size)
|
||||
dff = lff.collect(engine="streaming")
|
||||
dff: pl.DataFrame = postprocess_page(dff)
|
||||
|
||||
if height > 0:
|
||||
nb_rows = (
|
||||
f"{format_number(height)} lignes "
|
||||
f"({format_number(dff.select('uid').unique().height)} marchés)"
|
||||
f"{format_number(height)} lignes ({format_number(total_unique)} marchés)"
|
||||
)
|
||||
else:
|
||||
nb_rows = "0 lignes (0 marchés)"
|
||||
|
||||
start_row = page_current * page_size
|
||||
dff = dff.slice(start_row, page_size)
|
||||
|
||||
table_columns, tooltip = setup_table_columns(dff)
|
||||
|
||||
dicts = dff.to_dicts()
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import polars as pl
|
||||
|
||||
from src.utils import logger
|
||||
from src.utils.table import split_filter_part
|
||||
|
||||
|
||||
def filter_query_to_sql(filter_query: str, schema: pl.Schema) -> tuple[str, list]:
|
||||
"""Traduit le DSL de filtres de dash_table.DataTable en fragment SQL DuckDB.
|
||||
|
||||
Retourne (where_clause, params) où where_clause est un fragment à injecter
|
||||
après WHERE et params est la liste des valeurs à passer à
|
||||
cursor.execute(sql, params). Les identifiants de colonnes sont validés
|
||||
contre le schéma fourni ; jamais concaténés avec des valeurs utilisateur.
|
||||
"""
|
||||
if not filter_query:
|
||||
return "TRUE", []
|
||||
|
||||
clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
for part in filter_query.split(" && "):
|
||||
col_name, operator, raw_value = split_filter_part(part)
|
||||
if not isinstance(col_name, str) or not isinstance(raw_value, str):
|
||||
continue
|
||||
|
||||
if col_name not in schema.names():
|
||||
logger.warning(f"Colonne inconnue ignorée : {col_name!r}")
|
||||
continue
|
||||
|
||||
col_type = schema[col_name]
|
||||
is_numeric = col_type.is_numeric()
|
||||
col_is_date = col_type == pl.Date
|
||||
quoted_col = f'"{col_name}"'
|
||||
|
||||
if is_numeric:
|
||||
try:
|
||||
value = int(raw_value) if col_type.is_integer() else float(raw_value)
|
||||
except ValueError:
|
||||
logger.warning(f"Valeur numérique invalide ignorée : {raw_value!r}")
|
||||
continue
|
||||
|
||||
if operator == "contains":
|
||||
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} = ?")
|
||||
elif operator == ">":
|
||||
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} > ?")
|
||||
elif operator == "<":
|
||||
clauses.append(f"{quoted_col} IS NOT NULL AND {quoted_col} < ?")
|
||||
else:
|
||||
logger.warning(f"Opérateur invalide pour numérique : {operator!r}")
|
||||
continue
|
||||
params.append(value)
|
||||
continue
|
||||
|
||||
# String / Date : toujours traité comme texte (parité avec Polars)
|
||||
value = raw_value.strip('"')
|
||||
|
||||
if operator == "contains":
|
||||
if col_is_date:
|
||||
target = f"CAST({quoted_col} AS VARCHAR)"
|
||||
|
||||
if col_name in ("acheteur_id", "titulaire_id"):
|
||||
value = value.replace(" ", "")
|
||||
where_clause, param_list = tokenize_text_filter(
|
||||
col_name, value, col_is_date
|
||||
)
|
||||
clauses.append(where_clause)
|
||||
params.extend(param_list)
|
||||
logger.debug(params)
|
||||
continue
|
||||
|
||||
elif operator in (">", "<"):
|
||||
target = f"CAST({quoted_col} AS VARCHAR)" if col_is_date else quoted_col
|
||||
clauses.append(f"{quoted_col} IS NOT NULL AND {target} {operator} ?")
|
||||
params.append(value)
|
||||
else:
|
||||
logger.warning(f"Opérateur invalide pour chaîne : {operator!r}")
|
||||
continue
|
||||
|
||||
if not clauses:
|
||||
return "TRUE", []
|
||||
return " AND ".join(clauses), params
|
||||
|
||||
|
||||
def sort_by_to_sql(sort_by: list[dict] | None, schema: pl.Schema) -> str:
|
||||
"""Traduit sort_by (format Dash) en clause ORDER BY DuckDB.
|
||||
|
||||
Retourne '' si pas de tri (aucun ORDER BY à ajouter).
|
||||
"""
|
||||
if not sort_by:
|
||||
return ""
|
||||
|
||||
fragments: list[str] = []
|
||||
for entry in sort_by:
|
||||
col = entry.get("column_id")
|
||||
direction = entry.get("direction")
|
||||
if col not in schema.names():
|
||||
logger.warning(f"Tri sur colonne inconnue ignoré : {col!r}")
|
||||
continue
|
||||
if direction not in ("asc", "desc"):
|
||||
logger.warning(f"Tri sur direction inconnue ignoré : {direction!r}")
|
||||
continue
|
||||
fragments.append(f'"{col}" {direction.upper()} NULLS LAST')
|
||||
|
||||
return ", ".join(fragments)
|
||||
|
||||
|
||||
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))
|
||||
|
||||
if dashboard_acheteur_id:
|
||||
dashboard_acheteur_id = dashboard_acheteur_id.replace(" ", "")
|
||||
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:
|
||||
dashboard_titulaire_id = dashboard_titulaire_id.replace(" ", "")
|
||||
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)
|
||||
|
||||
if dashboard_marche_type:
|
||||
clauses.append('"type" = ?')
|
||||
params.append(dashboard_marche_type)
|
||||
|
||||
if dashboard_marche_objet:
|
||||
where_clause, param_list = tokenize_text_filter("objet", dashboard_marche_objet)
|
||||
clauses.append(where_clause)
|
||||
params.extend(param_list)
|
||||
|
||||
if dashboard_marche_code_cpv:
|
||||
clauses.append('"codeCPV" LIKE ?')
|
||||
params.append(f"{dashboard_marche_code_cpv}%")
|
||||
|
||||
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)
|
||||
|
||||
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))
|
||||
|
||||
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)
|
||||
|
||||
return " AND ".join(clauses), params
|
||||
|
||||
|
||||
def tokenize_text_filter(
|
||||
column: str, text: str, col_is_date: bool = False
|
||||
) -> tuple[str, list]:
|
||||
terms = text.split()
|
||||
# si col_is_date alors le deuxième doit être casté en VARCHAR
|
||||
if col_is_date:
|
||||
quoted_col = f'CAST("{column}" AS VARCHAR)'
|
||||
else:
|
||||
quoted_col = f'"{column}"'
|
||||
|
||||
conditions = [f'"{column}" IS NOT NULL', f"{quoted_col} <> ''"]
|
||||
|
||||
params = []
|
||||
|
||||
for term in terms:
|
||||
conditions.append(f"{quoted_col} ILIKE ?")
|
||||
|
||||
if term.startswith("*") or term.endswith("*"):
|
||||
params.append(term.replace("*", "%"))
|
||||
elif "+" in term:
|
||||
params.append(f"%{term.replace('+', ' ')}%")
|
||||
else:
|
||||
params.append(f"%{term}%")
|
||||
|
||||
where_clause = " AND ".join(conditions)
|
||||
return where_clause, params
|
||||
+24
-13
@@ -6,10 +6,7 @@ import polars as pl
|
||||
import pytest
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def test_data():
|
||||
data = [
|
||||
_TEST_DATA = [
|
||||
{
|
||||
"uid": "1",
|
||||
"id": "1",
|
||||
@@ -41,20 +38,34 @@ def test_data():
|
||||
"acheteur_categorie": "Collectivité",
|
||||
"titulaire_categorie": "PME",
|
||||
}
|
||||
]
|
||||
parquet_path = Path(os.path.abspath("tests/test.parquet"))
|
||||
db_path = parquet_path.parent / "decp.duckdb"
|
||||
print(f"Writing test data to: {parquet_path}")
|
||||
]
|
||||
_PARQUET_PATH = Path(os.path.abspath("tests/test.parquet"))
|
||||
_DB_PATH = Path(os.path.abspath("decp.duckdb"))
|
||||
|
||||
pl.DataFrame(data).write_parquet(parquet_path)
|
||||
|
||||
# Remove any stale DuckDB from a previous run so src.db rebuilds from
|
||||
# the freshly-written parquet at import time.
|
||||
for artifact in (db_path, db_path.with_suffix(".duckdb.tmp")):
|
||||
def _cleanup_db_artifacts() -> None:
|
||||
for artifact in (
|
||||
_DB_PATH,
|
||||
_DB_PATH.with_suffix(".duckdb.tmp"),
|
||||
_DB_PATH.with_suffix(".duckdb.lock"),
|
||||
):
|
||||
if artifact.exists():
|
||||
artifact.unlink()
|
||||
|
||||
yield str(parquet_path)
|
||||
|
||||
# Runs at conftest import, before test modules import src.db (which builds the
|
||||
# DuckDB at import time). Guarantees the test parquet exists and the stale DB
|
||||
# from a previous `python run.py` is wiped so src.db rebuilds from test data.
|
||||
pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH)
|
||||
_cleanup_db_artifacts()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def test_data():
|
||||
yield str(_PARQUET_PATH)
|
||||
# Teardown: remove the test DuckDB so the next `python run.py` rebuilds
|
||||
# from decp_prod.parquet.
|
||||
_cleanup_db_artifacts()
|
||||
|
||||
|
||||
def pytest_setup_options():
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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]
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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" IS NOT NULL AND "objet" <> \'\' 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%"]
|
||||
|
||||
|
||||
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%"]
|
||||
|
||||
|
||||
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."]]
|
||||
|
||||
|
||||
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]
|
||||
@@ -141,6 +141,7 @@ def built_db(tmp_path, monkeypatch):
|
||||
)
|
||||
data.write_parquet(parquet_path)
|
||||
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
|
||||
monkeypatch.setenv("DUCKDB_PATH", str(db_path))
|
||||
|
||||
from src.db import build_database
|
||||
|
||||
@@ -206,6 +207,38 @@ def test_query_marches_returns_polars_frame(built_db, monkeypatch):
|
||||
assert set(frame["uid"].to_list()) == {"1", "2"}
|
||||
|
||||
|
||||
def test_count_marches_returns_total_without_filter():
|
||||
from src.db import count_marches
|
||||
|
||||
n = count_marches()
|
||||
assert isinstance(n, int)
|
||||
assert n > 0
|
||||
|
||||
|
||||
def test_count_marches_with_filter():
|
||||
from src.db import count_marches
|
||||
|
||||
n = count_marches('"uid" = ?', ["__nonexistent__"])
|
||||
assert n == 0
|
||||
|
||||
|
||||
def test_count_unique_marches_respects_distinct():
|
||||
from src.db import count_unique_marches
|
||||
|
||||
n = count_unique_marches()
|
||||
assert isinstance(n, int)
|
||||
assert n > 0
|
||||
|
||||
|
||||
def test_query_marches_with_offset():
|
||||
from src.db import query_marches
|
||||
|
||||
page_0 = query_marches(limit=2, offset=0)
|
||||
page_1 = query_marches(limit=2, offset=2)
|
||||
if page_0.height == 2 and page_1.height >= 1:
|
||||
assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list()))
|
||||
|
||||
|
||||
def test_concurrent_build_serialized(tmp_path):
|
||||
"""Multiple threads calling _ensure_database must serialize via flock.
|
||||
|
||||
|
||||
+22
-44
@@ -215,47 +215,6 @@ def test_008_search_to_observatoire(dash_duo: DashComposite):
|
||||
)
|
||||
|
||||
|
||||
def test_010_observatoire_montant_filter():
|
||||
import datetime
|
||||
|
||||
from src.utils.data import prepare_dashboard_data
|
||||
|
||||
data = pl.DataFrame(
|
||||
{
|
||||
"uid": ["1", "2", "3"],
|
||||
"montant": [100.0, 500.0, 1000.0],
|
||||
"dateNotification": [datetime.date(2025, 1, 1)] * 3,
|
||||
}
|
||||
)
|
||||
|
||||
def apply(min_val=None, max_val=None):
|
||||
return prepare_dashboard_data(
|
||||
data.lazy(),
|
||||
dashboard_year="2025",
|
||||
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=min_val,
|
||||
dashboard_montant_max=max_val,
|
||||
).collect()
|
||||
|
||||
assert apply().height == 3
|
||||
assert apply(min_val=400).height == 2 # 500, 1000
|
||||
assert apply(max_val=500).height == 2 # 100, 500
|
||||
assert apply(min_val=200, max_val=600).height == 1 # 500 only
|
||||
|
||||
|
||||
def test_009_observatoire_filter_persistence(dash_duo: DashComposite):
|
||||
import time
|
||||
|
||||
@@ -333,7 +292,7 @@ def test_011_observatoire_multi_param_url(dash_duo: DashComposite):
|
||||
)
|
||||
|
||||
|
||||
def test_get_distance_histogram_returns_graph():
|
||||
def test_012_get_distance_histogram_returns_graph():
|
||||
import polars as pl
|
||||
from dash import dcc
|
||||
|
||||
@@ -344,7 +303,7 @@ def test_get_distance_histogram_returns_graph():
|
||||
assert isinstance(result, dcc.Graph)
|
||||
|
||||
|
||||
def test_get_distance_histogram_handles_nulls():
|
||||
def test_013_get_distance_histogram_handles_nulls():
|
||||
import polars as pl
|
||||
from dash import dcc
|
||||
|
||||
@@ -355,7 +314,7 @@ def test_get_distance_histogram_handles_nulls():
|
||||
assert isinstance(result, dcc.Graph)
|
||||
|
||||
|
||||
def test_get_distance_histogram_all_nulls():
|
||||
def test_014_get_distance_histogram_all_nulls():
|
||||
import polars as pl
|
||||
from dash import dcc
|
||||
|
||||
@@ -363,4 +322,23 @@ def test_get_distance_histogram_all_nulls():
|
||||
|
||||
lff = pl.LazyFrame({"titulaire_distance": pl.Series([], dtype=pl.Int64)})
|
||||
result = get_distance_histogram(lff)
|
||||
|
||||
assert isinstance(result, dcc.Graph)
|
||||
|
||||
|
||||
def test_015_tableau_filter_date(dash_duo: DashComposite):
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
for page in ["tableau", "acheteurs/123", "titulaires/345"]:
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
|
||||
filter_input = '.marches_table th[data-dash-column="dateNotification"] input'
|
||||
filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p'
|
||||
dash_duo.wait_for_element(filter_input, timeout=2)
|
||||
_filter_input: WebElement = dash_duo.find_element(filter_input)
|
||||
_filter_input.send_keys("3333") # a dateNotification that doesn't exist
|
||||
_filter_input.send_keys(Keys.ENTER)
|
||||
_filter_result: list[WebElement] = dash_duo.find_elements(filter_cell_result)
|
||||
assert len(_filter_result) == 0, f"Page : {page}"
|
||||
|
||||
+67
-84
@@ -83,7 +83,7 @@ def flask_app():
|
||||
"""Minimal Flask app with SimpleCache so @cache.memoize() works in tests."""
|
||||
from flask import Flask
|
||||
|
||||
from utils.cache import cache
|
||||
from src.utils.cache import cache
|
||||
|
||||
app = Flask(__name__)
|
||||
cache.init_app(app, config={"CACHE_TYPE": "SimpleCache"})
|
||||
@@ -95,7 +95,7 @@ def reset_cache(flask_app):
|
||||
"""Ensure the flask-caching backend is empty between tests so that
|
||||
cache-hit assertions are meaningful. Falls back to no-op when no
|
||||
Flask app context is active (NullCache)."""
|
||||
from utils.cache import cache
|
||||
from src.utils.cache import cache
|
||||
|
||||
with flask_app.app_context():
|
||||
try:
|
||||
@@ -106,60 +106,9 @@ def reset_cache(flask_app):
|
||||
yield
|
||||
|
||||
|
||||
def test_load_filter_sort_postprocess_returns_dataframe(
|
||||
flask_app, monkeypatch, sample_lff
|
||||
):
|
||||
def test_prepare_table_data_returns_expected_tuple(flask_app):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
|
||||
|
||||
with flask_app.app_context():
|
||||
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
|
||||
|
||||
assert isinstance(df, pl.DataFrame)
|
||||
assert df.height == 1
|
||||
# All values must be strings after post-processing
|
||||
for col in df.columns:
|
||||
assert df.schema[col] == pl.String
|
||||
|
||||
|
||||
def test_load_filter_sort_postprocess_applies_filter(
|
||||
flask_app, monkeypatch, sample_lff
|
||||
):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
|
||||
|
||||
with flask_app.app_context():
|
||||
df = table._load_filter_sort_postprocess(
|
||||
filter_query="{objet} icontains travaux", sort_by_key=()
|
||||
)
|
||||
assert df.height == 1
|
||||
|
||||
df_empty = table._load_filter_sort_postprocess(
|
||||
filter_query="{objet} icontains nonexistent", sort_by_key=()
|
||||
)
|
||||
assert df_empty.height == 0
|
||||
|
||||
|
||||
def test_load_filter_sort_postprocess_adds_links(flask_app, monkeypatch, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
|
||||
|
||||
with flask_app.app_context():
|
||||
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
|
||||
# add_links injects an <a href> wrapper around uid, acheteur_nom, titulaire_nom
|
||||
assert "<a href" in df["uid"][0]
|
||||
assert "<a href" in df["acheteur_nom"][0]
|
||||
assert "<a href" in df["titulaire_nom"][0]
|
||||
|
||||
|
||||
def test_prepare_table_data_returns_expected_tuple(monkeypatch, flask_app, sample_lff):
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
|
||||
|
||||
with flask_app.app_context():
|
||||
result = table.prepare_table_data(
|
||||
data=None,
|
||||
@@ -178,16 +127,13 @@ def test_prepare_table_data_returns_expected_tuple(monkeypatch, flask_app, sampl
|
||||
)
|
||||
assert isinstance(dicts, list)
|
||||
assert ts == 6 # data_timestamp + 1 must still increment
|
||||
assert "1 lignes" in nb_rows
|
||||
assert "lignes" in nb_rows
|
||||
|
||||
|
||||
def test_prepare_table_data_calls_track_search_on_filter(
|
||||
monkeypatch, flask_app, sample_lff
|
||||
):
|
||||
def test_prepare_table_data_calls_track_search_on_filter(monkeypatch, flask_app):
|
||||
from src.utils import table
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
|
||||
monkeypatch.setattr(table, "track_search", lambda *a, **kw: calls.append(a))
|
||||
|
||||
with flask_app.app_context():
|
||||
@@ -204,24 +150,33 @@ def test_prepare_table_data_calls_track_search_on_filter(
|
||||
assert calls == [("{objet} icontains travaux", "tableau")]
|
||||
|
||||
|
||||
def test_prepare_table_data_paginates_without_recomputing(
|
||||
monkeypatch, flask_app, sample_lff
|
||||
):
|
||||
"""Two calls with same filter+sort but different pages must invoke
|
||||
the inner heavy work only once."""
|
||||
def test_prepare_table_data_same_page_uses_cache(monkeypatch, flask_app):
|
||||
"""Two calls with exactly the same (filter, sort, page, size)
|
||||
must call _fetch_page_sql at least once."""
|
||||
from src.utils import table
|
||||
|
||||
call_count = {"n": 0}
|
||||
real_query = sample_lff.collect()
|
||||
|
||||
def counting_query():
|
||||
def counting_fetch(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
return real_query
|
||||
import polars as pl
|
||||
|
||||
monkeypatch.setattr(table, "query_marches", counting_query)
|
||||
return (
|
||||
pl.DataFrame(
|
||||
{
|
||||
"uid": [],
|
||||
"acheteur_id": [],
|
||||
"titulaire_id": [],
|
||||
"titulaire_typeIdentifiant": [],
|
||||
}
|
||||
),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(table, "_fetch_page_sql", counting_fetch)
|
||||
|
||||
with flask_app.app_context():
|
||||
# First call: cache miss
|
||||
table.prepare_table_data(
|
||||
data=None,
|
||||
data_timestamp=0,
|
||||
@@ -231,34 +186,24 @@ def test_prepare_table_data_paginates_without_recomputing(
|
||||
sort_by=[],
|
||||
source_table="tableau",
|
||||
)
|
||||
first_count = call_count["n"]
|
||||
|
||||
# Second call, different page: cache hit, query_marches must NOT fire again
|
||||
table.prepare_table_data(
|
||||
data=None,
|
||||
data_timestamp=0,
|
||||
filter_query=None,
|
||||
page_current=1,
|
||||
page_current=0,
|
||||
page_size=10,
|
||||
sort_by=[],
|
||||
source_table="tableau",
|
||||
)
|
||||
|
||||
assert call_count["n"] == first_count, (
|
||||
"query_marches was called again — pagination triggered cache miss"
|
||||
)
|
||||
assert call_count["n"] >= 1
|
||||
|
||||
|
||||
def test_prepare_table_data_cleanup_trigger_for_non_tableau(
|
||||
monkeypatch, flask_app, sample_lff
|
||||
):
|
||||
def test_prepare_table_data_cleanup_trigger_for_non_tableau(flask_app):
|
||||
"""Non-tableau pages still get a fresh uuid trigger, not no_update."""
|
||||
from dash import no_update
|
||||
|
||||
from src.utils import table
|
||||
|
||||
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
|
||||
|
||||
with flask_app.app_context():
|
||||
result = table.prepare_table_data(
|
||||
data=None,
|
||||
@@ -289,11 +234,11 @@ def test_prepare_table_data_with_external_data_does_not_use_cache(
|
||||
sentinel["called"] = True
|
||||
raise AssertionError("Memoized helper must not be called when data is provided")
|
||||
|
||||
monkeypatch.setattr(table, "_load_filter_sort_postprocess", should_not_be_called)
|
||||
monkeypatch.setattr(table, "_fetch_page_sql", should_not_be_called)
|
||||
|
||||
with flask_app.app_context():
|
||||
table.prepare_table_data(
|
||||
data=sample_lff, # external LazyFrame
|
||||
data=sample_lff,
|
||||
data_timestamp=0,
|
||||
filter_query=None,
|
||||
page_current=0,
|
||||
@@ -303,3 +248,41 @@ def test_prepare_table_data_with_external_data_does_not_use_cache(
|
||||
)
|
||||
|
||||
assert sentinel["called"] is False
|
||||
|
||||
|
||||
def test_fetch_page_sql_respects_pagination(flask_app):
|
||||
"""New path: returns (page_dff, total_count, total_unique) via DuckDB."""
|
||||
from src.utils import table
|
||||
|
||||
with flask_app.app_context():
|
||||
page, total, total_unique = table._fetch_page_sql(
|
||||
filter_query=None, sort_by_key=(), page_current=0, page_size=5
|
||||
)
|
||||
assert page.height <= 5
|
||||
assert total >= page.height
|
||||
assert isinstance(total_unique, int)
|
||||
|
||||
|
||||
def test_fetch_page_sql_applies_filter(flask_app):
|
||||
from src.utils import table
|
||||
|
||||
with flask_app.app_context():
|
||||
page, total, total_unique = table._fetch_page_sql(
|
||||
filter_query="{uid} icontains __ne_matche_rien__",
|
||||
sort_by_key=(),
|
||||
page_current=0,
|
||||
page_size=20,
|
||||
)
|
||||
assert total == 0
|
||||
assert page.height == 0
|
||||
|
||||
|
||||
def test_fetch_page_sql_post_processes_links(flask_app):
|
||||
from src.utils import table
|
||||
|
||||
with flask_app.app_context():
|
||||
page, _, _ = table._fetch_page_sql(
|
||||
filter_query=None, sort_by_key=(), page_current=0, page_size=1
|
||||
)
|
||||
if page.height > 0:
|
||||
assert "<a href" in page["uid"][0]
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import polars as pl
|
||||
|
||||
SCHEMA = pl.Schema(
|
||||
{
|
||||
"uid": pl.String,
|
||||
"objet": pl.String,
|
||||
"acheteur_id": pl.String,
|
||||
"montant": pl.Float64,
|
||||
"dureeMois": pl.Int64,
|
||||
"dateNotification": pl.Date,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_empty_filter_returns_true():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("", SCHEMA)
|
||||
assert where == "TRUE"
|
||||
assert params == []
|
||||
|
||||
|
||||
def test_icontains_string_is_case_insensitive_like():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{objet} icontains travaux", SCHEMA)
|
||||
assert where == '"objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?'
|
||||
assert params == ["%travaux%"]
|
||||
|
||||
|
||||
def test_icontains_with_trailing_wildcard_is_starts_with():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql(
|
||||
"{acheteur_id} icontains 24350013900189*", SCHEMA
|
||||
)
|
||||
assert (
|
||||
where
|
||||
== '"acheteur_id" IS NOT NULL AND "acheteur_id" <> \'\' AND "acheteur_id" ILIKE ?'
|
||||
)
|
||||
assert params == ["24350013900189%"]
|
||||
|
||||
|
||||
def test_icontains_with_leading_wildcard_is_ends_with():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{uid} icontains *2024", SCHEMA)
|
||||
assert where == '"uid" IS NOT NULL AND "uid" <> \'\' AND "uid" ILIKE ?'
|
||||
assert params == ["%2024"]
|
||||
|
||||
|
||||
def test_numeric_greater_than():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{montant} i> 40000", SCHEMA)
|
||||
assert where == '"montant" IS NOT NULL AND "montant" > ?'
|
||||
assert params == [40000.0]
|
||||
|
||||
|
||||
def test_numeric_less_than():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{montant} i< 1000", SCHEMA)
|
||||
assert where == '"montant" IS NOT NULL AND "montant" < ?'
|
||||
assert params == [1000.0]
|
||||
|
||||
|
||||
def test_numeric_equality_via_icontains():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{dureeMois} icontains 12", SCHEMA)
|
||||
assert where == '"dureeMois" IS NOT NULL AND "dureeMois" = ?'
|
||||
assert params == [12]
|
||||
|
||||
|
||||
def test_date_column_treated_as_string_ilike():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{dateNotification} icontains 2024*", SCHEMA)
|
||||
assert "ILIKE" in where
|
||||
assert params == ["2024%"]
|
||||
|
||||
|
||||
def test_multiple_filters_joined_by_and():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
filter_query = "{objet} icontains voirie && {montant} i> 40000"
|
||||
where, params = filter_query_to_sql(filter_query, SCHEMA)
|
||||
assert " AND " in where
|
||||
assert params == ["%voirie%", 40000.0]
|
||||
|
||||
|
||||
def test_invalid_numeric_value_is_skipped():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{montant} i> notanumber", SCHEMA)
|
||||
assert where == "TRUE"
|
||||
assert params == []
|
||||
|
||||
|
||||
def test_unknown_column_is_skipped():
|
||||
from src.utils.table_sql import filter_query_to_sql
|
||||
|
||||
where, params = filter_query_to_sql("{inexistant} icontains foo", SCHEMA)
|
||||
assert where == "TRUE"
|
||||
assert params == []
|
||||
|
||||
|
||||
def test_sort_by_empty():
|
||||
from src.utils.table_sql import sort_by_to_sql
|
||||
|
||||
assert sort_by_to_sql([], SCHEMA) == ""
|
||||
assert sort_by_to_sql(None, SCHEMA) == ""
|
||||
|
||||
|
||||
def test_sort_by_single_column_desc():
|
||||
from src.utils.table_sql import sort_by_to_sql
|
||||
|
||||
result = sort_by_to_sql([{"column_id": "montant", "direction": "desc"}], SCHEMA)
|
||||
assert result == '"montant" DESC NULLS LAST'
|
||||
|
||||
|
||||
def test_sort_by_multiple_columns_preserves_order():
|
||||
from src.utils.table_sql import sort_by_to_sql
|
||||
|
||||
result = sort_by_to_sql(
|
||||
[
|
||||
{"column_id": "dateNotification", "direction": "desc"},
|
||||
{"column_id": "montant", "direction": "asc"},
|
||||
],
|
||||
SCHEMA,
|
||||
)
|
||||
assert result == '"dateNotification" DESC NULLS LAST, "montant" ASC NULLS LAST'
|
||||
|
||||
|
||||
def test_sort_by_ignores_unknown_column():
|
||||
from src.utils.table_sql import sort_by_to_sql
|
||||
|
||||
result = sort_by_to_sql([{"column_id": "fake", "direction": "asc"}], SCHEMA)
|
||||
assert result == ""
|
||||
Reference in New Issue
Block a user