Merge branch 'dev' into feature/73_compte_utilisateur
This commit is contained in:
@@ -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()
|
||||
@@ -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}"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 == ""
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user