Merge branch 'dev' into feature/73_compte_utilisateur

This commit is contained in:
Colin Maudry
2026-06-24 03:01:43 +02:00
76 changed files with 15560 additions and 1888 deletions
View File
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python
"""Benchmark comparatif de l'endpoint /data : decp.info vs data.gouv.fr.
Les deux APIs partagent le même schéma de requête (mêmes opérateurs), donc
chaque scénario est envoyé à l'identique aux deux et les temps de réponse
sont comparés côte à côte.
Usage :
python tests/api/benchmark.py --token decpinfo_xxx
python tests/api/benchmark.py --url http://localhost:8050/api/v1/data --token decpinfo_xxx
python tests/api/benchmark.py --decp-only --token decpinfo_xxx --runs 20
Par défaut, --url pointe vers la production decp.info ; data.gouv.fr est
interrogé sans authentification.
AVERTISSEMENT : les deux bases n'ont pas le même volume (data.gouv.fr ~3M
lignes, decp.info ~1,5M). C'est une comparaison d'implémentation, pas à
volume égal.
"""
import argparse
import sys
import time
from dataclasses import dataclass, field
from urllib.parse import quote
import httpx
DECP_DEFAULT_URL = "https://decp.info/api/v1/data"
DATAGOUV_DEFAULT_URL = (
"https://tabular-api.data.gouv.fr/api/resources/"
"22847056-61df-452d-837d-8b8ceadbfc52/data/"
)
# Chaque scénario : liste de (clé, valeur). valeur=None → drapeau nu (sans `=`),
# requis par data.gouv.fr pour les opérateurs d'agrégation et isnull.
SCENARIOS: list[dict] = [
{
"name": "sans filtre (page 1, 50 résultats)",
"params": [("page", "1"), ("page_size", "50")],
},
{
"name": "filtre __exact sur département",
"params": [("acheteur_departement_code__exact", "44"), ("page_size", "50")],
},
{
"name": "filtre __differs sur département",
"params": [("acheteur_departement_code__differs", "44"), ("page_size", "50")],
},
{
"name": "filtre __contains sur objet",
"params": [("objet__contains", "informatique"), ("page_size", "50")],
},
{
"name": "filtre __greater sur date",
"params": [("dateNotification__greater", "2024-01-01"), ("page_size", "50")],
},
{
"name": "filtre __strictly_greater sur montant",
"params": [("montant__strictly_greater", "100000"), ("page_size", "50")],
},
{
"name": "filtre __in (CPV multiples)",
"params": [("codeCPV__in", "72000000,72200000"), ("page_size", "50")],
},
{
"name": "filtre __isnull sur montant",
"params": [("montant__isnull", None), ("page_size", "50")],
},
{
"name": "tri desc + colonnes sélectionnées",
"params": [
("dateNotification__sort", "desc"),
("columns", "uid,objet,montant,dateNotification"),
("page_size", "50"),
],
},
{
"name": "filtres combinés",
"params": [
("acheteur_departement_code__exact", "75"),
("dateNotification__greater", "2023-01-01"),
("montant__strictly_greater", "50000"),
("dateNotification__sort", "desc"),
("page_size", "50"),
],
},
{
"name": "agrégation groupby + count",
"params": [
("acheteur_departement_code__groupby", None),
("uid__count", None),
("page_size", "100"),
],
},
{
"name": "agrégation groupby + sum + avg",
"params": [
("acheteur_departement_code__groupby", None),
("montant__sum", None),
("montant__avg", None),
("page_size", "100"),
],
},
{
"name": "page 2",
"params": [("page", "2"), ("page_size", "50")],
},
{
"name": "count_results=false (optim COUNT(*))",
"params": [("page_size", "50"), ("count_results", "false")],
"decp_only": True,
},
]
COL_NAME = 40
COL_STAT = 9
@dataclass
class Target:
label: str
base_url: str
headers: dict = field(default_factory=dict)
def build_query(params: list[tuple[str, str | None]]) -> str:
"""Construit la query string. valeur=None → clé nue (sans `=`)."""
parts = []
for key, value in params:
if value is None:
parts.append(key)
else:
parts.append(f"{key}={quote(str(value), safe=',:')}")
return "&".join(parts)
def percentile(data: list[float], p: float) -> float:
if not data:
return float("nan")
sorted_data = sorted(data)
k = (len(sorted_data) - 1) * p / 100
lo, hi = int(k), min(int(k) + 1, len(sorted_data) - 1)
return sorted_data[lo] + (sorted_data[hi] - sorted_data[lo]) * (k - lo)
def measure(target: Target, query: str, runs: int) -> dict | None:
"""Chauffe (1 requête non mesurée) puis chronomètre `runs` requêtes."""
url = f"{target.base_url}?{query}"
try:
warm = httpx.get(url, headers=target.headers, timeout=30)
last_status = warm.status_code
except httpx.RequestError as exc:
return {"error": str(exc), "status": 0}
timings: list[float] = []
for _ in range(runs):
try:
t0 = time.perf_counter()
resp = httpx.get(url, headers=target.headers, timeout=30)
timings.append((time.perf_counter() - t0) * 1000)
last_status = resp.status_code
except httpx.RequestError as exc:
return {"error": str(exc), "status": 0}
return {
"status": last_status,
"median": percentile(timings, 50),
"p95": percentile(timings, 95),
"min": min(timings),
"max": max(timings),
}
def run_benchmark(targets: list[Target], decp_label: str, runs: int) -> None:
print("\nAVERTISSEMENT : volumes de données différents entre les deux APIs.")
print(f"Scénarios : {len(SCENARIOS)} | Répétitions : {runs}\n")
rows: list[dict] = []
for scenario in SCENARIOS:
query = build_query(scenario["params"])
decp_only = scenario.get("decp_only", False)
active = [t for t in targets if not (decp_only and t.label != decp_label)]
measures = {t.label: measure(t, query, runs) for t in active}
rows.append({"name": scenario["name"], "measures": measures})
bits = []
for t in active:
m = measures[t.label]
if "error" in m:
bits.append(f"{t.label}: ERREUR")
else:
bits.append(f"{t.label}: méd {m['median']:.0f}ms [{m['status']}]")
print(f" {scenario['name'][:COL_NAME]:<{COL_NAME}} " + " | ".join(bits))
_print_summary(rows, targets, decp_label)
def _fmt(m: dict | None, key: str) -> str:
if m is None:
return ""
if "error" in m:
return "ERR"
return f"{m[key]:.0f}"
def _print_summary(rows: list[dict], targets: list[Target], decp_label: str) -> None:
dg = next((t.label for t in targets if t.label != decp_label), None)
header = (
f"{'Scénario':<{COL_NAME}}"
f" {'DG méd':>{COL_STAT}} {'DG p95':>{COL_STAT}}"
f" {'decp méd':>{COL_STAT}} {'decp p95':>{COL_STAT}}"
f" {'ratio':>7}"
)
sep = "-" * len(header)
print(
f"\n{'=' * len(header)}\nRÉSUMÉ (ratio = decp / data.gouv.fr, <1 = decp plus rapide)"
)
print(f"{'=' * len(header)}\n{header}\n{sep}")
for row in rows:
m_decp = row["measures"].get(decp_label)
m_dg = row["measures"].get(dg) if dg else None
ratio = ""
if m_decp and m_dg and "error" not in m_decp and "error" not in m_dg:
if m_dg["median"] > 0:
ratio = f"{m_decp['median'] / m_dg['median']:.2f}"
print(
f"{row['name'][:COL_NAME]:<{COL_NAME}}"
f" {_fmt(m_dg, 'median'):>{COL_STAT}} {_fmt(m_dg, 'p95'):>{COL_STAT}}"
f" {_fmt(m_decp, 'median'):>{COL_STAT}} {_fmt(m_decp, 'p95'):>{COL_STAT}}"
f" {ratio:>7}"
)
print(sep)
def main() -> None:
parser = argparse.ArgumentParser(
description="Benchmark comparatif decp.info vs data.gouv.fr (/data)"
)
parser.add_argument(
"--url",
default=DECP_DEFAULT_URL,
help=f"Endpoint /data de decp.info (défaut : {DECP_DEFAULT_URL})",
)
parser.add_argument(
"--datagouv-url",
default=DATAGOUV_DEFAULT_URL,
help="Endpoint /data/ de la ressource data.gouv.fr",
)
parser.add_argument(
"--token",
default=None,
help="Token Bearer decp.info (format decpinfo_xxx)",
)
parser.add_argument(
"--runs",
type=int,
default=5,
help="Répétitions chronométrées par scénario (défaut : 5)",
)
parser.add_argument(
"--decp-only",
action="store_true",
help="Ne benchmarker que decp.info (saute data.gouv.fr)",
)
args = parser.parse_args()
if args.runs < 1:
print("--runs doit être ≥ 1", file=sys.stderr)
sys.exit(1)
decp_label = "decp.info"
decp_headers = {"Authorization": f"Bearer {args.token}"} if args.token else {}
decp = Target(label=decp_label, base_url=args.url, headers=decp_headers)
targets = [decp]
if not args.decp_only:
# data.gouv.fr d'abord pour l'affichage côte à côte
targets.insert(0, Target(label="data.gouv.fr", base_url=args.datagouv_url))
run_benchmark(targets, decp_label, args.runs)
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
import pytest
@pytest.fixture
def temp_db(tmp_path, monkeypatch):
"""Une SQLite éphémère pour les tests qui modifient la DB."""
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
from src.api import tokens_db
tokens_db.init_schema(db_path)
return db_path
@pytest.fixture
def api_client(monkeypatch, tmp_path):
"""Client Flask test avec USERS_DB_PATH éphémère et blueprint API monté."""
db_path = tmp_path / "users.test.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
from flask import Flask
from src.api import init_api, tokens_db, tracking
tokens_db.init_schema(db_path)
server = Flask(__name__)
init_api(server)
yield server.test_client(), db_path
tracking.stop_worker()
@pytest.fixture
def valid_token_header(api_client):
from src.api import tokens_db
_, db_path = api_client
token, _ = tokens_db.create_token(db_path, "test-token")
return {"Authorization": f"Bearer {token}"}
+59
View File
@@ -0,0 +1,59 @@
from flask import Flask, g, jsonify
from src.api import tokens_db
from src.api.auth import require_token
def _make_app():
app = Flask(__name__)
@app.route("/protected")
@require_token
def protected():
return jsonify({"token_id": g.token_id})
return app
def test_missing_header_returns_401(temp_db):
app = _make_app()
resp = app.test_client().get("/protected")
assert resp.status_code == 401
assert resp.get_json()["message"] == "missing_token"
def test_bearer_without_value_returns_401(temp_db):
app = _make_app()
resp = app.test_client().get("/protected", headers={"Authorization": "Bearer "})
assert resp.status_code == 401
assert resp.get_json()["message"] == "missing_token"
def test_invalid_token_returns_401(temp_db):
app = _make_app()
resp = app.test_client().get(
"/protected", headers={"Authorization": "Bearer decpinfo_unknown"}
)
assert resp.status_code == 401
assert resp.get_json()["message"] == "invalid_token"
def test_revoked_token_returns_401(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x")
tokens_db.revoke_token(temp_db, token_id)
app = _make_app()
resp = app.test_client().get(
"/protected", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 401
assert resp.get_json()["message"] == "revoked_token"
def test_valid_token_sets_g_and_calls_view(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x")
app = _make_app()
resp = app.test_client().get(
"/protected", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert resp.get_json()["token_id"] == token_id
+19
View File
@@ -0,0 +1,19 @@
import polars as pl
from src.db import aggregate_marches
def test_aggregate_groupby_count_returns_named_columns():
df = aggregate_marches(
select_sql='"acheteur_departement_code", COUNT("uid") AS "uid__count"',
group_by='"acheteur_departement_code"',
)
assert isinstance(df, pl.DataFrame)
assert df.columns == ["acheteur_departement_code", "uid__count"]
assert df["uid__count"].sum() > 0
def test_aggregate_global_without_groupby_returns_one_row():
df = aggregate_marches(select_sql='COUNT("uid") AS "uid__count"')
assert df.height == 1
assert df["uid__count"][0] > 0
+155
View File
@@ -0,0 +1,155 @@
def test_data_without_token_returns_401(api_client):
client, _ = api_client
resp = client.get("/api/v1/data")
assert resp.status_code == 401
def test_data_default_pagination(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert set(body.keys()) >= {"data", "meta", "links"}
assert isinstance(body["data"], list)
assert len(body["data"]) <= 50 # default page_size
assert body["meta"]["page"] == 1
assert body["meta"]["page_size"] == 50
assert "total" in body["meta"]
def test_data_count_results_false_omits_total(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?count_results=false", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert "total" not in body["meta"]
def test_data_page_size_max_enforced(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?page_size=5000", headers=valid_token_header)
assert resp.status_code == 400
def test_data_page_size_below_min_rejected(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?page_size=0", headers=valid_token_header)
assert resp.status_code == 400
def test_data_pagination_links(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?page=1&page_size=1", headers=valid_token_header)
body = resp.get_json()
assert body["links"]["prev"] is None
if body["meta"]["total"] > 1:
assert body["links"]["next"] is not None
assert "page=2" in body["links"]["next"]
def test_data_filter_exact_string(api_client, valid_token_header):
client, _ = api_client
# On choisit une valeur qui existe dans test.parquet : récupère via la 1re ligne
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
assert base["data"], "test.parquet vide ?"
uid = base["data"][0]["uid"]
resp = client.get(f"/api/v1/data?uid__exact={uid}", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert all(row["uid"] == uid for row in body["data"])
def test_data_unknown_column_filter_returns_400(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?colonne_inexistante__exact=x",
headers=valid_token_header,
)
assert resp.status_code == 400
def test_data_columns_selection(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?columns=uid,objet&page_size=3",
headers=valid_token_header,
)
assert resp.status_code == 200
body = resp.get_json()
for row in body["data"]:
assert set(row.keys()) == {"uid", "objet"}
def test_data_columns_unknown_returns_400(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?columns=uid,foobar",
headers=valid_token_header,
)
assert resp.status_code == 400
def test_data_sort_desc(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?dateNotification__sort=desc&page_size=5",
headers=valid_token_header,
)
assert resp.status_code == 200
body = resp.get_json()
dates = [
row["dateNotification"] for row in body["data"] if row.get("dateNotification")
]
assert dates == sorted(dates, reverse=True)
def test_data_differs_excludes_value(api_client, valid_token_header):
client, _ = api_client
base = client.get("/api/v1/data?page_size=1", headers=valid_token_header).get_json()
uid = base["data"][0]["uid"]
resp = client.get(f"/api/v1/data?uid__differs={uid}", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert all(row["uid"] != uid for row in body["data"])
def test_data_aggregation_groupby_count(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?acheteur_departement_code__groupby&uid__count",
headers=valid_token_header,
)
assert resp.status_code == 200
body = resp.get_json()
assert body["data"], "agrégation vide ?"
for row in body["data"]:
assert set(row.keys()) == {"acheteur_departement_code", "uid__count"}
assert "total" not in body["meta"]
def test_data_aggregation_global_count(api_client, valid_token_header):
client, _ = api_client
resp = client.get("/api/v1/data?uid__count", headers=valid_token_header)
assert resp.status_code == 200
body = resp.get_json()
assert len(body["data"]) == 1
assert "uid__count" in body["data"][0]
def test_data_aggregation_with_filter(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?acheteur_departement_code__groupby&uid__count&montant__greater=0",
headers=valid_token_header,
)
assert resp.status_code == 200
def test_data_aggregation_with_columns_returns_400(api_client, valid_token_header):
client, _ = api_client
resp = client.get(
"/api/v1/data?uid__count&columns=uid",
headers=valid_token_header,
)
assert resp.status_code == 400
+18
View File
@@ -0,0 +1,18 @@
def test_schema_accessible_without_token(api_client):
client, _ = api_client
resp = client.get("/api/v1/schema")
assert resp.status_code == 200
def test_schema_returns_fields(api_client):
client, _ = api_client
resp = client.get("/api/v1/schema")
assert resp.status_code == 200
data = resp.get_json()
assert "fields" in data
assert isinstance(data["fields"], list)
assert len(data["fields"]) > 0
first = data["fields"][0]
assert set(first.keys()) >= {"name", "type", "title", "description"}
names = [f["name"] for f in data["fields"]]
assert "uid" in names
+204
View File
@@ -0,0 +1,204 @@
import polars as pl
import pytest
from src.api.filters import AggregationSpec, FilterError, build_where, parse_aggregators
SCHEMA = pl.Schema(
{
"uid": pl.String,
"objet": pl.String,
"montant": pl.Float64,
"annee": pl.Int64,
"dateNotification": pl.Date,
}
)
def test_no_filters_returns_true():
where, params, order = build_where([], SCHEMA)
assert where == "TRUE"
assert params == []
assert order is None
def test_exact_filter():
where, params, _ = build_where([("uid__exact", "abc")], SCHEMA)
assert where == '"uid" = ?'
assert params == ["abc"]
def test_contains_filter_uses_like_wildcards():
where, params, _ = build_where([("objet__contains", "informatique")], SCHEMA)
assert where == '"objet" LIKE ?'
assert params == ["%informatique%"]
def test_notcontains_filter():
where, params, _ = build_where([("objet__notcontains", "x")], SCHEMA)
assert where == '"objet" NOT LIKE ?'
assert params == ["%x%"]
def test_comparison_operators_on_int():
where, params, _ = build_where([("annee__strictly_greater", "2023")], SCHEMA)
assert where == '"annee" > ?'
assert params == [2024 - 1] # int coercion: 2023
def test_in_filter_splits_on_commas():
where, params, _ = build_where([("annee__in", "2022,2023,2024")], SCHEMA)
assert where == '"annee" IN (?,?,?)'
assert params == [2022, 2023, 2024]
def test_notin_filter():
where, params, _ = build_where([("annee__notin", "2020,2021")], SCHEMA)
assert where == '"annee" NOT IN (?,?)'
assert params == [2020, 2021]
def test_isnull_filter_ignores_value():
where, params, _ = build_where([("dateNotification__isnull", "anything")], SCHEMA)
assert where == '"dateNotification" IS NULL'
assert params == []
def test_isnotnull_filter():
where, params, _ = build_where([("dateNotification__isnotnull", "")], SCHEMA)
assert where == '"dateNotification" IS NOT NULL'
def test_multiple_filters_joined_by_and():
where, params, _ = build_where(
[("uid__exact", "a"), ("annee__greater", "2020")], SCHEMA
)
assert where == '"uid" = ? AND "annee" >= ?'
assert params == ["a", 2020]
def test_unknown_column_raises():
with pytest.raises(FilterError) as exc:
build_where([("foo__exact", "bar")], SCHEMA)
assert "foo" in str(exc.value)
assert exc.value.field == "foo__exact"
def test_unknown_operator_raises():
with pytest.raises(FilterError) as exc:
build_where([("uid__weird", "x")], SCHEMA)
assert "weird" in str(exc.value)
def test_bad_int_value_raises():
with pytest.raises(FilterError):
build_where([("annee__exact", "notanint")], SCHEMA)
def test_bad_date_value_raises():
with pytest.raises(FilterError):
build_where([("dateNotification__exact", "notadate")], SCHEMA)
def test_date_iso_coercion():
where, params, _ = build_where(
[("dateNotification__greater", "2024-01-01")], SCHEMA
)
from datetime import date
assert params == [date(2024, 1, 1)]
def test_reserved_params_are_ignored():
where, params, order = build_where(
[
("page", "2"),
("page_size", "100"),
("columns", "uid"),
("count_results", "false"),
("uid__exact", "z"),
],
SCHEMA,
)
assert where == '"uid" = ?'
assert params == ["z"]
def test_sort_returns_order_by():
where, params, order = build_where(
[("annee__sort", "desc"), ("uid__sort", "asc")], SCHEMA
)
assert where == "TRUE"
assert order == '"annee" DESC, "uid" ASC'
def test_sort_invalid_direction_raises():
with pytest.raises(FilterError):
build_where([("uid__sort", "sideways")], SCHEMA)
def test_param_without_operator_raises():
with pytest.raises(FilterError):
build_where([("uidexact", "x")], SCHEMA)
def test_differs_filter():
where, params, _ = build_where([("uid__differs", "abc")], SCHEMA)
assert where == '"uid" IS DISTINCT FROM ?'
assert params == ["abc"]
def test_differs_filter_on_int():
where, params, _ = build_where([("annee__differs", "2020")], SCHEMA)
assert where == '"annee" IS DISTINCT FROM ?'
assert params == [2020]
def test_parse_aggregators_none_when_absent():
assert parse_aggregators([("uid__exact", "a")], SCHEMA) is None
def test_parse_aggregators_groupby_and_count():
spec = parse_aggregators([("annee__groupby", ""), ("uid__count", "")], SCHEMA)
assert isinstance(spec, AggregationSpec)
assert spec.select_sql == '"annee", COUNT("uid") AS "uid__count"'
assert spec.group_by_sql == '"annee"'
def test_parse_aggregators_multiple_aggregates():
spec = parse_aggregators(
[
("annee__groupby", ""),
("montant__sum", ""),
("montant__avg", ""),
("montant__min", ""),
("montant__max", ""),
],
SCHEMA,
)
assert spec.select_sql == (
'"annee", SUM("montant") AS "montant__sum", '
'AVG("montant") AS "montant__avg", '
'MIN("montant") AS "montant__min", '
'MAX("montant") AS "montant__max"'
)
assert spec.group_by_sql == '"annee"'
def test_parse_aggregators_global_without_groupby():
spec = parse_aggregators([("uid__count", "")], SCHEMA)
assert spec.select_sql == 'COUNT("uid") AS "uid__count"'
assert spec.group_by_sql is None
def test_parse_aggregators_unknown_column_raises():
with pytest.raises(FilterError):
parse_aggregators([("nope__count", "")], SCHEMA)
def test_build_where_ignores_aggregator_flags():
where, params, _ = build_where(
[("annee__groupby", ""), ("uid__count", ""), ("montant__greater", "100")],
SCHEMA,
)
assert where == '"montant" >= ?'
assert params == [100.0]
+25
View File
@@ -0,0 +1,25 @@
from flask import Flask
from src.api import init_api
def _make_app():
app = Flask(__name__)
init_api(app)
return app
def test_health_returns_ok_without_auth():
app = _make_app()
resp = app.test_client().get("/api/v1/health")
assert resp.status_code == 200
assert resp.get_json() == {"status": "ok"}
def test_health_via_real_app():
"""Vérifie que init_api est bien branché dans src.app."""
from src.app import app as dash_app
resp = dash_app.server.test_client().get("/api/v1/health")
assert resp.status_code == 200
assert resp.get_json() == {"status": "ok"}
+15
View File
@@ -0,0 +1,15 @@
def test_openapi_documents_new_keywords(api_client):
client, _ = api_client
resp = client.get("/api/v1/openapi.json")
assert resp.status_code == 200
raw = resp.get_data(as_text=True)
for keyword in [
"count_results",
"differs",
"groupby",
"__sum",
"__avg",
"__min",
"__max",
]:
assert keyword in raw, f"{keyword} absent de la doc OpenAPI"
+33
View File
@@ -0,0 +1,33 @@
from src.api import tokens_cli, tokens_db
def _run(args, env):
return tokens_cli.main(args, env=env)
def test_create_prints_plaintext_token_once(temp_db, capsys):
rc = _run(["create", "--label", "alice"], env={"USERS_DB_PATH": str(temp_db)})
out = capsys.readouterr().out
assert rc == 0
assert "decpinfo_" in out
tokens = tokens_db.list_tokens(temp_db)
assert len(tokens) == 1
assert tokens[0]["label"] == "alice"
def test_list_shows_tokens(temp_db, capsys):
tokens_db.create_token(temp_db, "alice")
tokens_db.create_token(temp_db, "bob")
rc = _run(["list"], env={"USERS_DB_PATH": str(temp_db)})
out = capsys.readouterr().out
assert rc == 0
assert "alice" in out
assert "bob" in out
def test_revoke_sets_revoked_at(temp_db, capsys):
_, token_id = tokens_db.create_token(temp_db, "alice")
rc = _run(["revoke", str(token_id)], env={"USERS_DB_PATH": str(temp_db)})
assert rc == 0
tokens = tokens_db.list_tokens(temp_db)
assert tokens[0]["revoked_at"] is not None
+64
View File
@@ -0,0 +1,64 @@
import sqlite3
from src.api import tokens_db
def test_init_schema_creates_table(temp_db):
with sqlite3.connect(str(temp_db)) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'"
).fetchall()
assert rows == [("api_tokens",)]
def test_create_token_returns_plaintext_and_stores_hash(temp_db):
token, token_id = tokens_db.create_token(temp_db, "test-label")
assert token.startswith("decpinfo_")
assert len(token) == len("decpinfo_") + 64 # 32 octets hex = 64 chars
assert token_id >= 1
with sqlite3.connect(str(temp_db)) as conn:
row = conn.execute(
"SELECT token_hash, label, count_total FROM api_tokens WHERE id = ?",
(token_id,),
).fetchone()
assert row[1] == "test-label"
assert row[2] == 0
assert row[0] != token # stocké en clair impossible
assert len(row[0]) == 64 # sha256 hex
def test_get_token_by_plaintext_returns_row(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x")
row = tokens_db.get_token_by_plaintext(temp_db, token)
assert row is not None
assert row["id"] == token_id
assert row["label"] == "x"
assert row["revoked_at"] is None
def test_get_token_unknown_returns_none(temp_db):
assert tokens_db.get_token_by_plaintext(temp_db, "decpinfo_zzz") is None
def test_revoke_token_sets_revoked_at(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x")
tokens_db.revoke_token(temp_db, token_id)
row = tokens_db.get_token_by_plaintext(temp_db, token)
assert row["revoked_at"] is not None
def test_increment_usage_updates_counter_and_timestamp(temp_db):
token, token_id = tokens_db.create_token(temp_db, "x")
tokens_db.increment_usage(temp_db, token_id)
tokens_db.increment_usage(temp_db, token_id)
row = tokens_db.get_token_by_plaintext(temp_db, token)
assert row["count_total"] == 2
assert row["last_used_at"] is not None
def test_list_tokens_returns_all(temp_db):
tokens_db.create_token(temp_db, "a")
tokens_db.create_token(temp_db, "b")
rows = tokens_db.list_tokens(temp_db)
assert [r["label"] for r in rows] == ["a", "b"]
+80
View File
@@ -0,0 +1,80 @@
from src.api import tokens_db, tracking
def test_counter_worker_increments_count(temp_db):
_, token_id = tokens_db.create_token(temp_db, "x")
tracking.stop_worker() # reset any worker left by earlier tests
tracking.start_worker(str(temp_db))
try:
tracking.enqueue_counter_update(token_id)
tracking.enqueue_counter_update(token_id)
# Laisser le worker drainer la queue
tracking.flush(timeout=2.0)
finally:
tracking.stop_worker()
rows = tokens_db.list_tokens(temp_db)
assert rows[0]["count_total"] == 2
assert rows[0]["last_used_at"] is not None
def test_after_request_hook_increments_counter_async(api_client, valid_token_header):
client, db_path = api_client
# Récupérer le token_id du token créé par la fixture
from src.api import tokens_db, tracking
rows = tokens_db.list_tokens(db_path)
assert len(rows) == 1 # vérification du token créé par la fixture
# Faire une requête (qui doit déclencher l'incrément)
client.get("/api/v1/health") # pas authentifiée → ne compte pas
client.get("/api/v1/data", headers=valid_token_header) # /schema est public
tracking.flush(timeout=2.0)
rows = tokens_db.list_tokens(db_path)
assert rows[0]["count_total"] == 1
def test_matomo_disabled_skips_call(monkeypatch, api_client, valid_token_header):
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "false")
client, _ = api_client
from src.api import tracking
called = []
monkeypatch.setattr(
tracking,
"_post_matomo",
lambda **kw: called.append(kw),
)
client.get("/api/v1/data", headers=valid_token_header) # authentifiée → hook actif
tracking.flush(timeout=2.0)
assert called == []
def test_matomo_enabled_posts_event(monkeypatch, api_client, valid_token_header):
monkeypatch.setenv("MATOMO_TRACKING_ENABLED", "true")
monkeypatch.setenv("MATOMO_URL", "https://matomo.example/matomo.php")
monkeypatch.setenv("MATOMO_SITE_ID", "42")
from src.api import tracking
captured = []
monkeypatch.setattr(
tracking,
"_post_matomo",
lambda **kw: captured.append(kw),
)
client, _ = api_client
client.get("/api/v1/data", headers=valid_token_header) # /schema est public
tracking.flush(timeout=2.0)
assert len(captured) == 1
call = captured[0]
assert call["params"]["idsite"] == "42"
assert call["params"]["rec"] == "1"
assert "token-" in call["params"]["uid"]
assert call["params"]["dimension2"] == "200"
+62 -44
View File
@@ -6,58 +6,76 @@ import polars as pl
import pytest
from selenium.webdriver.chrome.options import Options
_TEST_DATA = [
{
"uid": "1",
"id": "1",
"acheteur_nom": "ACHETEUR 1",
"acheteur_id": "123",
"titulaire_nom": "TITULAIRE 1",
"titulaire_id": "345",
"montant": 10,
"dateNotification": datetime.date(2025, 1, 1),
"codeCPV": "71600000",
"donneesActuelles": True,
"acheteur_departement_code": "75",
"acheteur_departement_nom": "Paris",
"acheteur_commune_nom": "Paris",
"titulaire_departement_code": "35",
"titulaire_departement_nom": "Ille-et-Vilaine",
"titulaire_commune_nom": "Rennes",
"titulaire_distance": 10,
"titulaire_typeIdentifiant": "SIRET",
"objet": "Objet test",
"dureeRestanteMois": 12,
"lieuExecution_code": "75001",
"sourceFile": "test.xml",
"sourceDataset": "test_dataset",
"datePublicationDonnees": datetime.date(2025, 1, 1),
"considerationsSociales": "",
"considerationsEnvironnementales": "",
"type": "Marché",
"acheteur_categorie": "Collectivité",
"titulaire_categorie": "PME",
}
]
_PARQUET_PATH = Path(os.path.abspath("tests/test.parquet"))
_DB_PATH = Path(os.path.abspath("decp.duckdb"))
@pytest.fixture(scope="session", autouse=True)
def test_data():
data = [
{
"uid": "1",
"id": "1",
"acheteur_nom": "ACHETEUR 1",
"acheteur_id": "123",
"titulaire_nom": "TITULAIRE 1",
"titulaire_id": "345",
"montant": 10,
"dateNotification": datetime.date(2025, 1, 1),
"codeCPV": "71600000",
"donneesActuelles": True,
"acheteur_departement_code": "75",
"acheteur_departement_nom": "Paris",
"acheteur_commune_nom": "Paris",
"titulaire_departement_code": "35",
"titulaire_departement_nom": "Ille-et-Vilaine",
"titulaire_commune_nom": "Rennes",
"titulaire_distance": 10,
"titulaire_typeIdentifiant": "SIRET",
"objet": "Objet test",
"dureeRestanteMois": 12,
"lieuExecution_code": "75001",
"sourceFile": "test.xml",
"sourceDataset": "test_dataset",
"datePublicationDonnees": datetime.date(2025, 1, 1),
"considerationsSociales": "",
"considerationsEnvironnementales": "",
"type": "Marché",
"acheteur_categorie": "Collectivité",
"titulaire_categorie": "PME",
}
]
parquet_path = Path(os.path.abspath("tests/test.parquet"))
db_path = parquet_path.parent / "decp.duckdb"
print(f"Writing test data to: {parquet_path}")
# Schéma déterministe et hors-ligne pour les tests : on pointe le cache sur un
# fixture commité et on désactive la récupération distante.
_SCHEMA_FIXTURE = Path(os.path.abspath("tests/schema.fixture.json"))
os.environ["DATA_SCHEMA_CACHE"] = str(_SCHEMA_FIXTURE)
os.environ.pop("DATA_SCHEMA_PATH", None)
pl.DataFrame(data).write_parquet(parquet_path)
# Remove any stale DuckDB from a previous run so src.db rebuilds from
# the freshly-written parquet at import time.
for artifact in (db_path, db_path.with_suffix(".duckdb.tmp")):
def _cleanup_db_artifacts() -> None:
for artifact in (
_DB_PATH,
_DB_PATH.with_suffix(".duckdb.tmp"),
_DB_PATH.with_suffix(".duckdb.lock"),
):
if artifact.exists():
artifact.unlink()
yield str(parquet_path)
# Runs at conftest import, before test modules import src.db (which builds the
# DuckDB at import time). Guarantees the test parquet exists and the stale DB
# from a previous `python run.py` is wiped so src.db rebuilds from test data.
pl.DataFrame(_TEST_DATA).write_parquet(_PARQUET_PATH)
_cleanup_db_artifacts()
def pytest_setup_options():
@pytest.fixture(scope="session", autouse=True)
def test_data():
yield str(_PARQUET_PATH)
# Teardown: remove the test DuckDB so the next `python run.py` rebuilds
# from decp_prod.parquet.
_cleanup_db_artifacts()
@pytest.fixture(scope="session")
def chrome_options():
options = Options()
options.add_argument("--window-size=1200,1200 ")
options.add_experimental_option(
+472
View File
@@ -0,0 +1,472 @@
{
"fields": [
{
"name": "acheteur_categorie",
"type": "string",
"title": "Cat\u00e9gorie de l'acheteur",
"description": "Cat\u00e9gorie de l'acheteur selon son code juridique INSEE.",
"short_title": "Cat\u00e9gorie acheteur",
"enum": [
"Commune",
"Groupement de communes",
"D\u00e9partement",
"D\u00e9partement outre-mer",
"R\u00e9gion",
"\u00c9tat",
"\u00c9tablissement hospitalier",
"EPIC",
"Syndicat mixte"
]
},
{
"name": "acheteur_commune_code",
"type": "string",
"title": "Commune de l'acheteur (code)",
"description": "Code de la commune o\u00f9 se trouve l'acheteur.",
"short_title": "Commune ach. (code)"
},
{
"name": "acheteur_commune_nom",
"type": "string",
"title": "Commune de l'acheteur",
"description": "Nom de la commune o\u00f9 se trouve l'acheteur.",
"short_title": "Commune acheteur"
},
{
"name": "acheteur_departement_code",
"type": "string",
"title": "D\u00e9partement de l'acheteur (code)",
"description": "Code du d\u00e9partement o\u00f9 se trouve l'acheteur.",
"short_title": "D\u00e9partement ach. (code)"
},
{
"name": "acheteur_departement_nom",
"type": "string",
"title": "D\u00e9partement de l'acheteur",
"description": "Nom du d\u00e9partement o\u00f9 se trouve l'acheteur.",
"short_title": "D\u00e9partement acheteur"
},
{
"name": "acheteur_id",
"type": "integer",
"title": "SIRET acheteur",
"description": "Identifiant de l'\u00e9tablissement de l'acheteur (SIRET), r\u00e9f\u00e9renc\u00e9 dans la base SIRENE de l'INSEE.",
"short_title": null
},
{
"name": "acheteur_latitude",
"type": "number",
"title": "Latitude de l'acheteur",
"description": "Latitude des coordonn\u00e9es g\u00e9ographiques de l'acheteur.",
"short_title": "Latitude acheteur"
},
{
"name": "acheteur_longitude",
"type": "number",
"title": "Longitude de l'acheteur",
"description": "Longitude des coordonn\u00e9es g\u00e9ographiques de l'acheteur.",
"short_title": "Longitude acheteur"
},
{
"name": "acheteur_nom",
"type": "string",
"title": "Nom acheteur",
"description": "Nom de l'acheteur tel que renseign\u00e9 dans la base SIRENE de l'INSEE.",
"short_title": "Acheteur"
},
{
"name": "acheteur_region_code",
"type": "string",
"title": "R\u00e9gion de l'acheteur (code)",
"description": "Code de la r\u00e9gion o\u00f9 se trouve l'acheteur.",
"short_title": "R\u00e9gion ach. (code)"
},
{
"name": "acheteur_region_nom",
"type": "string",
"title": "R\u00e9gion de l'acheteur",
"description": "Nom de la r\u00e9gion o\u00f9 se trouve l'acheteur.",
"short_title": "R\u00e9gion acheteur"
},
{
"name": "attributionAvance",
"type": "boolean",
"title": "Attribution avance",
"description": "Si une avance sur le montant du march\u00e9 public a \u00e9t\u00e9 attribu\u00e9e aux titulaires.",
"short_title": null
},
{
"name": "ccag",
"type": "string",
"title": "CCAG",
"description": "Cahier des clauses administratives g\u00e9n\u00e9rales et techniques (CCAG) utilis\u00e9 pour le march\u00e9 public.",
"short_title": null,
"enum": [
"Travaux",
"Maitrise d'\u0153uvre",
"Fournitures courantes et services",
"March\u00e9s industriels",
"Prestations intellectuelles",
"Techniques de l'information et de la communication"
]
},
{
"name": "codeCPV",
"type": "string",
"title": "Code CPV",
"description": "Cat\u00e9gorie de bien, service ou travaux achet\u00e9s, selon le Vocabulaire commun pour les march\u00e9s publics (CPV).",
"short_title": "CPV"
},
{
"name": "considerationsEnvironnementales",
"type": "string",
"title": "Consid\u00e9rations environnementales",
"description": "Les consid\u00e9rations environnementales pr\u00e9vues dans le march\u00e9 public.",
"short_title": "Cons. environnementales",
"enum": ["Clause environnementale", "Crit\u00e8re environnemental"]
},
{
"name": "considerationsSociales",
"type": "string",
"title": "Consid\u00e9rations sociales",
"description": "Les consid\u00e9rations sociales pr\u00e9vues dans le march\u00e9 public.",
"short_title": "Cons. sociales",
"enum": [
"Clause sociale",
"Crit\u00e8re social",
"March\u00e9 r\u00e9serv\u00e9"
]
},
{
"name": "dateNotification",
"type": "date",
"title": "Date notification",
"description": "Date \u00e0 laquelle le march\u00e9 public ou de la modification a \u00e9t\u00e9 notifi\u00e9e aux titulaires du march\u00e9 public.",
"short_title": null,
"format": "default"
},
{
"name": "datePublicationDonnees",
"type": "date",
"title": "Date publication donn\u00e9es",
"description": "Date \u00e0 laquelle les donn\u00e9es du march\u00e9 public ou de la modification ont \u00e9t\u00e9 publi\u00e9es sur data.gouv.fr.",
"short_title": "Date pub. donn\u00e9es",
"format": "default"
},
{
"name": "donneesActuelles",
"type": "boolean",
"title": "Donn\u00e9es actuelles",
"description": "Si les donn\u00e9es de cette ligne sont les donn\u00e9es actuelles du march\u00e9 public, une fois les \u00e9ventuelles modifications prises en compte.",
"short_title": null
},
{
"name": "dureeMois",
"type": "integer",
"title": "Dur\u00e9e (mois)",
"description": "Dur\u00e9e en mois du march\u00e9 attribu\u00e9.",
"short_title": null
},
{
"name": "dureeRestanteMois",
"type": "number",
"title": "Dur\u00e9e restante (mois)",
"description": "Dur\u00e9e approximative en mois restante dans le march\u00e9, en tenant compte de la date de notification et de la dur\u00e9e du march\u00e9. Ce nombre ne peut \u00eatre inf\u00e9rieur \u00e0 0.",
"short_title": null
},
{
"name": "formePrix",
"type": "string",
"title": "Forme prix",
"description": "La forme du prix du march\u00e9 public. Unitaire, Forfaitaire ou Mixte.",
"short_title": null
},
{
"name": "id",
"type": "string",
"title": "Identifiant interne",
"description": "Identifiant attribu\u00e9 par l'acheteur, cens\u00e9 \u00eatre unique au sein de ses march\u00e9s.",
"short_title": "Id. interne"
},
{
"name": "idAccordCadre",
"type": "string",
"title": "Identifiant accord-cadre",
"description": "Pour un march\u00e9 subs\u00e9quent, l'identifiant interne du march\u00e9 public relevant de la technique d'achat accord-cadre auquel il est li\u00e9.",
"short_title": "Id. accord-cadre"
},
{
"name": "lieuExecution_code",
"type": "integer",
"title": "Code lieu ex\u00e9cution",
"description": "Code du lieu d'ex\u00e9cution du march\u00e9 public. Le type de code est renseign\u00e9 par 'Type code lieu ex\u00e9cution'.",
"short_title": "Lieu ex\u00e9cution"
},
{
"name": "lieuExecution_typeCode",
"type": "string",
"title": "Type code lieu ex\u00e9cution",
"description": "Type du code du lieu d'ex\u00e9cution.",
"short_title": "Type lieu ex\u00e9cution",
"enum": [
"Code postal",
"Code commune",
"Code arrondissement",
" Code canton",
"Code d\u00e9partement",
"Code r\u00e9gion",
"Code pays"
]
},
{
"name": "marcheInnovant",
"type": "boolean",
"title": "March\u00e9 innovant",
"description": "Si le march\u00e9 comporte des travaux, services ou fournitures innovantes.",
"short_title": null
},
{
"name": "modalitesExecution",
"type": "string",
"title": "Modalit\u00e9s ex\u00e9cution",
"description": "Les modalit\u00e9s d'ex\u00e9cution du march\u00e9 public.",
"short_title": null,
"enum": ["Tranches", "Bons de commande", "March\u00e9s subs\u00e9quents"]
},
{
"name": "modification_id",
"type": "integer",
"title": "Identifiant modification",
"description": "Identifiant de la modification. 0 = donn\u00e9es initiales du march\u00e9 public, 1 = premi\u00e8re modification, etc.",
"short_title": "Id. modification"
},
{
"name": "montant",
"type": "number",
"title": "Montant attribu\u00e9",
"description": "Montant forfaitaire ou montant maximum estim\u00e9 hors-taxes, en euros. Ce montant est le montant attribu\u00e9. Le montant final pay\u00e9 aux titulaires peut \u00e9voluer lors de la signature du contrat et de l'ex\u00e9cution du march\u00e9.",
"short_title": "Montant"
},
{
"name": "nature",
"type": "string",
"title": "Nature",
"description": "March\u00e9, March\u00e9 de partenariat ou March\u00e9 de s\u00e9curit\u00e9.",
"short_title": null
},
{
"name": "objet",
"type": "string",
"title": "Objet",
"description": "Objet du march\u00e9 public. Potentiellement coup\u00e9 \u00e0 256 ou 1 000 caract\u00e8res par le producteur de donn\u00e9es.",
"short_title": null
},
{
"name": "offresRecues",
"type": "integer",
"title": "Offres re\u00e7ues",
"description": "Le nombre d'offres re\u00e7ues pendant la phase d'appel d'offres. Comprend aussi les offres irr\u00e9guli\u00e8res, inacceptables, inappropri\u00e9es et anormalement basses.",
"short_title": null
},
{
"name": "origineFrance",
"type": "number",
"title": "Origine France",
"description": "Pour les march\u00e9s de fournitures de denr\u00e9es alimentaires, de v\u00e9hicules, de produits de sant\u00e9 et d'habillement, selon la liste annex\u00e9e \u00e0 l'arr\u00eat\u00e9 du 22 d\u00e9cembre 2022, la part des produits fran\u00e7ais avec laquelle le march\u00e9 sera ex\u00e9cut\u00e9. 0.2 = 20 % de la part des produits sont fran\u00e7ais. Cette valeur ne peut pas \u00eatre sup\u00e9rieure \u00e0 la valeur de origineUE.",
"short_title": null
},
{
"name": "origineUE",
"type": "number",
"title": "Origine UE",
"description": "Pour les march\u00e9s de fournitures de denr\u00e9es alimentaires, de v\u00e9hicules, de produits de sant\u00e9 et d'habillement, selon la liste annex\u00e9e \u00e0 l'arr\u00eat\u00e9 du 22 d\u00e9cembre 2022, la part des produits issus de l'Union europ\u00e9enne avec laquelle le march\u00e9 sera ex\u00e9cut\u00e9. 0.2 = 20 % de la part des produits provient de l'Union europ\u00e9enne. Cette valeur ne peut pas \u00eatre inf\u00e9rieure \u00e0 la valeur de origineFrance.",
"short_title": null
},
{
"name": "procedure",
"type": "string",
"title": "Proc\u00e9dure",
"description": "Le type de proc\u00e9dure utilis\u00e9 pour le march\u00e9 public.",
"short_title": null,
"enum": [
"Proc\u00e9dure n\u00e9goci\u00e9e ouverte",
"Proc\u00e9dure non n\u00e9goci\u00e9e ouverte",
"Proc\u00e9dure n\u00e9goci\u00e9e restreinte",
"Proc\u00e9dure non n\u00e9goci\u00e9e restreinte"
]
},
{
"name": "sourceDataset",
"type": "string",
"title": "Source dataset",
"description": "Code du jeu de donn\u00e9es dont proviennent les donn\u00e9es de ce march\u00e9 public.",
"short_title": null
},
{
"name": "sourceFile",
"type": "string",
"title": "Source fichier",
"description": "Lien vers le fichier de donn\u00e9es ouvertes dont proviennent les donn\u00e9es de ce march\u00e9 public.",
"short_title": null,
"format": "uri"
},
{
"name": "sousTraitanceDeclaree",
"type": "boolean",
"title": "Sous-traitance d\u00e9clar\u00e9e",
"description": "Au moment de la notification du march\u00e9, les titulaires du march\u00e9 ont d\u00e9clar\u00e9 s'appuyer sur un ou plusieurs sous-traitants pour ce march\u00e9 public.",
"short_title": "Sous-traitance"
},
{
"name": "tauxAvance",
"type": "number",
"title": "Taux avance",
"description": "Taux de l'avance attribu\u00e9e au titulaire principal du march\u00e9 public par rapport au montant du march\u00e9 (O.1 = 10 % du montant du march\u00e9). En fonction de la valeur de attributionAvance, une valeur \u00e9gale \u00e0 0 signifie qu'il y a une avance mais que le taux n'est pas connu (attributionAvance=true).",
"short_title": null
},
{
"name": "techniques",
"type": "string",
"title": "Techniques",
"description": "Les techniques d'achat utilis\u00e9es pour le march\u00e9 public.",
"short_title": null,
"enum": [
"Accord-cadre",
"Concours",
"Syst\u00e8me de qualification",
"Syst\u00e8me d'acquisition dynamique",
"Catalogue \u00e9lectronique",
"Ench\u00e8re \u00e9lectronique"
]
},
{
"name": "titulaire_categorie",
"type": "string",
"title": "Cat\u00e9gorie du titulaire",
"description": "Cat\u00e9gorie de l'entreprise titulaire selon la classification de l'INSEE.",
"short_title": "Cat\u00e9gorie titulaire",
"enum": ["PME", "ETI", "GE"]
},
{
"name": "titulaire_commune_code",
"type": "string",
"title": "Commune du titulaire (code)",
"description": "Code de la commune o\u00f9 se trouve le titulaire.",
"short_title": "Commune tit. (code)"
},
{
"name": "titulaire_commune_nom",
"type": "string",
"title": "Commune du titulaire",
"description": "Nom de la commune o\u00f9 se trouve le titulaire.",
"short_title": "Commune titulaire"
},
{
"name": "titulaire_departement_code",
"type": "string",
"title": "D\u00e9partement du titulaire (code)",
"description": "Code du d\u00e9partement o\u00f9 se trouve le titulaire.",
"short_title": "D\u00e9partement tit. (code)"
},
{
"name": "titulaire_departement_nom",
"type": "string",
"title": "D\u00e9partement du titulaire",
"description": "Nom du d\u00e9partement o\u00f9 se trouve le titulaire.",
"short_title": "D\u00e9partement titulaire"
},
{
"name": "titulaire_distance",
"type": "integer",
"title": "Distance acheteur-titulaire",
"description": "Distance en kilom\u00e8tres entre l'adresse de l'acheteur et celle du titulaire.",
"short_title": "Distance"
},
{
"name": "titulaire_id",
"type": "integer",
"title": "Identifiant titulaire",
"description": "Identifiant du titulaire du march\u00e9. Voir 'Type identifiant' pour le r\u00e9f\u00e9rentiel utilis\u00e9",
"short_title": "Id. titulaire"
},
{
"name": "titulaire_latitude",
"type": "number",
"title": "Latitude du titulaire",
"description": "Latitude des coordonn\u00e9es g\u00e9ographiques du titulaire.",
"short_title": "Latitude titulaire"
},
{
"name": "titulaire_longitude",
"type": "number",
"title": "Longitude du titulaire",
"description": "Longitude des coordonn\u00e9es g\u00e9ographiques du titulaire.",
"short_title": "Longitude titulaire"
},
{
"name": "titulaire_nom",
"type": "string",
"title": "Nom titulaire",
"description": "Nom du titulaire. Nom tel que renseign\u00e9 dans la base SIRENE de l'INSEE si c'est un SIRET.",
"short_title": "Titulaire"
},
{
"name": "titulaire_region_code",
"type": "string",
"title": "R\u00e9gion du titulaire (code)",
"description": "Code de la r\u00e9gion o\u00f9 se trouve le titulaire.",
"short_title": "R\u00e9gion tit. (code)"
},
{
"name": "titulaire_region_nom",
"type": "string",
"title": "R\u00e9gion du titulaire",
"description": "Nom de la r\u00e9gion o\u00f9 se trouve le titulaire.",
"short_title": "R\u00e9gion titulaire"
},
{
"name": "titulaire_typeIdentifiant",
"type": "string",
"title": "Type identifiant",
"description": "R\u00e9f\u00e9rentiel utilis\u00e9 pour l'identifiant du titulaire.",
"short_title": "Type id.",
"enum": ["SIRET", "TVA", "TAHITI", "RIDET", "FRWF", "IREP", "HORS-UE"]
},
{
"name": "type",
"type": "string",
"title": "Type",
"description": "Type de march\u00e9 public : fournitures, services ou travaux (d\u00e9riv\u00e9 du code CPV).",
"short_title": "Type",
"enum": ["Fournitures", "Services", "Travaux"]
},
{
"name": "typeGroupementOperateurs",
"type": "string",
"title": "Type groupement",
"description": "Le type de groupement d'entreprises ou d'op\u00e9rateurs \u00e9conomiques.",
"short_title": "Groupement",
"enum": ["Conjoint", "Solidaire"]
},
{
"name": "typesPrix",
"type": "string",
"title": "Types prix",
"description": "Les types de prix du march\u00e9 public.",
"short_title": null,
"enum": [
"D\u00e9finitif ferme",
"D\u00e9finitif actualisable",
"D\u00e9finitif r\u00e9visable",
"Provisoire"
]
},
{
"name": "uid",
"type": "string",
"title": "Identifiant unique",
"description": "Concat\u00e9nation du SIRET de l'acheteur (acheteur_id) et de l'identifiant interne de l'acheteur (id). Utilis\u00e9 comme identifiant de march\u00e9 unique au niveau national.",
"short_title": "Id. unique"
}
]
}
+225
View File
@@ -0,0 +1,225 @@
from datetime import datetime, timedelta
from src.utils.table_sql import dashboard_filters_to_sql
def test_no_filters_uses_default_365_day_window():
where_sql, params = dashboard_filters_to_sql()
assert where_sql == '"dateNotification" > ?'
assert len(params) == 1
assert isinstance(params[0], datetime)
expected = datetime.now() - timedelta(days=365)
assert abs((params[0] - expected).total_seconds()) < 2
def test_year_filter_overrides_default_window():
where_sql, params = dashboard_filters_to_sql(dashboard_year="2025")
assert where_sql == 'YEAR("dateNotification") = ?'
assert params == [2025]
def test_marche_type_equality():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_type="Marché",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "type" = ?'
assert params == [2025, "Marché"]
def test_innovant_value_all_is_skipped():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_innovant="all",
)
assert where_sql == 'YEAR("dateNotification") = ?'
assert params == [2025]
def test_innovant_value_oui_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_innovant="oui",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "marcheInnovant" = ?'
assert params == [2025, "oui"]
def test_sous_traitance_value_non_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_sous_traitance_declaree="non",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "sousTraitanceDeclaree" = ?'
assert params == [2025, "non"]
def test_acheteur_id_uses_like_wildcards():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_id="12345678900010",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
assert params == [2025, "%12345678900010%"]
def test_titulaire_id_uses_like_wildcards():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_id="999",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
assert params == [2025, "%999%"]
def test_marche_objet_uses_case_insensitive_ilike():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_objet="travaux",
)
assert (
where_sql
== 'YEAR("dateNotification") = ? AND "objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?'
)
assert params == [2025, "%travaux%"]
def test_code_cpv_uses_prefix_like():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_code_cpv="4521",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "codeCPV" LIKE ?'
assert params == [2025, "4521%"]
def test_acheteur_departement_multiple_uses_in_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_departement_code=["75", "92", "93"],
)
assert where_sql == (
'YEAR("dateNotification") = ? AND "acheteur_departement_code" IN (?, ?, ?)'
)
assert params == [2025, "75", "92", "93"]
def test_acheteur_categorie_adds_clause():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_categorie="Commune",
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_categorie" = ?'
assert params == [2025, "Commune"]
def test_titulaire_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_categorie="PME",
dashboard_titulaire_departement_code=["35"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
'AND "titulaire_categorie" = ? '
'AND "titulaire_departement_code" IN (?)'
)
assert params == [2025, "PME", "35"]
def test_acheteur_id_present_skips_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_acheteur_id="123",
dashboard_acheteur_categorie="Commune",
dashboard_acheteur_departement_code=["75"],
)
assert where_sql == 'YEAR("dateNotification") = ? AND "acheteur_id" LIKE ?'
assert params == [2025, "%123%"]
def test_titulaire_id_present_skips_categorie_and_departement():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_titulaire_id="999",
dashboard_titulaire_categorie="PME",
dashboard_titulaire_departement_code=["35"],
)
assert where_sql == 'YEAR("dateNotification") = ? AND "titulaire_id" LIKE ?'
assert params == [2025, "%999%"]
def test_marche_techniques_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_techniques=["Enchère", "Accord-cadre"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"techniques\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Enchère", "Accord-cadre"]]
def test_considerations_sociales_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_considerations_sociales=["Clause sociale"],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"considerationsSociales\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Clause sociale"]]
def test_considerations_environnementales_uses_list_has_any():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_marche_considerations_environnementales=["Clause env."],
)
assert where_sql == (
'YEAR("dateNotification") = ? '
"AND list_has_any(string_split(\"considerationsEnvironnementales\", ', '), ?::VARCHAR[])"
)
assert params == [2025, ["Clause env."]]
def test_montant_min_only():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=1000,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
assert params == [2025, 1000]
def test_montant_max_only():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_max=500,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" <= ?'
assert params == [2025, 500]
def test_montant_zero_is_a_valid_lower_bound():
# 0 est falsy mais reste un filtre valide (distinct de None)
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=0,
)
assert where_sql == 'YEAR("dateNotification") = ? AND "montant" >= ?'
assert params == [2025, 0]
def test_montant_min_and_max_combined():
where_sql, params = dashboard_filters_to_sql(
dashboard_year="2025",
dashboard_montant_min=100,
dashboard_montant_max=1000,
)
assert where_sql == (
'YEAR("dateNotification") = ? AND "montant" >= ? AND "montant" <= ?'
)
assert params == [2025, 100, 1000]
+109 -5
View File
@@ -31,6 +31,7 @@ def test_should_rebuild_prod_when_parquet_newer(parquet_and_db, monkeypatch):
os.utime(db, (now, now))
os.utime(parquet, (now + 10, now + 10))
monkeypatch.setenv("DEVELOPMENT", "false")
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
assert should_rebuild(db, parquet) is True
@@ -42,6 +43,7 @@ def test_should_not_rebuild_prod_when_parquet_older(parquet_and_db, monkeypatch)
os.utime(parquet, (now, now))
os.utime(db, (now + 10, now + 10))
monkeypatch.setenv("DEVELOPMENT", "false")
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
assert should_rebuild(db, parquet) is False
@@ -66,6 +68,7 @@ def test_should_rebuild_dev_when_rebuild_forced(parquet_and_db, monkeypatch):
os.utime(parquet, (now + 10, now + 10))
monkeypatch.setenv("DEVELOPMENT", "true")
monkeypatch.setenv("REBUILD_DUCKDB", "true")
monkeypatch.setattr("src.db.get_last_modified", lambda p: parquet.stat().st_mtime)
assert should_rebuild(db, parquet) is True
@@ -141,10 +144,11 @@ def built_db(tmp_path, monkeypatch):
)
data.write_parquet(parquet_path)
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
monkeypatch.setenv("DUCKDB_PATH", str(db_path))
from src.db import build_database
build_database(db_path, parquet_path)
build_database(db_path)
return db_path
@@ -189,8 +193,15 @@ def test_build_creates_derived_tables(built_db):
def test_query_marches_returns_polars_frame(built_db, monkeypatch):
monkeypatch.setenv(
"DATA_FILE_PARQUET_PATH", str(built_db.parent / "source.parquet")
parquet_path = built_db.parent / "source.parquet"
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
# Patch on both the source module and dst namespace: the reload re-imports
# get_last_modified from src.utils, so src.utils must be patched to survive.
monkeypatch.setattr(
"src.utils.get_last_modified", lambda p: parquet_path.stat().st_mtime
)
monkeypatch.setattr(
"src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime
)
# Force src.db to load pointing at this test DB.
import importlib
@@ -206,7 +217,39 @@ def test_query_marches_returns_polars_frame(built_db, monkeypatch):
assert set(frame["uid"].to_list()) == {"1", "2"}
def test_concurrent_build_serialized(tmp_path):
def test_count_marches_returns_total_without_filter():
from src.db import count_marches
n = count_marches()
assert isinstance(n, int)
assert n > 0
def test_count_marches_with_filter():
from src.db import count_marches
n = count_marches('"uid" = ?', ["__nonexistent__"])
assert n == 0
def test_count_unique_marches_respects_distinct():
from src.db import count_unique_marches
n = count_unique_marches()
assert isinstance(n, int)
assert n > 0
def test_query_marches_with_offset():
from src.db import query_marches
page_0 = query_marches(limit=2, offset=0)
page_1 = query_marches(limit=2, offset=2)
if page_0.height == 2 and page_1.height >= 1:
assert set(page_0["uid"].to_list()).isdisjoint(set(page_1["uid"].to_list()))
def test_concurrent_build_serialized(tmp_path, monkeypatch):
"""Multiple threads calling _ensure_database must serialize via flock.
Only one should actually build; others wait, see the fresh DB, and skip.
@@ -236,6 +279,10 @@ def test_concurrent_build_serialized(tmp_path):
}
)
df.write_parquet(parquet_path)
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", str(parquet_path))
monkeypatch.setattr(
"src.db.get_last_modified", lambda p: parquet_path.stat().st_mtime
)
db_path = tmp_path / "decp.duckdb"
lock_path = db_path.with_suffix(".duckdb.lock")
@@ -250,7 +297,7 @@ def test_concurrent_build_serialized(tmp_path):
fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
try:
if db.should_rebuild(db_path, parquet_path):
db.build_database(db_path, parquet_path)
db.build_database(db_path)
finally:
fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
except BaseException as exc:
@@ -265,3 +312,60 @@ def test_concurrent_build_serialized(tmp_path):
assert errors == []
assert db_path.exists()
assert not tmp_path_artifact.exists()
def _raise(*args, **kwargs):
raise RuntimeError("boom")
def test_ensure_database_reuses_db_when_should_rebuild_raises(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb"
dbf.write_bytes(b"existing")
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
monkeypatch.setattr(db, "should_rebuild", _raise)
result = db._ensure_database() # ne doit pas lever
assert result == dbf
assert dbf.read_bytes() == b"existing"
def test_ensure_database_reuses_db_when_build_raises(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb"
dbf.write_bytes(b"existing")
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
monkeypatch.setattr(db, "build_database", _raise)
result = db._ensure_database() # ne doit pas lever
assert result == dbf
assert dbf.read_bytes() == b"existing"
def test_ensure_database_raises_on_cold_start(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb" # n'existe pas
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://unreachable")
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
monkeypatch.setattr(db, "build_database", _raise)
with pytest.raises(RuntimeError):
db._ensure_database()
def test_ensure_database_builds_when_needed(tmp_path, monkeypatch):
import src.db as db
dbf = tmp_path / "decp.duckdb"
dbf.write_bytes(b"old")
monkeypatch.setenv("DUCKDB_PATH", str(dbf))
monkeypatch.setenv("DATA_FILE_PARQUET_PATH", "http://x")
called = {}
monkeypatch.setattr(db, "should_rebuild", lambda *a, **k: True)
monkeypatch.setattr(db, "build_database", lambda p: called.setdefault("built", p))
db._ensure_database()
assert called.get("built") == dbf
+54
View File
@@ -0,0 +1,54 @@
import io
import openpyxl
import polars as pl
from src.utils.table import write_styled_excel
def test_write_styled_excel_header_bold_and_color():
df = pl.DataFrame({"objet": ["marché A"], "montant": [1000.0]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
cell = ws.cell(row=1, column=1)
assert cell.font.bold is True
assert cell.fill.fgColor.rgb == "FFB33821"
assert cell.font.color.rgb == "FFFFFFFF"
def test_write_styled_excel_data_cell_text_wrap():
df = pl.DataFrame({"objet": ["marché A"]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
cell = ws.cell(row=2, column=1)
assert cell.alignment.wrap_text is True
def test_write_styled_excel_known_column_wider_than_minimum():
# "objet" a une largeur explicite (350px) > minimum (132px)
# "autre" n'a pas de largeur explicite → reçoit le minimum
df = pl.DataFrame({"autre": ["x"], "objet": ["y"]})
buf = io.BytesIO()
write_styled_excel(df, buf)
buf.seek(0)
wb = openpyxl.load_workbook(buf)
ws = wb.active
width_autre = ws.column_dimensions["A"].width # colonne 1 = "autre"
width_objet = ws.column_dimensions["B"].width # colonne 2 = "objet"
assert width_autre >= 15 # 132px minimum → ~18.9 chars xlsxwriter, borne basse 15
assert width_objet >= 40 # 350px → ~50 chars xlsxwriter, borne basse 40
def test_write_styled_excel_custom_worksheet_name():
df = pl.DataFrame({"col": ["val"]})
buf = io.BytesIO()
write_styled_excel(df, buf, worksheet="2025")
buf.seek(0)
wb = openpyxl.load_workbook(buf)
assert "2025" in wb.sheetnames
+167
View File
@@ -0,0 +1,167 @@
import polars as pl
def _make_lff(rows):
return pl.LazyFrame(rows)
def test_compute_considerations_stats_basic():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# u1 : social oui (Clause), env non (Sans objet)
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
# u2 : social non (Sans objet), env oui (Critère)
{
"uid": "u2",
"considerationsSociales": "Sans objet",
"considerationsEnvironnementales": "Critère environnemental",
},
# u3 : social oui (Marché réservé), env null
{
"uid": "u3",
"considerationsSociales": "Marché réservé",
"considerationsEnvironnementales": None,
},
# u4 : social autre valeur (pas "Sans objet"), env null
{
"uid": "u4",
"considerationsSociales": "Pas de considération sociale",
"considerationsEnvironnementales": "Sans objet",
},
]
)
stats = compute_considerations_stats(lff)
# champs_renseignes : basé sur sociales. 4 non-null / 4 total -> (4, 100%).
assert stats["champs_renseignes"] == (4, 100)
# Sociales renseignées : dén=4 non-null, num=3 != "Sans objet" (u1/u3/u4) -> (4, 75%).
assert stats["sociales_renseignees"] == (4, 75)
# Env renseignées : dén=3 non-null (u1/u2/u4), num=1 != "Sans objet" (u2) -> (3, 33%).
assert stats["environnementales_renseignees"] == (3, 33)
def test_compute_considerations_stats_dedup_per_uid():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
# u1 présent 2 fois (2 titulaires) -> compté une seule fois
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
{
"uid": "u2",
"considerationsSociales": "Sans objet",
"considerationsEnvironnementales": "Sans objet",
},
]
)
stats = compute_considerations_stats(lff)
# 2 uid distincts. Social 2 non-null, 1 != "Sans objet" (u1) -> (2, 50%).
assert stats["champs_renseignes"] == (2, 100)
assert stats["sociales_renseignees"] == (2, 50)
# Env 2 non-null, 0 != "Sans objet" -> (2, 0%).
assert stats["environnementales_renseignees"] == (2, 0)
def test_compute_considerations_stats_missing_column():
from src.figures import compute_considerations_stats
lff = _make_lff(
[
{"uid": "u1", "considerationsSociales": "Clause sociale"},
{"uid": "u2", "considerationsSociales": "Sans objet"},
]
)
stats = compute_considerations_stats(lff)
# Colonne env absente -> (0, 0). Social : 2 non-null, 1 != "Sans objet" -> (2, 50%).
assert stats["champs_renseignes"] == (2, 100)
assert stats["sociales_renseignees"] == (2, 50)
assert stats["environnementales_renseignees"] == (0, 0)
def test_compute_considerations_stats_empty():
from src.figures import compute_considerations_stats
lff = pl.LazyFrame(
{
"uid": pl.Series([], dtype=pl.String),
"considerationsSociales": pl.Series([], dtype=pl.String),
"considerationsEnvironnementales": pl.Series([], dtype=pl.String),
}
)
stats = compute_considerations_stats(lff)
assert stats["champs_renseignes"] == (0, 0)
assert stats["sociales_renseignees"] == (0, 0)
assert stats["environnementales_renseignees"] == (0, 0)
def test_get_considerations_card_content_returns_three_progress_bars():
import dash_bootstrap_components as dbc
from dash import html
from src.figures import get_considerations_card_content
lff = pl.LazyFrame(
[
{
"uid": "u1",
"considerationsSociales": "Clause sociale",
"considerationsEnvironnementales": "Sans objet",
},
{
"uid": "u2",
"considerationsSociales": "Sans objet",
"considerationsEnvironnementales": "Critère environnemental",
},
]
)
div = get_considerations_card_content(lff)
assert isinstance(div, html.Div)
def find_progress(component, found):
if isinstance(component, dbc.Progress):
found.append(component)
children = getattr(component, "children", None)
if isinstance(children, (list, tuple)):
for c in children:
find_progress(c, found)
elif children is not None and not isinstance(children, str):
find_progress(children, found)
return found
inner_bars = [b for b in find_progress(div, []) if getattr(b, "bar", False)]
assert len(inner_bars) == 3
bar_ren, bar_social, bar_env = inner_bars
# Bar 1 : champs renseignés (2/2 = 100%, gris)
assert bar_ren.value == 100
assert bar_ren.color == "#6c757d"
# Bar 2 : sociales parmi renseignés (u1 != "Sans objet" -> 1/2 = 50%, rose)
assert bar_social.value == 50
assert bar_social.color == "#CC6677"
# Bar 3 : env parmi renseignés (u2 != "Sans objet" -> 1/2 = 50%, vert)
assert bar_env.value == 50
assert bar_env.color == "#117733"
+22 -44
View File
@@ -215,47 +215,6 @@ def test_008_search_to_observatoire(dash_duo: DashComposite):
)
def test_010_observatoire_montant_filter():
import datetime
from src.utils.data import prepare_dashboard_data
data = pl.DataFrame(
{
"uid": ["1", "2", "3"],
"montant": [100.0, 500.0, 1000.0],
"dateNotification": [datetime.date(2025, 1, 1)] * 3,
}
)
def apply(min_val=None, max_val=None):
return prepare_dashboard_data(
data.lazy(),
dashboard_year="2025",
dashboard_acheteur_id=None,
dashboard_acheteur_categorie=None,
dashboard_acheteur_departement_code=None,
dashboard_titulaire_id=None,
dashboard_titulaire_categorie=None,
dashboard_titulaire_departement_code=None,
dashboard_marche_type=None,
dashboard_marche_objet=None,
dashboard_marche_code_cpv=None,
dashboard_marche_considerations_sociales=None,
dashboard_marche_considerations_environnementales=None,
dashboard_marche_techniques=None,
dashboard_marche_innovant=None,
dashboard_marche_sous_traitance_declaree=None,
dashboard_montant_min=min_val,
dashboard_montant_max=max_val,
).collect()
assert apply().height == 3
assert apply(min_val=400).height == 2 # 500, 1000
assert apply(max_val=500).height == 2 # 100, 500
assert apply(min_val=200, max_val=600).height == 1 # 500 only
def test_009_observatoire_filter_persistence(dash_duo: DashComposite):
import time
@@ -333,7 +292,7 @@ def test_011_observatoire_multi_param_url(dash_duo: DashComposite):
)
def test_get_distance_histogram_returns_graph():
def test_012_get_distance_histogram_returns_graph():
import polars as pl
from dash import dcc
@@ -344,7 +303,7 @@ def test_get_distance_histogram_returns_graph():
assert isinstance(result, dcc.Graph)
def test_get_distance_histogram_handles_nulls():
def test_013_get_distance_histogram_handles_nulls():
import polars as pl
from dash import dcc
@@ -355,7 +314,7 @@ def test_get_distance_histogram_handles_nulls():
assert isinstance(result, dcc.Graph)
def test_get_distance_histogram_all_nulls():
def test_014_get_distance_histogram_all_nulls():
import polars as pl
from dash import dcc
@@ -363,4 +322,23 @@ def test_get_distance_histogram_all_nulls():
lff = pl.LazyFrame({"titulaire_distance": pl.Series([], dtype=pl.Int64)})
result = get_distance_histogram(lff)
assert isinstance(result, dcc.Graph)
def test_015_tableau_filter_date(dash_duo: DashComposite):
from src.app import app
dash_duo.start_server(app)
dash_duo.wait_for_text_to_equal(".logo > h1", "decp.info", timeout=4)
for page in ["tableau", "acheteurs/123", "titulaires/345"]:
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
filter_input = '.marches_table th[data-dash-column="dateNotification"] input'
filter_cell_result = '.marches_table td[data-dash-column="dateNotification"] p'
dash_duo.wait_for_element(filter_input, timeout=2)
_filter_input: WebElement = dash_duo.find_element(filter_input)
_filter_input.send_keys("3333") # a dateNotification that doesn't exist
_filter_input.send_keys(Keys.ENTER)
_filter_result: list[WebElement] = dash_duo.find_elements(filter_cell_result)
assert len(_filter_result) == 0, f"Page : {page}"
+61
View File
@@ -0,0 +1,61 @@
import os
def test_update_timestamp_falls_back_to_db_mtime(tmp_path, monkeypatch):
import src.utils as u
from src.utils import get_data_update_timestamp
def boom(*a, **k):
raise RuntimeError("net down")
monkeypatch.setattr(u, "get_last_modified", boom)
fb = tmp_path / "decp.duckdb"
fb.write_bytes(b"x")
assert get_data_update_timestamp("http://x", str(fb)) == os.path.getmtime(str(fb))
def test_update_timestamp_none_when_all_fail(monkeypatch):
import src.utils as u
from src.utils import get_data_update_timestamp
def boom(*a, **k):
raise RuntimeError("net down")
monkeypatch.setattr(u, "get_last_modified", boom)
assert get_data_update_timestamp("http://x", None) is None
def test_update_timestamp_nominal(monkeypatch):
import src.utils as u
from src.utils import get_data_update_timestamp
monkeypatch.setattr(u, "get_last_modified", lambda p: 123.0)
assert get_data_update_timestamp("http://x", None) == 123.0
def test_sources_tables_none_path():
from src.figures import get_sources_tables
div = get_sources_tables(None)
assert "indisponible" in str(div.children).lower()
def test_sources_tables_missing_file():
from src.figures import get_sources_tables
div = get_sources_tables("/does/not/exist.csv")
assert "indisponible" in str(div.children).lower()
def test_sources_tables_valid_csv(tmp_path):
from dash import dash_table
from src.figures import get_sources_tables
csv = tmp_path / "s.csv"
csv.write_text(
"nom,organisation,nb_marchés,nb_acheteurs,code,url,unique\n"
"Source A,Org A,5,2,XA,http://a,1\n"
)
div = get_sources_tables(str(csv))
assert isinstance(div.children, dash_table.DataTable)
+77
View File
@@ -0,0 +1,77 @@
import json
import httpx
import pytest
from src.utils import data as data_mod
VALID = {"fields": [{"name": "uid", "title": "UID"}, {"name": "objet"}]}
class FakeResp:
def __init__(self, payload, ok=True, bad_json=False):
self._payload = payload
self._ok = ok
self._bad_json = bad_json
def raise_for_status(self):
if not self._ok:
raise httpx.HTTPError("boom")
return self
def json(self):
if self._bad_json:
raise json.JSONDecodeError("bad", "", 0)
return self._payload
def test_remote_ok_returns_schema_and_writes_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
result = data_mod.get_data_schema()
assert "uid" in result
assert json.loads(cache.read_text())["fields"][0]["name"] == "uid"
def test_remote_http_error_falls_back_to_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(None, ok=False))
assert "uid" in data_mod.get_data_schema()
def test_remote_malformed_falls_back_to_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp({"nope": 1}))
assert "uid" in data_mod.get_data_schema()
def test_no_url_uses_cache(tmp_path, monkeypatch):
cache = tmp_path / "schema.cache.json"
cache.write_text(json.dumps(VALID))
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
assert "uid" in data_mod.get_data_schema()
def test_no_source_raises(tmp_path, monkeypatch):
monkeypatch.delenv("DATA_SCHEMA_PATH", raising=False)
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(tmp_path / "missing.json"))
with pytest.raises(RuntimeError):
data_mod.get_data_schema()
def test_cache_write_failure_is_non_blocking(tmp_path, monkeypatch):
# parent inexistant => l'écriture du cache échoue, mais le schéma est renvoyé
cache = tmp_path / "nodir" / "schema.cache.json"
monkeypatch.setenv("DATA_SCHEMA_PATH", "http://x")
monkeypatch.setenv("DATA_SCHEMA_CACHE", str(cache))
monkeypatch.setattr(data_mod, "get", lambda *a, **k: FakeResp(VALID))
assert "uid" in data_mod.get_data_schema()
+120 -82
View File
@@ -44,6 +44,33 @@ def test_filter_table_data_does_not_call_track_search(monkeypatch, sample_lff):
assert result.height == 1
def test_filter_table_data_accent_insensitive():
"""Chercher sans accent doit trouver des valeurs accentuées, et vice versa."""
from src.utils.table import filter_table_data
lff = pl.LazyFrame(
[
{"uid": "1", "acheteur_nom": "Mairie de Nîmes", "objet": "Voirie"},
{"uid": "2", "acheteur_nom": "Commune de Reims", "objet": "Éclairage"},
{"uid": "3", "acheteur_nom": "Ville de Paris", "objet": "Travaux"},
]
)
# Sans accent → trouve la valeur accentuée
result = filter_table_data(lff, "{acheteur_nom} icontains Nimes").collect()
assert result.height == 1
assert result["uid"][0] == "1"
# Avec accent → trouve la valeur accentuée
result = filter_table_data(lff, "{acheteur_nom} icontains Nîmes").collect()
assert result.height == 1
# Sans accent → trouve la valeur avec accent initial (É)
result = filter_table_data(lff, "{objet} icontains eclairage").collect()
assert result.height == 1
assert result["uid"][0] == "2"
def test_normalize_sort_by_handles_empty():
from src.utils.table import normalize_sort_by
@@ -106,60 +133,9 @@ def reset_cache(flask_app):
yield
def test_load_filter_sort_postprocess_returns_dataframe(
flask_app, monkeypatch, sample_lff
):
def test_prepare_table_data_returns_expected_tuple(flask_app):
from src.utils import table
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
with flask_app.app_context():
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
assert isinstance(df, pl.DataFrame)
assert df.height == 1
# All values must be strings after post-processing
for col in df.columns:
assert df.schema[col] == pl.String
def test_load_filter_sort_postprocess_applies_filter(
flask_app, monkeypatch, sample_lff
):
from src.utils import table
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
with flask_app.app_context():
df = table._load_filter_sort_postprocess(
filter_query="{objet} icontains travaux", sort_by_key=()
)
assert df.height == 1
df_empty = table._load_filter_sort_postprocess(
filter_query="{objet} icontains nonexistent", sort_by_key=()
)
assert df_empty.height == 0
def test_load_filter_sort_postprocess_adds_links(flask_app, monkeypatch, sample_lff):
from src.utils import table
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
with flask_app.app_context():
df = table._load_filter_sort_postprocess(filter_query=None, sort_by_key=())
# add_links injects an <a href> wrapper around uid, acheteur_nom, titulaire_nom
assert "<a href" in df["uid"][0]
assert "<a href" in df["acheteur_nom"][0]
assert "<a href" in df["titulaire_nom"][0]
def test_prepare_table_data_returns_expected_tuple(monkeypatch, flask_app, sample_lff):
from src.utils import table
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
with flask_app.app_context():
result = table.prepare_table_data(
data=None,
@@ -178,16 +154,13 @@ def test_prepare_table_data_returns_expected_tuple(monkeypatch, flask_app, sampl
)
assert isinstance(dicts, list)
assert ts == 6 # data_timestamp + 1 must still increment
assert "1 lignes" in nb_rows
assert "lignes" in nb_rows
def test_prepare_table_data_calls_track_search_on_filter(
monkeypatch, flask_app, sample_lff
):
def test_prepare_table_data_calls_track_search_on_filter(monkeypatch, flask_app):
from src.utils import table
calls = []
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
monkeypatch.setattr(table, "track_search", lambda *a, **kw: calls.append(a))
with flask_app.app_context():
@@ -204,24 +177,33 @@ def test_prepare_table_data_calls_track_search_on_filter(
assert calls == [("{objet} icontains travaux", "tableau")]
def test_prepare_table_data_paginates_without_recomputing(
monkeypatch, flask_app, sample_lff
):
"""Two calls with same filter+sort but different pages must invoke
the inner heavy work only once."""
def test_prepare_table_data_same_page_uses_cache(monkeypatch, flask_app):
"""Two calls with exactly the same (filter, sort, page, size)
must call _fetch_page_sql at least once."""
from src.utils import table
call_count = {"n": 0}
real_query = sample_lff.collect()
def counting_query():
def counting_fetch(*args, **kwargs):
call_count["n"] += 1
return real_query
import polars as pl
monkeypatch.setattr(table, "query_marches", counting_query)
return (
pl.DataFrame(
{
"uid": [],
"acheteur_id": [],
"titulaire_id": [],
"titulaire_typeIdentifiant": [],
}
),
0,
0,
)
monkeypatch.setattr(table, "_fetch_page_sql", counting_fetch)
with flask_app.app_context():
# First call: cache miss
table.prepare_table_data(
data=None,
data_timestamp=0,
@@ -231,34 +213,24 @@ def test_prepare_table_data_paginates_without_recomputing(
sort_by=[],
source_table="tableau",
)
first_count = call_count["n"]
# Second call, different page: cache hit, query_marches must NOT fire again
table.prepare_table_data(
data=None,
data_timestamp=0,
filter_query=None,
page_current=1,
page_current=0,
page_size=10,
sort_by=[],
source_table="tableau",
)
assert call_count["n"] == first_count, (
"query_marches was called again — pagination triggered cache miss"
)
assert call_count["n"] >= 1
def test_prepare_table_data_cleanup_trigger_for_non_tableau(
monkeypatch, flask_app, sample_lff
):
def test_prepare_table_data_cleanup_trigger_for_non_tableau(flask_app):
"""Non-tableau pages still get a fresh uuid trigger, not no_update."""
from dash import no_update
from src.utils import table
monkeypatch.setattr(table, "query_marches", lambda: sample_lff.collect())
with flask_app.app_context():
result = table.prepare_table_data(
data=None,
@@ -289,11 +261,11 @@ def test_prepare_table_data_with_external_data_does_not_use_cache(
sentinel["called"] = True
raise AssertionError("Memoized helper must not be called when data is provided")
monkeypatch.setattr(table, "_load_filter_sort_postprocess", should_not_be_called)
monkeypatch.setattr(table, "_fetch_page_sql", should_not_be_called)
with flask_app.app_context():
table.prepare_table_data(
data=sample_lff, # external LazyFrame
data=sample_lff,
data_timestamp=0,
filter_query=None,
page_current=0,
@@ -303,3 +275,69 @@ def test_prepare_table_data_with_external_data_does_not_use_cache(
)
assert sentinel["called"] is False
def test_fetch_page_sql_respects_pagination(flask_app):
"""New path: returns (page_dff, total_count, total_unique) via DuckDB."""
from src.utils import table
with flask_app.app_context():
page, total, total_unique = table._fetch_page_sql(
filter_query=None, sort_by_key=(), page_current=0, page_size=5
)
assert page.height <= 5
assert total >= page.height
assert isinstance(total_unique, int)
def test_fetch_page_sql_applies_filter(flask_app):
from src.utils import table
with flask_app.app_context():
page, total, total_unique = table._fetch_page_sql(
filter_query="{uid} icontains __ne_matche_rien__",
sort_by_key=(),
page_current=0,
page_size=20,
)
assert total == 0
assert page.height == 0
def test_fetch_page_sql_post_processes_links(flask_app):
from src.utils import table
with flask_app.app_context():
page, _, _ = table._fetch_page_sql(
filter_query=None, sort_by_key=(), page_current=0, page_size=1
)
if page.height > 0:
assert "<a href" in page["uid"][0]
def test_postprocess_page_adds_marche_column():
"""postprocess_page doit créer une colonne 'marche' en première position,
un lien 🔍 vers /marches/{uid}, tout en conservant la colonne uid."""
from src.utils.table import postprocess_page
dff = pl.DataFrame({"uid": ["abc"], "objet": ["Travaux divers"]})
result = postprocess_page(dff)
# 'marche' existe et est la première colonne
assert result.columns[0] == "marche"
# Lien loupe vers la fiche du marché
assert "/marches/abc" in result["marche"][0]
assert "🔍" in result["marche"][0]
# uid est conservée et reste un lien affichant sa propre valeur
assert "uid" in result.columns
assert "abc" in result["uid"][0]
def test_postprocess_page_without_uid_has_no_marche():
"""Sans colonne uid, aucune colonne 'marche' n'est ajoutée."""
from src.utils.table import postprocess_page
dff = pl.DataFrame({"objet": ["Travaux divers"]})
result = postprocess_page(dff)
assert "marche" not in result.columns
+140
View File
@@ -0,0 +1,140 @@
import polars as pl
SCHEMA = pl.Schema(
{
"uid": pl.String,
"objet": pl.String,
"acheteur_id": pl.String,
"montant": pl.Float64,
"dureeMois": pl.Int64,
"dateNotification": pl.Date,
}
)
def test_empty_filter_returns_true():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("", SCHEMA)
assert where == "TRUE"
assert params == []
def test_icontains_string_is_case_insensitive_like():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{objet} icontains travaux", SCHEMA)
assert where == '"objet" IS NOT NULL AND "objet" <> \'\' AND "objet" ILIKE ?'
assert params == ["%travaux%"]
def test_icontains_with_trailing_wildcard_is_starts_with():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql(
"{acheteur_id} icontains 24350013900189*", SCHEMA
)
assert (
where
== '"acheteur_id" IS NOT NULL AND "acheteur_id" <> \'\' AND "acheteur_id" ILIKE ?'
)
assert params == ["24350013900189%"]
def test_icontains_with_leading_wildcard_is_ends_with():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{uid} icontains *2024", SCHEMA)
assert where == '"uid" IS NOT NULL AND "uid" <> \'\' AND "uid" ILIKE ?'
assert params == ["%2024"]
def test_numeric_greater_than():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{montant} i> 40000", SCHEMA)
assert where == '"montant" IS NOT NULL AND "montant" > ?'
assert params == [40000.0]
def test_numeric_less_than():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{montant} i< 1000", SCHEMA)
assert where == '"montant" IS NOT NULL AND "montant" < ?'
assert params == [1000.0]
def test_numeric_equality_via_icontains():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{dureeMois} icontains 12", SCHEMA)
assert where == '"dureeMois" IS NOT NULL AND "dureeMois" = ?'
assert params == [12]
def test_date_column_treated_as_string_ilike():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{dateNotification} icontains 2024*", SCHEMA)
assert "ILIKE" in where
assert params == ["2024%"]
def test_multiple_filters_joined_by_and():
from src.utils.table_sql import filter_query_to_sql
filter_query = "{objet} icontains voirie && {montant} i> 40000"
where, params = filter_query_to_sql(filter_query, SCHEMA)
assert " AND " in where
assert params == ["%voirie%", 40000.0]
def test_invalid_numeric_value_is_skipped():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{montant} i> notanumber", SCHEMA)
assert where == "TRUE"
assert params == []
def test_unknown_column_is_skipped():
from src.utils.table_sql import filter_query_to_sql
where, params = filter_query_to_sql("{inexistant} icontains foo", SCHEMA)
assert where == "TRUE"
assert params == []
def test_sort_by_empty():
from src.utils.table_sql import sort_by_to_sql
assert sort_by_to_sql([], SCHEMA) == ""
assert sort_by_to_sql(None, SCHEMA) == ""
def test_sort_by_single_column_desc():
from src.utils.table_sql import sort_by_to_sql
result = sort_by_to_sql([{"column_id": "montant", "direction": "desc"}], SCHEMA)
assert result == '"montant" DESC NULLS LAST'
def test_sort_by_multiple_columns_preserves_order():
from src.utils.table_sql import sort_by_to_sql
result = sort_by_to_sql(
[
{"column_id": "dateNotification", "direction": "desc"},
{"column_id": "montant", "direction": "asc"},
],
SCHEMA,
)
assert result == '"dateNotification" DESC NULLS LAST, "montant" ASC NULLS LAST'
def test_sort_by_ignores_unknown_column():
from src.utils.table_sql import sort_by_to_sql
result = sort_by_to_sql([{"column_id": "fake", "direction": "asc"}], SCHEMA)
assert result == ""
+18
View File
@@ -0,0 +1,18 @@
from dash.testing.composite import DashComposite
def test_tableau_hscroll_bar_present(dash_duo: DashComposite):
"""La barre de défilement est injectée et le conteneur scroll horizontalement."""
from src.app import app
dash_duo.start_server(app)
dash_duo.wait_for_page(f"{dash_duo.server_url}/tableau")
dash_duo.wait_for_element(".marches_table", timeout=20)
# Barre injectée par table_hscroll.js
dash_duo.wait_for_element(".marches_table .dt-hscroll", timeout=10)
dash_duo.wait_for_element(".marches_table .dt-hscroll-thumb", timeout=5)
# Le conteneur Dash doit avoir overflow-x:hidden (scroll contenu, pas de scrollbar page)
container = dash_duo.find_element(".marches_table .dash-spreadsheet-container")
overflow = container.value_of_css_property("overflow-x")
assert overflow == "hidden"