Merge branch 'release/2.7.0'
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
#### 2.7.0 (23 mars 2026)
|
||||
|
||||
- Remplacement de la page Statistiques par l'observatoire
|
||||
- Généralisation de la grille dash (`dbc.Row`, `dbc.Col`)
|
||||
- Ajout de l'histogramme de distances aux pages acheteur et titulaire
|
||||
- Ajout de la colonne `acheteur_categorie` (commune, État, etc.)
|
||||
|
||||
##### 2.6.2 (22 février 2026)
|
||||
|
||||
- Correction du téléchargemnent buggé dans /tableau
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**decp.info** is a French public procurement data explorer — a Dash (Python) web app for browsing, filtering, and visualizing _Données Essentielles de la Commande Publique_ (DECP). The UI is in French.
|
||||
|
||||
## Commands
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install ".[dev]"
|
||||
cp template.env .env # then customize .env
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
uv run run.py # starts Dash with debug=True and hot reload
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
gunicorn app:server
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
uv run pytest # run all tests (Selenium-based integration tests)
|
||||
uv run 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.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 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
|
||||
- `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.)
|
||||
|
||||
### Key pages
|
||||
|
||||
| Page | URL | Purpose |
|
||||
| ----------------- | --------------- | -------------------------------------- |
|
||||
| `recherche.py` | `/` | Search homepage for buyers/contractors |
|
||||
| `acheteur.py` | `/acheteur` | Buyer detail with stats, charts, maps |
|
||||
| `titulaire.py` | `/titulaire` | Contractor detail |
|
||||
| `tableau.py` | `/tableau` | Filterable data table with exports |
|
||||
| `marche.py` | `/marche` | Individual contract detail |
|
||||
| `observatoire.py` | `/observatoire` | An interactive analytics dashboard |
|
||||
|
||||
### Data layer
|
||||
|
||||
- Data is stored as **Parquet** and loaded with **Polars** (fast columnar operations)
|
||||
- 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/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)
|
||||
- the TableSchema of the dataset with the list of field and their definition is located at `../decp-processing/reference/base_schema.json`
|
||||
- `tests/test.parquet` is very small and may not contain all possible columns, only those necessary for testing
|
||||
|
||||
### UI stack
|
||||
|
||||
- **Dash 3.4** + **Dash Bootstrap Components** for layout
|
||||
- **Plotly Express** for charts
|
||||
- **Dash Leaflet** + **Dash Extensions** for interactive maps with clustering
|
||||
- Custom CSS in `src/assets/css/`
|
||||
|
||||
### Environment
|
||||
|
||||
- `DEVELOPMENT=true` enables debug logging and is set automatically during tests
|
||||
- `.env` file is required at runtime (copy from `template.env`)
|
||||
|
||||
### Deployment
|
||||
|
||||
- `main` branch → manual deploy to decp.info via GitHub Actions
|
||||
- `dev` branch → auto-deploy to test.decp.info via GitHub Actions
|
||||
@@ -1,6 +1,6 @@
|
||||
# decp.info
|
||||
|
||||
> v2.6.2
|
||||
> v2.7.0
|
||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
|
||||
=> [decp.info](https://decp.info)
|
||||
|
||||
@@ -391,8 +391,16 @@
|
||||
"departement": "La Réunion",
|
||||
"region": "La Réunion"
|
||||
},
|
||||
"975": {
|
||||
"departement": "Saint-Pierre-et-Miquelon",
|
||||
"region": "Saint-Pierre-et-Miquelon"
|
||||
},
|
||||
"976": {
|
||||
"departement": "Mayotte",
|
||||
"region": "Mayotte"
|
||||
},
|
||||
"977": {
|
||||
"departement": "Saint-Barthelemy",
|
||||
"region": "Saint-Barthelemy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
# Observatoire Link from Search & Tableau Results — 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:** Let users jump from search/tableau results to the observatoire page, pre-filtered for a given organization, via a 📊 link in the `_nom` columns.
|
||||
|
||||
**Architecture:** Modify `add_links()` in `src/utils.py` to append an observatoire link to `_nom` columns. Add two callbacks to `src/pages/observatoire.py` for bidirectional URL ↔ filter sync using the existing `dcc.Location(id="dashboard_url")`. Add a share URL input and clipboard button to the observatoire layout.
|
||||
|
||||
**Tech Stack:** Dash 3.4, Polars, `urllib.parse`, `dcc.Location`, `dcc.Clipboard`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-18-observatoire-link-from-search-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add observatoire link to `acheteur_nom` in `add_links()`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils.py:82-91` (the `acheteur_` block inside `add_links()`)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** The `add_links()` function loops over column names. The `if col.startswith("acheteur_")` block (lines 82-91) currently wraps both `acheteur_nom` and `acheteur_id` in a detail page link. We must only append the observatoire link when `col == "acheteur_nom"`.
|
||||
|
||||
- [ ] **Step 1: Write a unit test for the observatoire link in acheteur_nom**
|
||||
|
||||
In `tests/test_main.py`, add a test that calls `add_links()` on a minimal DataFrame and checks the `acheteur_nom` column contains both the detail link and the observatoire link, while `acheteur_id` does NOT contain the observatoire link.
|
||||
|
||||
```python
|
||||
def test_004_add_links_observatoire_acheteur():
|
||||
import polars as pl
|
||||
|
||||
from src.utils import add_links
|
||||
|
||||
dff = pl.DataFrame(
|
||||
{
|
||||
"acheteur_id": ["a1"],
|
||||
"acheteur_nom": ["ACHETEUR 1"],
|
||||
}
|
||||
)
|
||||
result = add_links(dff)
|
||||
nom_value = result["acheteur_nom"][0]
|
||||
id_value = result["acheteur_id"][0]
|
||||
|
||||
# acheteur_nom should contain detail link + observatoire link
|
||||
assert "/acheteurs/a1" in nom_value
|
||||
assert "ACHETEUR 1" in nom_value
|
||||
assert '/observatoire?acheteur_id=a1' in nom_value
|
||||
assert "📊" in nom_value
|
||||
|
||||
# acheteur_id should NOT contain observatoire link
|
||||
assert "/observatoire" not in id_value
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
|
||||
Expected: FAIL — `'/observatoire?acheteur_id=a1'` not found in the output string.
|
||||
|
||||
- [ ] **Step 3: Implement the observatoire link for acheteur_nom**
|
||||
|
||||
In `src/utils.py`, modify the `if col.startswith("acheteur_")` block (lines 82-91). Gate the observatoire link append on `col == "acheteur_nom"`:
|
||||
|
||||
```python
|
||||
if col.startswith("acheteur_"):
|
||||
detail_link = (
|
||||
'<a href = "/acheteurs/'
|
||||
+ pl.col("acheteur_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
if col == "acheteur_nom":
|
||||
detail_link = (
|
||||
detail_link
|
||||
+ ' <a href="/observatoire?acheteur_id='
|
||||
+ pl.col("acheteur_id")
|
||||
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||
)
|
||||
dff = dff.with_columns(detail_link.alias(col))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Update `test_001` to account for the new emoji in cell text**
|
||||
|
||||
The existing `test_001` asserts `result_table.find_element(...).text == name` for `acheteur_nom`. The cell text now includes "📊" from the observatoire link. Update the assertion in `tests/test_main.py` to use `startswith` instead of exact match:
|
||||
|
||||
```python
|
||||
assert result_table.find_element(
|
||||
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||
).text.startswith(
|
||||
name
|
||||
), f"The search result should have the right {org_type} name"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run `test_001` to verify it still passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_001_logo_and_search -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils.py tests/test_main.py
|
||||
git commit -m "Ajout du lien observatoire dans acheteur_nom via add_links() #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add observatoire link to `titulaire_nom` in `add_links()`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/utils.py:64-81` (the `titulaire_` block inside `add_links()`)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** The `titulaire_` block (lines 64-81) uses a `pl.when().then().otherwise()` pattern because it guards on `titulaire_typeIdentifiant` being SIRET or null. The observatoire link must be appended inside the `.then()` branch, and only when `col == "titulaire_nom"`. Note: this block requires `titulaire_typeIdentifiant` to be present in the DataFrame.
|
||||
|
||||
- [ ] **Step 1: Write a unit test for the observatoire link in titulaire_nom**
|
||||
|
||||
```python
|
||||
def test_005_add_links_observatoire_titulaire():
|
||||
import polars as pl
|
||||
|
||||
from src.utils import add_links
|
||||
|
||||
dff = pl.DataFrame(
|
||||
{
|
||||
"titulaire_id": ["t1"],
|
||||
"titulaire_nom": ["TITULAIRE 1"],
|
||||
"titulaire_typeIdentifiant": ["SIRET"],
|
||||
}
|
||||
)
|
||||
result = add_links(dff)
|
||||
nom_value = result["titulaire_nom"][0]
|
||||
id_value = result["titulaire_id"][0]
|
||||
|
||||
# titulaire_nom should contain detail link + observatoire link
|
||||
assert "/titulaires/t1" in nom_value
|
||||
assert "TITULAIRE 1" in nom_value
|
||||
assert '/observatoire?titulaire_id=t1' in nom_value
|
||||
assert "📊" in nom_value
|
||||
|
||||
# titulaire_id should NOT contain observatoire link
|
||||
assert "/observatoire" not in id_value
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||
Expected: FAIL — `'/observatoire?titulaire_id=t1'` not found.
|
||||
|
||||
- [ ] **Step 3: Implement the observatoire link for titulaire_nom**
|
||||
|
||||
In `src/utils.py`, modify the `if col.startswith("titulaire_")` block (lines 64-81). The `.then()` branch must build the link differently when `col == "titulaire_nom"`:
|
||||
|
||||
```python
|
||||
if col.startswith("titulaire_"):
|
||||
detail_link = (
|
||||
'<a href = "/titulaires/'
|
||||
+ pl.col("titulaire_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
if col == "titulaire_nom":
|
||||
detail_link = (
|
||||
detail_link
|
||||
+ ' <a href="/observatoire?titulaire_id='
|
||||
+ pl.col("titulaire_id")
|
||||
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||
)
|
||||
dff = dff.with_columns(
|
||||
pl.when(
|
||||
pl.Expr.or_(
|
||||
pl.col("titulaire_typeIdentifiant").is_null(),
|
||||
pl.col("titulaire_typeIdentifiant") == "SIRET",
|
||||
)
|
||||
)
|
||||
.then(detail_link)
|
||||
.otherwise(pl.col(col))
|
||||
.alias(col)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Run all tests so far to check for regressions**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_004_add_links_observatoire_acheteur tests/test_main.py::test_005_add_links_observatoire_titulaire -v`
|
||||
Expected: both PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/utils.py tests/test_main.py
|
||||
git commit -m "Ajout du lien observatoire dans titulaire_nom via add_links() #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Observatoire Callback A — URL → Inputs (page load)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py` (add import + new callback after line 281)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** The existing `dcc.Location(id="dashboard_url")` is in the observatoire layout. A new callback reads `dashboard_url.search` on page load, parses query params, and sets `dashboard_acheteur_id.value` and/or `dashboard_titulaire_id.value`. It also clears `dashboard_url.search` to `""` to prevent re-triggering. Two imports must be added: `import urllib.parse` at the top of the file, and `no_update` to the existing `from dash import ...` line (currently: `from dash import ALL, Input, Output, State, callback, ctx, dcc, html, register_page` — add `no_update` to this).
|
||||
|
||||
- [ ] **Step 1: Write a Selenium test for URL → Input sync**
|
||||
|
||||
This test navigates to `/observatoire?acheteur_id=a1` and verifies the SIRET input gets populated.
|
||||
|
||||
```python
|
||||
def test_006_observatoire_url_to_input(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)
|
||||
|
||||
# Navigate to observatoire with acheteur_id query param
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
dash_duo.wait_for_text_to_equal(
|
||||
"#dashboard_acheteur_id", "", timeout=4
|
||||
) # Wait for callback
|
||||
import time
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
assert acheteur_input.get_attribute("value") == "a1", (
|
||||
"acheteur_id input should be populated from URL param"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
|
||||
Expected: FAIL — the input value is empty because no callback reads URL params yet.
|
||||
|
||||
- [ ] **Step 3: Implement Callback A**
|
||||
|
||||
Add `import urllib.parse` to the imports at the top of `src/pages/observatoire.py` (after line 1). Also add `no_update` to the existing dash import line:
|
||||
|
||||
```python
|
||||
from dash import ALL, Input, Output, State, callback, ctx, dcc, html, no_update, register_page
|
||||
```
|
||||
|
||||
Add the callback after the `layout` list ends, before existing callbacks:
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("dashboard_acheteur_id", "value"),
|
||||
Output("dashboard_titulaire_id", "value"),
|
||||
Output("dashboard_url", "search"),
|
||||
Input("dashboard_url", "search"),
|
||||
)
|
||||
def restore_filters_from_url(search):
|
||||
if not search:
|
||||
return no_update, no_update, no_update
|
||||
|
||||
params = urllib.parse.parse_qs(search.lstrip("?"))
|
||||
|
||||
acheteur_id = params.get("acheteur_id", [None])[0] or no_update
|
||||
titulaire_id = params.get("titulaire_id", [None])[0] or no_update
|
||||
|
||||
return acheteur_id, titulaire_id, ""
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_006_observatoire_url_to_input -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py tests/test_main.py
|
||||
git commit -m "Callback URL → filtres sur la page observatoire #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Observatoire Callback B — Inputs → shareable URL + layout
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/pages/observatoire.py` (add layout components + new callback)
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** Following the tableau.py pattern (lines 237-238 for layout, lines 399-450 for callback), add a hidden `share-url` input and a `copy-container` div to the observatoire layout. The callback listens to the ID inputs and builds a shareable URL. Component IDs must be unique across the app, so use `observatoire-share-url` and `observatoire-copy-container` to avoid collisions with tableau's `share-url` and `copy-container`.
|
||||
|
||||
- [ ] **Step 1: Write a test for the shareable URL generation**
|
||||
|
||||
```python
|
||||
def test_007_observatoire_share_url(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)
|
||||
|
||||
# Navigate to observatoire with acheteur_id query param
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=a1")
|
||||
dash_duo.wait_for_element("#observatoire-share-url", timeout=4)
|
||||
|
||||
import time
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
share_url_input = dash_duo.find_element("#observatoire-share-url")
|
||||
share_url_value = share_url_input.get_attribute("value")
|
||||
|
||||
assert "acheteur_id=a1" in share_url_value, (
|
||||
f"Share URL should contain acheteur_id param, got: {share_url_value}"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
|
||||
Expected: FAIL — `#observatoire-share-url` element does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add layout components to observatoire**
|
||||
|
||||
In `src/pages/observatoire.py`, add the share URL input and copy container inside the filters column (after the download button, before the closing `]` of the `id="filters"` children list, around line 264):
|
||||
|
||||
```python
|
||||
dcc.Input(
|
||||
id="observatoire-share-url",
|
||||
readOnly=True,
|
||||
style={"display": "none"},
|
||||
),
|
||||
html.Div(id="observatoire-copy-container"),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement Callback B**
|
||||
|
||||
Add after Callback A in `src/pages/observatoire.py`:
|
||||
|
||||
```python
|
||||
@callback(
|
||||
Output("observatoire-share-url", "value"),
|
||||
Output("observatoire-copy-container", "children"),
|
||||
Input("dashboard_acheteur_id", "value"),
|
||||
Input("dashboard_titulaire_id", "value"),
|
||||
State("dashboard_url", "href"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def sync_observatoire_share_url(acheteur_id, titulaire_id, href):
|
||||
if not href:
|
||||
return no_update, no_update
|
||||
|
||||
base_url = href.split("?")[0]
|
||||
|
||||
params = {}
|
||||
if acheteur_id:
|
||||
params["acheteur_id"] = acheteur_id
|
||||
if titulaire_id:
|
||||
params["titulaire_id"] = titulaire_id
|
||||
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
||||
|
||||
copy_button = dcc.Clipboard(
|
||||
id="btn-copy-observatoire-url",
|
||||
target_id="observatoire-share-url",
|
||||
title="Copier l'URL de cette vue",
|
||||
style={
|
||||
"display": "inline-block",
|
||||
"fontSize": 20,
|
||||
"verticalAlign": "top",
|
||||
"cursor": "pointer",
|
||||
},
|
||||
className="fa fa-link",
|
||||
children=[
|
||||
dbc.Button(
|
||||
"Partager",
|
||||
className="btn btn-primary mt-2",
|
||||
title="Copier l'adresse de cette vue filtrée pour la partager.",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return full_url, copy_button
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_007_observatoire_share_url -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: Run all tests to check for regressions**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
|
||||
Expected: all tests PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/pages/observatoire.py tests/test_main.py
|
||||
git commit -m "URL partageable pour la page observatoire #65"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: End-to-end integration test
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `tests/test_main.py`
|
||||
|
||||
**Context:** Verify the full flow: search for an organization on the homepage, see the 📊 link in results, click it, arrive on the observatoire with the correct input populated.
|
||||
|
||||
- [ ] **Step 1: Write end-to-end test**
|
||||
|
||||
```python
|
||||
def test_008_search_to_observatoire(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)
|
||||
|
||||
# Search for an acheteur
|
||||
search_bar = dash_duo.find_element("#search")
|
||||
search_bar.send_keys("ACHETEUR 1")
|
||||
search_bar.send_keys(Keys.ENTER)
|
||||
|
||||
dash_duo.wait_for_element("#results_acheteur_datatable", timeout=2)
|
||||
|
||||
# Find the observatoire link in acheteur_nom column
|
||||
observatoire_link = dash_duo.find_element(
|
||||
'#results_acheteur_datatable td[data-dash-column="acheteur_nom"] a[href*="observatoire"]'
|
||||
)
|
||||
assert "📊" in observatoire_link.text
|
||||
|
||||
# Click the observatoire link
|
||||
observatoire_link.click()
|
||||
|
||||
# Wait for observatoire page to load
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
import time
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "a1", (
|
||||
"acheteur_id input should be populated after navigating from search"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run end-to-end test**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py::test_008_search_to_observatoire -v`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
Run: `source .venv/bin/activate && pytest tests/test_main.py -v`
|
||||
Expected: all tests PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_main.py
|
||||
git commit -m "Test e2e : recherche → observatoire #65"
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# Distance Histogram — Design Spec
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Branch:** feature/65_observatoire
|
||||
|
||||
## Goal
|
||||
|
||||
Display the distribution of distances (in km) between buyers and winning contractors, to help users assess whether a buyer or contractor tends to deal locally or at a national scale.
|
||||
|
||||
## Data
|
||||
|
||||
- Column: `titulaire_distance` (`Int64`, km)
|
||||
- Measured at address level — values are always > 0, no zero-handling needed
|
||||
- Already selected in the observatoire LazyFrame via `cs.starts_with("titulaire")`
|
||||
- Already available on acheteur and titulaire detail pages
|
||||
|
||||
## Figure Function
|
||||
|
||||
**Location:** `src/figures.py`
|
||||
|
||||
**Signature:**
|
||||
|
||||
```python
|
||||
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
|
||||
```
|
||||
|
||||
**Behaviour:**
|
||||
|
||||
- Collects `titulaire_distance` from the LazyFrame, drops nulls
|
||||
- If the resulting DataFrame is empty after dropping nulls, `px.histogram` produces a blank figure without errors — no guard logic needed. The order of operations must be: drop nulls → log-transform → histogram
|
||||
- Drop nulls first, then pre-log-transform the column (`pl.col("titulaire_distance").log(10)`) so bins are truly equal-width on a log scale. Use `px.histogram` with `nbins=50` on the transformed values
|
||||
- Set custom X-axis tick values at powers of 10 (1, 10, 100, 1000, 10000) with km labels, using `fig.update_xaxes(tickvals=[0,1,2,3,4], ticktext=["1","10","100","1 000","10 000"])`
|
||||
- Y axis: count of contracts
|
||||
- French axis labels: x = `"Distance (km)"`, y = `"Nombre de marchés"`
|
||||
- Returns a `dcc.Graph`
|
||||
|
||||
## Integration
|
||||
|
||||
### Observatoire (`src/pages/observatoire.py`)
|
||||
|
||||
- `get_distance_histogram` imported and called inside `udpate_dashboard_cards`
|
||||
- Result wrapped in `make_card(title="Distance acheteur–titulaire", subtitle="en nombre de marchés, échelle logarithmique", fig=...)`
|
||||
- Card appended to the `cards` list alongside existing donuts and charts
|
||||
- No changes to the data pipeline — `titulaire_distance` is already in the LazyFrame
|
||||
|
||||
### Acheteur page (`src/pages/acheteur.py`)
|
||||
|
||||
The acheteur page uses a `dcc.Store` (`acheteur_data`) that holds serialised contract rows as a list of dicts. The integration follows the existing pattern used by other chart callbacks on this page:
|
||||
|
||||
- Add a new `html.Div(id="acheteur-distance-histogram")` placeholder in the layout
|
||||
- Add a new callback with `Input("acheteur_data", "data")` that:
|
||||
- Reconstructs `pl.LazyFrame(data)` from the store
|
||||
- Calls `get_distance_histogram(lff)`
|
||||
- Wraps the result in `make_card(...)` and returns it to the placeholder div
|
||||
|
||||
### Titulaire page (`src/pages/titulaire.py`)
|
||||
|
||||
Same pattern as acheteur: `dcc.Store` (`titulaire_data`) → new callback → `html.Div` placeholder.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Filtering by distance range (could be a future filter on the observatoire page)
|
||||
- Showing distance on a map or as a trend over time
|
||||
- Bucket-based (named zone) grouping
|
||||
@@ -0,0 +1,78 @@
|
||||
# Observatoire Link from Search & Tableau Results
|
||||
|
||||
## Problem
|
||||
|
||||
Users searching for an organization (acheteur or titulaire) on the search page or browsing the tableau cannot jump directly to the observatoire page filtered for that organization. They must manually navigate and re-enter the identifier.
|
||||
|
||||
## Solution
|
||||
|
||||
Extend `add_links()` in `src/utils.py` to append an observatoire link (📊 emoji) to `_nom` columns, and add bidirectional URL parameter sync to the observatoire page.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `src/utils.py` — `add_links()` modification
|
||||
|
||||
The existing `add_links()` loop iterates over `["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]`. The `if col.startswith("acheteur_")` and `if col.startswith("titulaire_")` blocks match both `_nom` and `_id` columns. The observatoire link must only be appended to `_nom` columns, so it must be gated on `col == "acheteur_nom"` or `col == "titulaire_nom"` explicitly.
|
||||
|
||||
For `acheteur_nom`, append an observatoire link after the existing detail page link:
|
||||
|
||||
```
|
||||
Before: <a href="/acheteurs/12345678901234">Ville de Paris</a>
|
||||
After: <a href="/acheteurs/12345678901234">Ville de Paris</a> <a href="/observatoire?acheteur_id=12345678901234" title="Voir dans l'observatoire">📊</a>
|
||||
```
|
||||
|
||||
For `titulaire_nom`, same pattern but only when the existing `typeIdentifiant` guard passes (SIRET or null):
|
||||
|
||||
```
|
||||
Before: <a href="/titulaires/12345678901234">Entreprise X</a>
|
||||
After: <a href="/titulaires/12345678901234">Entreprise X</a> <a href="/observatoire?titulaire_id=12345678901234" title="Voir dans l'observatoire">📊</a>
|
||||
```
|
||||
|
||||
The identifier used in the observatoire link (`acheteur_id` / `titulaire_id`) is the same `pl.col("acheteur_id")` / `pl.col("titulaire_id")` column value already used for the detail page link.
|
||||
|
||||
The `_id` and `uid` columns are unchanged.
|
||||
|
||||
### 2. `src/pages/observatoire.py` — URL parameter handling
|
||||
|
||||
#### Callback A: URL → Inputs (page load)
|
||||
|
||||
- Trigger: `Input("dashboard_url", "search")`
|
||||
- Outputs: `Output("dashboard_acheteur_id", "value")`, `Output("dashboard_titulaire_id", "value")`, `Output("dashboard_url", "search")` (to clear it)
|
||||
- `prevent_initial_call=False` (must fire on page load to read URL params)
|
||||
- If `search` is empty or None: return `no_update` for all outputs
|
||||
- Otherwise: parse query params with `urllib.parse.parse_qs`
|
||||
- Set `dashboard_acheteur_id` from `?acheteur_id=` param, or `no_update` if absent
|
||||
- Set `dashboard_titulaire_id` from `?titulaire_id=` param, or `no_update` if absent
|
||||
- Return `""` for `dashboard_url.search` to clear the URL and prevent re-triggering
|
||||
- No validation of param values — consistent with existing input handling in the observatoire callbacks
|
||||
|
||||
#### Callback B: Inputs → shareable URL
|
||||
|
||||
- Trigger: `Input("dashboard_acheteur_id", "value")`, `Input("dashboard_titulaire_id", "value")`
|
||||
- State: `State("dashboard_url", "href")` for base URL
|
||||
- `prevent_initial_call=True` (avoid generating URL on initial empty state)
|
||||
- Build query string with `urllib.parse.urlencode`, omitting empty values
|
||||
- Write full URL to a new `share-url` input component
|
||||
- Render a `dcc.Clipboard` + share button (same pattern as tableau.py)
|
||||
|
||||
#### Callback chain
|
||||
|
||||
When navigating from search with `?acheteur_id=123`: Callback A fires on page load, sets input values, clears URL search. The input value changes then trigger both the existing `udpate_dashboard_cards` callback and Callback B. Dash handles this chaining deterministically — no race condition.
|
||||
|
||||
#### Layout additions
|
||||
|
||||
- A `dcc.Input(id="share-url", ...)` (hidden or read-only) to hold the shareable URL
|
||||
- A `dcc.Clipboard` share/copy button near the filters
|
||||
|
||||
### 3. Reuse of existing `dcc.Location`
|
||||
|
||||
The existing `dcc.Location(id="dashboard_url")` component is reused — no new Location component needed.
|
||||
|
||||
## Future extension
|
||||
|
||||
The bidirectional URL sync pattern is designed to extend to all observatoire filters (year, categories, departments, market type, etc.) by adding more params to both callbacks.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/utils.py` — modify `add_links()`
|
||||
- `src/pages/observatoire.py` — add 2 callbacks, add share-url + clipboard to layout
|
||||
@@ -0,0 +1,117 @@
|
||||
# Observatoire: Full URL Sharing for All Filters
|
||||
|
||||
## Problem
|
||||
|
||||
The "Partager" button on `/observatoire` currently only encodes `acheteur_id` and `titulaire_id` in the shareable URL. The other 15 filter parameters are lost, so a shared link does not reproduce the sender's filtered view.
|
||||
|
||||
## Goal
|
||||
|
||||
Extend URL sharing so that **all 17 filter parameters** are encoded in the URL and restored when a recipient opens it. The recipient sees exactly what the sender intended — URL params replace all local filter state.
|
||||
|
||||
## Approach
|
||||
|
||||
Flat query parameters with short, readable keys. Multi-value filters use repeated keys (native to `urllib.parse`). Only non-default values appear in the URL.
|
||||
|
||||
## URL Parameter Mapping
|
||||
|
||||
| Component ID | URL key | Type | Default (omitted) |
|
||||
|---|---|---|---|
|
||||
| `dashboard_year` | `annee` | single | `None` |
|
||||
| `dashboard_acheteur_id` | `acheteur_id` | single | `None` |
|
||||
| `dashboard_acheteur_categorie` | `acheteur_cat` | single | `None` |
|
||||
| `dashboard_acheteur_departement_code` | `acheteur_dept` | multi | `[]`/`None` |
|
||||
| `dashboard_titulaire_id` | `titulaire_id` | single | `None` |
|
||||
| `dashboard_titulaire_categorie` | `titulaire_cat` | single | `None` |
|
||||
| `dashboard_titulaire_departement_code` | `titulaire_dept` | multi | `[]`/`None` |
|
||||
| `dashboard_marche_type` | `type` | single | `None` |
|
||||
| `dashboard_marche_objet` | `objet` | single | `None` |
|
||||
| `dashboard_marche_code_cpv` | `cpv` | single | `None` |
|
||||
| `dashboard_montant_min` | `montant_min` | single (number) | `None` |
|
||||
| `dashboard_montant_max` | `montant_max` | single (number) | `None` |
|
||||
| `dashboard_marche_techniques` | `techniques` | multi | `[]`/`None` |
|
||||
| `dashboard_marche_innovant` | `innovant` | single | `"all"` |
|
||||
| `dashboard_marche_sousTraitanceDeclaree` | `sous_traitance` | single | `"all"` |
|
||||
| `dashboard_marche_considerationsSociales` | `social` | multi | `[]`/`None` |
|
||||
| `dashboard_marche_considerationsEnvironnementales` | `env` | multi | `[]`/`None` |
|
||||
|
||||
Example URL:
|
||||
```
|
||||
/observatoire?annee=2024&acheteur_id=12345678901234&acheteur_dept=75&acheteur_dept=13&montant_min=10000&innovant=oui
|
||||
```
|
||||
|
||||
## Data Structure
|
||||
|
||||
A list of tuples defines the mapping, used by both callbacks to avoid scattered string literals:
|
||||
|
||||
```python
|
||||
FILTER_PARAMS = [
|
||||
# (component_id, url_key, is_multi, default_value)
|
||||
("dashboard_year", "annee", False, None),
|
||||
("dashboard_acheteur_id", "acheteur_id", False, None),
|
||||
("dashboard_acheteur_categorie", "acheteur_cat", False, None),
|
||||
("dashboard_acheteur_departement_code", "acheteur_dept", True, None),
|
||||
("dashboard_titulaire_id", "titulaire_id", False, None),
|
||||
("dashboard_titulaire_categorie", "titulaire_cat", False, None),
|
||||
("dashboard_titulaire_departement_code", "titulaire_dept", True, None),
|
||||
("dashboard_marche_type", "type", False, None),
|
||||
("dashboard_marche_objet", "objet", False, None),
|
||||
("dashboard_marche_code_cpv", "cpv", False, None),
|
||||
("dashboard_montant_min", "montant_min", False, None),
|
||||
("dashboard_montant_max", "montant_max", False, None),
|
||||
("dashboard_marche_techniques", "techniques", True, None),
|
||||
("dashboard_marche_innovant", "innovant", False, "all"),
|
||||
("dashboard_marche_sousTraitanceDeclaree", "sous_traitance", False, "all"),
|
||||
("dashboard_marche_considerationsSociales", "social", True, None),
|
||||
("dashboard_marche_considerationsEnvironnementales", "env", True, None),
|
||||
]
|
||||
```
|
||||
|
||||
## Callback Changes
|
||||
|
||||
### 1. `sync_observatoire_share_url` (line 575)
|
||||
|
||||
**Current:** Takes `acheteur_id` and `titulaire_id` as Inputs.
|
||||
|
||||
**New:** Takes all 17 filter values as Inputs (same as `udpate_dashboard_cards`). Builds the URL using `FILTER_PARAMS`, skipping default values. Uses `urllib.parse.urlencode(params, doseq=True)` for multi-value params.
|
||||
|
||||
### 2. `restore_filters` (line 539)
|
||||
|
||||
**Current:** Extracts only `acheteur_id` and `titulaire_id` from URL.
|
||||
|
||||
**New:**
|
||||
- Iterates over `FILTER_PARAMS` to extract all values from `parse_qs`
|
||||
- For multi-value params: reads the full list from `parse_qs` (returns lists natively)
|
||||
- For number params (`montant_min`, `montant_max`): casts to `float`
|
||||
- The guard condition changes from `if acheteur_id or titulaire_id` to "if any URL param is present" — this is necessary so URLs like `?annee=2024&montant_min=10000` (without an ID) work correctly
|
||||
- When **any** URL param is present: returns explicit values for all 17 outputs — the URL value for params present, `None`/default for params absent. This ensures "URL replaces all" semantics.
|
||||
- When **no** URL params are present: returns `(no_update,) * 17` (preserving local persistence)
|
||||
- Radio buttons (`innovant`, `sous_traitance`): value from URL if present, otherwise `"all"` (their default)
|
||||
|
||||
### 3. Layout bug fix
|
||||
|
||||
Remove the duplicate `dcc.Input(id="observatoire-share-url")` (lines 413-422 — two identical elements).
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
Old URLs with only `?acheteur_id=...` or `?titulaire_id=...` continue to work — the new `restore_filters` will read those keys and reset all others to defaults, which is the same effective behavior as before.
|
||||
|
||||
Links generated by `add_links()` in `src/utils.py` (used on search results to link to `/observatoire?acheteur_id=...`) are unaffected.
|
||||
|
||||
## Test Changes
|
||||
|
||||
### Fix broken test `test_010_observatoire_montant_filter`
|
||||
|
||||
This test imports `_apply_filters` from `pages.observatoire`, which no longer exists (replaced by `prepare_dashboard_data` in `src/utils.py`). Fix:
|
||||
- Replace import with `from src.utils import prepare_dashboard_data`
|
||||
- Update the call to match `prepare_dashboard_data`'s signature: rename `marche_type` keyword to `type`, and add missing params `objet`, `code_cpv`, `techniques`, `marche_innovant`, `sous_traitance_declaree` (all as `None`)
|
||||
|
||||
### New test: multi-param URL round-trip
|
||||
|
||||
Add a test that navigates to `/observatoire?annee=2024&acheteur_id=<test_id>&montant_min=10000` and verifies that:
|
||||
- `dashboard_year` dropdown shows "2024"
|
||||
- `dashboard_acheteur_id` input contains the test ID
|
||||
- `dashboard_montant_min` input contains "10000"
|
||||
|
||||
### Update existing tests
|
||||
|
||||
Tests `test_006` and `test_007` validate `acheteur_id` round-trip. These should continue to pass without changes since `acheteur_id` keeps the same URL key.
|
||||
+6
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "decp.info"
|
||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||
version = "2.6.2"
|
||||
version = "2.7.0"
|
||||
requires-python = ">= 3.10"
|
||||
authors = [
|
||||
{ name = "Colin Maudry", email = "colin@colmo.tech" }
|
||||
@@ -17,7 +17,9 @@ dependencies = [
|
||||
"plotly[express]",
|
||||
"httpx",
|
||||
"pandas", # utilisé pour la création de certains graphiques
|
||||
"unidecode"
|
||||
"unidecode",
|
||||
"dash-leaflet",
|
||||
"dash-extensions"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -40,6 +42,7 @@ testpaths = [
|
||||
]
|
||||
env = [
|
||||
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
|
||||
"DEVELOPMENT=true"
|
||||
"DEVELOPMENT=true",
|
||||
"DATA_SCHEMA_PATH=/home/colin/git/decp-processing/dist/schema.json"
|
||||
]
|
||||
addopts = "-p no:warnings"
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ def sitemap():
|
||||
base_url = "https://decp.info"
|
||||
pages = [
|
||||
"/",
|
||||
"/statistiques",
|
||||
"/observatoire",
|
||||
"/tableau",
|
||||
"/a-propos",
|
||||
]
|
||||
@@ -160,7 +160,7 @@ navbar = dbc.Navbar(
|
||||
)
|
||||
for page in page_registry.values()
|
||||
if page["name"]
|
||||
in ["Recherche", "À propos", "Tableau", "Statistiques"]
|
||||
in ["Recherche", "À propos", "Tableau", "Observatoire"]
|
||||
],
|
||||
className="ms-auto",
|
||||
navbar=True,
|
||||
|
||||
+30
-40
@@ -94,13 +94,6 @@ button:hover:not([disabled]) {
|
||||
padding: 28px 24px 0 24px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
margin-bottom: 50px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#header > * {
|
||||
margin: 0 0 20px 0px;
|
||||
}
|
||||
@@ -151,6 +144,10 @@ p.version > a {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.seeBorder {
|
||||
border: dotted 1px green;
|
||||
}
|
||||
|
||||
/* --- Search Page --- */
|
||||
.tagline {
|
||||
text-align: center;
|
||||
@@ -176,14 +173,22 @@ p.version > a {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.results_acheteur {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
/* --- Dashboard inputs --- */
|
||||
|
||||
.Select--multi .Select-value {
|
||||
color: var(--primary-color) !important;
|
||||
background-color: rgba(255, 240, 240, 0.4) !important;
|
||||
}
|
||||
|
||||
.results_titulaire {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
#filters .row > * {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
#filters input[type="text"],
|
||||
#filters input[type="number"] {
|
||||
border: 1px #ccc solid;
|
||||
border-radius: 3px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* --- Tables (Dash & Custom) --- */
|
||||
@@ -419,40 +424,15 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
/* --- Organization Cards (Grid Items) --- */
|
||||
.org_title {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.org_year {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.org_infos {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
#cards .card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.org_infos > p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.org_stats {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.org_map {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.org_top {
|
||||
grid-column: 1/3;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
/* --- About Page (A Propos) --- */
|
||||
.a-propos-container {
|
||||
display: flex;
|
||||
@@ -552,3 +532,13 @@ summary > h4 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
window.dash_clientside = Object.assign({}, window.dash_clientside, {
|
||||
leaflet: {
|
||||
pointToLayer: function (feature, latlng, context) {
|
||||
return L.circleMarker(latlng, {
|
||||
radius: 5,
|
||||
fillColor: feature.properties.marker_color,
|
||||
color: "white",
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8,
|
||||
}).bindTooltip(feature.properties.tooltip);
|
||||
},
|
||||
clusterToLayer: function (feature, latlng, index, context) {
|
||||
console.log(feature);
|
||||
console.log(index);
|
||||
console.log(context);
|
||||
|
||||
const count = feature.properties.point_count;
|
||||
const size = count < 100 ? 30 : count < 1000 ? 40 : 50;
|
||||
const color = "#555"; // Default cluster color
|
||||
const icon = L.divIcon({
|
||||
html: `<div style="background-color: ${context.fillColor}; width: ${size}px; height: ${size}px; border-radius: 50%; display: flex; align-items:center; justify-content:center; color: white; border: 2px solid white; font-weight: bold;">${count}</div>`,
|
||||
className: "marker-cluster",
|
||||
iconSize: L.point(size, size),
|
||||
});
|
||||
return L.marker(latlng, { icon: icon });
|
||||
},
|
||||
},
|
||||
clientside: {
|
||||
clean_filters: function (trigger) {
|
||||
if (!trigger) {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import polars as pl
|
||||
from dash import html
|
||||
|
||||
from src.figures import DataTable
|
||||
from utils import add_links_in_dict, format_values, setup_table_columns
|
||||
|
||||
|
||||
def get_top_org_table(data, org_type: str):
|
||||
dff = pl.DataFrame(data, strict=False, infer_schema_length=5000)
|
||||
if dff.height == 0:
|
||||
return html.Div()
|
||||
|
||||
dff = dff.select(
|
||||
["uid", f"{org_type}_id", f"{org_type}_nom", "titulaire_distance", "montant"]
|
||||
)
|
||||
dff_nb = dff.group_by(
|
||||
f"{org_type}_id", f"{org_type}_nom", "titulaire_distance"
|
||||
).agg(pl.len().alias("Attributions"), pl.sum("montant").alias("montant"))
|
||||
dff_nb = dff_nb.sort(by="montant", descending=True, nulls_last=True)
|
||||
dff_nb = dff_nb.cast(pl.String)
|
||||
dff_nb = dff_nb.fill_null("")
|
||||
dff_nb = format_values(dff_nb)
|
||||
columns, tooltip = setup_table_columns(
|
||||
dff_nb, hideable=False, exclude=[f"{org_type}_id"], new_columns=["Attributions"]
|
||||
)
|
||||
data = dff_nb.to_dicts()
|
||||
data = add_links_in_dict(data, f"{org_type}")
|
||||
|
||||
return DataTable(
|
||||
dtid=f"top10_{org_type}",
|
||||
data=data,
|
||||
page_action="native",
|
||||
page_size=10,
|
||||
columns=columns,
|
||||
tooltip_header=tooltip,
|
||||
)
|
||||
+496
-105
@@ -1,62 +1,25 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
import dash_bootstrap_components as dbc
|
||||
import dash_leaflet as dl
|
||||
import dash_leaflet.express as dlx
|
||||
import numpy as np
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
import polars as pl
|
||||
from dash import dash_table, dcc, html
|
||||
from dash_extensions.javascript import Namespace
|
||||
|
||||
from src.utils import data_schema, df, format_number
|
||||
|
||||
|
||||
def get_map_count_marches():
|
||||
lf = df.lazy()
|
||||
lf = lf.with_columns(
|
||||
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
|
||||
)
|
||||
lf = (
|
||||
lf.select(["uid", "Département"])
|
||||
.drop_nulls()
|
||||
.unique(subset="uid")
|
||||
.group_by("Département")
|
||||
.len("uid")
|
||||
)
|
||||
# Suppression des infos pour les DOM/TOM pour l'instant
|
||||
lf = lf.remove(pl.col("Département").is_in(["97", "98"]))
|
||||
|
||||
with open("./data/departements-1000m.geojson") as f:
|
||||
departements = json.load(f)
|
||||
|
||||
# Ajout de feature.id
|
||||
for f in departements["features"]:
|
||||
f["id"] = f["properties"]["code"]
|
||||
|
||||
df_map = lf.collect(engine="streaming")
|
||||
|
||||
fig = px.choropleth(
|
||||
df_map,
|
||||
geojson=departements,
|
||||
locations="Département",
|
||||
color="uid",
|
||||
color_continuous_scale="Reds",
|
||||
title="Nombres de marchés attribués par département (lieu d'exécution)",
|
||||
range_color=(df_map["uid"].min(), df_map["uid"].max()),
|
||||
labels={"uid": "Marchés attribués"},
|
||||
scope="europe",
|
||||
width=900,
|
||||
height=700,
|
||||
)
|
||||
|
||||
fig.update_geos(fitbounds="locations", visible=False)
|
||||
fig.update_layout(
|
||||
mapbox={
|
||||
"style": "carto-positron",
|
||||
"center": {"lon": 10, "lat": 10},
|
||||
"zoom": 1,
|
||||
"domain": {"x": [0, 1], "y": [0, 1]},
|
||||
}
|
||||
)
|
||||
return fig
|
||||
from src.utils import (
|
||||
add_links,
|
||||
data_schema,
|
||||
departements_geojson,
|
||||
df,
|
||||
format_number,
|
||||
setup_table_columns,
|
||||
)
|
||||
|
||||
|
||||
def get_yearly_statistics(statistics, today_str) -> html.Div:
|
||||
@@ -77,11 +40,11 @@ def get_yearly_statistics(statistics, today_str) -> html.Div:
|
||||
}
|
||||
)
|
||||
|
||||
df = pl.DataFrame(data)
|
||||
dff = pl.DataFrame(data)
|
||||
|
||||
# Create Dash DataTable
|
||||
table = dash_table.DataTable(
|
||||
data=df.to_dicts(),
|
||||
data=dff.to_dicts(),
|
||||
columns=[
|
||||
{"name": "Année", "id": "Année"},
|
||||
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
|
||||
@@ -98,19 +61,20 @@ def get_yearly_statistics(statistics, today_str) -> html.Div:
|
||||
return html.Div(children=table, className="marches_table")
|
||||
|
||||
|
||||
def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
|
||||
lf = df_source.lazy()
|
||||
def get_barchart_sources(lff: pl.LazyFrame, type_date: str):
|
||||
labels = {
|
||||
"dateNotification": "notification",
|
||||
"datePublicationDonnees": "publication des données",
|
||||
}
|
||||
|
||||
lf = lf.select("uid", type_date, "sourceDataset")
|
||||
now_year = datetime.now().year
|
||||
|
||||
lf = lf.unique("uid")
|
||||
lff = lff.select("uid", type_date, "sourceDataset")
|
||||
|
||||
lff = lff.unique("uid")
|
||||
|
||||
# Rassemblement des datasets Atexo pour ne pas surcharger le graphique
|
||||
lf = lf.with_columns(
|
||||
lff = lff.with_columns(
|
||||
pl.when(pl.col("sourceDataset").str.starts_with("atexo"))
|
||||
.then(pl.lit("plateformes atexo"))
|
||||
.otherwise(pl.col("sourceDataset"))
|
||||
@@ -118,38 +82,33 @@ def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
|
||||
)
|
||||
|
||||
# Rassemblement des datasets AWS pour ne pas surcharger le graphique
|
||||
lf = lf.with_columns(
|
||||
lff = lff.with_columns(
|
||||
pl.when(pl.col("sourceDataset").str.contains(r"aws|marches\-publics.info"))
|
||||
.then(pl.lit("aws"))
|
||||
.otherwise(pl.col("sourceDataset"))
|
||||
.alias("sourceDataset")
|
||||
)
|
||||
|
||||
lf = lf.with_columns(pl.col(type_date).dt.year().alias("annee"))
|
||||
lf = lf.filter(
|
||||
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, 2025)
|
||||
lff = lff.with_columns(pl.col(type_date).dt.year().alias("annee"))
|
||||
lff = lff.filter(
|
||||
pl.col(type_date).is_not_null() & pl.col("annee").is_between(2019, now_year)
|
||||
)
|
||||
lf = lf.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
|
||||
lf = (
|
||||
lf.group_by([type_date, "sourceDataset"])
|
||||
lff = lff.with_columns(pl.col(type_date).cast(pl.String).str.head(7))
|
||||
lff = (
|
||||
lff.group_by([type_date, "sourceDataset"])
|
||||
.len()
|
||||
.sort(by=[type_date, "len"], descending=True)
|
||||
)
|
||||
|
||||
# lf = lf.with_columns(
|
||||
# pl.when(pl.col("sourceDataset").is_null()).then(
|
||||
# pl.lit("Source inconnue")).alias("sourceDataset")
|
||||
# )
|
||||
lff = lff.sort(by=["sourceDataset"], descending=False)
|
||||
|
||||
lf = lf.sort(by=["sourceDataset"], descending=False)
|
||||
df: pl.DataFrame = lf.collect(engine="streaming")
|
||||
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||
|
||||
fig = px.bar(
|
||||
df,
|
||||
dff,
|
||||
x=type_date,
|
||||
y="len",
|
||||
color="sourceDataset",
|
||||
title=f"Nombre de marchés attribués par date de {labels[type_date]} et source de données",
|
||||
labels={
|
||||
"len": "Nombre de marchés",
|
||||
type_date: f"Mois de {labels[type_date]}",
|
||||
@@ -157,12 +116,17 @@ def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
|
||||
},
|
||||
)
|
||||
|
||||
return fig
|
||||
graph = dcc.Graph(figure=fig)
|
||||
|
||||
return graph
|
||||
|
||||
|
||||
def get_sources_tables(source_path) -> html.Div:
|
||||
df = pl.read_csv(source_path)
|
||||
df = df.with_columns(
|
||||
try:
|
||||
dff = pl.read_csv(source_path)
|
||||
except (URLError, HTTPError):
|
||||
return html.Div("Erreur de connexion")
|
||||
dff = dff.with_columns(
|
||||
(
|
||||
pl.lit('<a href = "')
|
||||
+ pl.col("url")
|
||||
@@ -171,8 +135,8 @@ def get_sources_tables(source_path) -> html.Div:
|
||||
+ pl.lit("</a>")
|
||||
).alias("nom")
|
||||
)
|
||||
df = df.drop("url", "unique")
|
||||
df = df.sort(by=["nb_marchés"], descending=True)
|
||||
dff = dff.drop("url", "unique")
|
||||
dff = dff.sort(by=["nb_marchés"], descending=True)
|
||||
|
||||
columns = {
|
||||
"nom": "Nom de la source",
|
||||
@@ -184,7 +148,7 @@ def get_sources_tables(source_path) -> html.Div:
|
||||
|
||||
datatable = dash_table.DataTable(
|
||||
id="source_table",
|
||||
data=df.to_dicts(),
|
||||
data=dff.to_dicts(),
|
||||
columns=[
|
||||
{
|
||||
"name": columns[i],
|
||||
@@ -193,7 +157,7 @@ def get_sources_tables(source_path) -> html.Div:
|
||||
"type": "text",
|
||||
"format": {"nully": "N/A"},
|
||||
}
|
||||
for i in df.schema.names()
|
||||
for i in dff.schema.names()
|
||||
],
|
||||
style_cell_conditional=[
|
||||
{
|
||||
@@ -344,33 +308,24 @@ class DataTable(dash_table.DataTable):
|
||||
)
|
||||
|
||||
|
||||
def get_duplicate_matrix() -> html.Div:
|
||||
def get_duplicate_matrix() -> dcc.Graph:
|
||||
"""
|
||||
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
|
||||
:return:
|
||||
"""
|
||||
result_df = pl.read_parquet(
|
||||
lff = pl.scan_parquet(
|
||||
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
|
||||
).sort("sourceDataset")
|
||||
result_df = result_df.select(
|
||||
["sourceDataset", "unique"] + sorted(result_df.columns[2:])
|
||||
lff = lff.select(
|
||||
["sourceDataset", "unique"] + sorted(lff.collect_schema().names()[2:])
|
||||
)
|
||||
|
||||
description = dcc.Markdown("""
|
||||
Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source. Il s'appuie sur les identifiants `uid` qui sont pour chaque marché la concaténation du SIRET de l'acheteur et de l'identifiant interne du marché.
|
||||
|
||||
**Comment lire ce graphique ?**
|
||||
|
||||
On part des codes de sources de données en ordonnée. Ces jeux de données sont documentés dans [À propos](/a-propos#sources).
|
||||
|
||||
La première colonne (**unique**) représente le pourcentage de marchés fournis par cette source qui sont uniquement disponibles dans cette source. Plus le rouge est foncé, plus important est le pourcentage. Donc, à l'inverse, plus le rouge est clair dans la première colonne, plus la source en ordonnée a des marchés en commun avec d'autres sources, et donc plus on trouvera sur la même ligne d'autres cases plus ou moins foncées qui indiqueront avec quelles autres sources cette source partage des marchés.
|
||||
|
||||
Passez votre souris sur une case pour avoir les pourcentages exacts. À noter que ces statistiques sont produites avant le dédoublonnement qui a lieu avant la publication en Open Data et sur ce site.""")
|
||||
dff = lff.collect()
|
||||
|
||||
# Extract data
|
||||
z_data = result_df.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
|
||||
x_labels = result_df.columns[1:] # columns after "sourceDataset"
|
||||
y_labels = result_df["sourceDataset"].to_list()
|
||||
z_data = dff.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
|
||||
x_labels = dff.columns[1:] # columns after "sourceDataset"
|
||||
y_labels = dff["sourceDataset"].to_list()
|
||||
|
||||
# Create heatmap
|
||||
fig = go.Figure(
|
||||
@@ -388,7 +343,7 @@ def get_duplicate_matrix() -> html.Div:
|
||||
hoverongaps=False,
|
||||
showscale=True,
|
||||
hovertemplate=(
|
||||
"<b>%{z:.0%}</b> des marchés de <b>%{y}</b> sont également présents dans <b>%{x}</b>"
|
||||
"<b>%{z:.0%}</b> des marchés présents dans <b>%{y}</b> sont également présents dans <b>%{x}</b>"
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -407,14 +362,409 @@ def get_duplicate_matrix() -> html.Div:
|
||||
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
|
||||
)
|
||||
|
||||
return html.Div(
|
||||
children=[
|
||||
html.H3("Doublons de marchés entre les sources"),
|
||||
description,
|
||||
dcc.Graph(figure=fig),
|
||||
]
|
||||
return dcc.Graph(figure=fig)
|
||||
|
||||
|
||||
def get_geographic_maps(dff: pl.DataFrame) -> list | None:
|
||||
"""
|
||||
Génère les cartes géographiques pour l'hexagone et les DOM-TOM.
|
||||
"""
|
||||
|
||||
regions: dict = {
|
||||
"Hexagone": {
|
||||
"coordinates": [46.6, 2.2],
|
||||
"zoom_leaflet": 5,
|
||||
"zoom_chloropleth": 1,
|
||||
"name": "Hexagone",
|
||||
},
|
||||
"971": {
|
||||
"coordinates": [16.23, -61.55],
|
||||
"zoom_leaflet": 9,
|
||||
"zoom_chloropleth": 1,
|
||||
"name": "Guadeloupe",
|
||||
},
|
||||
"972": {
|
||||
"coordinates": [14.64, -61.02],
|
||||
"zoom_leaflet": 10,
|
||||
"zoom_chloropleth": 1,
|
||||
"name": "Martinique",
|
||||
},
|
||||
"973": {
|
||||
"coordinates": [3.93, -53.12],
|
||||
"zoom_leaflet": 7,
|
||||
"zoom_chloropleth": 1,
|
||||
"name": "Guyane",
|
||||
},
|
||||
"974": {
|
||||
"coordinates": [-21.11, 55.53],
|
||||
"zoom_leaflet": 9,
|
||||
"zoom_chloropleth": 1,
|
||||
"name": "La Réunion",
|
||||
},
|
||||
"976": {
|
||||
"coordinates": [-12.82, 45.16],
|
||||
"zoom_leaflet": 10,
|
||||
"zoom_chloropleth": 1,
|
||||
"name": "Mayotte",
|
||||
},
|
||||
}
|
||||
|
||||
def make_map_data(region_code: str) -> tuple[list, str or None]:
|
||||
lff: pl.LazyFrame = dff.lazy()
|
||||
if region_code == "Hexagone":
|
||||
lff = lff.filter(
|
||||
(pl.col("acheteur_departement_code").str.len_chars() == 2)
|
||||
& (pl.col("titulaire_departement_code").str.len_chars() == 2)
|
||||
)
|
||||
else:
|
||||
lff = lff.filter(
|
||||
(pl.col("acheteur_departement_code") == code)
|
||||
| (pl.col("titulaire_departement_code") == code)
|
||||
)
|
||||
|
||||
nb_marches = lff.select("uid").collect()["uid"].n_unique()
|
||||
|
||||
if nb_marches == 0:
|
||||
return [], None
|
||||
|
||||
dfs = []
|
||||
|
||||
if (code == "Hexagone" and nb_marches > 30000) or (
|
||||
code != "Hexagone" and nb_marches > 10000
|
||||
):
|
||||
_map_type: str = "chloropleth"
|
||||
|
||||
lff = lff.rename({"acheteur_departement_code": "Département"})
|
||||
lff = (
|
||||
lff.select(["uid", "Département"])
|
||||
.drop_nulls()
|
||||
.group_by("uid")
|
||||
.agg(pl.col("Département").first())
|
||||
.group_by("Département")
|
||||
.len("uid")
|
||||
)
|
||||
dfs.append(lff.collect())
|
||||
else:
|
||||
_map_type: str = "clusters"
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
lff_org = (
|
||||
lff.select(
|
||||
"uid",
|
||||
f"{org_type}_longitude",
|
||||
f"{org_type}_latitude",
|
||||
f"{org_type}_nom",
|
||||
)
|
||||
.group_by(
|
||||
f"{org_type}_longitude",
|
||||
f"{org_type}_latitude",
|
||||
f"{org_type}_nom",
|
||||
)
|
||||
.len("nb_marches")
|
||||
.filter(
|
||||
pl.col(f"{org_type}_latitude").is_not_null()
|
||||
& pl.col(f"{org_type}_longitude").is_not_null()
|
||||
)
|
||||
)
|
||||
|
||||
markers = []
|
||||
|
||||
# Couleurs accessibles (Okabe-Ito)
|
||||
colors = {
|
||||
"acheteur": "#E69F00", # orange
|
||||
"titulaire": "#56B4E9", # bleu ciel
|
||||
}
|
||||
|
||||
for row in lff_org.collect().to_dicts():
|
||||
markers.append(
|
||||
{
|
||||
"lat": row[f"{org_type}_latitude"],
|
||||
"lon": row[f"{org_type}_longitude"],
|
||||
"tooltip": f"{row[f'{org_type}_nom']} ({row['nb_marches']} marchés)",
|
||||
"marker_color": colors[org_type],
|
||||
}
|
||||
)
|
||||
dfs.append(markers)
|
||||
|
||||
return dfs, _map_type
|
||||
|
||||
cols = []
|
||||
|
||||
for code in regions.keys():
|
||||
regions[code]["data"], map_type = make_map_data(code)
|
||||
|
||||
if map_type == "chloropleth":
|
||||
map_graph = make_chloropleth_map(regions[code])
|
||||
elif map_type == "clusters":
|
||||
map_graph = make_clusters_map(regions[code])
|
||||
elif map_type is None:
|
||||
continue
|
||||
else:
|
||||
raise ValueError(f"Map type '{map_type}' not recognised")
|
||||
|
||||
lg, xl = (12, 8) if code == "Hexagone" else (6, 4)
|
||||
|
||||
col = make_card(regions[code]["name"], fig=map_graph, lg=lg, xl=xl)
|
||||
cols.append(col)
|
||||
|
||||
return cols
|
||||
|
||||
|
||||
def make_chloropleth_map(region: dict) -> dcc.Graph:
|
||||
df_map = region["data"][0]
|
||||
|
||||
fig = px.choropleth(
|
||||
df_map,
|
||||
geojson=departements_geojson,
|
||||
locations="Département",
|
||||
color="uid",
|
||||
color_continuous_scale="Reds",
|
||||
range_color=(df_map["uid"].min(), df_map["uid"].max()),
|
||||
labels={"uid": "Marchés attribués"},
|
||||
scope="europe",
|
||||
)
|
||||
|
||||
fig.update_geos(fitbounds="locations", visible=False)
|
||||
fig.update_layout(
|
||||
mapbox={
|
||||
"style": "carto-positron",
|
||||
"center": {"lon": 10, "lat": 10},
|
||||
"zoom": 8,
|
||||
"domain": {"x": [0, 1], "y": [0, 1]},
|
||||
}
|
||||
)
|
||||
|
||||
graph = dcc.Graph(figure=fig, config={"displayModeBar": False})
|
||||
return graph
|
||||
|
||||
|
||||
def make_clusters_map(region: dict) -> dl.Map:
|
||||
# JavaScript functions for styling
|
||||
ns = Namespace("dash_clientside", "leaflet")
|
||||
point_to_layer = ns("pointToLayer")
|
||||
cluster_to_layer = ns("clusterToLayer")
|
||||
|
||||
name = region["name"]
|
||||
|
||||
# Données de la région
|
||||
region_acheteurs = region["data"][0]
|
||||
region_titulaires = region["data"][1]
|
||||
|
||||
# Couleurs
|
||||
color_acheteur = region_acheteurs[0]["marker_color"]
|
||||
color_titulaire = region_titulaires[0]["marker_color"]
|
||||
|
||||
acheteurs_geojson_data = dlx.dicts_to_geojson(region_acheteurs)
|
||||
titulaires_geojson_data = dlx.dicts_to_geojson(region_titulaires)
|
||||
|
||||
center, zoom = region["coordinates"], region["zoom_leaflet"]
|
||||
region_id = name.lower().replace(" ", "-")
|
||||
leaflet_map = dl.Map(
|
||||
[
|
||||
dl.TileLayer(),
|
||||
dl.GeoJSON(
|
||||
data=titulaires_geojson_data,
|
||||
cluster=True,
|
||||
zoomToBoundsOnClick=True,
|
||||
pointToLayer=point_to_layer,
|
||||
clusterToLayer=cluster_to_layer,
|
||||
id=f"geojson-{region_id}-titulaires",
|
||||
options={"fillColor": color_titulaire},
|
||||
),
|
||||
dl.GeoJSON(
|
||||
data=acheteurs_geojson_data,
|
||||
cluster=True,
|
||||
zoomToBoundsOnClick=True,
|
||||
pointToLayer=point_to_layer,
|
||||
clusterToLayer=cluster_to_layer,
|
||||
id=f"geojson-{region_id}-acheteurs",
|
||||
options={"fillColor": color_acheteur},
|
||||
),
|
||||
],
|
||||
center=center,
|
||||
zoom=zoom,
|
||||
style={
|
||||
"width": "100%",
|
||||
"height": "400px" if name == "Hexagone" else "300px",
|
||||
},
|
||||
id=f"map-{region_id}",
|
||||
)
|
||||
return leaflet_map
|
||||
|
||||
|
||||
def get_distance_histogram(lff: pl.LazyFrame) -> dcc.Graph:
|
||||
if "titulaire_distance" not in lff.collect_schema().names():
|
||||
dff = pl.DataFrame({"titulaire_distance": pl.Series([], dtype=pl.Float64)})
|
||||
else:
|
||||
dff = (
|
||||
lff.select("titulaire_distance")
|
||||
.drop_nulls()
|
||||
.filter(pl.col("titulaire_distance") > 0)
|
||||
.collect(engine="streaming")
|
||||
)
|
||||
log_distances = dff["titulaire_distance"].log(10).to_numpy()
|
||||
|
||||
fig = go.Figure()
|
||||
if len(log_distances) > 0:
|
||||
counts, bin_edges = np.histogram(log_distances, bins=25)
|
||||
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2
|
||||
bin_widths = bin_edges[1:] - bin_edges[:-1]
|
||||
bin_edges_km = 10.0**bin_edges
|
||||
|
||||
def fmt_km(km):
|
||||
if km < 10:
|
||||
return f"{km:.1f}"
|
||||
elif km < 1000:
|
||||
return f"{round(km)}"
|
||||
else:
|
||||
return f"{round(km):,}".replace(",", " ")
|
||||
|
||||
hover_texts = []
|
||||
for i in range(len(counts)):
|
||||
nb = f"{counts[i]:,}".replace(",", " ")
|
||||
hover_texts.append(
|
||||
f"Distance : {fmt_km(bin_edges_km[i])} – {fmt_km(bin_edges_km[i + 1])} km"
|
||||
f"<br>Nombre de marchés : {nb}"
|
||||
)
|
||||
|
||||
fig.add_trace(
|
||||
go.Bar(
|
||||
x=bin_centers,
|
||||
y=counts,
|
||||
width=bin_widths,
|
||||
hovertext=hover_texts,
|
||||
hoverinfo="text",
|
||||
)
|
||||
)
|
||||
fig.update_layout(bargap=0)
|
||||
|
||||
fig.update_layout(margin=dict(r=10, t=10))
|
||||
fig.update_xaxes(
|
||||
tickvals=[0, 1, 2, 3, 4],
|
||||
ticktext=["1", "10", "100", "1 000", "10 000"],
|
||||
title_text="Distance (km)",
|
||||
)
|
||||
fig.update_yaxes(title_text="Nombre de marchés")
|
||||
return dcc.Graph(figure=fig)
|
||||
|
||||
|
||||
def get_dashboard_summary_table(dff, dff_per_uid, nb_marches):
|
||||
nb_acheteurs = dff.select("acheteur_id").n_unique()
|
||||
nb_titulaires = dff.select("titulaire_id", "titulaire_typeIdentifiant").n_unique()
|
||||
total_montant = int(dff_per_uid.select(pl.col("montant").sum()).item())
|
||||
median_distance = dff.select(pl.median("titulaire_distance")).item()
|
||||
|
||||
summary_table = [
|
||||
html.P(["Nombre de marchés : ", html.Strong(str(format_number(nb_marches)))]),
|
||||
html.P(
|
||||
[
|
||||
"Nombre d'acheteurs uniques : ",
|
||||
html.Strong(str(format_number(nb_acheteurs))),
|
||||
]
|
||||
),
|
||||
html.P(
|
||||
[
|
||||
"Nombre de titulaires uniques : ",
|
||||
html.Strong(str(format_number(nb_titulaires))),
|
||||
]
|
||||
),
|
||||
html.P(
|
||||
[
|
||||
"Montant total (",
|
||||
html.Span(
|
||||
"?",
|
||||
id={"type": "modal-trigger", "index": "montant"},
|
||||
style={"cursor": "pointer", "textDecoration": "underline dotted"},
|
||||
),
|
||||
") : ",
|
||||
html.Strong(format_number(total_montant) + " €"),
|
||||
]
|
||||
),
|
||||
html.P(
|
||||
[
|
||||
"Distance acheteur-titulaire médiane : ",
|
||||
html.Strong(format_number(median_distance) + " km"),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
return summary_table
|
||||
|
||||
|
||||
def make_card(
|
||||
title: str, subtitle=None, fig=None, paragraphs=None, lg=6, xl=4
|
||||
) -> dbc.Col:
|
||||
children = []
|
||||
if title:
|
||||
children.append(html.H5(title, className="card-title"))
|
||||
if subtitle:
|
||||
children.append(html.H6(subtitle, className="card-subtitle mb-2 text-muted"))
|
||||
if fig is not None:
|
||||
children.append(fig)
|
||||
if paragraphs:
|
||||
for p in paragraphs:
|
||||
p.className = "card-text"
|
||||
children.append(p)
|
||||
|
||||
card = dbc.Col(
|
||||
html.Div(html.Div(className="card-body", children=children), className="card"),
|
||||
lg=lg,
|
||||
xl=xl,
|
||||
# width=width,
|
||||
# className="mb-4",
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
def make_donut(
|
||||
lff: pl.LazyFrame,
|
||||
names_col,
|
||||
per_uid: bool,
|
||||
nulls="?",
|
||||
potentially_many_names: bool = False,
|
||||
):
|
||||
title = data_schema[names_col]["title"]
|
||||
lff = lff.rename({names_col: title})
|
||||
lff = lff.select("uid", title)
|
||||
|
||||
if per_uid:
|
||||
lff = lff.group_by("uid").first()
|
||||
|
||||
lff = lff.group_by(title).len("Nombre")
|
||||
lff = lff.with_columns(pl.col(title).replace(None, pl.lit(nulls)))
|
||||
dff = lff.collect(engine="streaming")
|
||||
nb_names = dff[title].n_unique()
|
||||
|
||||
sum_values = dff["Nombre"].sum()
|
||||
dff = dff.with_columns(
|
||||
pl.when((pl.col("Nombre") / sum_values) < 0.01)
|
||||
.then(pl.lit("Autres"))
|
||||
.otherwise(pl.col(title))
|
||||
.alias(title)
|
||||
)
|
||||
|
||||
dff = dff.with_columns(
|
||||
pl.col("Nombre")
|
||||
.map_elements(format_number, return_dtype=pl.String)
|
||||
.alias("Nombre_fmt")
|
||||
)
|
||||
fig = px.pie(
|
||||
dff,
|
||||
values="Nombre",
|
||||
names=title,
|
||||
hole=0.4,
|
||||
color_discrete_sequence=px.colors.qualitative.Safe,
|
||||
custom_data=["Nombre_fmt"],
|
||||
)
|
||||
fig = fig.update_traces(
|
||||
texttemplate="<b>%{label}</b><br><b>%{percent}</b>",
|
||||
hovertemplate="<b>%{label}</b><br>%{customdata[0]}<extra></extra>",
|
||||
)
|
||||
fig = fig.update_layout(showlegend=False, font=dict(size=14))
|
||||
graph = dcc.Graph(figure=fig)
|
||||
if potentially_many_names:
|
||||
return graph, nb_names
|
||||
return graph
|
||||
|
||||
|
||||
def make_column_picker(page: str):
|
||||
table_data = []
|
||||
@@ -468,3 +818,44 @@ def make_column_picker(page: str):
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def get_top_org_table(data, org_type: str, extra_columns: list, filters: bool = True):
|
||||
if isinstance(data, pl.LazyFrame):
|
||||
lff = data
|
||||
else:
|
||||
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||
|
||||
if org_type == "titulaire":
|
||||
extra_columns.append("titulaire_typeIdentifiant")
|
||||
columns = ["uid", f"{org_type}_id", f"{org_type}_nom"] + extra_columns
|
||||
|
||||
lff = lff.select(columns)
|
||||
lff = lff.group_by([f"{org_type}_id", f"{org_type}_nom"] + extra_columns).agg(
|
||||
pl.len().alias("Attributions")
|
||||
)
|
||||
lff = lff.sort(by="Attributions", descending=True, nulls_last=True)
|
||||
lff = lff.cast(pl.String)
|
||||
lff = lff.fill_null("")
|
||||
|
||||
dff: pl.DataFrame = lff.collect(engine="streaming")
|
||||
|
||||
if dff.height == 0:
|
||||
return html.Div()
|
||||
|
||||
columns, tooltip = setup_table_columns(
|
||||
dff, hideable=False, exclude=[f"{org_type}_id"], new_columns=["Attributions"]
|
||||
)
|
||||
dff = add_links(dff)
|
||||
data = dff.to_dicts()
|
||||
# data = add_links_in_dict(data, f"{org_type}")
|
||||
|
||||
return DataTable(
|
||||
dtid=f"top10_{org_type}",
|
||||
data=data,
|
||||
page_action="native",
|
||||
page_size=10,
|
||||
columns=columns,
|
||||
tooltip_header=tooltip,
|
||||
filter_action="native" if filters else "none",
|
||||
)
|
||||
|
||||
@@ -87,7 +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 [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
|
||||
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)
|
||||
|
||||
+103
-54
@@ -15,8 +15,14 @@ from dash import (
|
||||
register_page,
|
||||
)
|
||||
|
||||
from src.callbacks import get_top_org_table
|
||||
from src.figures import DataTable, make_column_picker, point_on_map
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_top_org_table,
|
||||
make_card,
|
||||
make_column_picker,
|
||||
point_on_map,
|
||||
)
|
||||
from src.utils import (
|
||||
columns,
|
||||
df,
|
||||
@@ -76,68 +82,94 @@ layout = [
|
||||
html.Div(
|
||||
children=[
|
||||
html.Div(
|
||||
className="wrapper",
|
||||
style={"marginBottom": "50px"},
|
||||
children=[
|
||||
html.H2(
|
||||
className="org_title",
|
||||
dbc.Row(
|
||||
className="mb-2",
|
||||
children=[
|
||||
html.Span(id="acheteur_siret"),
|
||||
" - ",
|
||||
html.Span(id="acheteur_nom"),
|
||||
],
|
||||
),
|
||||
html.Div(
|
||||
className="org_year",
|
||||
children=dcc.Dropdown(
|
||||
id="acheteur_year",
|
||||
options=["Toutes les années"]
|
||||
+ [
|
||||
str(year)
|
||||
for year in range(
|
||||
2018, int(datetime.date.today().year) + 1
|
||||
)
|
||||
],
|
||||
placeholder="Année",
|
||||
),
|
||||
),
|
||||
html.Div(
|
||||
className="org_infos",
|
||||
children=[
|
||||
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
|
||||
html.P(["Commune : ", html.Strong(id="acheteur_commune")]),
|
||||
html.P(
|
||||
[
|
||||
"Département : ",
|
||||
html.Strong(id="acheteur_departement"),
|
||||
]
|
||||
dbc.Col(
|
||||
html.H2(
|
||||
children=[
|
||||
html.Span(id="acheteur_siret"),
|
||||
" - ",
|
||||
html.Span(id="acheteur_nom"),
|
||||
],
|
||||
),
|
||||
width=8,
|
||||
),
|
||||
html.P(["Région : ", html.Strong(id="acheteur_region")]),
|
||||
html.A(
|
||||
id="acheteur_lien_annuaire",
|
||||
children="Plus de détails sur l'Annuaire des entreprises",
|
||||
dbc.Col(
|
||||
dcc.Dropdown(
|
||||
id="acheteur_year",
|
||||
options=["Toutes les années"]
|
||||
+ [
|
||||
str(year)
|
||||
for year in range(
|
||||
2018, int(datetime.date.today().year) + 1
|
||||
)
|
||||
],
|
||||
placeholder="Année",
|
||||
),
|
||||
width=4,
|
||||
),
|
||||
],
|
||||
),
|
||||
html.Div(
|
||||
className="org_stats",
|
||||
dbc.Row(
|
||||
className="mb-2",
|
||||
children=[
|
||||
html.P(id="acheteur_titre_stats"),
|
||||
html.P(id="acheteur_marches_attribues"),
|
||||
html.P(id="acheteur_titulaires_differents"),
|
||||
html.Button(
|
||||
"Téléchargement au format Excel",
|
||||
id="btn-download-data-acheteur",
|
||||
className="btn btn-primary",
|
||||
dbc.Col(
|
||||
className="org_infos",
|
||||
children=[
|
||||
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
|
||||
html.P(
|
||||
[
|
||||
"Commune : ",
|
||||
html.Strong(id="acheteur_commune"),
|
||||
]
|
||||
),
|
||||
html.P(
|
||||
[
|
||||
"Département : ",
|
||||
html.Strong(id="acheteur_departement"),
|
||||
]
|
||||
),
|
||||
html.P(
|
||||
["Région : ", html.Strong(id="acheteur_region")]
|
||||
),
|
||||
html.A(
|
||||
id="acheteur_lien_annuaire",
|
||||
children="Plus de détails sur l'Annuaire des entreprises",
|
||||
),
|
||||
],
|
||||
width=4,
|
||||
),
|
||||
dbc.Col(
|
||||
children=[
|
||||
html.P(id="acheteur_titre_stats"),
|
||||
html.P(id="acheteur_marches_attribues"),
|
||||
html.P(id="acheteur_titulaires_differents"),
|
||||
html.Button(
|
||||
"Téléchargement au format Excel",
|
||||
id="btn-download-data-acheteur",
|
||||
className="btn btn-primary",
|
||||
),
|
||||
dcc.Download(id="download-data-acheteur"),
|
||||
],
|
||||
width=4,
|
||||
),
|
||||
dbc.Col(
|
||||
id="acheteur_map",
|
||||
width=4,
|
||||
),
|
||||
dcc.Download(id="download-data-acheteur"),
|
||||
],
|
||||
),
|
||||
html.Div(className="org_map", id="acheteur_map"),
|
||||
html.Div(
|
||||
className="org_top",
|
||||
dbc.Row(
|
||||
children=[
|
||||
html.H3("Top titulaires"),
|
||||
html.Div(className="marches_table", id="top10_titulaires"),
|
||||
dbc.Col(
|
||||
className="marches_table",
|
||||
id="top10_titulaires",
|
||||
width=8,
|
||||
),
|
||||
dbc.Col(id="acheteur-distance-histogram", width=4),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -340,7 +372,8 @@ def get_last_marches_data(
|
||||
Input(component_id="acheteur_data", component_property="data"),
|
||||
)
|
||||
def get_top_titulaires(data):
|
||||
return get_top_org_table(data, "titulaire")
|
||||
table = get_top_org_table(data, "titulaire", ["titulaire_distance"])
|
||||
return make_card(fig=table, title="Top titulaires", lg=12, xl=12)
|
||||
|
||||
|
||||
@callback(
|
||||
@@ -476,3 +509,19 @@ def toggle_acheteur_columns(click_open, click_close, is_open):
|
||||
)
|
||||
def reset_view(n_clicks):
|
||||
return "", []
|
||||
|
||||
|
||||
@callback(
|
||||
Output("acheteur-distance-histogram", "children"),
|
||||
Input("acheteur_data", "data"),
|
||||
)
|
||||
def update_acheteur_distance_histogram(data):
|
||||
lff = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||
fig = get_distance_histogram(lff)
|
||||
return make_card(
|
||||
title="Distance acheteur–titulaire",
|
||||
subtitle="en nombre de marchés, échelle logarithmique",
|
||||
fig=fig,
|
||||
lg=12,
|
||||
xl=12,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -1,3 +1,4 @@
|
||||
import dash_bootstrap_components as dbc
|
||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||
|
||||
from src.figures import DataTable
|
||||
@@ -77,7 +78,7 @@ layout = html.Div(
|
||||
# className="search_options",
|
||||
# children=[dcc.RadioItems(options=["Acheteur(s)"])],
|
||||
# ),
|
||||
html.Div(id="search_results", className="wrapper"),
|
||||
dbc.Row(id="search_results"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -92,7 +93,7 @@ layout = html.Div(
|
||||
)
|
||||
def update_search_results(n_submit, n_clicks, query):
|
||||
if query and len(query) >= 1:
|
||||
content = []
|
||||
cols = []
|
||||
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
if org_type == "acheteur":
|
||||
@@ -109,9 +110,8 @@ def update_search_results(n_submit, n_clicks, query):
|
||||
# Format output
|
||||
columns, tooltip = setup_table_columns(results, hideable=False)
|
||||
|
||||
org_content = [
|
||||
html.Div(
|
||||
className=f"results_{org_type}",
|
||||
col = (
|
||||
dbc.Col(
|
||||
children=[
|
||||
html.H3(f"{org_type.title()}s : {count}"),
|
||||
DataTable(
|
||||
@@ -123,12 +123,13 @@ def update_search_results(n_submit, n_clicks, query):
|
||||
filter_action="none",
|
||||
),
|
||||
],
|
||||
md=6,
|
||||
)
|
||||
if count > 0
|
||||
else html.P(f"Aucun {org_type} trouvé."),
|
||||
]
|
||||
content.extend(org_content)
|
||||
style = {"textAlign": "center", "display": "none"}
|
||||
else html.P(f"Aucun {org_type} trouvé.")
|
||||
)
|
||||
cols.append(col)
|
||||
|
||||
return content, style
|
||||
style = {"textAlign": "center", "display": "none"}
|
||||
return cols, style
|
||||
return html.P(""), {"textAlign": "center"}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from dash import dcc, html, register_page
|
||||
|
||||
from src.figures import (
|
||||
get_barchart_sources,
|
||||
get_duplicate_matrix,
|
||||
get_map_count_marches,
|
||||
get_yearly_statistics,
|
||||
)
|
||||
from src.utils import df, format_number, get_statistics, meta_content
|
||||
|
||||
name = "Statistiques"
|
||||
|
||||
register_page(
|
||||
__name__,
|
||||
path="/statistiques",
|
||||
title="Statistiques | decp.info",
|
||||
name=name,
|
||||
description="Visualisez l'état de la publication des données essentielles des marchés publics en France.",
|
||||
image_url=meta_content["image_url"],
|
||||
order=3,
|
||||
)
|
||||
|
||||
statistics: dict = get_statistics()
|
||||
today_str = datetime.fromisoformat(statistics["datetime"]).strftime("%d/%m/%Y")
|
||||
|
||||
layout = [
|
||||
html.Div(
|
||||
className="container",
|
||||
children=[
|
||||
html.H2(name),
|
||||
dcc.Loading(
|
||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||
id="loading-statistques",
|
||||
type="default",
|
||||
children=[
|
||||
html.Div(
|
||||
children=[
|
||||
dcc.Markdown(f"""
|
||||
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par
|
||||
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas,
|
||||
ou publient d'une manière qui rend la récupération des données compliquée. Les données présentées sur ce site
|
||||
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative.
|
||||
|
||||
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20label%3A%22source%20de%20donn%C3%A9es%22),
|
||||
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
|
||||
|
||||
Les statistiques publiées sur cette page ont été produites automatiquement à partir des données les plus récentes ({today_str}).
|
||||
"""),
|
||||
html.H3(
|
||||
"Statistiques générales sur les marchés",
|
||||
id="marches",
|
||||
),
|
||||
html.P(
|
||||
"À noter qu'une fois un marché attribué ses données essentielles peuvent malheureusement mettre plusieurs mois à être publiées par l'acheteur."
|
||||
),
|
||||
html.H4("Statistiques cumulées"),
|
||||
dcc.Markdown(f"""
|
||||
- Nombre de marchés publics et accord-cadres : {format_number(statistics["nb_marches"])}
|
||||
- Nombre d'acheteurs publics (SIRET) : {format_number(statistics["nb_acheteurs_uniques"])}
|
||||
- Nombre de titulaires (SIRET) : {format_number(statistics["nb_titulaires_uniques"])}
|
||||
|
||||
Je ne publie pas encore de statistiques sur les montants de marchés car je n'ai pas encore trouvé la bonne formule pour traiter les trop nombreux montants fantaisistes qui polluent les calculs.
|
||||
"""),
|
||||
html.H4("Statistiques par année"),
|
||||
get_yearly_statistics(statistics, today_str),
|
||||
dcc.Graph(figure=get_map_count_marches()),
|
||||
get_duplicate_matrix(),
|
||||
html.H3("Nombre de marchés par source dans le temps"),
|
||||
dcc.Graph(
|
||||
figure=get_barchart_sources(df, "dateNotification")
|
||||
),
|
||||
dcc.Graph(
|
||||
figure=get_barchart_sources(
|
||||
df, "datePublicationDonnees"
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
@@ -19,8 +19,7 @@ from dash import (
|
||||
register_page,
|
||||
)
|
||||
|
||||
from figures import make_column_picker
|
||||
from src.figures import DataTable
|
||||
from src.figures import DataTable, make_column_picker
|
||||
from src.utils import (
|
||||
columns,
|
||||
df,
|
||||
@@ -29,10 +28,10 @@ from src.utils import (
|
||||
invert_columns,
|
||||
logger,
|
||||
meta_content,
|
||||
prepare_table_data,
|
||||
schema,
|
||||
sort_table_data,
|
||||
)
|
||||
from utils import prepare_table_data
|
||||
|
||||
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||
@@ -440,7 +439,7 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
||||
className="fa fa-link",
|
||||
children=[
|
||||
dbc.Button(
|
||||
"Partager",
|
||||
"Partager la vue",
|
||||
className="btn btn-primary",
|
||||
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
|
||||
)
|
||||
|
||||
+113
-54
@@ -15,8 +15,13 @@ from dash import (
|
||||
register_page,
|
||||
)
|
||||
|
||||
from src.callbacks import get_top_org_table
|
||||
from src.figures import DataTable, make_column_picker, point_on_map
|
||||
from src.figures import (
|
||||
DataTable,
|
||||
get_distance_histogram,
|
||||
get_top_org_table,
|
||||
make_column_picker,
|
||||
point_on_map,
|
||||
)
|
||||
from src.utils import (
|
||||
columns,
|
||||
df,
|
||||
@@ -76,68 +81,104 @@ layout = [
|
||||
html.Div(
|
||||
children=[
|
||||
html.Div(
|
||||
className="wrapper",
|
||||
style={"marginBottom": "50px"},
|
||||
children=[
|
||||
html.H2(
|
||||
className="org_title",
|
||||
dbc.Row(
|
||||
className="mb-2",
|
||||
children=[
|
||||
html.Span(id="titulaire_siret"),
|
||||
" - ",
|
||||
html.Span(id="titulaire_nom"),
|
||||
],
|
||||
),
|
||||
html.Div(
|
||||
className="org_year",
|
||||
children=dcc.Dropdown(
|
||||
id="titulaire_year",
|
||||
options=["Toutes les années"]
|
||||
+ [
|
||||
str(year)
|
||||
for year in range(
|
||||
2018, int(datetime.date.today().year) + 1
|
||||
)
|
||||
],
|
||||
placeholder="Année",
|
||||
),
|
||||
),
|
||||
html.Div(
|
||||
className="org_infos",
|
||||
children=[
|
||||
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
|
||||
html.P(["Commune : ", html.Strong(id="titulaire_commune")]),
|
||||
html.P(
|
||||
[
|
||||
"Département : ",
|
||||
html.Strong(id="titulaire_departement"),
|
||||
]
|
||||
dbc.Col(
|
||||
html.H2(
|
||||
children=[
|
||||
html.Span(id="titulaire_siret"),
|
||||
" - ",
|
||||
html.Span(id="titulaire_nom"),
|
||||
],
|
||||
),
|
||||
width=8,
|
||||
),
|
||||
html.P(["Région : ", html.Strong(id="titulaire_region")]),
|
||||
html.A(
|
||||
id="titulaire_lien_annuaire",
|
||||
children="Plus de détails sur l'Annuaire des entreprises",
|
||||
dbc.Col(
|
||||
dcc.Dropdown(
|
||||
id="titulaire_year",
|
||||
options=["Toutes les années"]
|
||||
+ [
|
||||
str(year)
|
||||
for year in range(
|
||||
2018, int(datetime.date.today().year) + 1
|
||||
)
|
||||
],
|
||||
placeholder="Année",
|
||||
),
|
||||
width=4,
|
||||
),
|
||||
],
|
||||
),
|
||||
html.Div(
|
||||
className="org_stats",
|
||||
dbc.Row(
|
||||
className="mb-2",
|
||||
children=[
|
||||
html.P(id="titulaire_titre_stats"),
|
||||
html.P(id="titulaire_marches_remportes"),
|
||||
html.P(id="titulaire_acheteurs_differents"),
|
||||
html.Button(
|
||||
"Téléchargement au format Excel",
|
||||
id="btn-download-data-titulaire",
|
||||
className="btn btn-primary",
|
||||
dbc.Col(
|
||||
className="org_infos",
|
||||
children=[
|
||||
# TODO: ajouter le type d'acheteur : commune, CD, CR, etc.
|
||||
html.P(
|
||||
[
|
||||
"Commune : ",
|
||||
html.Strong(id="titulaire_commune"),
|
||||
]
|
||||
),
|
||||
html.P(
|
||||
[
|
||||
"Département : ",
|
||||
html.Strong(id="titulaire_departement"),
|
||||
]
|
||||
),
|
||||
html.P(
|
||||
[
|
||||
"Région : ",
|
||||
html.Strong(id="titulaire_region"),
|
||||
]
|
||||
),
|
||||
html.A(
|
||||
id="titulaire_lien_annuaire",
|
||||
children="Plus de détails sur l'Annuaire des entreprises",
|
||||
),
|
||||
],
|
||||
width=4,
|
||||
),
|
||||
dbc.Col(
|
||||
children=[
|
||||
html.P(id="titulaire_titre_stats"),
|
||||
html.P(id="titulaire_marches_remportes"),
|
||||
html.P(id="titulaire_acheteurs_differents"),
|
||||
html.Button(
|
||||
"Téléchargement au format Excel",
|
||||
id="btn-download-data-titulaire",
|
||||
className="btn btn-primary",
|
||||
),
|
||||
dcc.Download(id="download-data-titulaire"),
|
||||
],
|
||||
width=4,
|
||||
),
|
||||
dbc.Col(
|
||||
id="titulaire_map",
|
||||
width=4,
|
||||
),
|
||||
dcc.Download(id="download-data-titulaire"),
|
||||
],
|
||||
),
|
||||
html.Div(className="org_map", id="titulaire_map"),
|
||||
html.Div(
|
||||
className="org_top",
|
||||
dbc.Row(
|
||||
children=[
|
||||
html.H3("Top acheteurs"),
|
||||
html.Div(className="marches_table", id="top10_acheteurs"),
|
||||
dbc.Col(
|
||||
html.Div(
|
||||
children=[
|
||||
html.H3("Top acheteurs"),
|
||||
html.Div(
|
||||
className="marches_table",
|
||||
id="top10_acheteurs",
|
||||
),
|
||||
],
|
||||
),
|
||||
width=8,
|
||||
),
|
||||
dbc.Col(id="titulaire-distance-histogram", width=4),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -354,7 +395,7 @@ def get_last_marches_data(
|
||||
Input(component_id="titulaire_data", component_property="data"),
|
||||
)
|
||||
def get_top_acheteurs(data):
|
||||
return get_top_org_table(data, "acheteur")
|
||||
return get_top_org_table(data, "acheteur", ["titulaire_distance"])
|
||||
|
||||
|
||||
@callback(
|
||||
@@ -490,3 +531,21 @@ def toggle_titulaire_columns(click_open, click_close, is_open):
|
||||
)
|
||||
def reset_view(n_clicks):
|
||||
return "", []
|
||||
|
||||
|
||||
@callback(
|
||||
Output("titulaire-distance-histogram", "children"),
|
||||
Input("titulaire_data", "data"),
|
||||
)
|
||||
def update_titulaire_distance_histogram(data):
|
||||
lff = pl.LazyFrame(data)
|
||||
if "titulaire_distance" in lff.collect_schema().names():
|
||||
lff = lff.with_columns(
|
||||
pl.col("titulaire_distance").cast(pl.Float64, strict=False)
|
||||
)
|
||||
fig = get_distance_histogram(lff)
|
||||
return [
|
||||
html.H3("Distance acheteur-titulaire"),
|
||||
html.H6("par nombre de marchés", className="card-subtitle mb-2 text-muted"),
|
||||
fig,
|
||||
]
|
||||
|
||||
+153
-16
@@ -3,6 +3,7 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from time import localtime, sleep
|
||||
|
||||
import polars as pl
|
||||
@@ -62,6 +63,20 @@ def add_links(dff: pl.DataFrame):
|
||||
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
|
||||
if col in dff.columns:
|
||||
if col.startswith("titulaire_"):
|
||||
detail_link = (
|
||||
'<a href = "/titulaires/'
|
||||
+ pl.col("titulaire_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
if col == "titulaire_nom":
|
||||
detail_link = (
|
||||
detail_link
|
||||
+ ' <a href="/observatoire?titulaire_id='
|
||||
+ pl.col("titulaire_id")
|
||||
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||
)
|
||||
dff = dff.with_columns(
|
||||
pl.when(
|
||||
pl.Expr.or_(
|
||||
@@ -69,26 +84,26 @@ def add_links(dff: pl.DataFrame):
|
||||
pl.col("titulaire_typeIdentifiant") == "SIRET",
|
||||
)
|
||||
)
|
||||
.then(
|
||||
'<a href = "/titulaires/'
|
||||
+ pl.col("titulaire_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
.then(detail_link)
|
||||
.otherwise(pl.col(col))
|
||||
.alias(col)
|
||||
)
|
||||
if col.startswith("acheteur_"):
|
||||
dff = dff.with_columns(
|
||||
(
|
||||
'<a href = "/acheteurs/'
|
||||
+ pl.col("acheteur_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
).alias(col)
|
||||
detail_link = (
|
||||
'<a href = "/acheteurs/'
|
||||
+ pl.col("acheteur_id")
|
||||
+ '">'
|
||||
+ pl.col(col)
|
||||
+ "</a>"
|
||||
)
|
||||
if col == "acheteur_nom":
|
||||
detail_link = (
|
||||
detail_link
|
||||
+ ' <a href="/observatoire?acheteur_id='
|
||||
+ pl.col("acheteur_id")
|
||||
+ '" title="Voir dans l\'observatoire">📊</a>'
|
||||
)
|
||||
dff = dff.with_columns(detail_link.alias(col))
|
||||
if col == "uid":
|
||||
dff = dff.with_columns(
|
||||
(
|
||||
@@ -286,6 +301,17 @@ def get_departements() -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def get_departements_geojson() -> dict:
|
||||
with open("./data/departements-1000m.geojson") as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
# Ajout de feature.id
|
||||
for f in geojson["features"]:
|
||||
f["id"] = f["properties"]["code"]
|
||||
|
||||
return geojson
|
||||
|
||||
|
||||
def get_departement_region(code_postal):
|
||||
if code_postal > "97000":
|
||||
code_departement = code_postal[:3]
|
||||
@@ -605,6 +631,8 @@ def prepare_table_data(
|
||||
# Récupération des données
|
||||
if isinstance(data, list):
|
||||
lff: pl.LazyFrame = pl.LazyFrame(data, strict=False, infer_schema_length=5000)
|
||||
elif isinstance(data, pl.LazyFrame):
|
||||
lff = data
|
||||
else:
|
||||
lff: pl.LazyFrame = df.lazy() # start from the original data
|
||||
|
||||
@@ -637,7 +665,7 @@ def prepare_table_data(
|
||||
# Remplace les strings null par "", mais pas les numeric null
|
||||
dff = dff.fill_null("")
|
||||
|
||||
# Ajout des liens vers l'annuaire des entreprises
|
||||
# Ajout des liens vers les pages de détails
|
||||
dff = add_links(dff)
|
||||
|
||||
# Ajout des liens vers les fichiers Open Data
|
||||
@@ -669,6 +697,104 @@ def prepare_table_data(
|
||||
)
|
||||
|
||||
|
||||
def prepare_dashboard_data(
|
||||
lff: pl.LazyFrame,
|
||||
year,
|
||||
acheteur_id,
|
||||
acheteur_categorie,
|
||||
acheteur_departement_code,
|
||||
titulaire_id,
|
||||
titulaire_categorie,
|
||||
titulaire_departement_code,
|
||||
type,
|
||||
objet,
|
||||
code_cpv,
|
||||
considerations_sociales,
|
||||
considerations_environnementales,
|
||||
techniques,
|
||||
marche_innovant,
|
||||
sous_traitance_declaree,
|
||||
montant_min=None,
|
||||
montant_max=None,
|
||||
) -> pl.LazyFrame:
|
||||
if year:
|
||||
lff = lff.filter(pl.col("dateNotification").dt.year() == int(year))
|
||||
else:
|
||||
lff = lff.filter(
|
||||
pl.col("dateNotification") > (datetime.now() - timedelta(days=365))
|
||||
)
|
||||
|
||||
if acheteur_id:
|
||||
lff = lff.filter(pl.col("acheteur_id").str.contains(acheteur_id))
|
||||
else:
|
||||
if acheteur_categorie:
|
||||
lff = lff.filter(pl.col("acheteur_categorie") == acheteur_categorie)
|
||||
if acheteur_departement_code:
|
||||
lff = lff.filter(
|
||||
pl.col("acheteur_departement_code").is_in(acheteur_departement_code)
|
||||
)
|
||||
|
||||
if titulaire_id:
|
||||
lff = lff.filter(pl.col("titulaire_id").str.contains(titulaire_id))
|
||||
else:
|
||||
if titulaire_categorie:
|
||||
lff = lff.filter(pl.col("titulaire_categorie") == titulaire_categorie)
|
||||
if titulaire_departement_code:
|
||||
lff = lff.filter(
|
||||
pl.col("titulaire_departement_code").is_in(titulaire_departement_code)
|
||||
)
|
||||
|
||||
if type:
|
||||
lff = lff.filter(pl.col("type") == type)
|
||||
|
||||
if objet:
|
||||
lff = lff.filter(pl.col("objet").str.contains(f"(?i){objet}"))
|
||||
|
||||
if code_cpv:
|
||||
lff = lff.filter(pl.col("codeCPV").str.starts_with(code_cpv))
|
||||
|
||||
if marche_innovant and marche_innovant != "all":
|
||||
lff = lff.filter(pl.col("marcheInnovant") == marche_innovant)
|
||||
|
||||
if sous_traitance_declaree and sous_traitance_declaree != "all":
|
||||
lff = lff.filter(pl.col("sousTraitanceDeclaree") == sous_traitance_declaree)
|
||||
|
||||
if techniques:
|
||||
lff = lff.filter(
|
||||
pl.col("techniques")
|
||||
.str.split(", ")
|
||||
.list.set_intersection(techniques)
|
||||
.list.len()
|
||||
> 0
|
||||
)
|
||||
|
||||
if considerations_sociales:
|
||||
lff = lff.filter(
|
||||
pl.col("considerationsSociales")
|
||||
.str.split(", ")
|
||||
.list.set_intersection(considerations_sociales)
|
||||
.list.len()
|
||||
> 0
|
||||
)
|
||||
|
||||
if considerations_environnementales:
|
||||
lff = lff.filter(
|
||||
pl.col("considerationsEnvironnementales")
|
||||
.str.split(", ")
|
||||
.list.set_intersection(considerations_environnementales)
|
||||
.list.len()
|
||||
> 0
|
||||
)
|
||||
|
||||
if montant_min is not None:
|
||||
lff = lff.filter(pl.col("montant") >= montant_min)
|
||||
|
||||
if montant_max is not None:
|
||||
lff = lff.filter(pl.col("montant") <= montant_max)
|
||||
|
||||
return lff
|
||||
|
||||
|
||||
def get_button_properties(height):
|
||||
if height > 65000:
|
||||
download_disabled = True
|
||||
@@ -685,6 +811,16 @@ def get_button_properties(height):
|
||||
return download_disabled, download_text, download_title
|
||||
|
||||
|
||||
def get_enum_values_as_dict(column_name):
|
||||
try:
|
||||
options = {}
|
||||
for value in data_schema[column_name]["enum"]:
|
||||
options[value] = value
|
||||
return options
|
||||
except KeyError:
|
||||
return {"not_found": "not found"}
|
||||
|
||||
|
||||
def invert_columns(columns):
|
||||
"""
|
||||
Renvoie les colonnes du schéma non spécifiées en paramètre. Utile pour passer d'une colonnes masquées à une liste de colonnes affichées, et vice versa.
|
||||
@@ -764,6 +900,7 @@ df_titulaires_marches: pl.DataFrame = (
|
||||
)
|
||||
|
||||
departements = get_departements()
|
||||
departements_geojson = get_departements_geojson()
|
||||
domain_name = (
|
||||
"test.decp.info" if os.getenv("DEVELOPMENT").lower() == "true" else "decp.info"
|
||||
)
|
||||
|
||||
+7
-6
@@ -13,9 +13,9 @@ def test_data():
|
||||
"uid": "1",
|
||||
"id": "1",
|
||||
"acheteur_nom": "ACHETEUR 1",
|
||||
"acheteur_id": "a1",
|
||||
"acheteur_id": "123",
|
||||
"titulaire_nom": "TITULAIRE 1",
|
||||
"titulaire_id": "t1",
|
||||
"titulaire_id": "345",
|
||||
"montant": 10,
|
||||
"dateNotification": datetime.date(2025, 1, 1),
|
||||
"codeCPV": "71600000",
|
||||
@@ -34,6 +34,11 @@ def test_data():
|
||||
"sourceFile": "test.xml",
|
||||
"sourceDataset": "test_dataset",
|
||||
"datePublicationDonnees": datetime.date(2025, 1, 1),
|
||||
"considerationsSociales": "",
|
||||
"considerationsEnvironnementales": "",
|
||||
"type": "Marché",
|
||||
"acheteur_categorie": "Collectivité",
|
||||
"titulaire_categorie": "PME",
|
||||
}
|
||||
]
|
||||
path = "tests/test.parquet"
|
||||
@@ -43,10 +48,6 @@ def test_data():
|
||||
pl.DataFrame(data).write_parquet("tests/test.parquet")
|
||||
yield path
|
||||
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
print(path, "deleted")
|
||||
|
||||
|
||||
def pytest_setup_options():
|
||||
options = Options()
|
||||
|
||||
+289
-9
@@ -29,12 +29,11 @@ def test_001_logo_and_search(dash_duo: DashComposite):
|
||||
assert len(result_table.find_elements(by=By.TAG_NAME, value="tr")) == 2, (
|
||||
"The search should return only one result"
|
||||
) # header row + 1 result
|
||||
assert (
|
||||
result_table.find_element(
|
||||
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||
).text
|
||||
== name
|
||||
), f"The search result should have the right {org_type} name"
|
||||
assert result_table.find_element(
|
||||
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||
).text.startswith(name), (
|
||||
f"The search result should have the right {org_type} name"
|
||||
)
|
||||
|
||||
|
||||
def test_002_filter_persistence(dash_duo: DashComposite):
|
||||
@@ -52,7 +51,7 @@ def test_002_filter_persistence(dash_duo: DashComposite):
|
||||
_filter_input: WebElement = dash_duo.find_element(filter_input_selector)
|
||||
return _filter_input
|
||||
|
||||
for page in ["tableau", "acheteurs/a1", "titulaires/t1"]:
|
||||
for page in ["tableau", "acheteurs/123", "titulaires/345"]:
|
||||
print("page:", page)
|
||||
filter_input = open_page_and_check_filter_input()
|
||||
filter_input.send_keys("11") # a UID that doesn't exist
|
||||
@@ -74,8 +73,8 @@ def test_003_tableau_download(dash_duo: DashComposite):
|
||||
|
||||
outputs = [
|
||||
download_data(1, "", [], None),
|
||||
download_acheteur_data(1, dicts, "a1", "2025"),
|
||||
download_titulaire_data(1, dicts, "t1", "2025"),
|
||||
download_acheteur_data(1, dicts, "123", "2025"),
|
||||
download_titulaire_data(1, dicts, "345", "2025"),
|
||||
]
|
||||
for output in outputs:
|
||||
assert isinstance(output, dict)
|
||||
@@ -87,3 +86,284 @@ def test_003_tableau_download(dash_duo: DashComposite):
|
||||
)
|
||||
assert output["type"] is None
|
||||
assert output["base64"] is True
|
||||
|
||||
|
||||
def test_004_add_links_observatoire_acheteur():
|
||||
import polars as pl
|
||||
|
||||
from src.utils import add_links
|
||||
|
||||
dff = pl.DataFrame(
|
||||
{
|
||||
"acheteur_id": ["123"],
|
||||
"acheteur_nom": ["ACHETEUR 1"],
|
||||
}
|
||||
)
|
||||
result = add_links(dff)
|
||||
nom_value = result["acheteur_nom"][0]
|
||||
id_value = result["acheteur_id"][0]
|
||||
|
||||
# acheteur_nom should contain detail link + observatoire link
|
||||
assert "/acheteurs/123" in nom_value
|
||||
assert "ACHETEUR 1" in nom_value
|
||||
assert "/observatoire?acheteur_id=123" in nom_value
|
||||
assert "📊" in nom_value
|
||||
|
||||
# acheteur_id should NOT contain observatoire link
|
||||
assert "/observatoire" not in id_value
|
||||
|
||||
|
||||
def test_005_add_links_observatoire_titulaire():
|
||||
import polars as pl
|
||||
|
||||
from src.utils import add_links
|
||||
|
||||
dff = pl.DataFrame(
|
||||
{
|
||||
"titulaire_id": ["345"],
|
||||
"titulaire_nom": ["TITULAIRE 1"],
|
||||
"titulaire_typeIdentifiant": ["SIRET"],
|
||||
}
|
||||
)
|
||||
result = add_links(dff)
|
||||
nom_value = result["titulaire_nom"][0]
|
||||
id_value = result["titulaire_id"][0]
|
||||
|
||||
# titulaire_nom should contain detail link + observatoire link
|
||||
assert "/titulaires/345" in nom_value
|
||||
assert "TITULAIRE 1" in nom_value
|
||||
assert "/observatoire?titulaire_id=345" in nom_value
|
||||
assert "📊" in nom_value
|
||||
|
||||
# titulaire_id should NOT contain observatoire link
|
||||
assert "/observatoire" not in id_value
|
||||
|
||||
|
||||
def test_006_observatoire_url_to_input(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)
|
||||
|
||||
# Navigate to observatoire with acheteur_id query param
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=123")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "123", (
|
||||
"acheteur_id input should be populated from URL param"
|
||||
)
|
||||
|
||||
|
||||
def test_007_observatoire_share_url(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)
|
||||
|
||||
# Navigate to observatoire with acheteur_id query param
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=123")
|
||||
dash_duo.wait_for_element("#observatoire-share-url", timeout=4)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
share_url_input = dash_duo.find_element("#observatoire-share-url")
|
||||
share_url_value = share_url_input.get_attribute("value")
|
||||
|
||||
assert "acheteur_id=123" in share_url_value, (
|
||||
f"Share URL should contain acheteur_id param, got: {share_url_value}"
|
||||
)
|
||||
|
||||
|
||||
def test_008_search_to_observatoire(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)
|
||||
|
||||
# Search for an acheteur
|
||||
search_bar = dash_duo.find_element("#search")
|
||||
search_bar.send_keys("ACHETEUR 1")
|
||||
search_bar.send_keys(Keys.ENTER)
|
||||
|
||||
dash_duo.wait_for_element("#results_acheteur_datatable", timeout=2)
|
||||
|
||||
# Find the observatoire link in acheteur_nom column
|
||||
observatoire_link = dash_duo.find_element(
|
||||
'#results_acheteur_datatable td[data-dash-column="acheteur_nom"] a[href*="observatoire"]'
|
||||
)
|
||||
assert "📊" in observatoire_link.text
|
||||
|
||||
# Click the observatoire link
|
||||
observatoire_link.click()
|
||||
|
||||
# Wait for observatoire page to load
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "123", (
|
||||
"acheteur_id input should be populated after navigating from search"
|
||||
)
|
||||
|
||||
|
||||
def test_010_observatoire_montant_filter():
|
||||
import datetime
|
||||
|
||||
import polars as pl
|
||||
|
||||
from src.utils 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(),
|
||||
year="2025",
|
||||
acheteur_id=None,
|
||||
acheteur_categorie=None,
|
||||
acheteur_departement_code=None,
|
||||
titulaire_id=None,
|
||||
titulaire_categorie=None,
|
||||
titulaire_departement_code=None,
|
||||
type=None,
|
||||
objet=None,
|
||||
code_cpv=None,
|
||||
considerations_sociales=None,
|
||||
considerations_environnementales=None,
|
||||
techniques=None,
|
||||
marche_innovant=None,
|
||||
sous_traitance_declaree=None,
|
||||
montant_min=min_val,
|
||||
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
|
||||
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
# Clear localStorage to start from a clean state
|
||||
dash_duo.driver.execute_script("localStorage.clear()")
|
||||
|
||||
# Navigate to observatoire without URL params
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
# Set the acheteur_id text input; press Enter to trigger the debounced save callback
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
dash_duo.clear_input(acheteur_input)
|
||||
acheteur_input.send_keys("123")
|
||||
acheteur_input.send_keys(Keys.ENTER)
|
||||
|
||||
time.sleep(0.3) # allow the save callback to write to localStorage
|
||||
|
||||
# Navigate away
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/")
|
||||
|
||||
# Navigate back without URL params
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
time.sleep(0.5) # allow restore callback chain to complete
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "123", (
|
||||
"acheteur_id should be restored from localStorage after navigating back"
|
||||
)
|
||||
|
||||
# Also verify URL params still override localStorage
|
||||
dash_duo.wait_for_page(f"{dash_duo.server_url}/observatoire?acheteur_id=123")
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
time.sleep(0.5)
|
||||
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "123", (
|
||||
"URL param acheteur_id should override the value stored in localStorage"
|
||||
)
|
||||
|
||||
|
||||
def test_011_observatoire_multi_param_url(dash_duo: DashComposite):
|
||||
import time
|
||||
|
||||
from src.app import app
|
||||
|
||||
dash_duo.start_server(app)
|
||||
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
|
||||
|
||||
# Navigate with multiple filter params
|
||||
dash_duo.wait_for_page(
|
||||
f"{dash_duo.server_url}/observatoire?annee=2024&acheteur_id=12345678901234&montant_min=10000"
|
||||
)
|
||||
dash_duo.wait_for_element("#dashboard_acheteur_id", timeout=4)
|
||||
|
||||
time.sleep(1) # Allow callback chain to complete
|
||||
|
||||
# Verify acheteur_id input
|
||||
acheteur_input = dash_duo.find_element("#dashboard_acheteur_id")
|
||||
assert acheteur_input.get_attribute("value") == "12345678901234", (
|
||||
"acheteur_id input should be populated from URL param"
|
||||
)
|
||||
|
||||
# Verify montant_min input
|
||||
montant_input = dash_duo.find_element("#dashboard_montant_min")
|
||||
montant_value = montant_input.get_attribute("value")
|
||||
assert montant_value in ("10000", "10000.0"), (
|
||||
f"montant_min input should be populated from URL param, got: {montant_value}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_distance_histogram_returns_graph():
|
||||
import polars as pl
|
||||
from dash import dcc
|
||||
|
||||
from src.figures import get_distance_histogram
|
||||
|
||||
lff = pl.LazyFrame({"titulaire_distance": [1, 10, 100, 500, 1000]})
|
||||
result = get_distance_histogram(lff)
|
||||
assert isinstance(result, dcc.Graph)
|
||||
|
||||
|
||||
def test_get_distance_histogram_handles_nulls():
|
||||
import polars as pl
|
||||
from dash import dcc
|
||||
|
||||
from src.figures import get_distance_histogram
|
||||
|
||||
lff = pl.LazyFrame({"titulaire_distance": [None, None, 50]})
|
||||
result = get_distance_histogram(lff)
|
||||
assert isinstance(result, dcc.Graph)
|
||||
|
||||
|
||||
def test_get_distance_histogram_all_nulls():
|
||||
import polars as pl
|
||||
from dash import dcc
|
||||
|
||||
from src.figures import get_distance_histogram
|
||||
|
||||
lff = pl.LazyFrame({"titulaire_distance": pl.Series([], dtype=pl.Int64)})
|
||||
result = get_distance_histogram(lff)
|
||||
assert isinstance(result, dcc.Graph)
|
||||
|
||||
Reference in New Issue
Block a user