Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0db800fdab | |||
| 4619dd2708 | |||
| 367e5f64ff | |||
| adda58bada | |||
| 2b0b048520 | |||
| 5c43bfea78 | |||
| 83119b86e9 | |||
| af89bb0630 | |||
| eb5be1972e | |||
| b303a8bea6 | |||
| 472fbb7cbb | |||
| 6891aabd4d | |||
| 3beedbcfe4 | |||
| 37e893d642 | |||
| 64fc40e5aa | |||
| cb885eeb90 | |||
| 61dbf237cf | |||
| 39af4bafbe | |||
| f480439985 | |||
| ad9b4f0fdf | |||
| 0532f6b0a7 | |||
| 6fdf01e5e1 | |||
| 15de2aaf17 | |||
| 55e2992559 | |||
| f16cca036d | |||
| deba0a244e | |||
| ef11184286 | |||
| edd52d471e | |||
| 7b3ea9c580 | |||
| bfc4065cbb | |||
| 41a832d7be | |||
| 1561cb3c9a | |||
| f1725a95f8 | |||
| b59afd1cbc | |||
| 75b42f4baf | |||
| 8f5d38ff78 | |||
| bd149ff11f | |||
| cd81aa9532 | |||
| 2a381f6dbb | |||
| e609bc3e32 | |||
| c3ebdb0057 | |||
| 55bb92468e | |||
| 965aef06f9 | |||
| 3ce37784dc | |||
| cee51d929c | |||
| b11aa20743 | |||
| 674639cf6e | |||
| 68a87985c7 | |||
| 776aa271f1 | |||
| 427eb111bf | |||
| fe7277ce26 | |||
| b68fa3872f | |||
| b7ff04c69c | |||
| ccee27baee | |||
| 47e17c48e7 | |||
| e3b57608f4 | |||
| f4f0d70864 | |||
| 10c69013f0 | |||
| c08e2fa143 | |||
| afd812c50d | |||
| 536d37d67c | |||
| f43bb14e23 | |||
| 5422f95ea7 | |||
| 29f6e5c2c2 | |||
| c311ed5f23 | |||
| 31d0a626e6 | |||
| 307e3379e0 | |||
| 1fb18bcf6f | |||
| 30e822d435 | |||
| a44aaf6df6 | |||
| 8c11a018a0 | |||
| a0d6222424 | |||
| 25115c305c | |||
| 9d9533b8a2 | |||
| e4f06acc56 | |||
| e0dc2687c4 | |||
| 274e06cde4 | |||
| f9aadede8b | |||
| d35862beb0 | |||
| ed6811266d | |||
| 06f658ccf1 | |||
| e4c6659205 | |||
| 434a659778 | |||
| e573fcccae | |||
| 84739df7cb | |||
| 1d241ca9b7 | |||
| 0264aae3b9 | |||
| fb5c37b34b | |||
| 639b342178 | |||
| e8534c8108 | |||
| f5d8026061 | |||
| 1eecc45e29 | |||
| 07bdcf232d | |||
| 6e992e0a6e | |||
| a76a14b030 | |||
| b3bd488882 | |||
| a385e999d3 | |||
| a4bda0483f | |||
| 9a1e5bee63 | |||
| 1c4fccac6e | |||
| 187feee544 | |||
| 965f24ab92 | |||
| 3aee2667d0 | |||
| 3d96a5e1b8 | |||
| 35928d7245 | |||
| ac42dde488 | |||
| ae366bf014 | |||
| bd64c397dc | |||
| 937e8be356 | |||
| e880eaff12 | |||
| 07a97293cc | |||
| ef97cea679 | |||
| c9fc2a01fc | |||
| 8002bd711a | |||
| 1ba78fe8df | |||
| f1e24794e6 | |||
| 9110b78f2a | |||
| 2ce6005c88 | |||
| 4234736656 | |||
| 39faeca4d3 | |||
| 82bd82944a | |||
| 92f30a963f | |||
| 267e2e15f8 | |||
| dd52bd5158 | |||
| d499906bbe | |||
| 2fc501efbb | |||
| 945f7974c8 | |||
| 1044379425 | |||
| 421eaa0772 | |||
| 3776b18ff5 | |||
| 6005b2fcb5 | |||
| fc99723b2e | |||
| c3e801a15a | |||
| 38ea8834af | |||
| 5bcd17e8b0 | |||
| ec28e89788 | |||
| 338311fc62 | |||
| 86b445a007 | |||
| 16a914a98a | |||
| 0bc82fae1e | |||
| 3c68317f9d | |||
| d8dcb04fbf |
@@ -1,3 +1,43 @@
|
|||||||
|
##### 2.6.2 (22 février 2026)
|
||||||
|
|
||||||
|
- Correction du téléchargemnent buggé dans /tableau
|
||||||
|
|
||||||
|
##### 2.6.1 (17 février 2026)
|
||||||
|
|
||||||
|
- Corrections la création des liens canoniques (SEO)
|
||||||
|
|
||||||
|
#### 2.6.0 (5 février 2026)
|
||||||
|
|
||||||
|
- Suite de la refonte graphique
|
||||||
|
- Persistence des filtres, des tris et des choix de colonnes sur toutes les pages
|
||||||
|
- Joli tableau pour choisir les colonnes à afficher
|
||||||
|
- Meilleure gestion des acheteurs et titulaires absents de la base SIRENE
|
||||||
|
- Amélioration du SEO (liens canoniques)
|
||||||
|
|
||||||
|
##### 2.5.1 (29 janvier 2026)
|
||||||
|
|
||||||
|
- Mise en production un peu hâtive ([#67](https://github.com/ColinMaudry/decp.info/issues/67), [#68](https://github.com/ColinMaudry/decp.info/issues/68))
|
||||||
|
|
||||||
|
#### 2.5.0 (29 janvier 2026)
|
||||||
|
|
||||||
|
- Refonte graphique et amélioration des textes d'aide
|
||||||
|
- Amélioration du filtrage du tableau à partir d'une URL
|
||||||
|
- Renforcement du SEO avec une arborescence permettant l'accès aux marchés et des snippets JSON-LD
|
||||||
|
- Suppression de la dépendance à Google Fonts grâce à [Bunny Fonts](https://fonts.bunny.net) 🇪🇺 🇸🇮
|
||||||
|
|
||||||
|
##### 2.4.1 (22 janvier 2026)
|
||||||
|
|
||||||
|
- Meilleure gestion des colonnes absentes du schéma
|
||||||
|
|
||||||
|
#### 2.4.0 (22 janvier 2026)
|
||||||
|
|
||||||
|
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
|
||||||
|
- Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source)
|
||||||
|
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50))
|
||||||
|
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
|
||||||
|
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36))
|
||||||
|
- Désactivation du bloquage des robot d'agents de LLM (robots.txt)
|
||||||
|
|
||||||
##### 2.3.1 (16 janvier 2026)
|
##### 2.3.1 (16 janvier 2026)
|
||||||
|
|
||||||
- Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs
|
- Les champs absents du [schéma](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire?resource_id=9a4144c0-ee44-4dec-bee5-bbef38191d9a) sont ignorés pour éviter les erreurs
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# decp.info
|
# decp.info
|
||||||
|
|
||||||
> v2.3.1
|
> v2.6.2
|
||||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||||
|
|
||||||
=> [decp.info](https://decp.info)
|
=> [decp.info](https://decp.info)
|
||||||
|
|||||||
+21
-2
@@ -1,13 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "decp.info"
|
name = "decp.info"
|
||||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||||
version = "2.3.1"
|
version = "2.6.2"
|
||||||
requires-python = ">= 3.10"
|
requires-python = ">= 3.10"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Colin Maudry", email = "colin@colmo.tech" }
|
{ name = "Colin Maudry", email = "colin@colmo.tech" }
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dash==3.3.0",
|
"dash==3.4.0",
|
||||||
"dash[compress]",
|
"dash[compress]",
|
||||||
"polars",
|
"polars",
|
||||||
"gunicorn",
|
"gunicorn",
|
||||||
@@ -22,5 +22,24 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-env",
|
||||||
"pre-commit",
|
"pre-commit",
|
||||||
|
"selenium",
|
||||||
|
"webdriver-manager",
|
||||||
|
"dash[testing]",
|
||||||
|
"fastexcel"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = [
|
||||||
|
"src"
|
||||||
|
]
|
||||||
|
testpaths = [
|
||||||
|
"tests"
|
||||||
|
]
|
||||||
|
env = [
|
||||||
|
"DATA_FILE_PARQUET_PATH=tests/test.parquet",
|
||||||
|
"DEVELOPMENT=true"
|
||||||
|
]
|
||||||
|
addopts = "-p no:warnings"
|
||||||
|
|||||||
+121
-36
@@ -3,18 +3,36 @@ import os
|
|||||||
|
|
||||||
import dash_bootstrap_components as dbc
|
import dash_bootstrap_components as dbc
|
||||||
import tomllib
|
import tomllib
|
||||||
from dash import Dash, dcc, html, page_container, page_registry
|
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from flask import send_from_directory
|
from flask import Response
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
app = Dash(
|
# if os.getenv("PYTEST_CURRENT_TEST"):
|
||||||
external_stylesheets=[dbc.themes.SIMPLEX],
|
# os.environ["DATA_FILE_PARQUET_PATH"]
|
||||||
|
|
||||||
|
|
||||||
|
development = os.getenv("DEVELOPMENT").lower() == "true"
|
||||||
|
|
||||||
|
meta_tags = [
|
||||||
|
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
|
||||||
|
{
|
||||||
|
"name": "keywords",
|
||||||
|
"content": "commande publique, decp, marchés publics, données essentielles",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if development:
|
||||||
|
meta_tags.append({"name": "robots", "content": "noindex"})
|
||||||
|
|
||||||
|
app: Dash = Dash(
|
||||||
title="decp.info",
|
title="decp.info",
|
||||||
use_pages=True,
|
use_pages=True,
|
||||||
compress=True,
|
compress=True,
|
||||||
|
meta_tags=meta_tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
# COSMO (belle font, blue),
|
# COSMO (belle font, blue),
|
||||||
# UNITED (rouge, ubuntu font),
|
# UNITED (rouge, ubuntu font),
|
||||||
# LUMEN (gros séparateur, blue clair),
|
# LUMEN (gros séparateur, blue clair),
|
||||||
@@ -24,7 +42,29 @@ app = Dash(
|
|||||||
# robots.txt
|
# robots.txt
|
||||||
@app.server.route("/robots.txt")
|
@app.server.route("/robots.txt")
|
||||||
def robots():
|
def robots():
|
||||||
return send_from_directory("./assets", "robots.txt", mimetype="text/plain")
|
text = """User-agent: *
|
||||||
|
Allow: /
|
||||||
|
"""
|
||||||
|
return Response(text, mimetype="text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
@app.server.route("/sitemap.xml")
|
||||||
|
def sitemap():
|
||||||
|
base_url = "https://decp.info"
|
||||||
|
pages = [
|
||||||
|
"/",
|
||||||
|
"/statistiques",
|
||||||
|
"/tableau",
|
||||||
|
"/a-propos",
|
||||||
|
]
|
||||||
|
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||||
|
for page in pages:
|
||||||
|
xml += " <url>\n"
|
||||||
|
xml += f" <loc>{base_url}{page}</loc>\n"
|
||||||
|
xml += " </url>\n"
|
||||||
|
xml += "</urlset>"
|
||||||
|
return Response(xml, mimetype="text/xml")
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("decp.info")
|
logger = logging.getLogger("decp.info")
|
||||||
@@ -47,6 +87,7 @@ app.index_string = """
|
|||||||
<title>{%title%}</title>
|
<title>{%title%}</title>
|
||||||
{%favicon%}
|
{%favicon%}
|
||||||
{%css%}
|
{%css%}
|
||||||
|
<!-- canonical link -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{%app_entry%}
|
{%app_entry%}
|
||||||
@@ -73,43 +114,87 @@ app.index_string = """
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
app.layout = html.Div(
|
navbar = dbc.Navbar(
|
||||||
[
|
dbc.Container(
|
||||||
html.Div(
|
fluid=True,
|
||||||
[
|
children=[
|
||||||
html.Div(
|
dbc.NavItem(
|
||||||
[
|
children=[
|
||||||
html.A(children=html.H1("decp.info"), href="/"),
|
html.Div(
|
||||||
html.P(
|
[
|
||||||
children=html.A(
|
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
|
||||||
version,
|
html.P(
|
||||||
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
|
[
|
||||||
target="_blank",
|
html.A(
|
||||||
|
version,
|
||||||
|
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
className="version",
|
||||||
),
|
),
|
||||||
className="version",
|
],
|
||||||
),
|
className="logo-wrapper",
|
||||||
],
|
)
|
||||||
className="logo",
|
],
|
||||||
),
|
style={"minWidth": "230px"},
|
||||||
html.Div(
|
),
|
||||||
id="announcements",
|
dbc.Nav(
|
||||||
children=dcc.Markdown(os.getenv("ANNOUNCEMENTS")),
|
children=[dcc.Markdown(os.getenv("ANNOUNCEMENTS"), id="announcements")],
|
||||||
),
|
style={
|
||||||
html.Div(
|
"maxWidth": "1200px",
|
||||||
|
"display": "inline-block",
|
||||||
|
},
|
||||||
|
navbar=True,
|
||||||
|
id="announcements-nav",
|
||||||
|
),
|
||||||
|
dbc.NavbarToggler(id="navbar-toggler"),
|
||||||
|
dbc.Collapse(
|
||||||
|
dbc.Nav(
|
||||||
[
|
[
|
||||||
dcc.Link(
|
dbc.NavItem(
|
||||||
page["name"], href=page["relative_path"], className="nav"
|
dbc.NavLink(
|
||||||
|
page["name"].replace(" ", " "),
|
||||||
|
href=page["relative_path"],
|
||||||
|
active="exact",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for page in page_registry.values()
|
for page in page_registry.values()
|
||||||
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
|
if page["name"]
|
||||||
]
|
in ["Recherche", "À propos", "Tableau", "Statistiques"]
|
||||||
|
],
|
||||||
|
className="ms-auto",
|
||||||
|
navbar=True,
|
||||||
),
|
),
|
||||||
],
|
id="navbar-collapse",
|
||||||
className="navbar",
|
navbar=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
color="light",
|
||||||
|
dark=False,
|
||||||
|
className="mb-4",
|
||||||
|
expand="lg",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.layout = html.Div(
|
||||||
|
[
|
||||||
|
navbar,
|
||||||
|
dbc.Container(
|
||||||
|
page_container,
|
||||||
|
fluid=True,
|
||||||
|
id="page-content-container",
|
||||||
|
className="mb-4",
|
||||||
),
|
),
|
||||||
page_container,
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app.run(debug=True)
|
@app.callback(
|
||||||
|
Output("navbar-collapse", "is_open"),
|
||||||
|
[Input("navbar-toggler", "n_clicks")],
|
||||||
|
[State("navbar-collapse", "is_open")],
|
||||||
|
)
|
||||||
|
def toggle_navbar_collapse(n, is_open):
|
||||||
|
if n:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|||||||
Vendored
+11859
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
|||||||
|
@import url(https://fonts.bunny.net/css?family=fira-code:400|inter:400,600);
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Variables
|
||||||
|
========================================================================== */
|
||||||
|
:root {
|
||||||
|
--bs-font-monospace: "Fira Code";
|
||||||
|
--primary-color: rgb(179, 56, 33);
|
||||||
|
--primary-color-text: #b33821;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Base & Reset
|
||||||
|
========================================================================== */
|
||||||
|
body {
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: rgb(255 240 240 / 40%);
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-smooth: always;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong,
|
||||||
|
b {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 36px 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Button Styles
|
||||||
|
button {
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
appearance: auto;
|
||||||
|
border: solid var(--primary-color) 1px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
button.btn.btn-primary,
|
||||||
|
button.show-hide {
|
||||||
|
display: block;
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: 0;
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
height: 30px;
|
||||||
|
padding-top: 2px;
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgb(209, 96, 73),
|
||||||
|
rgb(179, 56, 33) 26%,
|
||||||
|
rgb(159, 36, 22)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.btn.btn-primary:hover,
|
||||||
|
button.show-hide:hover {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
rgb(239, 126, 103),
|
||||||
|
rgb(209, 86, 63) 26%,
|
||||||
|
rgb(189, 66, 52)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
border-color: #ccc;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not([disabled]) {
|
||||||
|
background-color: #fee;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Link Styles */
|
||||||
|
#_pages_content a {
|
||||||
|
color: #993333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Layout
|
||||||
|
========================================================================== */
|
||||||
|
#_pages_content {
|
||||||
|
padding: 28px 24px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-gap: 10px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header > * {
|
||||||
|
margin: 0 0 20px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Components
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* --- Navigation & Header --- */
|
||||||
|
a.logo {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logo > h1 {
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.version {
|
||||||
|
margin: 0 0 0 12px;
|
||||||
|
font-family: "Fira Code";
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.version > a {
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link.active {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#announcements {
|
||||||
|
margin: 25px 40px 0 60px;
|
||||||
|
font-size: 90%;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Search Page --- */
|
||||||
|
.tagline {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 120%;
|
||||||
|
display: block;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search {
|
||||||
|
margin: 30px auto 0px auto;
|
||||||
|
width: 500px;
|
||||||
|
font-size: 16px;
|
||||||
|
height: 30px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_options {
|
||||||
|
margin: 16px auto;
|
||||||
|
width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search_options input {
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results_acheteur {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results_titulaire {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tables (Dash & Custom) --- */
|
||||||
|
|
||||||
|
/* Table Menu (Exports etc) */
|
||||||
|
.table-menu {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 12px 0 12px 0;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-menu > * {
|
||||||
|
margin: 8px 16px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#source_table {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#source_table p {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dash Table Overrides */
|
||||||
|
.column-header--sort {
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dash-table-container dash-spreadsheet-menu table.cell-table {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-lef: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.cell-table,
|
||||||
|
table.cell-table tr {
|
||||||
|
border-color: #fff;
|
||||||
|
padding: 0;
|
||||||
|
border-collapse: separate !important;
|
||||||
|
/* Required for border-radius */
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.cell-table th {
|
||||||
|
border-collapse: separate !important;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
th.dash-header,
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
th.dash-select-header {
|
||||||
|
margin: 0;
|
||||||
|
color: white;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 12px 4px 2px;
|
||||||
|
border: 1px solid rgb(179, 56, 33) !important;
|
||||||
|
background-color: rgb(179, 56, 33);
|
||||||
|
border-bottom: none !important;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
table.cell-table
|
||||||
|
tr:first-of-type
|
||||||
|
th.dash-header:first-of-type,
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
table.cell-table
|
||||||
|
tr:first-of-type
|
||||||
|
th.dash-select-header:first-of-type {
|
||||||
|
border-top-left-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
table.cell-table
|
||||||
|
tr:first-of-type
|
||||||
|
th.dash-header:last-of-type {
|
||||||
|
border-top-right-radius: 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dash Filters */
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
.dash-filter
|
||||||
|
input[type="text"] {
|
||||||
|
border-color: #ccc;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 28px;
|
||||||
|
font-family: "Fira Code";
|
||||||
|
caret-color: #000;
|
||||||
|
background-color: rgb(250 250 250);
|
||||||
|
text-align: left !important;
|
||||||
|
padding: 1px 2px 0 2px;
|
||||||
|
vertical-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
.dash-filter
|
||||||
|
input[type="text"]::placeholder {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-filter--case {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
th.dash-filter {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Marches Table */
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
.cell-table
|
||||||
|
td {
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-table-container
|
||||||
|
.dash-spreadsheet-container
|
||||||
|
.dash-spreadsheet-inner
|
||||||
|
td
|
||||||
|
div.dash-cell-value.cell-markdown {
|
||||||
|
font-family: "Inter", sans-serif !important;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table.stuck {
|
||||||
|
position: relative;
|
||||||
|
right: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marches_table .cell-table tr:nth-child(even) td {
|
||||||
|
background-color: rgb(255 240 240 / 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column Visibility Menu */
|
||||||
|
.column-actions {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header--hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.show-hide {
|
||||||
|
position: relative;
|
||||||
|
width: 180px;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
.show-hide::before {
|
||||||
|
background: inherit;
|
||||||
|
content: "Colonnes affichées";
|
||||||
|
position: absolute;
|
||||||
|
left: 5px;
|
||||||
|
right: 5px;
|
||||||
|
|
||||||
|
|
||||||
|
#column_list .show-hide,
|
||||||
|
#table .show-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-hide-menu-item > input {
|
||||||
|
margin-right: 10px;
|
||||||
|
} */
|
||||||
|
|
||||||
|
#btn-copy-url:before {
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdowns */
|
||||||
|
.Select-placeholder {
|
||||||
|
color: #333 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes */
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
height: 17px;
|
||||||
|
width: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltips */
|
||||||
|
.dash-tooltip,
|
||||||
|
.dash-table-tooltip {
|
||||||
|
color: #333;
|
||||||
|
width: 400px !important;
|
||||||
|
max-width: 400px !important;
|
||||||
|
height: 150px !important;
|
||||||
|
max-height: 150px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-tooltip pre,
|
||||||
|
.dash-tooltip code {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 150px;
|
||||||
|
text-wrap: wrap;
|
||||||
|
font-family: "Inter", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Organization Cards (Grid Items) --- */
|
||||||
|
.org_title {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_year {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_infos {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_infos > p {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_stats {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_map {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org_top {
|
||||||
|
grid-column: 1/3;
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- About Page (A Propos) --- */
|
||||||
|
.a-propos-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-propos-content {
|
||||||
|
flex: 1 1 70%;
|
||||||
|
max-width: 75%;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-propos-toc {
|
||||||
|
flex: 0 0 25%;
|
||||||
|
max-width: 25%;
|
||||||
|
/* Keeps it from growing too large */
|
||||||
|
position: sticky;
|
||||||
|
top: 40px;
|
||||||
|
/* Sticks 40px from the top of the viewport */
|
||||||
|
border-left: 2px solid #333;
|
||||||
|
/* Dark vertical line like hedgedoc */
|
||||||
|
padding-left: 15px;
|
||||||
|
margin-top: 40px;
|
||||||
|
background-color: #fff;
|
||||||
|
/* Aligns visually with the first header */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TOC Links */
|
||||||
|
.toc-link {
|
||||||
|
display: block;
|
||||||
|
color: #666;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 2px 0;
|
||||||
|
transition: color 0.2s, font-weight 0.2s;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link:hover {
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-active {
|
||||||
|
color: #000;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-level-2 {
|
||||||
|
margin-left: 15px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-header {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: block;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Misc & Utility --- */
|
||||||
|
#instructions {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details > div {
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary > h4 {
|
||||||
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Media Queries
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
/* Navigation */
|
||||||
|
#announcements-nav {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About Page */
|
||||||
|
.a-propos-content {
|
||||||
|
max-width: 100%;
|
||||||
|
padding-right: 0;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.a-propos-toc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
window.dash_clientside = Object.assign({}, window.dash_clientside, {
|
||||||
|
clientside: {
|
||||||
|
clean_filters: function (trigger) {
|
||||||
|
if (!trigger) {
|
||||||
|
return window.dash_clientside.no_update;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set value on a React text input
|
||||||
|
const setNativeValue = (element, value) => {
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
element,
|
||||||
|
"value"
|
||||||
|
).set;
|
||||||
|
const prototype = Object.getPrototypeOf(element);
|
||||||
|
const prototypeValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
prototype,
|
||||||
|
"value"
|
||||||
|
).set;
|
||||||
|
|
||||||
|
if (valueSetter && valueSetter !== prototypeValueSetter) {
|
||||||
|
prototypeValueSetter.call(element, value);
|
||||||
|
} else {
|
||||||
|
valueSetter.call(element, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanInputs = () => {
|
||||||
|
const inputs = document.querySelectorAll(
|
||||||
|
'.dash-filter input[type="text"]'
|
||||||
|
);
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
let val = input.value;
|
||||||
|
let original = val;
|
||||||
|
|
||||||
|
// Remove "icontains " prefix
|
||||||
|
if (/^icontains\s+/i.test(val)) {
|
||||||
|
val = val.replace(/^icontains\s+/i, "");
|
||||||
|
// Check for surrounding quotes (single or double) and remove them
|
||||||
|
if (
|
||||||
|
(val.startsWith('"') && val.endsWith('"')) ||
|
||||||
|
(val.startsWith("'") && val.endsWith("'"))
|
||||||
|
) {
|
||||||
|
val = val.substring(1, val.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle relational operators (i<, s>, i<=, etc.)
|
||||||
|
else if (/^[is][<>]=?/i.test(val)) {
|
||||||
|
val = val.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val !== original) {
|
||||||
|
try {
|
||||||
|
// Try setting it the React-friendly way
|
||||||
|
setNativeValue(input, val);
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to direct assignment if fancy way fails
|
||||||
|
input.value = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use MutationObserver to wait for table to appear/update
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
cleanInputs();
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = document.querySelector(".dash-table-container");
|
||||||
|
if (target) {
|
||||||
|
observer.observe(target, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["value"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
observer.disconnect();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Also try immediately just in case
|
||||||
|
cleanInputs();
|
||||||
|
} else {
|
||||||
|
// Poll briefly if container not found yet
|
||||||
|
const checkInterval = setInterval(() => {
|
||||||
|
const t = document.querySelector(".dash-table-container");
|
||||||
|
if (t) {
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
observer.observe(t, { childList: true, subtree: true });
|
||||||
|
setTimeout(() => observer.disconnect(), 5000);
|
||||||
|
cleanInputs();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Stop polling after 2s if still nothing
|
||||||
|
setTimeout(() => clearInterval(checkInterval), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.dash_clientside.no_update;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# START YOAST BLOCK
|
|
||||||
# Copié depuis https://next.ink/robots.txt
|
|
||||||
# ---------------------------
|
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# END YOAST BLOCK
|
|
||||||
|
|
||||||
User-agent: GPTBot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: ChatGPT-User
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Google-Extended
|
|
||||||
Disallow: /
|
|
||||||
User-agent: PerplexityBot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Amazonbot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: ClaudeBot
|
|
||||||
Disallow: /
|
|
||||||
User-Agent: FacebookBot
|
|
||||||
Disallow: /
|
|
||||||
User-Agent: Applebot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: anthropic-ai
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Bytespider
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Claude-Web
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Diffbot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: ImagesiftBot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Omgilibot
|
|
||||||
Disallow: /
|
|
||||||
User-agent: Omgili
|
|
||||||
Disallow: /
|
|
||||||
User-agent: YouBot
|
|
||||||
Disallow: /
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
/* Change la marge bout d'export */
|
|
||||||
.table-menu {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 12px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-menu > * {
|
|
||||||
margin: 8px;
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
#source_table p {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
#source_table {
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#instructions {
|
|
||||||
max-width: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
details > div {
|
|
||||||
padding-top: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logo et version */
|
|
||||||
|
|
||||||
div.logo {
|
|
||||||
width: 230px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.version {
|
|
||||||
float: left;
|
|
||||||
margin-top: 21px;
|
|
||||||
margin-left: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p.version > a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.logo h1 {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.logo > a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Réduire la taille du texte de la colonne Objet */
|
|
||||||
|
|
||||||
/*
|
|
||||||
td[data-dash-column="objet"],td[data-dash-column="titulaire_nom"],td[data-dash-column="acheteur_nom"], {
|
|
||||||
font-size: 85%;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
/* Couleur des en-têtes */
|
|
||||||
.dash-table-container
|
|
||||||
.dash-spreadsheet-container
|
|
||||||
.dash-spreadsheet-inner
|
|
||||||
th.dash-header {
|
|
||||||
background-color: #b33821;
|
|
||||||
color: white;
|
|
||||||
font-family: "Open Sans", sans-serif;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-table-container
|
|
||||||
.dash-spreadsheet-container
|
|
||||||
.dash-spreadsheet-inner
|
|
||||||
th.dash-filter {
|
|
||||||
background-color: #f0afa3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-table-container p {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-filter--case {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-tooltip,
|
|
||||||
.dash-table-tooltip {
|
|
||||||
color: #333;
|
|
||||||
width: 400px !important;
|
|
||||||
max-width: 400px !important;
|
|
||||||
height: 150px !important;
|
|
||||||
max-height: 150px !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dash-tooltip pre,
|
|
||||||
.dash-tooltip code {
|
|
||||||
overflow: hidden;
|
|
||||||
height: 150px;
|
|
||||||
text-wrap: wrap;
|
|
||||||
font-family: "Open Sans", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Menu de masquage des colonnes */
|
|
||||||
.column-actions {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-header--hide {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-hide {
|
|
||||||
position: relative;
|
|
||||||
width: 180px;
|
|
||||||
margin: 0 0 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-hide::before {
|
|
||||||
background: inherit;
|
|
||||||
content: "Colonnes affichées";
|
|
||||||
position: absolute;
|
|
||||||
left: 5px;
|
|
||||||
right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-hide-menu-item > input {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Alternance des couleurs pour les lignes */
|
|
||||||
.marches_table {
|
|
||||||
font-family: "Open Sans", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marches_table.stuck {
|
|
||||||
position: relative;
|
|
||||||
right: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marches_table .cell-table tr:nth-child(even) td {
|
|
||||||
background-color: #feeeee;
|
|
||||||
font-family: "Open Sans", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header > *,
|
|
||||||
.dash-spreadsheet-menu button.export {
|
|
||||||
margin: 0 0 20px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Annonces */
|
|
||||||
#announcements {
|
|
||||||
margin-top: 25px;
|
|
||||||
max-width: 30%;
|
|
||||||
font-size: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Page de recherche */
|
|
||||||
|
|
||||||
.tagline {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 120%;
|
|
||||||
display: block;
|
|
||||||
margin-top: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search {
|
|
||||||
margin: 30px auto 0px auto;
|
|
||||||
width: 500px;
|
|
||||||
font-size: 16px;
|
|
||||||
height: 30px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search_options {
|
|
||||||
margin: 16px auto;
|
|
||||||
width: 450px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search_options input {
|
|
||||||
margin-right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results_acheteur {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results_titulaire {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Menu de navigation */
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
width: 100%;
|
|
||||||
height: 100px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: solid 1px black;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.nav {
|
|
||||||
float: left;
|
|
||||||
margin-right: 40px;
|
|
||||||
font-size: 120%;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 36px 0 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary > h3 {
|
|
||||||
margin: 0;
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
#_pages_content {
|
|
||||||
padding: 28px 24px 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vue acheteur/titulaire/recherche */
|
|
||||||
.wrapper {
|
|
||||||
display: grid;
|
|
||||||
grid-gap: 10px;
|
|
||||||
margin-bottom: 50px;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org_title {
|
|
||||||
grid-column: 1 / 3;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org_year {
|
|
||||||
grid-column: 3;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org_infos {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org_infos > p {
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org_stats {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org_map {
|
|
||||||
grid-column: 3;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org_top {
|
|
||||||
grid-column: 1/3;
|
|
||||||
grid-row: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Vue marché */
|
|
||||||
|
|
||||||
.marche_infos p {
|
|
||||||
margin-top: 6px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marche_title {
|
|
||||||
grid-column: 1/3;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marche_map {
|
|
||||||
grid-column: 3;
|
|
||||||
grid-row: 2;
|
|
||||||
width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marche_infos_1 {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marche_infos_2 {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
+5
-5
@@ -11,17 +11,17 @@ def get_top_org_table(data, org_type: str):
|
|||||||
return html.Div()
|
return html.Div()
|
||||||
|
|
||||||
dff = dff.select(
|
dff = dff.select(
|
||||||
["uid", f"{org_type}_id", f"{org_type}_nom", "distance", "montant"]
|
["uid", f"{org_type}_id", f"{org_type}_nom", "titulaire_distance", "montant"]
|
||||||
)
|
|
||||||
dff_nb = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "distance").agg(
|
|
||||||
pl.len().alias("Attributions"), pl.sum("montant").alias("montant")
|
|
||||||
)
|
)
|
||||||
|
dff_nb = dff.group_by(
|
||||||
|
f"{org_type}_id", f"{org_type}_nom", "titulaire_distance"
|
||||||
|
).agg(pl.len().alias("Attributions"), pl.sum("montant").alias("montant"))
|
||||||
dff_nb = dff_nb.sort(by="montant", descending=True, nulls_last=True)
|
dff_nb = dff_nb.sort(by="montant", descending=True, nulls_last=True)
|
||||||
dff_nb = dff_nb.cast(pl.String)
|
dff_nb = dff_nb.cast(pl.String)
|
||||||
dff_nb = dff_nb.fill_null("")
|
dff_nb = dff_nb.fill_null("")
|
||||||
dff_nb = format_values(dff_nb)
|
dff_nb = format_values(dff_nb)
|
||||||
columns, tooltip = setup_table_columns(
|
columns, tooltip = setup_table_columns(
|
||||||
dff_nb, hideable=False, exclude=[f"{org_type}_id"]
|
dff_nb, hideable=False, exclude=[f"{org_type}_id"], new_columns=["Attributions"]
|
||||||
)
|
)
|
||||||
data = dff_nb.to_dicts()
|
data = dff_nb.to_dicts()
|
||||||
data = add_links_in_dict(data, f"{org_type}")
|
data = add_links_in_dict(data, f"{org_type}")
|
||||||
|
|||||||
+227
-18
@@ -2,11 +2,14 @@ import json
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
|
import plotly.graph_objects as go
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import dash_table, dcc, html
|
from dash import dash_table, dcc, html
|
||||||
|
|
||||||
|
from src.utils import data_schema, df, format_number
|
||||||
|
|
||||||
def get_map_count_marches(df: pl.DataFrame):
|
|
||||||
|
def get_map_count_marches():
|
||||||
lf = df.lazy()
|
lf = df.lazy()
|
||||||
lf = lf.with_columns(
|
lf = lf.with_columns(
|
||||||
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
|
pl.col("lieuExecution_code").str.head(2).str.zfill(2).alias("Département")
|
||||||
@@ -28,16 +31,16 @@ def get_map_count_marches(df: pl.DataFrame):
|
|||||||
for f in departements["features"]:
|
for f in departements["features"]:
|
||||||
f["id"] = f["properties"]["code"]
|
f["id"] = f["properties"]["code"]
|
||||||
|
|
||||||
df = lf.collect(engine="streaming")
|
df_map = lf.collect(engine="streaming")
|
||||||
|
|
||||||
fig = px.choropleth(
|
fig = px.choropleth(
|
||||||
df,
|
df_map,
|
||||||
geojson=departements,
|
geojson=departements,
|
||||||
locations="Département",
|
locations="Département",
|
||||||
color="uid",
|
color="uid",
|
||||||
color_continuous_scale="Reds",
|
color_continuous_scale="Reds",
|
||||||
title="Nombres de marchés attribués par département (lieu d'exécution)",
|
title="Nombres de marchés attribués par département (lieu d'exécution)",
|
||||||
range_color=(df["uid"].min(), df["uid"].max()),
|
range_color=(df_map["uid"].min(), df_map["uid"].max()),
|
||||||
labels={"uid": "Marchés attribués"},
|
labels={"uid": "Marchés attribués"},
|
||||||
scope="europe",
|
scope="europe",
|
||||||
width=900,
|
width=900,
|
||||||
@@ -56,8 +59,47 @@ def get_map_count_marches(df: pl.DataFrame):
|
|||||||
return fig
|
return fig
|
||||||
|
|
||||||
|
|
||||||
def get_barchart_sources(df: pl.DataFrame, type_date: str):
|
def get_yearly_statistics(statistics, today_str) -> html.Div:
|
||||||
lf = df.lazy()
|
# Build DataFrame from statistics
|
||||||
|
years = list(reversed(range(2018, int(today_str.split("/")[-1]) + 1)))
|
||||||
|
data = []
|
||||||
|
for year in years:
|
||||||
|
year_str = str(year)
|
||||||
|
stat = statistics[year_str]
|
||||||
|
data.append(
|
||||||
|
{
|
||||||
|
"Année": year_str,
|
||||||
|
"Marchés et accord-cadres": format_number(
|
||||||
|
stat["nb_notifications_marches"]
|
||||||
|
),
|
||||||
|
"Acheteurs": format_number(stat["nb_acheteurs_uniques"]),
|
||||||
|
"Titulaires": format_number(stat["nb_titulaires_uniques"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
df = pl.DataFrame(data)
|
||||||
|
|
||||||
|
# Create Dash DataTable
|
||||||
|
table = dash_table.DataTable(
|
||||||
|
data=df.to_dicts(),
|
||||||
|
columns=[
|
||||||
|
{"name": "Année", "id": "Année"},
|
||||||
|
{"name": "Marchés et accord-cadres", "id": "Marchés et accord-cadres"},
|
||||||
|
{"name": "Acheteurs", "id": "Acheteurs"},
|
||||||
|
{"name": "Titulaires", "id": "Titulaires"},
|
||||||
|
],
|
||||||
|
page_size=10,
|
||||||
|
sort_action="none",
|
||||||
|
filter_action="none",
|
||||||
|
style_header={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
|
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return html.Div(children=table, className="marches_table")
|
||||||
|
|
||||||
|
|
||||||
|
def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
|
||||||
|
lf = df_source.lazy()
|
||||||
labels = {
|
labels = {
|
||||||
"dateNotification": "notification",
|
"dateNotification": "notification",
|
||||||
"datePublicationDonnees": "publication des données",
|
"datePublicationDonnees": "publication des données",
|
||||||
@@ -129,14 +171,23 @@ def get_sources_tables(source_path) -> html.Div:
|
|||||||
+ pl.lit("</a>")
|
+ pl.lit("</a>")
|
||||||
).alias("nom")
|
).alias("nom")
|
||||||
)
|
)
|
||||||
df = df.drop("url")
|
df = df.drop("url", "unique")
|
||||||
df = df.sort(by=["nb_marchés"], descending=True)
|
df = df.sort(by=["nb_marchés"], descending=True)
|
||||||
|
|
||||||
|
columns = {
|
||||||
|
"nom": "Nom de la source",
|
||||||
|
"organisation": "Responsable de publication",
|
||||||
|
"nb_marchés": "Nb de marchés",
|
||||||
|
"nb_acheteurs": "Nb d'acheteurs",
|
||||||
|
"code": "Code",
|
||||||
|
}
|
||||||
|
|
||||||
datatable = dash_table.DataTable(
|
datatable = dash_table.DataTable(
|
||||||
id="source_table",
|
id="source_table",
|
||||||
|
data=df.to_dicts(),
|
||||||
columns=[
|
columns=[
|
||||||
{
|
{
|
||||||
"name": i,
|
"name": columns[i],
|
||||||
"id": i,
|
"id": i,
|
||||||
"presentation": "markdown",
|
"presentation": "markdown",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -156,8 +207,9 @@ def get_sources_tables(source_path) -> html.Div:
|
|||||||
],
|
],
|
||||||
sort_action="native",
|
sort_action="native",
|
||||||
markdown_options={"html": True},
|
markdown_options={"html": True},
|
||||||
|
style_header={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
|
style_cell={"fontFamily": "Inter", "fontSize": "16px"},
|
||||||
)
|
)
|
||||||
datatable.data = df.to_dicts()
|
|
||||||
|
|
||||||
return html.Div(children=datatable)
|
return html.Div(children=datatable)
|
||||||
|
|
||||||
@@ -192,6 +244,7 @@ def point_on_map(lat, lon):
|
|||||||
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
|
fig.update_layout(map_center={"lat": 46.6, "lon": 1.89}, map_zoom=4)
|
||||||
|
|
||||||
graph = dcc.Graph(id="map", figure=fig)
|
graph = dcc.Graph(id="map", figure=fig)
|
||||||
|
graph = html.Div(style={"width": "400px"})
|
||||||
return graph
|
return graph
|
||||||
|
|
||||||
|
|
||||||
@@ -199,29 +252,35 @@ class DataTable(dash_table.DataTable):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
dtid: str,
|
dtid: str,
|
||||||
hidden_columns: list = None,
|
hidden_columns: list[str] | None = None,
|
||||||
data=None,
|
data: list[dict[str, str | int | float | bool]] | None = None,
|
||||||
columns: list = None,
|
columns: list[dict[str, str]] | None = None,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
page_action: Literal["native", "custom", "none"] = "native",
|
page_action: Literal["native", "custom", "none"] = "native",
|
||||||
sort_action: Literal["native", "custom", "none"] = "native",
|
sort_action: Literal["native", "custom", "none"] = "native",
|
||||||
filter_action: Literal["native", "custom", "none"] = "native",
|
filter_action: Literal["native", "custom", "none"] = "native",
|
||||||
|
style_cell_conditional: list | None = None,
|
||||||
|
style_cell: dict | None = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
# Styles de base
|
# Styles de base
|
||||||
style_cell_conditional = [
|
style_cell_conditional_common = [
|
||||||
{
|
{
|
||||||
"if": {"column_id": "objet"},
|
"if": {"column_id": "objet"},
|
||||||
"minWidth": "350px",
|
"minWidth": "350px",
|
||||||
"textAlign": "left",
|
|
||||||
"overflow": "hidden",
|
"overflow": "hidden",
|
||||||
"lineHeight": "18px",
|
"lineHeight": "18px",
|
||||||
"whiteSpace": "normal",
|
"whiteSpace": "normal",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"if": {"column_id": "acheteur_id"},
|
||||||
|
"minWidth": "160px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"if": {"column_id": "acheteur_nom"},
|
"if": {"column_id": "acheteur_nom"},
|
||||||
"minWidth": "250px",
|
"minWidth": "250px",
|
||||||
"textAlign": "left",
|
|
||||||
"overflow": "hidden",
|
"overflow": "hidden",
|
||||||
"lineHeight": "18px",
|
"lineHeight": "18px",
|
||||||
"whiteSpace": "normal",
|
"whiteSpace": "normal",
|
||||||
@@ -229,13 +288,33 @@ class DataTable(dash_table.DataTable):
|
|||||||
{
|
{
|
||||||
"if": {"column_id": "titulaire_nom"},
|
"if": {"column_id": "titulaire_nom"},
|
||||||
"minWidth": "250px",
|
"minWidth": "250px",
|
||||||
"textAlign": "left",
|
|
||||||
"overflow": "hidden",
|
"overflow": "hidden",
|
||||||
"lineHeight": "18px",
|
"lineHeight": "18px",
|
||||||
"whiteSpace": "normal",
|
"whiteSpace": "normal",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
style_cell_common = {"fontFamily": "Inter", "fontSize": "16px"}
|
||||||
|
|
||||||
|
for key in data_schema.keys():
|
||||||
|
field = data_schema[key]
|
||||||
|
if field["type"] in ["number", "integer"]:
|
||||||
|
rule = {
|
||||||
|
"if": {"column_id": field["name"]},
|
||||||
|
"textAlign": "right",
|
||||||
|
# "fontFamily": "Fira Code",
|
||||||
|
}
|
||||||
|
style_cell_conditional_common.append(rule)
|
||||||
|
|
||||||
|
style_cell_conditional = (
|
||||||
|
style_cell_conditional or []
|
||||||
|
) + style_cell_conditional_common
|
||||||
|
if style_cell:
|
||||||
|
style_cell.update(style_cell_common)
|
||||||
|
else:
|
||||||
|
style_cell = style_cell_common
|
||||||
|
style_header = style_cell
|
||||||
|
|
||||||
# Initialisation de la classe parente avec les arguments
|
# Initialisation de la classe parente avec les arguments
|
||||||
super().__init__(
|
super().__init__(
|
||||||
id=dtid,
|
id=dtid,
|
||||||
@@ -245,17 +324,147 @@ class DataTable(dash_table.DataTable):
|
|||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
filter_action=filter_action,
|
filter_action=filter_action,
|
||||||
page_action=page_action,
|
page_action=page_action,
|
||||||
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
filter_options={
|
||||||
|
"case": "insensitive",
|
||||||
|
"placeholder_text": "Filtre de colonne...",
|
||||||
|
},
|
||||||
sort_action=sort_action,
|
sort_action=sort_action,
|
||||||
sort_mode="multi",
|
sort_mode="multi",
|
||||||
sort_by=[],
|
|
||||||
row_deletable=False,
|
row_deletable=False,
|
||||||
page_current=0,
|
page_current=0,
|
||||||
style_cell_conditional=style_cell_conditional,
|
style_cell_conditional=style_cell_conditional,
|
||||||
data_timestamp=0,
|
data_timestamp=0,
|
||||||
markdown_options={"html": True},
|
markdown_options={"html": True},
|
||||||
|
style_header=style_header,
|
||||||
|
style_cell=style_cell,
|
||||||
tooltip_duration=8000,
|
tooltip_duration=8000,
|
||||||
tooltip_delay=350,
|
tooltip_delay=350,
|
||||||
hidden_columns=hidden_columns,
|
hidden_columns=hidden_columns,
|
||||||
**kwargs, # Possibilité de remplacer des arguments
|
**kwargs, # Possibilité de remplacer des arguments
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_duplicate_matrix() -> html.Div:
|
||||||
|
"""
|
||||||
|
Fonction développée avec l'aide de la LLM Euria d'Infomaniak.
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
result_df = pl.read_parquet(
|
||||||
|
"https://www.data.gouv.fr/api/1/datasets/r/a545bf6c-8b24-46ed-b49f-a32bf02eaffa"
|
||||||
|
).sort("sourceDataset")
|
||||||
|
result_df = result_df.select(
|
||||||
|
["sourceDataset", "unique"] + sorted(result_df.columns[2:])
|
||||||
|
)
|
||||||
|
|
||||||
|
description = dcc.Markdown("""
|
||||||
|
Ce graphique illustre les doublons de marchés publics entre sources, c'est-à-dire la proportion de marchés publiés par plus d'une source. Il s'appuie sur les identifiants `uid` qui sont pour chaque marché la concaténation du SIRET de l'acheteur et de l'identifiant interne du marché.
|
||||||
|
|
||||||
|
**Comment lire ce graphique ?**
|
||||||
|
|
||||||
|
On part des codes de sources de données en ordonnée. Ces jeux de données sont documentés dans [À propos](/a-propos#sources).
|
||||||
|
|
||||||
|
La première colonne (**unique**) représente le pourcentage de marchés fournis par cette source qui sont uniquement disponibles dans cette source. Plus le rouge est foncé, plus important est le pourcentage. Donc, à l'inverse, plus le rouge est clair dans la première colonne, plus la source en ordonnée a des marchés en commun avec d'autres sources, et donc plus on trouvera sur la même ligne d'autres cases plus ou moins foncées qui indiqueront avec quelles autres sources cette source partage des marchés.
|
||||||
|
|
||||||
|
Passez votre souris sur une case pour avoir les pourcentages exacts. À noter que ces statistiques sont produites avant le dédoublonnement qui a lieu avant la publication en Open Data et sur ce site.""")
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
z_data = result_df.select(pl.all().exclude("sourceDataset")).fill_null(0).to_numpy()
|
||||||
|
x_labels = result_df.columns[1:] # columns after "sourceDataset"
|
||||||
|
y_labels = result_df["sourceDataset"].to_list()
|
||||||
|
|
||||||
|
# Create heatmap
|
||||||
|
fig = go.Figure(
|
||||||
|
data=go.Heatmap(
|
||||||
|
z=z_data,
|
||||||
|
x=x_labels,
|
||||||
|
y=y_labels,
|
||||||
|
colorscale=[
|
||||||
|
[0.0, "white"], # 0% → white
|
||||||
|
[0.10, "lightsalmon"], # 10% → light warm tone
|
||||||
|
[1.0, "darkred"], # 100% → deep red
|
||||||
|
],
|
||||||
|
zmin=0,
|
||||||
|
zmax=1,
|
||||||
|
hoverongaps=False,
|
||||||
|
showscale=True,
|
||||||
|
hovertemplate=(
|
||||||
|
"<b>%{z:.0%}</b> des marchés de <b>%{y}</b> sont également présents dans <b>%{x}</b>"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update layout: make it wider and taller
|
||||||
|
fig.update_layout(
|
||||||
|
title="",
|
||||||
|
xaxis_title="Sources de données",
|
||||||
|
yaxis_title="Sources de données",
|
||||||
|
yaxis=dict(autorange="reversed"),
|
||||||
|
xaxis=dict(tickangle=45, tickfont=dict(size=10)), # Smaller x-tick labels
|
||||||
|
coloraxis_colorbar=dict(title="Percentage", tickfont=dict(size=10)),
|
||||||
|
width=1000, # Wider
|
||||||
|
height=1000, # Taller
|
||||||
|
font=dict(size=11), # Overall font size
|
||||||
|
margin=dict(l=100, r=50, t=80, b=100), # Add margin for labels
|
||||||
|
)
|
||||||
|
|
||||||
|
return html.Div(
|
||||||
|
children=[
|
||||||
|
html.H3("Doublons de marchés entre les sources"),
|
||||||
|
description,
|
||||||
|
dcc.Graph(figure=fig),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_column_picker(page: str):
|
||||||
|
table_data = []
|
||||||
|
table_columns = [
|
||||||
|
{
|
||||||
|
"id": col,
|
||||||
|
"name": data_schema[col]["title"],
|
||||||
|
"description": data_schema[col]["description"],
|
||||||
|
}
|
||||||
|
for col in df.columns
|
||||||
|
]
|
||||||
|
for column in table_columns:
|
||||||
|
new_column = {
|
||||||
|
"id": column["id"],
|
||||||
|
"name": column["name"],
|
||||||
|
"description": data_schema[column["id"]]["description"],
|
||||||
|
}
|
||||||
|
table_data.append(new_column)
|
||||||
|
|
||||||
|
table = (
|
||||||
|
DataTable(
|
||||||
|
row_selectable="multi",
|
||||||
|
data=table_data,
|
||||||
|
filter_action="native",
|
||||||
|
sort_action="none",
|
||||||
|
style_cell={
|
||||||
|
"textAlign": "left",
|
||||||
|
},
|
||||||
|
columns=[
|
||||||
|
{
|
||||||
|
"name": "Nom",
|
||||||
|
"id": "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Description",
|
||||||
|
"id": "description",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
style_cell_conditional=[
|
||||||
|
{
|
||||||
|
"if": {"column_id": "description"},
|
||||||
|
"minWidth": "450px",
|
||||||
|
"overflow": "hidden",
|
||||||
|
"lineHeight": "18px",
|
||||||
|
"whiteSpace": "normal",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page_action="none",
|
||||||
|
dtid=f"{page}_column_list",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return table
|
||||||
|
|||||||
+148
-58
@@ -10,20 +10,26 @@ name = "À propos"
|
|||||||
register_page(
|
register_page(
|
||||||
__name__,
|
__name__,
|
||||||
path="/a-propos",
|
path="/a-propos",
|
||||||
title=meta_content["title"],
|
title="À propos | decp.info",
|
||||||
name=name,
|
name="À propos",
|
||||||
description=meta_content["description"],
|
description="En savoir plus sur decp.info, l'outil d'exploration des données essentielles de la commande publique.",
|
||||||
image_url=meta_content["image_url"],
|
image_url=meta_content["image_url"],
|
||||||
order=5,
|
order=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [
|
layout = html.Div(
|
||||||
html.Div(
|
className="container",
|
||||||
className="container",
|
children=[
|
||||||
children=[
|
html.H2(name),
|
||||||
html.H2(name),
|
html.Div(
|
||||||
dcc.Markdown(
|
className="a-propos-container",
|
||||||
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
|
children=[
|
||||||
|
# Main Content Column
|
||||||
|
html.Div(
|
||||||
|
className="a-propos-content",
|
||||||
|
children=[
|
||||||
|
dcc.Markdown(
|
||||||
|
"""Outil d'exploration libre et gratuit des données de marchés publics, développé par Colin Maudry.
|
||||||
|
|
||||||
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
|
Ce projet vise à démocratiser l'accès aux données des marchés publics et à un outil performant et gratuit. Si vous le trouvez utile
|
||||||
j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos besoins. Cet outil ne peut rester performant que si je comprends les problèmes qu'il peut aider à résoudre. Ce projet ne peut rester gratuit que grâce au financement du développement de nouvelles fonctionnalités.
|
j'aimerais beaucoup échanger avec vous pour comprendre vos cas d'usages et vos besoins. Cet outil ne peut rester performant que si je comprends les problèmes qu'il peut aider à résoudre. Ce projet ne peut rester gratuit que grâce au financement du développement de nouvelles fonctionnalités.
|
||||||
@@ -32,30 +38,39 @@ En effet, le potentiel des données d'attribution de marchés et des données qu
|
|||||||
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
||||||
|
|
||||||
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-titulaire...)
|
- de nombreuses visualisations de données (cartes, graphiques, tableaux) sur des thématiques variées (vivacité de la concurrence, secteurs d'activité, insertion par l'activité économique (IAE), distance acheteur-titulaire...)
|
||||||
- la sauvegarde de filtres pour les retrouver plus tard et les partager
|
|
||||||
- des alertes par email si des marchés correspondant à certains critères
|
- des alertes par email si des marchés correspondant à certains critères
|
||||||
- le développement d'une API pour alimenter d'autres logiciels
|
|
||||||
- ...et toutes les fonctionnalités auxquelles vous pourrez penser
|
- ...et toutes les fonctionnalités auxquelles vous pourrez penser
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
html.H4("Consommer les données brutes", id="donnees-brutes"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
Vous pouvez consommer les données qui alimentent decp.info
|
||||||
|
|
||||||
"""
|
- en les téléchargeant [sur data.gouv.fr](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire) (Parquet, CSV), pensez à lire la description du jeu de données
|
||||||
),
|
- en interrogeant l'[API REST ouverte](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire#user-content-api-rest)
|
||||||
html.H4("Contact", id="contact"),
|
"""
|
||||||
dcc.Markdown("""
|
),
|
||||||
|
html.H4("Contact", id="contact"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
- Email : [colin@colmo.tech](mailto:colin@colmo.tech)
|
- Email : [colin@colmo.tech](mailto:colin@colmo.tech)
|
||||||
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
- Bluesky : [@col1m.bsky.social](https://bsky.app/profile/col1m.bsky.social)
|
||||||
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
- Mastodon : [col1m@mamot.fr](https://mamot.fr/@col1m)
|
||||||
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
- LinkedIn : [colinmaudry](https://www.linkedin.com/in/colinmaudry/)
|
||||||
|
"""
|
||||||
"""),
|
),
|
||||||
html.H4("Pour contribuer", id="contribuer"),
|
html.H4("Pour contribuer", id="contribuer"),
|
||||||
dcc.Markdown("""
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
|
- via l'achat d'une prestation de service (devis, prestation, facture), vous pouvez financer le développement de [fonctionnalités prévues](https://github.com/ColinMaudry/decp.info/issues), ou d'autres !
|
||||||
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
|
- ma société accepte aussi les dons (pas de réduction d'impôt possible)
|
||||||
- écrivez-moi et on discute !
|
- écrivez-moi et on discute !
|
||||||
|
"""
|
||||||
#### Pour explorer le projet
|
),
|
||||||
|
html.H4("Pour explorer le projet", id="explorer"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
|
- ✉️ [inscription à la liste de diffusion](https://6254d9a3.sibforms.com/serve/MUIFAEonUVkoSVrdgey18CTgLyI16xw4yeu-M-YOUzhWE_AgfQfbgkyT7GvA_RYLro9MfuRqkzQxSvu7-uzbMSv2a2ZQPsliM7wtiiqIL8kR2zOvl6m11fb5qjcOxMAYsLiY_YBi3P7NY95CTJ8vRY4CpsDclF2iLooOElKkTgIgi5nePe7zAIrgiYM5v2EuALlGJZMEG9vBP-Cu) (annonces des mises à jour et évènements, maximum une fois par mois)
|
||||||
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
|
- 💾 [données consolidées en Open Data](https://www.data.gouv.fr/datasets/donnees-essentielles-de-la-commande-publique-consolidees-format-tabulaire/)
|
||||||
- 🗞️ [mon blog](https://colin.maudry.com)
|
- 🗞️ [mon blog](https://colin.maudry.com)
|
||||||
@@ -63,34 +78,40 @@ les fonctionnalités actuelles de decp.info. Il est ainsi possible de rajouter
|
|||||||
- 🚰 code source
|
- 🚰 code source
|
||||||
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
- [de decp.info](https://github.com/ColinMaudry/decp.info)
|
||||||
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
- [du traitement des données](https://github.com/ColinMaudry/decp-processing)
|
||||||
"""),
|
"""
|
||||||
html.H4("Qualité et exhaustivité des données", id="qualite-exhausitivite"),
|
),
|
||||||
dcc.Markdown("""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
|
html.H4(
|
||||||
|
"Qualité et exhaustivité des données",
|
||||||
|
id="qualite-exhausitivite",
|
||||||
|
),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""Les données visibles sur ce site proviennent exclusivement de la publication de données ouvertes par les acheteurs publics ou en leur nom, régie par [l'arrêté du 22 décembre 2022](https://www.legifrance.gouv.fr/jorf/id/JORFTEXT000046850496). Leur qualité est donc principalement liée à la qualité de leur saisie par les agents publics, parfois peu aidé·es par la qualité des outils à leur disposition. Je pense que l'analyse de marchés individuels et le comptage de marchés sur des critères autres que financiers sont plutôt fiables. En revanche, certains montants de marché estimés à des valeurs farfelues ([1 euro](https://decp.info/marches/432766947000192025S01301), [1 milliard](https://decp.info/marches/2459004280001320210000000271)) faussent les calculs par aggrégation (sommes, moyennes, médianes) et donc la production de statistiques financières fiables. Acheteurs, acheteuses : s'il vous plaît, essayez d'estimer les montants des marchés publics attribués de manière plus précise.
|
||||||
|
|
||||||
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
|
Quant à l'exhaustivité, je consolide toutes les sources de données exploitables que j'ai pu identifier (voir [statistiques](/statistiques)). Certains profils d'acheteurs ne publient pas leurs données malgré l'obligation réglementaire :
|
||||||
|
|
||||||
- marches-securises.fr
|
|
||||||
- klekoon.fr (ils y travaillent)
|
- klekoon.fr (ils y travaillent)
|
||||||
|
- safetender.com (Omnikles)
|
||||||
**marches-publics.gouv.fr** (PLACE, [AIFE](https://aife.economie.gouv.fr)), la plateforme des marchés de l'État, ne publie plus de données depuis le 1er janvier 2024 et l'entrée en vigueur de l'arrêté. Nous n'avons donc plus de données sur les marchés publics passés par les ministères, ainsi que sur ceux passés par les acheteurs clients d'[achatpublic.com](https://www.achatpublic.com), qui s'appuie sur les moyens de publication de l'AIFE.
|
|
||||||
|
|
||||||
**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics.
|
**marches-publics.info** (AWS) publie ses données de manière assez sporadique depuis début 2023. Compte tenu de son poids dans le secteur, c'est assez dommageable pour la transparence des marchés publics.
|
||||||
|
|
||||||
Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""),
|
Au milieu de ces mauvaises nouvelles, je tiens à souligner la belle continuité de la publication par la DGFiP des données des marchés publics remontées via le [protocole PES](https://www.collectivites-locales.gouv.fr/finances-locales/le-protocole-dechange-standard-pes). Merci à leurs équipes."""
|
||||||
html.H4("Sources de données ", id="qualite-exhausitivite"),
|
),
|
||||||
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
html.H4("Sources de données ", id="sources"),
|
||||||
html.H4("Mentions légales", id="mentions-legales"),
|
get_sources_tables(os.getenv("SOURCE_STATS_CSV_PATH")),
|
||||||
dcc.Markdown("""
|
html.H4("Mentions légales", id="mentions-legales"),
|
||||||
##### Publication
|
html.H5("Publication", id="publication"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
|
Site Web développé et édité par [SAS Colmo](https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350), 989 393 350 RCS Rennes au capital de 3 000 euros.
|
||||||
|
|
||||||
Siège social : 1 carrefour Jouaust, 35000 Rennes
|
Siège social : 1 carrefour Jouaust, 35000 Rennes
|
||||||
|
|
||||||
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville l’Evêque, 75008 Paris
|
Hébergement : serveur situé en France et administré par Scaleway, 8 rue de la Ville l’Evêque, 75008 Paris
|
||||||
|
"""
|
||||||
##### Suivi d'audience
|
),
|
||||||
|
html.H5("Suivi d'audience", id="audience"),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
|
Ce site dépose un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le consultez ([Wikipédia](https://fr.wikipedia.org/wiki/Cookie_(informatique))). Cela me permet de mesurer le nombre de visites, de distinguer les nouveaux visiteurs des utilisateurs réguliers et ainsi de communiquer sur l'impact de decp.info.
|
||||||
|
|
||||||
**Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ?**
|
**Ce site n’affiche pas de bannière de consentement aux cookies, pourquoi ?**
|
||||||
@@ -99,21 +120,90 @@ C’est vrai, vous n’avez pas eu à cliquer sur un bloc qui recouvre la moiti
|
|||||||
|
|
||||||
Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable.
|
Rien d’exceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi d’audience, correctement configurés pour respecter la vie privée, sont exemptés d’autorisation préalable.
|
||||||
|
|
||||||
J’utilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il m’est donc impossible d’associer vos visites sur ce site à votre personne."""),
|
J’utilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://matomo.org/free-software/), paramétré pour être en conformité avec [la recommandation « Cookies »](https://www.cnil.fr/fr/solutions-pour-les-cookies-de-mesure-daudience) de la CNIL. Cela signifie que votre adresse IP, par exemple, est anonymisée avant d’être enregistrée. Il m’est donc impossible d’associer vos visites sur ce site à votre personne.
|
||||||
# Matomo propose cependant ce formulaire si vous souhaitez totalement désactiver le suivi de vos sessions sur ce site :"""),
|
|
||||||
# html.Div(
|
J'enregistre également les données suivantes, de manière anonyme, afin de mieux comprendre comment vous utilisez le site et l'améliorer :
|
||||||
# id="matomo-opt-out",
|
|
||||||
# style={
|
- recherches sur la page d'accueil
|
||||||
# "border": "1pt solid lightgrey",
|
- filtres appliqués aux données
|
||||||
# "padding": "12px",
|
"""
|
||||||
# "margin": "auto 0 auto 12px",
|
),
|
||||||
# "width": "80%",
|
html.H5("Attributions", id="attributions"),
|
||||||
# },
|
dcc.Markdown("""
|
||||||
# children=["Vous utilisez un bloqueur de suivi de trafic."],
|
Les polices de caractères sont distribuées par [Bunny fonts](https://fonts.bunny.net), une alternative européenne et qualitative à Google Fonts.
|
||||||
# ),
|
|
||||||
# html.Script(
|
- la police de caractère [Inter](https://fonts.bunny.net/family/inter), principale police de ce site, a été créée par The Inter Project Authors ([source](https://github.com/rsms/inter))
|
||||||
# src="https://analytics.maudry.com/index.php?module=CoreAdminHome&action=optOutJS&divId=matomo-opt-out&language=auto&showIntro=1"
|
- la police de caractère [Fira Code](https://fonts.bunny.net/family/fira-code), la police à largeure fixe, a été créée par The Fira Code Project Authors (https://github.com/tonsky/FiraCode)
|
||||||
# ),
|
"""),
|
||||||
],
|
html.H4(
|
||||||
)
|
"Liste des marchés par département", id="liste_marches"
|
||||||
]
|
),
|
||||||
|
dcc.Markdown(
|
||||||
|
"""
|
||||||
|
- [Marchés par département](/departements)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# Table of Contents Column
|
||||||
|
html.Div(
|
||||||
|
className="a-propos-toc",
|
||||||
|
children=[
|
||||||
|
html.Div(
|
||||||
|
[
|
||||||
|
html.A(
|
||||||
|
"Consommer les données brutes",
|
||||||
|
href="#donnees-brutes",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Contact", href="#contact", className="toc-link"
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Pour contribuer",
|
||||||
|
href="#contribuer",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Pour explorer le projet",
|
||||||
|
href="#explorer",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Qualité et exhaustivité des données",
|
||||||
|
href="#qualite-exhausitivite",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Sources de données",
|
||||||
|
href="#sources",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Mentions légales",
|
||||||
|
href="#mentions-legales",
|
||||||
|
className="toc-link",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Publication",
|
||||||
|
href="#publication",
|
||||||
|
className="toc-link toc-level-2",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Suivi d'audience",
|
||||||
|
href="#audience",
|
||||||
|
className="toc-link toc-level-2",
|
||||||
|
),
|
||||||
|
html.A(
|
||||||
|
"Attributions",
|
||||||
|
href="#attributions",
|
||||||
|
className="toc-link toc-level-2",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
+189
-33
@@ -1,12 +1,26 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
from dash import (
|
||||||
|
ClientsideFunction,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
clientside_callback,
|
||||||
|
dcc,
|
||||||
|
html,
|
||||||
|
register_page,
|
||||||
|
)
|
||||||
|
|
||||||
from src.callbacks import get_top_org_table
|
from src.callbacks import get_top_org_table
|
||||||
from src.figures import DataTable, point_on_map
|
from src.figures import DataTable, make_column_picker, point_on_map
|
||||||
from src.utils import (
|
from src.utils import (
|
||||||
|
columns,
|
||||||
df,
|
df,
|
||||||
|
df_acheteurs,
|
||||||
filter_table_data,
|
filter_table_data,
|
||||||
format_number,
|
format_number,
|
||||||
get_annuaire_data,
|
get_annuaire_data,
|
||||||
@@ -18,12 +32,22 @@ from src.utils import (
|
|||||||
sort_table_data,
|
sort_table_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(acheteur_id: str = None) -> str:
|
||||||
|
acheteur_nom = df_acheteurs.filter(pl.col("acheteur_id") == acheteur_id).select(
|
||||||
|
"acheteur_nom"
|
||||||
|
)
|
||||||
|
if acheteur_nom.height > 0:
|
||||||
|
return f"Marchés publics attribués par {acheteur_nom.item(0, 0)} | decp.info"
|
||||||
|
return "Marchés publics attribués | decp.info"
|
||||||
|
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
__name__,
|
__name__,
|
||||||
path_template="/acheteurs/<acheteur_id>",
|
path_template="/acheteurs/<acheteur_id>",
|
||||||
title=meta_content["title"],
|
title=get_title,
|
||||||
name="Acheteur",
|
name="Acheteur",
|
||||||
description=meta_content["description"],
|
description="Consultez les marchés publics attribués par cet acheteur.",
|
||||||
image_url=meta_content["image_url"],
|
image_url=meta_content["image_url"],
|
||||||
order=5,
|
order=5,
|
||||||
)
|
)
|
||||||
@@ -32,17 +56,23 @@ datatable = html.Div(
|
|||||||
className="marches_table",
|
className="marches_table",
|
||||||
children=DataTable(
|
children=DataTable(
|
||||||
dtid="acheteur_datatable",
|
dtid="acheteur_datatable",
|
||||||
|
persistence=True,
|
||||||
|
persistence_type="local",
|
||||||
|
persisted_props=["filter_query", "sort_by"],
|
||||||
page_action="custom",
|
page_action="custom",
|
||||||
filter_action="custom",
|
filter_action="custom",
|
||||||
sort_action="custom",
|
sort_action="custom",
|
||||||
page_size=10,
|
page_size=10,
|
||||||
hidden_columns=get_default_hidden_columns(page="acheteur"),
|
hidden_columns=[],
|
||||||
|
columns=[{"id": col, "name": col} for col in df.columns],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
dcc.Store(id="acheteur_data", storage_type="memory"),
|
dcc.Store(id="acheteur_data", storage_type="memory"),
|
||||||
dcc.Location(id="url", refresh="callback-nav"),
|
dcc.Store(id="acheteur-hidden-columns", storage_type="local"),
|
||||||
|
dcc.Store(id="filter-cleanup-trigger-acheteur"),
|
||||||
|
dcc.Location(id="acheteur_url", refresh="callback-nav"),
|
||||||
html.Div(
|
html.Div(
|
||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
@@ -60,7 +90,7 @@ layout = [
|
|||||||
className="org_year",
|
className="org_year",
|
||||||
children=dcc.Dropdown(
|
children=dcc.Dropdown(
|
||||||
id="acheteur_year",
|
id="acheteur_year",
|
||||||
options=["Toutes"]
|
options=["Toutes les années"]
|
||||||
+ [
|
+ [
|
||||||
str(year)
|
str(year)
|
||||||
for year in range(
|
for year in range(
|
||||||
@@ -85,7 +115,6 @@ layout = [
|
|||||||
html.A(
|
html.A(
|
||||||
id="acheteur_lien_annuaire",
|
id="acheteur_lien_annuaire",
|
||||||
children="Plus de détails sur l'Annuaire des entreprises",
|
children="Plus de détails sur l'Annuaire des entreprises",
|
||||||
target="_blank",
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -98,6 +127,7 @@ layout = [
|
|||||||
html.Button(
|
html.Button(
|
||||||
"Téléchargement au format Excel",
|
"Téléchargement au format Excel",
|
||||||
id="btn-download-data-acheteur",
|
id="btn-download-data-acheteur",
|
||||||
|
className="btn btn-primary",
|
||||||
),
|
),
|
||||||
dcc.Download(id="download-data-acheteur"),
|
dcc.Download(id="download-data-acheteur"),
|
||||||
],
|
],
|
||||||
@@ -121,16 +151,52 @@ layout = [
|
|||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
|
# Bouton modal des colonnes affichées
|
||||||
|
dbc.Button(
|
||||||
|
"Colonnes affichées",
|
||||||
|
id="acheteur_columns_open",
|
||||||
|
className="column_list",
|
||||||
|
),
|
||||||
html.P("lignes", id="acheteur_nb_rows"),
|
html.P("lignes", id="acheteur_nb_rows"),
|
||||||
html.Button(
|
html.Button(
|
||||||
"Téléchargement désactivé au-delà de 65 000 lignes",
|
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||||
id="btn-download-filtered-data-acheteur",
|
id="btn-download-filtered-data-acheteur",
|
||||||
|
className="btn btn-primary",
|
||||||
disabled=True,
|
disabled=True,
|
||||||
),
|
),
|
||||||
dcc.Download(id="acheteur-download-filtered-data"),
|
dcc.Download(id="acheteur-download-filtered-data"),
|
||||||
|
dbc.Button(
|
||||||
|
"Remise à zéro",
|
||||||
|
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||||
|
id="btn-acheteur-reset",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
className="table-menu",
|
className="table-menu",
|
||||||
),
|
),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(
|
||||||
|
dbc.ModalTitle("Choix des colonnes à afficher")
|
||||||
|
),
|
||||||
|
dbc.ModalBody(
|
||||||
|
id="acheteur_columns_body",
|
||||||
|
children=make_column_picker("acheteur"),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="acheteur_columns_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="acheteur_columns",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="xl",
|
||||||
|
),
|
||||||
datatable,
|
datatable,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -147,30 +213,44 @@ layout = [
|
|||||||
Output(component_id="acheteur_departement", component_property="children"),
|
Output(component_id="acheteur_departement", component_property="children"),
|
||||||
Output(component_id="acheteur_region", component_property="children"),
|
Output(component_id="acheteur_region", component_property="children"),
|
||||||
Output(component_id="acheteur_lien_annuaire", component_property="href"),
|
Output(component_id="acheteur_lien_annuaire", component_property="href"),
|
||||||
Input(component_id="url", component_property="pathname"),
|
Input(component_id="acheteur_url", component_property="pathname"),
|
||||||
)
|
)
|
||||||
def update_acheteur_infos(url):
|
def update_acheteur_infos(url):
|
||||||
acheteur_siret = url.split("/")[-1]
|
acheteur_siret = url.split("/")[-1]
|
||||||
if len(acheteur_siret) != 14:
|
# if len(acheteur_siret) != 14:
|
||||||
acheteur_siret = (
|
# acheteur_siret = (
|
||||||
f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
|
# f"Le SIRET renseigné doit faire 14 caractères ({acheteur_siret})"
|
||||||
)
|
# )
|
||||||
data = get_annuaire_data(acheteur_siret)
|
data = get_annuaire_data(acheteur_siret)
|
||||||
data_etablissement = data["matching_etablissements"][0]
|
data_etablissement = data.get("matching_etablissements") if data else None
|
||||||
acheteur_map = point_on_map(
|
if data_etablissement:
|
||||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
data_etablissement = data_etablissement[0]
|
||||||
)
|
|
||||||
code_departement, nom_departement, nom_region = get_departement_region(
|
acheteur_map = point_on_map(
|
||||||
data_etablissement["code_postal"]
|
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||||
)
|
)
|
||||||
departement = f"{nom_departement} ({code_departement})"
|
code_departement, nom_departement, nom_region = get_departement_region(
|
||||||
lien_annuaire = (
|
data_etablissement["code_postal"]
|
||||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
)
|
||||||
)
|
departement = f"{nom_departement} ({code_departement})"
|
||||||
|
lien_annuaire = (
|
||||||
|
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{acheteur_siret}"
|
||||||
|
)
|
||||||
|
raison_sociale = data["nom_raison_sociale"]
|
||||||
|
libelle_commune = data_etablissement["libelle_commune"]
|
||||||
|
|
||||||
|
else:
|
||||||
|
acheteur_map = html.Div()
|
||||||
|
code_departement, nom_departement, nom_region = "", "", ""
|
||||||
|
departement = ""
|
||||||
|
lien_annuaire = ""
|
||||||
|
raison_sociale = ""
|
||||||
|
libelle_commune = ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
acheteur_siret,
|
acheteur_siret,
|
||||||
data["nom_raison_sociale"],
|
raison_sociale,
|
||||||
data_etablissement["libelle_commune"],
|
libelle_commune,
|
||||||
acheteur_map,
|
acheteur_map,
|
||||||
departement,
|
departement,
|
||||||
nom_region,
|
nom_region,
|
||||||
@@ -211,14 +291,14 @@ def update_acheteur_stats(data):
|
|||||||
Output("btn-download-data-acheteur", "disabled"),
|
Output("btn-download-data-acheteur", "disabled"),
|
||||||
Output("btn-download-data-acheteur", "children"),
|
Output("btn-download-data-acheteur", "children"),
|
||||||
Output("btn-download-data-acheteur", "title"),
|
Output("btn-download-data-acheteur", "title"),
|
||||||
Input(component_id="url", component_property="pathname"),
|
Input(component_id="acheteur_url", component_property="pathname"),
|
||||||
Input(component_id="acheteur_year", component_property="value"),
|
Input(component_id="acheteur_year", component_property="value"),
|
||||||
)
|
)
|
||||||
def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
||||||
acheteur_siret = url.split("/")[-1]
|
acheteur_siret = url.split("/")[-1]
|
||||||
lff = df.lazy()
|
lff = df.lazy()
|
||||||
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
|
lff = lff.filter(pl.col("acheteur_id") == acheteur_siret)
|
||||||
if acheteur_year and acheteur_year != "Toutes":
|
if acheteur_year and acheteur_year != "Toutes les années":
|
||||||
acheteur_year = int(acheteur_year)
|
acheteur_year = int(acheteur_year)
|
||||||
lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year)
|
lff = lff.filter(pl.col("dateNotification").dt.year() == acheteur_year)
|
||||||
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
|
lff = lff.sort(["dateNotification", "uid"], descending=True, nulls_last=True)
|
||||||
@@ -238,6 +318,8 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
|||||||
Output("btn-download-filtered-data-acheteur", "disabled"),
|
Output("btn-download-filtered-data-acheteur", "disabled"),
|
||||||
Output("btn-download-filtered-data-acheteur", "children"),
|
Output("btn-download-filtered-data-acheteur", "children"),
|
||||||
Output("btn-download-filtered-data-acheteur", "title"),
|
Output("btn-download-filtered-data-acheteur", "title"),
|
||||||
|
Output("filter-cleanup-trigger-acheteur", "data"),
|
||||||
|
Input("acheteur_url", "href"),
|
||||||
Input("acheteur_data", "data"),
|
Input("acheteur_data", "data"),
|
||||||
Input("acheteur_datatable", "page_current"),
|
Input("acheteur_datatable", "page_current"),
|
||||||
Input("acheteur_datatable", "page_size"),
|
Input("acheteur_datatable", "page_size"),
|
||||||
@@ -246,10 +328,10 @@ def get_acheteur_marches_data(url, acheteur_year: str) -> tuple:
|
|||||||
State("acheteur_datatable", "data_timestamp"),
|
State("acheteur_datatable", "data_timestamp"),
|
||||||
)
|
)
|
||||||
def get_last_marches_data(
|
def get_last_marches_data(
|
||||||
data, page_current, page_size, filter_query, sort_by, data_timestamp
|
href, data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
return prepare_table_data(
|
return prepare_table_data(
|
||||||
data, data_timestamp, filter_query, page_current, page_size, sort_by
|
data, data_timestamp, filter_query, page_current, page_size, sort_by, "acheteur"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +353,7 @@ def get_top_titulaires(data):
|
|||||||
)
|
)
|
||||||
def download_acheteur_data(
|
def download_acheteur_data(
|
||||||
n_clicks,
|
n_clicks,
|
||||||
data: [dict],
|
data: list[dict[str, Any]],
|
||||||
acheteur_nom: str,
|
acheteur_nom: str,
|
||||||
annee: str,
|
annee: str,
|
||||||
):
|
):
|
||||||
@@ -279,7 +361,7 @@ def download_acheteur_data(
|
|||||||
|
|
||||||
def to_bytes(buffer):
|
def to_bytes(buffer):
|
||||||
df_to_download.write_excel(
|
df_to_download.write_excel(
|
||||||
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
|
buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee
|
||||||
)
|
)
|
||||||
|
|
||||||
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
@@ -308,7 +390,7 @@ def download_filtered_acheteur_data(
|
|||||||
lff = lff.drop(hidden_columns)
|
lff = lff.drop(hidden_columns)
|
||||||
|
|
||||||
if filter_query:
|
if filter_query:
|
||||||
lff = filter_table_data(lff, filter_query)
|
lff = filter_table_data(lff, filter_query, "ach download")
|
||||||
|
|
||||||
if len(sort_by) > 0:
|
if len(sort_by) > 0:
|
||||||
lff = sort_table_data(lff, sort_by)
|
lff = sort_table_data(lff, sort_by)
|
||||||
@@ -320,3 +402,77 @@ def download_filtered_acheteur_data(
|
|||||||
return dcc.send_bytes(
|
return dcc.send_bytes(
|
||||||
to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx"
|
to_bytes, filename=f"decp_filtrées_{acheteur_nom}_{date}.xlsx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Pour nettoyer les icontains et i< des filtres
|
||||||
|
# voir aussi src/assets/dash_clientside.js
|
||||||
|
clientside_callback(
|
||||||
|
ClientsideFunction(
|
||||||
|
namespace="clientside",
|
||||||
|
function_name="clean_filters",
|
||||||
|
),
|
||||||
|
Output("filter-cleanup-trigger-acheteur", "data", allow_duplicate=True),
|
||||||
|
Input("filter-cleanup-trigger-acheteur", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur-hidden-columns", "data", allow_duplicate=True),
|
||||||
|
Input("acheteur_column_list", "selected_rows"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_hidden_columns_from_checkboxes(selected_columns):
|
||||||
|
if selected_columns:
|
||||||
|
selected_columns = [columns[i] for i in selected_columns]
|
||||||
|
hidden_columns = [col for col in columns if col not in selected_columns]
|
||||||
|
return hidden_columns
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_datatable", "hidden_columns", allow_duplicate=True),
|
||||||
|
Input(
|
||||||
|
"acheteur-hidden-columns",
|
||||||
|
"data",
|
||||||
|
),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def store_hidden_columns(hidden_columns):
|
||||||
|
return hidden_columns
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_column_list", "selected_rows"),
|
||||||
|
Input("acheteur_datatable", "hidden_columns"),
|
||||||
|
State("acheteur_column_list", "selected_rows"), # pour éviter la boucle infinie
|
||||||
|
)
|
||||||
|
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
|
||||||
|
hidden_cols = hidden_cols or get_default_hidden_columns("acheteur")
|
||||||
|
|
||||||
|
# Show all columns that are NOT hidden
|
||||||
|
visible_cols = [columns.index(col) for col in columns if col not in hidden_cols]
|
||||||
|
return visible_cols
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_columns", "is_open"),
|
||||||
|
Input("acheteur_columns_open", "n_clicks"),
|
||||||
|
Input("acheteur_columns_close", "n_clicks"),
|
||||||
|
State("acheteur_columns", "is_open"),
|
||||||
|
)
|
||||||
|
def toggle_acheteur_columns(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("acheteur_datatable", "filter_query", allow_duplicate=True),
|
||||||
|
Output("acheteur_datatable", "sort_by"),
|
||||||
|
Input("btn-acheteur-reset", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def reset_view(n_clicks):
|
||||||
|
return "", []
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils import departements, df_acheteurs_departement, df_titulaires_departement
|
||||||
|
|
||||||
|
name = "Département"
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(code):
|
||||||
|
return f"Marchés publics de {departements[code]['departement']} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
def get_description(code):
|
||||||
|
return f"Marchés publics passés dans le département {departements[code]['departement']} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/departements/<code>",
|
||||||
|
title=get_title,
|
||||||
|
description=get_description,
|
||||||
|
order=50,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
[
|
||||||
|
dcc.Location(id="departement_url", refresh="callback-nav"),
|
||||||
|
html.Div(id="departement_marches"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="departement_marches", component_property="children"),
|
||||||
|
Input(component_id="departement_url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def departement_marches(url):
|
||||||
|
departement = url.split("/")[-1]
|
||||||
|
|
||||||
|
def make_link_list(org_type) -> list:
|
||||||
|
link_list = []
|
||||||
|
if org_type == "acheteur":
|
||||||
|
df = df_acheteurs_departement
|
||||||
|
elif org_type == "titulaire":
|
||||||
|
df = df_titulaires_departement
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
df = df.filter(pl.col(f"{org_type}_departement_code") == departement)
|
||||||
|
|
||||||
|
for row in df.iter_rows(named=True):
|
||||||
|
li = html.Li(
|
||||||
|
[
|
||||||
|
dcc.Link(
|
||||||
|
row[f"{org_type}_nom"],
|
||||||
|
href=url + f"/{org_type}/{row[f'{org_type}_id']}",
|
||||||
|
title=f"Marchés publics de {row[f'{org_type}_nom']}",
|
||||||
|
),
|
||||||
|
" ",
|
||||||
|
dcc.Link(
|
||||||
|
"(page dédiée)",
|
||||||
|
href=f"/{org_type}s/{row[f'{org_type}_id']}",
|
||||||
|
title=f"Page dédiée aux marchés publics de {row[f'{org_type}_nom']}",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
link_list.append(li)
|
||||||
|
return link_list
|
||||||
|
|
||||||
|
content = [
|
||||||
|
html.H3("Acheteurs publics du département"),
|
||||||
|
html.Ul(make_link_list("acheteur")),
|
||||||
|
html.H3("Titulaires du département"),
|
||||||
|
html.Ul(make_link_list("titulaire")),
|
||||||
|
]
|
||||||
|
|
||||||
|
return content
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from dash import dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils import departements
|
||||||
|
|
||||||
|
name = "Départements"
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path="/departements",
|
||||||
|
title="Marchés par département | decp.info",
|
||||||
|
name="Départements",
|
||||||
|
description="Tous les marchés publics, classés par départements",
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
[
|
||||||
|
html.H3("Départements"),
|
||||||
|
html.Ul(
|
||||||
|
[
|
||||||
|
html.Li(dcc.Link(d["departement"], href=f"/departements/{k}"))
|
||||||
|
for k, d in departements.items()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import polars as pl
|
||||||
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
|
|
||||||
|
from src.utils import (
|
||||||
|
df_acheteurs,
|
||||||
|
df_acheteurs_marches,
|
||||||
|
df_titulaires,
|
||||||
|
df_titulaires_marches,
|
||||||
|
)
|
||||||
|
|
||||||
|
name = "Liste des marchés publics"
|
||||||
|
|
||||||
|
|
||||||
|
def make_org_nom_verbe(org_type, org_id) -> tuple:
|
||||||
|
if org_type == "titulaire":
|
||||||
|
df = df_titulaires
|
||||||
|
verbe = "remportés"
|
||||||
|
elif org_type == "acheteur":
|
||||||
|
df = df_acheteurs
|
||||||
|
verbe = "attribués"
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
org_nom = (
|
||||||
|
df.filter(pl.col(f"{org_type}_id") == org_id)
|
||||||
|
.select(f"{org_type}_nom")
|
||||||
|
.item(0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
return org_nom, verbe
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(code, org_type, org_id):
|
||||||
|
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||||
|
|
||||||
|
return f"Marchés publics {verbe} par {org_nom} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
|
def get_description(code, org_type, org_id):
|
||||||
|
org_nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||||
|
|
||||||
|
return f"Liste complète des marchés publics {verbe} par {org_nom} et publiés par decp.info. Cliquez sur les liens pour consulter les détails de chaque marché."
|
||||||
|
|
||||||
|
|
||||||
|
register_page(
|
||||||
|
__name__,
|
||||||
|
path_template="/departements/<code>/<org_type>/<org_id>",
|
||||||
|
title=get_title,
|
||||||
|
description=get_description,
|
||||||
|
order=40,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
layout = html.Div(
|
||||||
|
[
|
||||||
|
dcc.Location(id="liste_marches_url", refresh="callback-nav"),
|
||||||
|
html.Div(id="liste_marches"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="liste_marches", component_property="children"),
|
||||||
|
Input(component_id="liste_marches_url", component_property="pathname"),
|
||||||
|
)
|
||||||
|
def liste_marches(url):
|
||||||
|
org_type = url.split("/")[-2]
|
||||||
|
org_id = url.split("/")[-1]
|
||||||
|
|
||||||
|
def make_link_list() -> list:
|
||||||
|
link_list = []
|
||||||
|
if org_type == "acheteur":
|
||||||
|
df = df_acheteurs_marches
|
||||||
|
elif org_type == "titulaire":
|
||||||
|
df = df_titulaires_marches
|
||||||
|
else:
|
||||||
|
raise ValueError
|
||||||
|
|
||||||
|
df = df.filter(pl.col(f"{org_type}_id") == org_id)
|
||||||
|
|
||||||
|
for row in df.iter_rows(named=True):
|
||||||
|
li = html.Li(
|
||||||
|
[
|
||||||
|
dcc.Link(
|
||||||
|
row["objet"],
|
||||||
|
href=f"/marches/{row['uid']}",
|
||||||
|
title=f"Marchés public attribué : {row['objet']}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
link_list.append(li)
|
||||||
|
return link_list
|
||||||
|
|
||||||
|
nom, verbe = make_org_nom_verbe(org_type, org_id)
|
||||||
|
|
||||||
|
content = [
|
||||||
|
html.H3(f"Marchés publics {verbe} par {nom}"),
|
||||||
|
html.Ul(make_link_list()),
|
||||||
|
]
|
||||||
|
|
||||||
|
return content
|
||||||
+121
-37
@@ -1,17 +1,31 @@
|
|||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import Input, Output, callback, dcc, html, register_page
|
||||||
from polars import selectors as cs
|
from polars import selectors as cs
|
||||||
|
|
||||||
from src.utils import data_schema, df, format_values, meta_content
|
from src.utils import (
|
||||||
|
data_schema,
|
||||||
|
df,
|
||||||
|
format_values,
|
||||||
|
make_org_jsonld,
|
||||||
|
meta_content,
|
||||||
|
unformat_montant,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(uid: str = None) -> str:
|
||||||
|
return f"Marché {uid} | decp.info"
|
||||||
|
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
__name__,
|
__name__,
|
||||||
path_template="/marches/<uid>",
|
path_template="/marches/<uid>",
|
||||||
title=meta_content["title"],
|
title=get_title,
|
||||||
name="Marché",
|
name="Marché",
|
||||||
description=meta_content["description"],
|
description="Consultez les détails de ce marché public : montant, acheteur, titulaires, modifications, etc.",
|
||||||
image_url=meta_content["image_url"],
|
image_url=meta_content["image_url"],
|
||||||
order=7,
|
order=7,
|
||||||
)
|
)
|
||||||
@@ -19,38 +33,47 @@ register_page(
|
|||||||
layout = [
|
layout = [
|
||||||
dcc.Store(id="marche_data"),
|
dcc.Store(id="marche_data"),
|
||||||
dcc.Store(id="titulaires_data"),
|
dcc.Store(id="titulaires_data"),
|
||||||
dcc.Location(id="url", refresh="callback-nav"),
|
dcc.Location(id="marche_url", refresh="callback-nav"),
|
||||||
html.Div(
|
html.Script(type="application/ld+json", id="marche_jsonld"),
|
||||||
className="container marche_infos",
|
dbc.Container(
|
||||||
|
className="marche_infos",
|
||||||
children=[
|
children=[
|
||||||
html.P("Vous consultez un résumé des données de ce marché public"),
|
dbc.Row(
|
||||||
html.Ul(
|
dbc.Col(
|
||||||
|
[
|
||||||
|
html.H1(id="marche_objet", style={"fontSize": "1.5em"}),
|
||||||
|
html.P(
|
||||||
|
"Vous consultez un résumé des données de ce marché public"
|
||||||
|
),
|
||||||
|
html.Ul(
|
||||||
|
[
|
||||||
|
html.Li(
|
||||||
|
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
|
||||||
|
),
|
||||||
|
html.Li(
|
||||||
|
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dbc.Row(
|
||||||
[
|
[
|
||||||
html.Li(
|
dbc.Col(id="marche_infos_1", width=12, md=4),
|
||||||
"après son attribution aux titulaires qui l'ont remporté à la suite d'un appel d'offres (ou sans appel d'offres via une attribution directe)"
|
dbc.Col(id="marche_infos_2", width=12, md=4),
|
||||||
),
|
dbc.Col(
|
||||||
html.Li(
|
width=12,
|
||||||
"après avoir appliqué les éventuelles modifications de montant, durée ou titulaires renseignées par l'acheteur"
|
md=4,
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
html.P(
|
|
||||||
"Le montant total payé aux titulaires, la durée du marché et la liste des titulaires peuvent cependant encore évoluer jusqu'à la fin de l'exécution du marché."
|
|
||||||
),
|
|
||||||
html.Div(
|
|
||||||
className="wrapper",
|
|
||||||
children=[
|
|
||||||
html.Div(
|
|
||||||
className="marche_map",
|
|
||||||
id="marche_map",
|
|
||||||
children=[
|
children=[
|
||||||
html.H4("Titulaires"),
|
html.H4("Titulaires"),
|
||||||
html.Ul(id="marche_infos_titulaires"),
|
html.Ul(id="marche_infos_titulaires"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
html.Div(className="marche_infos_1", id="marche_infos_1"),
|
]
|
||||||
html.Div(className="marche_infos_2", id="marche_infos_2"),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -60,7 +83,7 @@ layout = [
|
|||||||
@callback(
|
@callback(
|
||||||
Output("marche_data", "data"),
|
Output("marche_data", "data"),
|
||||||
Output("titulaires_data", "data"),
|
Output("titulaires_data", "data"),
|
||||||
Input(component_id="url", component_property="pathname"),
|
Input(component_id="marche_url", component_property="pathname"),
|
||||||
)
|
)
|
||||||
def get_marche_data(url) -> tuple[dict, list]:
|
def get_marche_data(url) -> tuple[dict, list]:
|
||||||
marche_uid = url.split("/")[-1]
|
marche_uid = url.split("/")[-1]
|
||||||
@@ -81,6 +104,7 @@ def get_marche_data(url) -> tuple[dict, list]:
|
|||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
|
Output("marche_objet", "children"),
|
||||||
Output("marche_infos_1", "children"),
|
Output("marche_infos_1", "children"),
|
||||||
Output("marche_infos_2", "children"),
|
Output("marche_infos_2", "children"),
|
||||||
Output("marche_infos_titulaires", "children"),
|
Output("marche_infos_titulaires", "children"),
|
||||||
@@ -88,7 +112,7 @@ def get_marche_data(url) -> tuple[dict, list]:
|
|||||||
Input("titulaires_data", "data"),
|
Input("titulaires_data", "data"),
|
||||||
)
|
)
|
||||||
def update_marche_info(marche, titulaires):
|
def update_marche_info(marche, titulaires):
|
||||||
def make_parameter(col):
|
def make_parameter(col, bold=True):
|
||||||
column_object = data_schema.get(col)
|
column_object = data_schema.get(col)
|
||||||
column_name = column_object.get("title") if column_object else col
|
column_name = column_object.get("title") if column_object else col
|
||||||
|
|
||||||
@@ -132,12 +156,14 @@ def update_marche_info(marche, titulaires):
|
|||||||
else:
|
else:
|
||||||
value = ""
|
value = ""
|
||||||
|
|
||||||
param_content = html.P([column_name, " : ", html.Strong(value)])
|
value = html.Strong(value) if bold else value
|
||||||
|
param_content = html.P([column_name, " : ", value])
|
||||||
return param_content
|
return param_content
|
||||||
|
|
||||||
|
marche_objet = make_parameter("objet", bold=False)
|
||||||
|
|
||||||
marche_infos = [
|
marche_infos = [
|
||||||
make_parameter("id"),
|
make_parameter("id"),
|
||||||
make_parameter("objet"),
|
|
||||||
make_parameter("dateNotification"), # date
|
make_parameter("dateNotification"), # date
|
||||||
make_parameter("nature"),
|
make_parameter("nature"),
|
||||||
make_parameter("acheteur_nom"), # lien
|
make_parameter("acheteur_nom"), # lien
|
||||||
@@ -146,6 +172,7 @@ def update_marche_info(marche, titulaires):
|
|||||||
make_parameter("procedure"),
|
make_parameter("procedure"),
|
||||||
make_parameter("techniques"), # list
|
make_parameter("techniques"), # list
|
||||||
make_parameter("dureeMois"),
|
make_parameter("dureeMois"),
|
||||||
|
make_parameter("dureeRestanteMois"),
|
||||||
make_parameter("offresRecues"),
|
make_parameter("offresRecues"),
|
||||||
make_parameter("datePublicationDonnees"), # date
|
make_parameter("datePublicationDonnees"), # date
|
||||||
make_parameter("formePrix"),
|
make_parameter("formePrix"),
|
||||||
@@ -171,14 +198,71 @@ def update_marche_info(marche, titulaires):
|
|||||||
titulaires_lines = []
|
titulaires_lines = []
|
||||||
for titulaire in titulaires:
|
for titulaire in titulaires:
|
||||||
if titulaire["titulaire_typeIdentifiant"] == "SIRET":
|
if titulaire["titulaire_typeIdentifiant"] == "SIRET":
|
||||||
|
categorie = titulaire.get("titulaire_categorie", "")
|
||||||
|
if titulaire.get("titulaire_distance"):
|
||||||
|
distance = str(titulaire.get("titulaire_distance")) + " km"
|
||||||
|
else:
|
||||||
|
distance = ""
|
||||||
|
|
||||||
content = html.Li(
|
content = html.Li(
|
||||||
html.A(
|
[
|
||||||
href=f"/titulaires/{titulaire['titulaire_id']}",
|
html.A(
|
||||||
children=titulaire["titulaire_nom"],
|
href=f"/titulaires/{titulaire['titulaire_id']}",
|
||||||
)
|
children=titulaire["titulaire_nom"],
|
||||||
|
),
|
||||||
|
f" ({categorie}, {distance})",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
content = html.Li(titulaire["titulaire_nom"])
|
content = html.Li(titulaire["titulaire_nom"])
|
||||||
titulaires_lines.append(content)
|
titulaires_lines.append(content)
|
||||||
|
|
||||||
return marche_infos[:half], marche_infos[half:], titulaires_lines
|
return marche_objet, marche_infos[:half], marche_infos[half:], titulaires_lines
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output(component_id="marche_jsonld", component_property="children"),
|
||||||
|
Input("marche_data", "data"),
|
||||||
|
Input("titulaires_data", "data"),
|
||||||
|
)
|
||||||
|
def get_marche_jsonld(marche, titulaires) -> str:
|
||||||
|
acheteur_id = marche.get("acheteur_id")
|
||||||
|
type_order = (
|
||||||
|
"Service" if marche.get("categorie") in ["Services", "Travaux"] else "Product"
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for titulaire in titulaires:
|
||||||
|
jsonld = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Order",
|
||||||
|
"@id": f"https://decp.info/marches/{marche.get('uid')}",
|
||||||
|
"name": f"{marche.get('nature')} conclu par {marche.get('acheteur_nom')} le {marche.get('dateNotification')}",
|
||||||
|
"description": marche.get("objet"),
|
||||||
|
"orderNumber": marche.get("uid"),
|
||||||
|
"orderDate": marche.get("dateNotification"),
|
||||||
|
"price": unformat_montant(marche.get("montant")),
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"customer": make_org_jsonld(
|
||||||
|
acheteur_id, org_name=marche.get("acheteur_nom"), org_type="acheteur"
|
||||||
|
),
|
||||||
|
"seller": make_org_jsonld(
|
||||||
|
titulaire.get("titulaire_id"),
|
||||||
|
org_name=titulaire.get("titulaire_nom"),
|
||||||
|
org_type="titulaire",
|
||||||
|
type_org_id=titulaire.get("titulaire_typeIdentifiant"),
|
||||||
|
),
|
||||||
|
"orderedItem": {
|
||||||
|
"@type": type_order,
|
||||||
|
"name": marche.get("objet"),
|
||||||
|
"category": {
|
||||||
|
"@type": "CategoryCode",
|
||||||
|
"propertyID": "cpv",
|
||||||
|
"codeValue": marche.get("codeCPV"),
|
||||||
|
# "description": "Description du code CPV"
|
||||||
|
},
|
||||||
|
# "serviceType": "Description du code CPV"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result.append(jsonld)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|||||||
+57
-16
@@ -1,4 +1,4 @@
|
|||||||
from dash import Input, Output, callback, dcc, html, register_page
|
from dash import Input, Output, State, callback, dcc, html, register_page
|
||||||
|
|
||||||
from src.figures import DataTable
|
from src.figures import DataTable
|
||||||
from src.utils import (
|
from src.utils import (
|
||||||
@@ -14,9 +14,9 @@ name = "Recherche"
|
|||||||
register_page(
|
register_page(
|
||||||
__name__,
|
__name__,
|
||||||
path="/",
|
path="/",
|
||||||
title=meta_content["title"],
|
title="Recherche de marchés publics | decp.info",
|
||||||
name=name,
|
name=name,
|
||||||
description=meta_content["description"],
|
description="Explorez et analysez les données des marchés publics français avec cet outil libre et gratuit. Pour une commande publique accessible à toutes et tous.",
|
||||||
image_url=meta_content["image_url"],
|
image_url=meta_content["image_url"],
|
||||||
order=0,
|
order=0,
|
||||||
)
|
)
|
||||||
@@ -26,15 +26,52 @@ layout = html.Div(
|
|||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
className="tagline",
|
className="tagline",
|
||||||
children=html.P(
|
children=html.P("Recherchez un acheteur ou un titulaire de marché public"),
|
||||||
"Exploration et téléchargement des données des marchés publics"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
dcc.Input(
|
html.Div(
|
||||||
id="search",
|
style={
|
||||||
type="text",
|
"display": "flex",
|
||||||
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
|
"justifyContent": "center",
|
||||||
autoFocus=True,
|
"marginTop": "30px",
|
||||||
|
"marginBottom": "30px",
|
||||||
|
},
|
||||||
|
children=[
|
||||||
|
dcc.Input(
|
||||||
|
id="search",
|
||||||
|
type="text",
|
||||||
|
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
|
||||||
|
autoFocus=True,
|
||||||
|
style={
|
||||||
|
"margin": "0",
|
||||||
|
"width": "500px",
|
||||||
|
"border": "1px solid #ccc",
|
||||||
|
"borderRight": "none",
|
||||||
|
"borderRadius": "3px 0 0 3px",
|
||||||
|
"padding": "5px 10px",
|
||||||
|
"outline": "none",
|
||||||
|
"height": "34px",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
html.Button(
|
||||||
|
"=>",
|
||||||
|
id="search-button",
|
||||||
|
className="btn btn-primary",
|
||||||
|
style={
|
||||||
|
"border": "1px solid #ccc",
|
||||||
|
"borderRadius": "0 3px 3px 0",
|
||||||
|
"marginLeft": "0",
|
||||||
|
"height": "auto", # Ensure it matches input height if necessary, often relying on padding/line-height
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
[
|
||||||
|
"...ou bien filtrez les marchés publics dans la vue ",
|
||||||
|
dcc.Link("Tableau", href="/tableau"),
|
||||||
|
],
|
||||||
|
style={"textAlign": "center"},
|
||||||
|
id="mention_tableau",
|
||||||
),
|
),
|
||||||
# html.Div(
|
# html.Div(
|
||||||
# className="search_options",
|
# className="search_options",
|
||||||
@@ -47,11 +84,14 @@ layout = html.Div(
|
|||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("search_results", "children"),
|
Output("search_results", "children"),
|
||||||
Input("search", "value"),
|
Output("mention_tableau", "style"),
|
||||||
|
Input("search", "n_submit"),
|
||||||
|
Input("search-button", "n_clicks"),
|
||||||
|
State("search", "value"),
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def update_search_results(query):
|
def update_search_results(n_submit, n_clicks, query):
|
||||||
if len(query) >= 1:
|
if query and len(query) >= 1:
|
||||||
content = []
|
content = []
|
||||||
|
|
||||||
for org_type in ["acheteur", "titulaire"]:
|
for org_type in ["acheteur", "titulaire"]:
|
||||||
@@ -88,6 +128,7 @@ def update_search_results(query):
|
|||||||
else html.P(f"Aucun {org_type} trouvé."),
|
else html.P(f"Aucun {org_type} trouvé."),
|
||||||
]
|
]
|
||||||
content.extend(org_content)
|
content.extend(org_content)
|
||||||
|
style = {"textAlign": "center", "display": "none"}
|
||||||
|
|
||||||
return content
|
return content, style
|
||||||
return html.P("")
|
return html.P(""), {"textAlign": "center"}
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from dash import dcc, html, register_page
|
from dash import dcc, html, register_page
|
||||||
|
|
||||||
from src.figures import get_barchart_sources, get_map_count_marches
|
from src.figures import (
|
||||||
from src.utils import df, meta_content
|
get_barchart_sources,
|
||||||
|
get_duplicate_matrix,
|
||||||
|
get_map_count_marches,
|
||||||
|
get_yearly_statistics,
|
||||||
|
)
|
||||||
|
from src.utils import df, format_number, get_statistics, meta_content
|
||||||
|
|
||||||
name = "Statistiques"
|
name = "Statistiques"
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
__name__,
|
__name__,
|
||||||
path="/statistiques",
|
path="/statistiques",
|
||||||
title=meta_content["title"],
|
title="Statistiques | decp.info",
|
||||||
name=name,
|
name=name,
|
||||||
description=meta_content["description"],
|
description="Visualisez l'état de la publication des données essentielles des marchés publics en France.",
|
||||||
image_url=meta_content["image_url"],
|
image_url=meta_content["image_url"],
|
||||||
order=3,
|
order=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
statistics: dict = get_statistics()
|
||||||
|
today_str = datetime.fromisoformat(statistics["datetime"]).strftime("%d/%m/%Y")
|
||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
html.Div(
|
html.Div(
|
||||||
@@ -28,16 +37,37 @@ layout = [
|
|||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
children=[
|
children=[
|
||||||
dcc.Markdown("""
|
dcc.Markdown(f"""
|
||||||
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par
|
La publication de données essentielles de marchés publics (DECP) est souvent effectuée par
|
||||||
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas,
|
les plateformes de marchés publics (profils d'acheteurs). Cependant, certaines plateformes ne publient pas,
|
||||||
ou publient d'une manière qui rend la récupération des données compliquée. Les données présentées sur ce site
|
ou publient d'une manière qui rend la récupération des données compliquée. Les données présentées sur ce site
|
||||||
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative.
|
ne représentent donc pas tous les marchés attribués en France, seulement une partie significative.
|
||||||
|
|
||||||
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22source%20de%20donn%C3%A9es%22),
|
L'ajout de nouvelles plateformes [est en cours](https://github.com/ColinMaudry/decp-processing/issues?q=is%3Aissue%20label%3A%22source%20de%20donn%C3%A9es%22),
|
||||||
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
|
toutes les [contributions](/a-propos#contribuer) sont les bienvenues pour atteindre l'exhaustivité.
|
||||||
|
|
||||||
|
Les statistiques publiées sur cette page ont été produites automatiquement à partir des données les plus récentes ({today_str}).
|
||||||
"""),
|
"""),
|
||||||
dcc.Graph(figure=get_map_count_marches(df)),
|
html.H3(
|
||||||
|
"Statistiques générales sur les marchés",
|
||||||
|
id="marches",
|
||||||
|
),
|
||||||
|
html.P(
|
||||||
|
"À noter qu'une fois un marché attribué ses données essentielles peuvent malheureusement mettre plusieurs mois à être publiées par l'acheteur."
|
||||||
|
),
|
||||||
|
html.H4("Statistiques cumulées"),
|
||||||
|
dcc.Markdown(f"""
|
||||||
|
- Nombre de marchés publics et accord-cadres : {format_number(statistics["nb_marches"])}
|
||||||
|
- Nombre d'acheteurs publics (SIRET) : {format_number(statistics["nb_acheteurs_uniques"])}
|
||||||
|
- Nombre de titulaires (SIRET) : {format_number(statistics["nb_titulaires_uniques"])}
|
||||||
|
|
||||||
|
Je ne publie pas encore de statistiques sur les montants de marchés car je n'ai pas encore trouvé la bonne formule pour traiter les trop nombreux montants fantaisistes qui polluent les calculs.
|
||||||
|
"""),
|
||||||
|
html.H4("Statistiques par année"),
|
||||||
|
get_yearly_statistics(statistics, today_str),
|
||||||
|
dcc.Graph(figure=get_map_count_marches()),
|
||||||
|
get_duplicate_matrix(),
|
||||||
|
html.H3("Nombre de marchés par source dans le temps"),
|
||||||
dcc.Graph(
|
dcc.Graph(
|
||||||
figure=get_barchart_sources(df, "dateNotification")
|
figure=get_barchart_sources(df, "dateNotification")
|
||||||
),
|
),
|
||||||
|
|||||||
+354
-100
@@ -1,34 +1,51 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import Input, Output, State, callback, dcc, html, no_update, register_page
|
from dash import (
|
||||||
|
ClientsideFunction,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
clientside_callback,
|
||||||
|
dcc,
|
||||||
|
html,
|
||||||
|
no_update,
|
||||||
|
register_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
from figures import make_column_picker
|
||||||
from src.figures import DataTable
|
from src.figures import DataTable
|
||||||
from src.utils import (
|
from src.utils import (
|
||||||
|
columns,
|
||||||
df,
|
df,
|
||||||
filter_table_data,
|
filter_table_data,
|
||||||
get_default_hidden_columns,
|
get_default_hidden_columns,
|
||||||
invert_columns,
|
invert_columns,
|
||||||
|
logger,
|
||||||
meta_content,
|
meta_content,
|
||||||
schema,
|
schema,
|
||||||
sort_table_data,
|
sort_table_data,
|
||||||
)
|
)
|
||||||
from utils import prepare_table_data
|
from utils import prepare_table_data
|
||||||
|
|
||||||
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||||
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
|
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||||
|
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||||
|
|
||||||
|
|
||||||
name = "Tableau"
|
name = "Tableau"
|
||||||
register_page(
|
register_page(
|
||||||
__name__,
|
__name__,
|
||||||
path="/tableau",
|
path="/tableau",
|
||||||
title=meta_content["title"],
|
title="Tableau des marchés publics | decp.info",
|
||||||
name=name,
|
name=name,
|
||||||
description=meta_content["description"],
|
description="Consultez, filtrez et exportez les données essentielles de la commande publique sous forme de tableau.",
|
||||||
image_url=meta_content["image_url"],
|
image_url=meta_content["image_url"],
|
||||||
order=1,
|
order=1,
|
||||||
)
|
)
|
||||||
@@ -36,75 +53,89 @@ register_page(
|
|||||||
datatable = html.Div(
|
datatable = html.Div(
|
||||||
className="marches_table",
|
className="marches_table",
|
||||||
children=DataTable(
|
children=DataTable(
|
||||||
dtid="table",
|
dtid="tableau_datatable",
|
||||||
|
persisted_props=["filter_query", "sort_by"],
|
||||||
|
persistence_type="local",
|
||||||
|
persistence=True,
|
||||||
page_size=20,
|
page_size=20,
|
||||||
page_action="custom",
|
page_action="custom",
|
||||||
filter_action="custom",
|
filter_action="custom",
|
||||||
sort_action="custom",
|
sort_action="custom",
|
||||||
hidden_columns=get_default_hidden_columns(None),
|
hidden_columns=[],
|
||||||
columns=[{"id": col, "name": col} for col in df.columns],
|
columns=[{"id": col, "name": col} for col in df.columns],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
dcc.Location(id="url", refresh=False),
|
dcc.Location(id="tableau_url", refresh=False),
|
||||||
|
dcc.Store(id="filter-cleanup-trigger-tableau"),
|
||||||
|
dcc.Store(id="tableau-hidden-columns", storage_type="local"),
|
||||||
|
dcc.Store(id="tableau-table"),
|
||||||
|
html.Script(
|
||||||
|
type="application/ld+json",
|
||||||
|
id="dataset_jsonld",
|
||||||
|
children=[
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org/",
|
||||||
|
"@type": "Dataset",
|
||||||
|
"name": "Données essentielles des marchés publics français (DECP)",
|
||||||
|
"description": "Données de marchés publics exhaustives décrivant les marchés publics attribués en France depuis 2018.",
|
||||||
|
"url": "https://decp.info",
|
||||||
|
"sameAs": "https://www.data.gouv.fr/datasets/608c055b35eb4e6ee20eb325",
|
||||||
|
"keywords": [
|
||||||
|
"marchés publics",
|
||||||
|
"commande publique",
|
||||||
|
"decp",
|
||||||
|
"public procurement",
|
||||||
|
],
|
||||||
|
"license": "https://www.etalab.gouv.fr/licence-ouverte-open-licence",
|
||||||
|
"isAccessibleForFree": True,
|
||||||
|
"creator": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"url": "https://colmo.tech",
|
||||||
|
"name": "Colmo",
|
||||||
|
"sameAs": "https://annuaire-entreprises.data.gouv.fr/entreprise/colmo-989393350",
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"contactType": "Support et contact commercial",
|
||||||
|
"email": "colin@colmo.tech",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"includedInDataCatalog": {
|
||||||
|
"@type": "DataCatalog",
|
||||||
|
"name": "data.gouv.fr",
|
||||||
|
},
|
||||||
|
"distribution": [
|
||||||
|
{
|
||||||
|
"@type": "DataDownload",
|
||||||
|
"encodingFormat": "CSV",
|
||||||
|
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/22847056-61df-452d-837d-8b8ceadbfc52",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "DataDownload",
|
||||||
|
"encodingFormat": "Parquet",
|
||||||
|
"contentUrl": "https://www.data.gouv.fr/api/1/datasets/r/11cea8e8-df3e-4ed1-932b-781e2635e432",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temporalCoverage": f"2018-01-01/{update_date_iso[:10]}",
|
||||||
|
"spatialCoverage": {
|
||||||
|
"@type": "Place",
|
||||||
|
"address": {"countryCode": "FR"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
dcc.Markdown(
|
||||||
|
f"Ce tableau contient tous les marchés attribués en France. Il vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin (exemples : [marchés de voirie < 40 k€ en 2025](/tableau?filtres=%7Bacheteur_id%7D+icontains+24350013900189+%26%26+%7BdateNotification%7D+icontains+2025%2A+%26%26+%7Bmontant%7D+i%3C+40000+%26%26+%7Bobjet%7D+icontains+voirie&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2Cacheteur_departement_code%2CsourceDataset), [marchés > 500 k€ avec clause sociale attribués à des PME à plus de 100 km dans le Var](/tableau?filtres=%7Btitulaire_categorie%7D+icontains+PME+%26%26+%7Btitulaire_distance%7D+i%3E+100+%26%26+%7Bmontant%7D+i%3E+500000+%26%26+%7Bacheteur_departement_code%7D+icontains+83+%26%26+%7BconsiderationsSociales%7D+icontains+clause&colonnes=uid%2Cacheteur_id%2Cacheteur_nom%2Ctitulaire_id%2Ctitulaire_nom%2Cobjet%2Cmontant%2CdureeMois%2CdateNotification%2CconsiderationsSociales%2Ctitulaire_distance%2Cacheteur_departement_code%2Ctitulaire_categorie%2CsourceDataset)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {str(df.width)} en cliquant sur le bouton **Choisir les colonnes**. Cet outil est assez puissant, je vous recommande de lire le mode d'emploi pour en tirer pleinement partie.",
|
||||||
|
style={"maxWidth": "1000px"},
|
||||||
|
),
|
||||||
html.Div(
|
html.Div(
|
||||||
html.Details(
|
[],
|
||||||
children=[
|
|
||||||
html.Summary(
|
|
||||||
html.H3("Mode d'emploi", style={"textDecoration": "underline"}),
|
|
||||||
),
|
|
||||||
dcc.Markdown(
|
|
||||||
dangerously_allow_html=True,
|
|
||||||
children="""
|
|
||||||
##### Définition des colonnes
|
|
||||||
|
|
||||||
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
|
|
||||||
|
|
||||||
##### Filtres
|
|
||||||
|
|
||||||
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
|
||||||
|
|
||||||
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
|
|
||||||
le texte recherché. Exemple : `rennes` retourne "RENNES METROPOLE". Lorsque vous ouvrez une URL de vue, le format équivalent `icontains rennes` est utilisé.
|
|
||||||
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
|
||||||
- Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024",
|
|
||||||
`2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé.
|
|
||||||
|
|
||||||
Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
|
|
||||||
|
|
||||||
##### Tri
|
|
||||||
|
|
||||||
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre : tri ascendant, tri descendant, pas de tri.
|
|
||||||
|
|
||||||
##### Partager une vue
|
|
||||||
|
|
||||||
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Vous pouvez copier une adresse Web qui reproduit la vue courante à l'identique en cliquant sur l'icône <img src="/assets/copy.svg" alt="drawing" width="20"/>. En la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres.
|
|
||||||
|
|
||||||
Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard.
|
|
||||||
|
|
||||||
##### Télécharger le résultat
|
|
||||||
|
|
||||||
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**.
|
|
||||||
|
|
||||||
##### Liens
|
|
||||||
|
|
||||||
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée
|
|
||||||
(informations, marchés attribués/remportés, etc.)
|
|
||||||
|
|
||||||
""",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
id="instructions",
|
|
||||||
),
|
|
||||||
id="header",
|
id="header",
|
||||||
),
|
),
|
||||||
# html.Div(
|
|
||||||
# [
|
|
||||||
# "Recherche dans objet : ",
|
|
||||||
# dcc.Input(id="search", value="", type="text"),
|
|
||||||
# ]
|
|
||||||
# )]),
|
|
||||||
dcc.Loading(
|
dcc.Loading(
|
||||||
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
overlay_style={"visibility": "visible", "filter": "blur(2px)"},
|
||||||
id="loading-home",
|
id="loading-home",
|
||||||
@@ -112,10 +143,100 @@ layout = [
|
|||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
|
# Modal du mode d'emploi
|
||||||
|
dbc.Button("Mode d'emploi", id="tableau_help_open"),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(dbc.ModalTitle("Mode d'emploi")),
|
||||||
|
dbc.ModalBody(
|
||||||
|
dcc.Markdown(
|
||||||
|
dangerously_allow_html=True,
|
||||||
|
children=f"""
|
||||||
|
##### Définition des colonnes
|
||||||
|
|
||||||
|
Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
|
||||||
|
|
||||||
|
##### Vos réglages sont persistents
|
||||||
|
|
||||||
|
Les filtres, les tris et le choix de colonnes sont automatiquement enregistrés dans votre navigateur et persistent même si vous changez de page ou si vous fermez votre navigateur. À votre retour, vous retrouverez cette page comme vous l'avez laissée.
|
||||||
|
|
||||||
|
##### Appliquer des filtres
|
||||||
|
|
||||||
|
Vous pouvez appliquer un filtre pour chaque colonne en entrant du texte sous le nom de la colonne, puis en tapant sur `Entrée`.
|
||||||
|
|
||||||
|
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules).
|
||||||
|
- Exemple : `rennes` retourne "RENNES METROPOLE".
|
||||||
|
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
|
||||||
|
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
|
||||||
|
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
|
||||||
|
- soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
||||||
|
- Champs date (Date de notification, ...) : vous pouvez également utiliser **>** ou **<**. Exemples :
|
||||||
|
- `< 2024-01-31` pour "avant le 31 janvier 2024"
|
||||||
|
- `2024` pour "en 2024", `> 2022` pour "à partir de 2022".
|
||||||
|
- Pour les champs textuels et les champs dates :
|
||||||
|
- pour chercher du texte qui **commence par** votre texte, entrez `texte*`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`) ou les marchés sur une année en particulier (`2024*`)
|
||||||
|
- pour chercher du texte qui **finit par** votre texte, entrez `*texte`
|
||||||
|
|
||||||
|
Vous pouvez filtrer plusieurs colonnes à la fois.
|
||||||
|
|
||||||
|
##### Trier les données
|
||||||
|
|
||||||
|
Pour trier une colonne, utilisez les flèches grises à côté des noms de colonnes. Chaque clic change le tri dans cet ordre :
|
||||||
|
|
||||||
|
1. tri croissant
|
||||||
|
2. tri décroissant
|
||||||
|
3. pas de tri
|
||||||
|
|
||||||
|
##### Afficher plus de colonnes
|
||||||
|
|
||||||
|
Par défaut, un nombre réduit de colonnes est affiché pour ne pas surcharger la page. Mais vous avez le choix parmi {str(df.width)} colonnes, ce serait dommage de vous limiter !
|
||||||
|
|
||||||
|
Pour afficher plus de colonnes, cliquez sur le bouton **Choisir les colonnes** et cochez les colonnes pour les afficher.
|
||||||
|
|
||||||
|
##### Partager une vue
|
||||||
|
|
||||||
|
Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqués. Cliquez sur **Partager** pour copier une adresse Web qui reproduit la vue courante à l'identique : en la collant dans la barre d'adresse d'un navigateur, vous ouvrez la vue Tableau avec les mêmes paramètres.
|
||||||
|
|
||||||
|
Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard.
|
||||||
|
|
||||||
|
##### Télécharger le résultat
|
||||||
|
|
||||||
|
Vous pouvez télécharger le résultat de vos filtres et tris, pour les colonnes affichées, en cliquant sur **Télécharger au format Excel**.
|
||||||
|
|
||||||
|
##### Liens
|
||||||
|
|
||||||
|
Les liens dans les colonnes Identifiant unique, Acheteur et Titulaire vous permettent de consulter une vue qui leur est dédiée
|
||||||
|
(informations, marchés attribués/remportés, etc.)
|
||||||
|
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="tableau_help_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="tableau_help",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="lg",
|
||||||
|
),
|
||||||
|
# Bouton modal des colonnes affichées
|
||||||
|
dbc.Button(
|
||||||
|
"Choisir les colonnes",
|
||||||
|
id="tableau_columns_open",
|
||||||
|
className="column_list",
|
||||||
|
title="Choisir les colonnes à afficher et masquer",
|
||||||
|
),
|
||||||
html.P("lignes", id="nb_rows"),
|
html.P("lignes", id="nb_rows"),
|
||||||
html.Div(id="copy-container"),
|
html.Div(id="copy-container"),
|
||||||
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
|
dcc.Input(id="share-url", readOnly=True, style={"display": "none"}),
|
||||||
html.Button(
|
dbc.Button(
|
||||||
"Téléchargement désactivé au-delà de 65 000 lignes",
|
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||||
id="btn-download-data",
|
id="btn-download-data",
|
||||||
disabled=True,
|
disabled=True,
|
||||||
@@ -123,9 +244,36 @@ layout = [
|
|||||||
dcc.Download(id="download-data"),
|
dcc.Download(id="download-data"),
|
||||||
dcc.Store(id="filtered_data", storage_type="memory"),
|
dcc.Store(id="filtered_data", storage_type="memory"),
|
||||||
html.P("Données mises à jour le " + str(update_date)),
|
html.P("Données mises à jour le " + str(update_date)),
|
||||||
|
dbc.Button(
|
||||||
|
"Remettre à zéro",
|
||||||
|
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||||
|
id="btn-tableau-reset",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
className="table-menu",
|
className="table-menu",
|
||||||
),
|
),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(dbc.ModalTitle("Choix des colonnes à afficher")),
|
||||||
|
dbc.ModalBody(
|
||||||
|
id="tableau_columns_body",
|
||||||
|
children=make_column_picker("tableau"),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="tableau_columns_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="tableau_columns",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="xl",
|
||||||
|
),
|
||||||
datatable,
|
datatable,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -133,36 +281,39 @@ layout = [
|
|||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("table", "data"),
|
Output("tableau_datatable", "data"),
|
||||||
Output("table", "columns"),
|
Output("tableau_datatable", "columns"),
|
||||||
Output("table", "tooltip_header"),
|
Output("tableau_datatable", "tooltip_header"),
|
||||||
Output("table", "data_timestamp"),
|
Output("tableau_datatable", "data_timestamp"),
|
||||||
Output("nb_rows", "children"),
|
Output("nb_rows", "children"),
|
||||||
Output("btn-download-data", "disabled"),
|
Output("btn-download-data", "disabled"),
|
||||||
Output("btn-download-data", "children"),
|
Output("btn-download-data", "children"),
|
||||||
Output("btn-download-data", "title"),
|
Output("btn-download-data", "title"),
|
||||||
Input("table", "page_current"),
|
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
||||||
Input("table", "page_size"),
|
Input("tableau_url", "href"),
|
||||||
Input("table", "filter_query"),
|
Input("tableau_datatable", "page_current"),
|
||||||
Input("table", "sort_by"),
|
Input("tableau_datatable", "page_size"),
|
||||||
State("table", "data_timestamp"),
|
Input("tableau_datatable", "filter_query"),
|
||||||
|
Input("tableau_datatable", "sort_by"),
|
||||||
|
State("tableau_datatable", "data_timestamp"),
|
||||||
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def update_table(page_current, page_size, filter_query, sort_by, data_timestamp):
|
def update_table(href, page_current, page_size, filter_query, sort_by, data_timestamp):
|
||||||
# if ctx.triggered_id != "url":
|
# if ctx.triggered_id != "url":
|
||||||
# search_params = None
|
# search_params = None
|
||||||
# else:
|
# else:
|
||||||
# search_params = urllib.parse.parse_qs(search_params.lstrip("?"))
|
# search_params = urllib.parse.parse_qs(search_params.lstrip("?"))
|
||||||
return prepare_table_data(
|
return prepare_table_data(
|
||||||
None, data_timestamp, filter_query, page_current, page_size, sort_by
|
None, data_timestamp, filter_query, page_current, page_size, sort_by, "tableau"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("download-data", "data"),
|
Output("download-data", "data"),
|
||||||
Input("btn-download-data", "n_clicks"),
|
Input("btn-download-data", "n_clicks"),
|
||||||
State("table", "filter_query"),
|
State("tableau_datatable", "filter_query"),
|
||||||
State("table", "sort_by"),
|
State("tableau_datatable", "sort_by"),
|
||||||
State("table", "hidden_columns"),
|
State("tableau_datatable", "hidden_columns"),
|
||||||
prevent_initial_call=True,
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
||||||
@@ -173,9 +324,9 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
|||||||
lff = lff.drop(hidden_columns)
|
lff = lff.drop(hidden_columns)
|
||||||
|
|
||||||
if filter_query:
|
if filter_query:
|
||||||
lff = filter_table_data(lff, filter_query)
|
lff = filter_table_data(lff, filter_query, "tab download")
|
||||||
|
|
||||||
if len(sort_by) > 0:
|
if sort_by and len(sort_by) > 0:
|
||||||
lff = sort_table_data(lff, sort_by)
|
lff = sort_table_data(lff, sort_by)
|
||||||
|
|
||||||
def to_bytes(buffer):
|
def to_bytes(buffer):
|
||||||
@@ -186,48 +337,73 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
|
|||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("table", "filter_query"),
|
Output("tableau_datatable", "filter_query"),
|
||||||
Output("table", "sort_by"),
|
Output("tableau_datatable", "sort_by"),
|
||||||
Output("table", "hidden_columns"),
|
Output("tableau-hidden-columns", "data"),
|
||||||
Output("url", "search", allow_duplicate=True),
|
Output("tableau_url", "search"),
|
||||||
Input("url", "search"),
|
Output("filter-cleanup-trigger-tableau", "data"),
|
||||||
prevent_initial_call=True,
|
Input("tableau_url", "search"),
|
||||||
|
State("tableau_datatable", "filter_query"),
|
||||||
|
State("tableau_datatable", "sort_by"),
|
||||||
)
|
)
|
||||||
def restore_view_from_url(search):
|
def restore_view_from_url(search, stored_filters, stored_sort):
|
||||||
if not search:
|
if not search and not stored_filters:
|
||||||
return no_update, no_update, no_update, no_update
|
return no_update, no_update, no_update, no_update, no_update
|
||||||
|
|
||||||
params = urllib.parse.parse_qs(search.lstrip("?"))
|
params = urllib.parse.parse_qs(search.lstrip("?")) if search else {}
|
||||||
print("params", params)
|
logger.debug("params " + json.dumps(params, indent=2))
|
||||||
|
|
||||||
filter_query = no_update
|
filter_query = no_update
|
||||||
sort_by = no_update
|
sort_by = no_update
|
||||||
hidden_columns = no_update
|
hidden_columns = no_update
|
||||||
|
trigger_cleanup = no_update
|
||||||
|
|
||||||
if "filtres" in params:
|
if "filtres" in params:
|
||||||
filter_query = params["filtres"][0]
|
filter_query = params["filtres"][0]
|
||||||
|
trigger_cleanup = str(uuid.uuid4())
|
||||||
|
elif stored_filters:
|
||||||
|
filter_query = stored_filters
|
||||||
|
trigger_cleanup = str(uuid.uuid4())
|
||||||
|
|
||||||
if "tris" in params:
|
if "tris" in params:
|
||||||
try:
|
try:
|
||||||
sort_by = json.loads(params["tris"][0])
|
sort_by = json.loads(params["tris"][0])
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
elif stored_sort:
|
||||||
|
sort_by = stored_sort
|
||||||
|
|
||||||
if "colonnes" in params:
|
if "colonnes" in params:
|
||||||
columns = params["colonnes"][0].split(",")
|
table_columns = params["colonnes"][0].split(",")
|
||||||
verified_columns = [column for column in columns if column in schema.names()]
|
verified_columns = [
|
||||||
|
column for column in table_columns if column in schema.names()
|
||||||
|
]
|
||||||
hidden_columns = invert_columns(verified_columns)
|
hidden_columns = invert_columns(verified_columns)
|
||||||
|
|
||||||
return filter_query, sort_by, hidden_columns, ""
|
return filter_query, sort_by, hidden_columns, "", trigger_cleanup
|
||||||
|
|
||||||
|
|
||||||
|
# Pour nettoyer les icontains et i< des filtres
|
||||||
|
# voir aussi src/assets/dash_clientside.js
|
||||||
|
clientside_callback(
|
||||||
|
ClientsideFunction(
|
||||||
|
namespace="clientside",
|
||||||
|
function_name="clean_filters",
|
||||||
|
),
|
||||||
|
Output("filter-cleanup-trigger-tableau", "data", allow_duplicate=True),
|
||||||
|
Input("filter-cleanup-trigger-tableau", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback(
|
@callback(
|
||||||
Output("share-url", "value"),
|
Output("share-url", "value"),
|
||||||
Output("copy-container", "children"),
|
Output("copy-container", "children"),
|
||||||
Input("table", "filter_query"),
|
Input("tableau_datatable", "filter_query"),
|
||||||
Input("table", "sort_by"),
|
Input("tableau_datatable", "sort_by"),
|
||||||
Input("table", "hidden_columns"),
|
Input("tableau_datatable", "hidden_columns"),
|
||||||
State("url", "href"),
|
State("tableau_url", "href"),
|
||||||
|
prevent_initial_call=True,
|
||||||
)
|
)
|
||||||
def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
||||||
if not href:
|
if not href:
|
||||||
@@ -244,9 +420,9 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
|||||||
params["tris"] = json.dumps(sort_by)
|
params["tris"] = json.dumps(sort_by)
|
||||||
|
|
||||||
if hidden_columns:
|
if hidden_columns:
|
||||||
columns = invert_columns(hidden_columns)
|
table_columns = invert_columns(hidden_columns)
|
||||||
columns = ",".join(columns)
|
table_columns = ",".join(table_columns)
|
||||||
params["colonnes"] = columns
|
params["colonnes"] = table_columns
|
||||||
|
|
||||||
query_string = urllib.parse.urlencode(params)
|
query_string = urllib.parse.urlencode(params)
|
||||||
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
full_url = f"{base_url}?{query_string}" if query_string else base_url
|
||||||
@@ -262,6 +438,13 @@ def sync_url_and_reset_button(filter_query, sort_by, hidden_columns, href):
|
|||||||
"cursor": "pointer",
|
"cursor": "pointer",
|
||||||
},
|
},
|
||||||
className="fa fa-link",
|
className="fa fa-link",
|
||||||
|
children=[
|
||||||
|
dbc.Button(
|
||||||
|
"Partager",
|
||||||
|
className="btn btn-primary",
|
||||||
|
title="Copier l'adresse de cette vue (filtres, tris, choix de colonnes) pour la partager.",
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
return full_url, copy_button
|
return full_url, copy_button
|
||||||
@@ -279,3 +462,74 @@ def show_confirmation(n_clicks):
|
|||||||
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
|
style={"color": "green", "fontWeight": "bold", "marginLeft": "10px"},
|
||||||
)
|
)
|
||||||
return no_update
|
return no_update
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_help", "is_open"),
|
||||||
|
[Input("tableau_help_open", "n_clicks"), Input("tableau_help_close", "n_clicks")],
|
||||||
|
[State("tableau_help", "is_open")],
|
||||||
|
)
|
||||||
|
def toggle_tableau_help(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau-hidden-columns", "data", allow_duplicate=True),
|
||||||
|
Input("tableau_column_list", "selected_rows"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_hidden_columns_from_checkboxes(selected_columns):
|
||||||
|
if selected_columns:
|
||||||
|
selected_columns = [columns[i] for i in selected_columns]
|
||||||
|
hidden_columns = [col for col in columns if col not in selected_columns]
|
||||||
|
return hidden_columns
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_datatable", "hidden_columns"),
|
||||||
|
Input(
|
||||||
|
"tableau-hidden-columns",
|
||||||
|
"data",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def store_hidden_columns(hidden_columns):
|
||||||
|
return hidden_columns
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_column_list", "selected_rows"),
|
||||||
|
Input("tableau_datatable", "hidden_columns"),
|
||||||
|
State("tableau_column_list", "selected_rows"), # pour éviter la boucle infinie
|
||||||
|
)
|
||||||
|
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
|
||||||
|
hidden_cols = hidden_cols or get_default_hidden_columns("tableau")
|
||||||
|
|
||||||
|
# Show all columns that are NOT hidden
|
||||||
|
visible_cols = [columns.index(col) for col in columns if col not in hidden_cols]
|
||||||
|
return visible_cols
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_columns", "is_open"),
|
||||||
|
Input("tableau_columns_open", "n_clicks"),
|
||||||
|
Input("tableau_columns_close", "n_clicks"),
|
||||||
|
State("tableau_columns", "is_open"),
|
||||||
|
)
|
||||||
|
def toggle_tableau_columns(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("tableau_datatable", "filter_query", allow_duplicate=True),
|
||||||
|
Output("tableau_datatable", "sort_by", allow_duplicate=True),
|
||||||
|
Input("btn-tableau-reset", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def reset_view(n_clicks):
|
||||||
|
return "", []
|
||||||
|
|||||||
+194
-33
@@ -1,12 +1,26 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import dash_bootstrap_components as dbc
|
||||||
import polars as pl
|
import polars as pl
|
||||||
from dash import Input, Output, State, callback, dcc, html, register_page
|
from dash import (
|
||||||
|
ClientsideFunction,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
State,
|
||||||
|
callback,
|
||||||
|
clientside_callback,
|
||||||
|
dcc,
|
||||||
|
html,
|
||||||
|
register_page,
|
||||||
|
)
|
||||||
|
|
||||||
from src.callbacks import get_top_org_table
|
from src.callbacks import get_top_org_table
|
||||||
from src.figures import DataTable, point_on_map
|
from src.figures import DataTable, make_column_picker, point_on_map
|
||||||
from src.utils import (
|
from src.utils import (
|
||||||
|
columns,
|
||||||
df,
|
df,
|
||||||
|
df_titulaires,
|
||||||
filter_table_data,
|
filter_table_data,
|
||||||
format_number,
|
format_number,
|
||||||
get_annuaire_data,
|
get_annuaire_data,
|
||||||
@@ -18,12 +32,22 @@ from src.utils import (
|
|||||||
sort_table_data,
|
sort_table_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_title(titulaire_id: str = None) -> str:
|
||||||
|
titulaire_nom = df_titulaires.filter(pl.col("titulaire_id") == titulaire_id).select(
|
||||||
|
"titulaire_nom"
|
||||||
|
)
|
||||||
|
if titulaire_nom.height > 0:
|
||||||
|
return f"Marchés publics remportés par {titulaire_nom.item(0, 0)} | decp.info"
|
||||||
|
return "Marchés publics remportés | decp.info"
|
||||||
|
|
||||||
|
|
||||||
register_page(
|
register_page(
|
||||||
__name__,
|
__name__,
|
||||||
path_template="/titulaires/<titulaire_id>",
|
path_template="/titulaires/<titulaire_id>",
|
||||||
title=meta_content["title"],
|
title=get_title,
|
||||||
name="Titulaire",
|
name="Titulaire",
|
||||||
description=meta_content["description"],
|
description="Consultez les marchés publics remportés par ce titulaire.",
|
||||||
image_url=meta_content["image_url"],
|
image_url=meta_content["image_url"],
|
||||||
order=5,
|
order=5,
|
||||||
)
|
)
|
||||||
@@ -32,17 +56,23 @@ datatable = html.Div(
|
|||||||
className="marches_table",
|
className="marches_table",
|
||||||
children=DataTable(
|
children=DataTable(
|
||||||
dtid="titulaire_datatable",
|
dtid="titulaire_datatable",
|
||||||
|
persistence=True,
|
||||||
|
persistence_type="local",
|
||||||
|
persisted_props=["filter_query", "sort_by"],
|
||||||
page_action="custom",
|
page_action="custom",
|
||||||
filter_action="custom",
|
filter_action="custom",
|
||||||
sort_action="custom",
|
sort_action="custom",
|
||||||
page_size=10,
|
page_size=10,
|
||||||
hidden_columns=get_default_hidden_columns(page="titulaire"),
|
hidden_columns=[],
|
||||||
|
columns=[{"id": col, "name": col} for col in df.columns],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
layout = [
|
layout = [
|
||||||
dcc.Store(id="titulaire_data", storage_type="memory"),
|
dcc.Store(id="titulaire_data", storage_type="memory"),
|
||||||
dcc.Location(id="url", refresh="callback-nav"),
|
dcc.Store(id="titulaire-hidden-columns", storage_type="local"),
|
||||||
|
dcc.Store(id="filter-cleanup-trigger-titulaire"),
|
||||||
|
dcc.Location(id="titulaire_url", refresh="callback-nav"),
|
||||||
html.Div(
|
html.Div(
|
||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
@@ -60,7 +90,7 @@ layout = [
|
|||||||
className="org_year",
|
className="org_year",
|
||||||
children=dcc.Dropdown(
|
children=dcc.Dropdown(
|
||||||
id="titulaire_year",
|
id="titulaire_year",
|
||||||
options=["Toutes"]
|
options=["Toutes les années"]
|
||||||
+ [
|
+ [
|
||||||
str(year)
|
str(year)
|
||||||
for year in range(
|
for year in range(
|
||||||
@@ -85,7 +115,6 @@ layout = [
|
|||||||
html.A(
|
html.A(
|
||||||
id="titulaire_lien_annuaire",
|
id="titulaire_lien_annuaire",
|
||||||
children="Plus de détails sur l'Annuaire des entreprises",
|
children="Plus de détails sur l'Annuaire des entreprises",
|
||||||
target="_blank",
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -98,6 +127,7 @@ layout = [
|
|||||||
html.Button(
|
html.Button(
|
||||||
"Téléchargement au format Excel",
|
"Téléchargement au format Excel",
|
||||||
id="btn-download-data-titulaire",
|
id="btn-download-data-titulaire",
|
||||||
|
className="btn btn-primary",
|
||||||
),
|
),
|
||||||
dcc.Download(id="download-data-titulaire"),
|
dcc.Download(id="download-data-titulaire"),
|
||||||
],
|
],
|
||||||
@@ -121,16 +151,53 @@ layout = [
|
|||||||
children=[
|
children=[
|
||||||
html.Div(
|
html.Div(
|
||||||
[
|
[
|
||||||
|
# Bouton modal des colonnes affichées
|
||||||
|
dbc.Button(
|
||||||
|
"Colonnes affichées",
|
||||||
|
id="titulaire_columns_open",
|
||||||
|
className="column_list",
|
||||||
|
),
|
||||||
html.P("lignes", id="titulaire_nb_rows"),
|
html.P("lignes", id="titulaire_nb_rows"),
|
||||||
html.Button(
|
html.Button(
|
||||||
"Téléchargement désactivé au-delà de 65 000 lignes",
|
"Téléchargement désactivé au-delà de 65 000 lignes",
|
||||||
id="btn-download-filtered-data-titulaire",
|
id="btn-download-filtered-data-titulaire",
|
||||||
disabled=True,
|
disabled=True,
|
||||||
|
className="btn btn-primary",
|
||||||
),
|
),
|
||||||
dcc.Download(id="titulaire-download-filtered-data"),
|
dcc.Download(id="titulaire-download-filtered-data"),
|
||||||
|
dbc.Button(
|
||||||
|
"Remise à zéro",
|
||||||
|
title="Supprime tous les filtres et les tris. Autrement ils sont conservés même si vous fermez la page.",
|
||||||
|
id="btn-titulaire-reset",
|
||||||
|
className="btn btn-primary",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
className="table-menu",
|
className="table-menu",
|
||||||
),
|
),
|
||||||
|
dbc.Modal(
|
||||||
|
[
|
||||||
|
dbc.ModalHeader(
|
||||||
|
dbc.ModalTitle("Choix des colonnes à afficher")
|
||||||
|
),
|
||||||
|
dbc.ModalBody(
|
||||||
|
id="titulaire_columns_body",
|
||||||
|
children=make_column_picker("titulaire"),
|
||||||
|
),
|
||||||
|
dbc.ModalFooter(
|
||||||
|
dbc.Button(
|
||||||
|
"Fermer",
|
||||||
|
id="titulaire_columns_close",
|
||||||
|
className="ms-auto",
|
||||||
|
n_clicks=0,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
id="titulaire_columns",
|
||||||
|
is_open=False,
|
||||||
|
fullscreen="md-down",
|
||||||
|
scrollable=True,
|
||||||
|
size="xl",
|
||||||
|
),
|
||||||
datatable,
|
datatable,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -147,30 +214,42 @@ layout = [
|
|||||||
Output(component_id="titulaire_departement", component_property="children"),
|
Output(component_id="titulaire_departement", component_property="children"),
|
||||||
Output(component_id="titulaire_region", component_property="children"),
|
Output(component_id="titulaire_region", component_property="children"),
|
||||||
Output(component_id="titulaire_lien_annuaire", component_property="href"),
|
Output(component_id="titulaire_lien_annuaire", component_property="href"),
|
||||||
Input(component_id="url", component_property="pathname"),
|
Input(component_id="titulaire_url", component_property="pathname"),
|
||||||
)
|
)
|
||||||
def update_titulaire_infos(url):
|
def update_titulaire_infos(url):
|
||||||
titulaire_siret = url.split("/")[-1]
|
titulaire_siret = url.split("/")[-1]
|
||||||
if len(titulaire_siret) != 14:
|
|
||||||
titulaire_siret = (
|
|
||||||
f"Le SIRET renseigné doit faire 14 caractères ({titulaire_siret})"
|
|
||||||
)
|
|
||||||
data = get_annuaire_data(titulaire_siret)
|
data = get_annuaire_data(titulaire_siret)
|
||||||
data_etablissement = data["matching_etablissements"][0]
|
data_etablissement = data.get("matching_etablissements") if data else None
|
||||||
titulaire_map = point_on_map(
|
if data_etablissement:
|
||||||
data_etablissement["latitude"], data_etablissement["longitude"]
|
data_etablissement = data_etablissement[0]
|
||||||
)
|
|
||||||
code_departement, nom_departement, nom_region = get_departement_region(
|
titulaire_map = point_on_map(
|
||||||
data_etablissement["code_postal"]
|
data_etablissement["latitude"], data_etablissement["longitude"]
|
||||||
)
|
)
|
||||||
departement = f"{nom_departement} ({code_departement})"
|
code_departement, nom_departement, nom_region = get_departement_region(
|
||||||
lien_annuaire = (
|
data_etablissement["code_postal"]
|
||||||
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
)
|
||||||
)
|
departement = f"{nom_departement} ({code_departement})"
|
||||||
|
lien_annuaire = (
|
||||||
|
f"https://annuaire-entreprises.data.gouv.fr/etablissement/{titulaire_siret}"
|
||||||
|
)
|
||||||
|
raison_sociale = data["nom_raison_sociale"]
|
||||||
|
libelle_commune = data_etablissement["libelle_commune"]
|
||||||
|
|
||||||
|
else:
|
||||||
|
titulaire_map = html.Div()
|
||||||
|
code_departement, nom_departement, nom_region = "", "", ""
|
||||||
|
departement = ""
|
||||||
|
lien_annuaire = ""
|
||||||
|
raison_sociale = html.Span(
|
||||||
|
f"N° SIREN inconnu de l'INSEE ({titulaire_siret[:9]})"
|
||||||
|
)
|
||||||
|
libelle_commune = ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
titulaire_siret,
|
titulaire_siret,
|
||||||
data["nom_raison_sociale"],
|
raison_sociale,
|
||||||
data_etablissement["libelle_commune"],
|
libelle_commune,
|
||||||
titulaire_map,
|
titulaire_map,
|
||||||
departement,
|
departement,
|
||||||
nom_region,
|
nom_region,
|
||||||
@@ -214,7 +293,7 @@ def update_titulaire_stats(data):
|
|||||||
Output("btn-download-data-titulaire", "disabled"),
|
Output("btn-download-data-titulaire", "disabled"),
|
||||||
Output("btn-download-data-titulaire", "children"),
|
Output("btn-download-data-titulaire", "children"),
|
||||||
Output("btn-download-data-titulaire", "title"),
|
Output("btn-download-data-titulaire", "title"),
|
||||||
Input(component_id="url", component_property="pathname"),
|
Input(component_id="titulaire_url", component_property="pathname"),
|
||||||
Input(component_id="titulaire_year", component_property="value"),
|
Input(component_id="titulaire_year", component_property="value"),
|
||||||
)
|
)
|
||||||
def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
||||||
@@ -224,7 +303,7 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
|||||||
(pl.col("titulaire_id") == titulaire_siret)
|
(pl.col("titulaire_id") == titulaire_siret)
|
||||||
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
|
& (pl.col("titulaire_typeIdentifiant") == "SIRET")
|
||||||
)
|
)
|
||||||
if titulaire_year and titulaire_year != "Toutes":
|
if titulaire_year and titulaire_year != "Toutes les années":
|
||||||
lff = lff.filter(
|
lff = lff.filter(
|
||||||
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
|
pl.col("dateNotification").cast(pl.String).str.starts_with(titulaire_year)
|
||||||
)
|
)
|
||||||
@@ -247,6 +326,8 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
|||||||
Output("btn-download-filtered-data-titulaire", "disabled"),
|
Output("btn-download-filtered-data-titulaire", "disabled"),
|
||||||
Output("btn-download-filtered-data-titulaire", "children"),
|
Output("btn-download-filtered-data-titulaire", "children"),
|
||||||
Output("btn-download-filtered-data-titulaire", "title"),
|
Output("btn-download-filtered-data-titulaire", "title"),
|
||||||
|
Output("filter-cleanup-trigger-titulaire", "data"),
|
||||||
|
Input(component_id="titulaire_url", component_property="href"),
|
||||||
Input("titulaire_data", "data"),
|
Input("titulaire_data", "data"),
|
||||||
Input("titulaire_datatable", "page_current"),
|
Input("titulaire_datatable", "page_current"),
|
||||||
Input("titulaire_datatable", "page_size"),
|
Input("titulaire_datatable", "page_size"),
|
||||||
@@ -255,10 +336,16 @@ def get_titulaire_marches_data(url, titulaire_year: str) -> tuple:
|
|||||||
State("titulaire_datatable", "data_timestamp"),
|
State("titulaire_datatable", "data_timestamp"),
|
||||||
)
|
)
|
||||||
def get_last_marches_data(
|
def get_last_marches_data(
|
||||||
data, page_current, page_size, filter_query, sort_by, data_timestamp
|
href, data, page_current, page_size, filter_query, sort_by, data_timestamp
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
return prepare_table_data(
|
return prepare_table_data(
|
||||||
data, data_timestamp, filter_query, page_current, page_size, sort_by
|
data,
|
||||||
|
data_timestamp,
|
||||||
|
filter_query,
|
||||||
|
page_current,
|
||||||
|
page_size,
|
||||||
|
sort_by,
|
||||||
|
"titulaire",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -280,7 +367,7 @@ def get_top_acheteurs(data):
|
|||||||
)
|
)
|
||||||
def download_titulaire_data(
|
def download_titulaire_data(
|
||||||
n_clicks,
|
n_clicks,
|
||||||
data: [dict],
|
data: list[dict[str, Any]],
|
||||||
titulaire_nom: str,
|
titulaire_nom: str,
|
||||||
annee: str,
|
annee: str,
|
||||||
):
|
):
|
||||||
@@ -288,7 +375,7 @@ def download_titulaire_data(
|
|||||||
|
|
||||||
def to_bytes(buffer):
|
def to_bytes(buffer):
|
||||||
df_to_download.write_excel(
|
df_to_download.write_excel(
|
||||||
buffer, worksheet="DECP" if annee in ["Toutes", None] else annee
|
buffer, worksheet="DECP" if annee in ["Toutes les années", None] else annee
|
||||||
)
|
)
|
||||||
|
|
||||||
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
date = datetime.datetime.now().strftime("%Y-%m-%d_%H:%M:%S")
|
||||||
@@ -317,7 +404,7 @@ def download_filtered_titulaire_data(
|
|||||||
lff = lff.drop(hidden_columns)
|
lff = lff.drop(hidden_columns)
|
||||||
|
|
||||||
if filter_query:
|
if filter_query:
|
||||||
lff = filter_table_data(lff, filter_query)
|
lff = filter_table_data(lff, filter_query, "titu download")
|
||||||
|
|
||||||
if len(sort_by) > 0:
|
if len(sort_by) > 0:
|
||||||
lff = sort_table_data(lff, sort_by)
|
lff = sort_table_data(lff, sort_by)
|
||||||
@@ -329,3 +416,77 @@ def download_filtered_titulaire_data(
|
|||||||
return dcc.send_bytes(
|
return dcc.send_bytes(
|
||||||
to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx"
|
to_bytes, filename=f"decp_filtrées_{titulaire_nom}_{date}.xlsx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Pour nettoyer les icontains et i< des filtres
|
||||||
|
# voir aussi src/assets/dash_clientside.js
|
||||||
|
clientside_callback(
|
||||||
|
ClientsideFunction(
|
||||||
|
namespace="clientside",
|
||||||
|
function_name="clean_filters",
|
||||||
|
),
|
||||||
|
Output("filter-cleanup-trigger-titulaire", "data", allow_duplicate=True),
|
||||||
|
Input("filter-cleanup-trigger-titulaire", "data"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire-hidden-columns", "data", allow_duplicate=True),
|
||||||
|
Input("titulaire_column_list", "selected_rows"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def update_hidden_columns_from_checkboxes(selected_columns):
|
||||||
|
if selected_columns:
|
||||||
|
selected_columns = [columns[i] for i in selected_columns]
|
||||||
|
hidden_columns = [col for col in columns if col not in selected_columns]
|
||||||
|
return hidden_columns
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_datatable", "hidden_columns", allow_duplicate=True),
|
||||||
|
Input(
|
||||||
|
"titulaire-hidden-columns",
|
||||||
|
"data",
|
||||||
|
),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def store_hidden_columns(hidden_columns):
|
||||||
|
return hidden_columns
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_column_list", "selected_rows"),
|
||||||
|
Input("titulaire_datatable", "hidden_columns"),
|
||||||
|
State("titulaire_column_list", "selected_rows"), # pour éviter la boucle infinie
|
||||||
|
)
|
||||||
|
def update_checkboxes_from_hidden_columns(hidden_cols, current_checkboxes):
|
||||||
|
hidden_cols = hidden_cols or get_default_hidden_columns("titulaire")
|
||||||
|
|
||||||
|
# Show all columns that are NOT hidden
|
||||||
|
visible_cols = [columns.index(col) for col in columns if col not in hidden_cols]
|
||||||
|
return visible_cols
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_columns", "is_open"),
|
||||||
|
Input("titulaire_columns_open", "n_clicks"),
|
||||||
|
Input("titulaire_columns_close", "n_clicks"),
|
||||||
|
State("titulaire_columns", "is_open"),
|
||||||
|
)
|
||||||
|
def toggle_titulaire_columns(click_open, click_close, is_open):
|
||||||
|
if click_open or click_close:
|
||||||
|
return not is_open
|
||||||
|
return is_open
|
||||||
|
|
||||||
|
|
||||||
|
@callback(
|
||||||
|
Output("titulaire_datatable", "filter_query", allow_duplicate=True),
|
||||||
|
Output("titulaire_datatable", "sort_by"),
|
||||||
|
Input("btn-titulaire-reset", "n_clicks"),
|
||||||
|
prevent_initial_call=True,
|
||||||
|
)
|
||||||
|
def reset_view(n_clicks):
|
||||||
|
return "", []
|
||||||
|
|||||||
+178
-87
@@ -2,22 +2,27 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections import OrderedDict
|
||||||
from time import localtime, sleep
|
from time import localtime, sleep
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
import polars.selectors as cs
|
import polars.selectors as cs
|
||||||
from httpx import get, post
|
from dash import no_update
|
||||||
|
from httpx import HTTPError, get, post
|
||||||
from polars.exceptions import ComputeError
|
from polars.exceptions import ComputeError
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
|
||||||
logger = logging.getLogger("decp.info")
|
|
||||||
logging.getLogger("httpx").setLevel("WARNING")
|
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
|
logger = logging.getLogger("decp.info")
|
||||||
|
development = os.getenv("DEVELOPMENT", "False").lower() == "true"
|
||||||
|
if development:
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
logging.getLogger("httpx").setLevel("WARNING")
|
||||||
|
|
||||||
|
|
||||||
def split_filter_part(filter_part):
|
def split_filter_part(filter_part):
|
||||||
@@ -29,14 +34,14 @@ def split_filter_part(filter_part):
|
|||||||
["icontains", "contains"],
|
["icontains", "contains"],
|
||||||
# [" ", "contains"]
|
# [" ", "contains"]
|
||||||
]
|
]
|
||||||
print("filter part", filter_part)
|
logger.debug("filter part " + filter_part)
|
||||||
for operator_group in operators:
|
for operator_group in operators:
|
||||||
if operator_group[0] in filter_part:
|
if operator_group[0] in filter_part:
|
||||||
name_part, value_part = filter_part.split(operator_group[0], 1)
|
name_part, value_part = filter_part.split(operator_group[0], 1)
|
||||||
name_part = name_part.strip()
|
name_part = name_part.strip()
|
||||||
value = value_part.strip()
|
value = value_part.strip()
|
||||||
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
name = name_part[name_part.find("{") + 1 : name_part.rfind("}")]
|
||||||
print("=>", name, operator_group[1], value)
|
logger.debug("=> " + " ".join([name, operator_group[1], value]))
|
||||||
|
|
||||||
return name, operator_group[1], value
|
return name, operator_group[1], value
|
||||||
|
|
||||||
@@ -53,7 +58,7 @@ def add_resource_link(dff: pl.DataFrame) -> pl.DataFrame:
|
|||||||
return dff
|
return dff
|
||||||
|
|
||||||
|
|
||||||
def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
def add_links(dff: pl.DataFrame):
|
||||||
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
|
for col in ["uid", "acheteur_nom", "titulaire_nom", "acheteur_id", "titulaire_id"]:
|
||||||
if col in dff.columns:
|
if col in dff.columns:
|
||||||
if col.startswith("titulaire_"):
|
if col.startswith("titulaire_"):
|
||||||
@@ -67,7 +72,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
|||||||
.then(
|
.then(
|
||||||
'<a href = "/titulaires/'
|
'<a href = "/titulaires/'
|
||||||
+ pl.col("titulaire_id")
|
+ pl.col("titulaire_id")
|
||||||
+ f'" target="{target}">'
|
+ '">'
|
||||||
+ pl.col(col)
|
+ pl.col(col)
|
||||||
+ "</a>"
|
+ "</a>"
|
||||||
)
|
)
|
||||||
@@ -79,7 +84,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
|||||||
(
|
(
|
||||||
'<a href = "/acheteurs/'
|
'<a href = "/acheteurs/'
|
||||||
+ pl.col("acheteur_id")
|
+ pl.col("acheteur_id")
|
||||||
+ f'" target="{target}">'
|
+ '">'
|
||||||
+ pl.col(col)
|
+ pl.col(col)
|
||||||
+ "</a>"
|
+ "</a>"
|
||||||
).alias(col)
|
).alias(col)
|
||||||
@@ -89,7 +94,7 @@ def add_links(dff: pl.DataFrame, target: str = "_blank"):
|
|||||||
(
|
(
|
||||||
'<a href = "/marches/'
|
'<a href = "/marches/'
|
||||||
+ pl.col("uid")
|
+ pl.col("uid")
|
||||||
+ f'" target="{target}">'
|
+ '">'
|
||||||
+ pl.col("uid")
|
+ pl.col("uid")
|
||||||
+ "</a>"
|
+ "</a>"
|
||||||
).alias("uid")
|
).alias("uid")
|
||||||
@@ -145,6 +150,14 @@ def format_number(number) -> str:
|
|||||||
return number
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def unformat_montant(number: str) -> float:
|
||||||
|
number = number.replace(" €", "")
|
||||||
|
number = number.replace(" €", "").replace(" ", "")
|
||||||
|
number = number.replace(",", ".")
|
||||||
|
number = number.strip()
|
||||||
|
return float(number)
|
||||||
|
|
||||||
|
|
||||||
def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
||||||
def format_montant(expr, scale=None):
|
def format_montant(expr, scale=None):
|
||||||
# https://stackoverflow.com/a/78636786
|
# https://stackoverflow.com/a/78636786
|
||||||
@@ -182,9 +195,11 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
|||||||
|
|
||||||
if "montant" in dff.columns:
|
if "montant" in dff.columns:
|
||||||
dff = dff.with_columns(pl.col("montant").pipe(format_montant).alias("montant"))
|
dff = dff.with_columns(pl.col("montant").pipe(format_montant).alias("montant"))
|
||||||
if "distance" in dff.columns:
|
if "titulaire_distance" in dff.columns:
|
||||||
dff = dff.with_columns(
|
dff = dff.with_columns(
|
||||||
pl.col("distance").pipe(format_distance).alias("distance")
|
pl.col("titulaire_distance")
|
||||||
|
.pipe(format_distance)
|
||||||
|
.alias("titulaire_distance")
|
||||||
)
|
)
|
||||||
|
|
||||||
return dff
|
return dff
|
||||||
@@ -192,8 +207,13 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
|||||||
|
|
||||||
def get_annuaire_data(siret: str) -> dict:
|
def get_annuaire_data(siret: str) -> dict:
|
||||||
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
url = f"https://recherche-entreprises.api.gouv.fr/search?q={siret}"
|
||||||
response = get(url)
|
try:
|
||||||
return response.json()["results"][0]
|
response = get(url).raise_for_status()
|
||||||
|
response = response.json()["results"][0]
|
||||||
|
except (HTTPError, IndexError):
|
||||||
|
response = None
|
||||||
|
logger.warning("Could not fetch data from recherche-entreprises.api.")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def get_decp_data() -> pl.DataFrame:
|
def get_decp_data() -> pl.DataFrame:
|
||||||
@@ -221,6 +241,15 @@ def get_decp_data() -> pl.DataFrame:
|
|||||||
# Convertir les colonnes booléennes en chaînes de caractères
|
# Convertir les colonnes booléennes en chaînes de caractères
|
||||||
lff = booleans_to_strings(lff)
|
lff = booleans_to_strings(lff)
|
||||||
|
|
||||||
|
# Mention pour les org dont on a pas le nom
|
||||||
|
for col in ["acheteur_nom", "titulaire_nom"]:
|
||||||
|
lff = lff.with_columns(
|
||||||
|
pl.when(pl.col(col).is_null())
|
||||||
|
.then(pl.lit("[Identifiant non reconnu dans la base INSEE]"))
|
||||||
|
.otherwise(pl.col(col))
|
||||||
|
.name.keep()
|
||||||
|
)
|
||||||
|
|
||||||
# Bizarrement je ne peux pas faire lff = lff.fill_null("") ici
|
# Bizarrement je ne peux pas faire lff = lff.fill_null("") ici
|
||||||
# ça génère une erreur dans la page acheteur (acheteur_data.table) :
|
# ça génère une erreur dans la page acheteur (acheteur_data.table) :
|
||||||
# AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import)
|
# AttributeError: partially initialized module 'pandas' has no attribute 'NaT' (most likely due to a circular import)
|
||||||
@@ -240,6 +269,17 @@ def get_org_data(dff: pl.DataFrame, org_type: str) -> pl.DataFrame:
|
|||||||
return lff.collect()
|
return lff.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def get_statistics() -> dict:
|
||||||
|
return (
|
||||||
|
get(
|
||||||
|
"https://www.data.gouv.fr/api/1/datasets/r/0ccf4a75-f3aa-4b46-8b6a-18aeb63e36df",
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
.raise_for_status()
|
||||||
|
.json()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_departements() -> dict:
|
def get_departements() -> dict:
|
||||||
with open("data/departements.json", "rb") as f:
|
with open("data/departements.json", "rb") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
@@ -256,18 +296,19 @@ def get_departement_region(code_postal):
|
|||||||
return code_departement, nom_departement, nom_region
|
return code_departement, nom_departement, nom_region
|
||||||
|
|
||||||
|
|
||||||
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
|
def filter_table_data(
|
||||||
debug = os.getenv("DEVELOPMENT", "False").lower() == "true"
|
lff: pl.LazyFrame, filter_query: str, filter_source: str
|
||||||
schema = lff.collect_schema()
|
) -> pl.LazyFrame:
|
||||||
|
_schema = lff.collect_schema()
|
||||||
|
track_search(filter_query, filter_source)
|
||||||
filtering_expressions = filter_query.split(" && ")
|
filtering_expressions = filter_query.split(" && ")
|
||||||
for filter_part in filtering_expressions:
|
for filter_part in filtering_expressions:
|
||||||
col_name, operator, filter_value = split_filter_part(filter_part)
|
col_name, operator, filter_value = split_filter_part(filter_part)
|
||||||
col_type = str(schema[col_name])
|
col_type = str(_schema[col_name])
|
||||||
if debug:
|
# logger.debug("filter_value:", filter_value)
|
||||||
print("filter_value:", filter_value)
|
# logger.debug("filter_value_type:", type(filter_value))
|
||||||
print("filter_value_type:", type(filter_value))
|
# logger.debug("operator:", operator)
|
||||||
print("operator:", operator)
|
# logger.debug("col_type:", col_type)
|
||||||
print("col_type:", col_type)
|
|
||||||
|
|
||||||
lff = lff.filter(pl.col(col_name).is_not_null())
|
lff = lff.filter(pl.col(col_name).is_not_null())
|
||||||
|
|
||||||
@@ -297,9 +338,18 @@ def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
|
|||||||
elif operator == "contains":
|
elif operator == "contains":
|
||||||
if col_type in ["String", "Date"]:
|
if col_type in ["String", "Date"]:
|
||||||
filter_value = filter_value.strip('"')
|
filter_value = filter_value.strip('"')
|
||||||
lff = lff.filter(
|
if filter_value.endswith("*"):
|
||||||
pl.col(col_name).str.contains("(?i)" + filter_value)
|
lff = lff.filter(
|
||||||
)
|
pl.col(col_name).str.starts_with(filter_value[:-1])
|
||||||
|
)
|
||||||
|
elif filter_value.startswith("*"):
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col(col_name).str.ends_with(filter_value[1:])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lff = lff.filter(
|
||||||
|
pl.col(col_name).str.contains("(?i)" + filter_value)
|
||||||
|
)
|
||||||
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
elif col_type.startswith("Int") or col_type.startswith("Float"):
|
||||||
lff = lff.filter(pl.col(col_name) == filter_value)
|
lff = lff.filter(pl.col(col_name) == filter_value)
|
||||||
else:
|
else:
|
||||||
@@ -319,12 +369,15 @@ def sort_table_data(lff: pl.LazyFrame, sort_by: list) -> pl.LazyFrame:
|
|||||||
descending=[col["direction"] == "desc" for col in sort_by],
|
descending=[col["direction"] == "desc" for col in sort_by],
|
||||||
nulls_last=True,
|
nulls_last=True,
|
||||||
)
|
)
|
||||||
print(sort_by)
|
logger.debug(sort_by)
|
||||||
return lff
|
return lff
|
||||||
|
|
||||||
|
|
||||||
def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tuple:
|
def setup_table_columns(
|
||||||
|
dff, hideable: bool = True, exclude: list = None, new_columns: list = None
|
||||||
|
) -> tuple:
|
||||||
# Liste finale de colonnes
|
# Liste finale de colonnes
|
||||||
|
markdown_exceptions = ["montant", "titulaire_distance", "distance", "dureeMois"]
|
||||||
columns = []
|
columns = []
|
||||||
tooltip = {}
|
tooltip = {}
|
||||||
for column_id in dff.columns:
|
for column_id in dff.columns:
|
||||||
@@ -334,26 +387,30 @@ def setup_table_columns(dff, hideable: bool = True, exclude: list = None) -> tup
|
|||||||
if column_object:
|
if column_object:
|
||||||
column_name = column_object.get("title")
|
column_name = column_object.get("title")
|
||||||
else:
|
else:
|
||||||
# Si le champ n'est pas dans le schéma, on le skip
|
# Si le champ est un champ créé par erreur lors d'une jointure, on le skip
|
||||||
print(f"Champ innatendu : {column_id}")
|
if column_id.endswith("_left") or column_id.endswith("_right"):
|
||||||
continue
|
logger.warning(f"Champ innatendu : {column_id}")
|
||||||
|
continue
|
||||||
|
column_name = column_id
|
||||||
|
column_object = {"title": column_name, "description": ""}
|
||||||
|
|
||||||
|
presentation = "input" if column_id in markdown_exceptions else "markdown"
|
||||||
|
|
||||||
column = {
|
column = {
|
||||||
"name": column_name,
|
"name": column_name,
|
||||||
"id": column_id,
|
"id": column_id,
|
||||||
"presentation": "markdown",
|
"presentation": presentation,
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"format": {"nully": "N/A"},
|
"format": {"nully": "N/A"},
|
||||||
"hideable": hideable,
|
"hideable": hideable,
|
||||||
}
|
}
|
||||||
columns.append(column)
|
columns.append(column)
|
||||||
|
|
||||||
if column_object:
|
if column_object:
|
||||||
tooltip[column_id] = {
|
tooltip[column_id] = {
|
||||||
"value": f"""**{column_object.get("title")}** ({column_id})
|
"value": f"""**{column_object.get("title")}** ({column_id})
|
||||||
|
|
||||||
"""
|
"""
|
||||||
+ column_object["description"],
|
+ column_object.get("description", ""),
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
}
|
}
|
||||||
return columns, tooltip
|
return columns, tooltip
|
||||||
@@ -368,7 +425,7 @@ def get_default_hidden_columns(page):
|
|||||||
"titulaire_id",
|
"titulaire_id",
|
||||||
"titulaire_typeIdentifiant",
|
"titulaire_typeIdentifiant",
|
||||||
"titulaire_nom",
|
"titulaire_nom",
|
||||||
"distance",
|
"titulaire_distance",
|
||||||
"montant",
|
"montant",
|
||||||
"codeCPV",
|
"codeCPV",
|
||||||
"dureeRestanteMois",
|
"dureeRestanteMois",
|
||||||
@@ -380,17 +437,16 @@ def get_default_hidden_columns(page):
|
|||||||
"dateNotification",
|
"dateNotification",
|
||||||
"acheteur_id",
|
"acheteur_id",
|
||||||
"acheteur_nom",
|
"acheteur_nom",
|
||||||
"distance",
|
"titulaire_distance",
|
||||||
"montant",
|
"montant",
|
||||||
"codeCPV",
|
"codeCPV",
|
||||||
"dureeRestanteMois",
|
"dureeRestanteMois",
|
||||||
]
|
]
|
||||||
|
elif page == "tableau":
|
||||||
|
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
||||||
else:
|
else:
|
||||||
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
displayed_columns = os.getenv("DISPLAYED_COLUMNS")
|
||||||
if displayed_columns is None:
|
logger.warning(f"Invalid page: {page}")
|
||||||
raise ValueError("DISPLAYED_COLUMNS n'est pas configuré")
|
|
||||||
else:
|
|
||||||
displayed_columns = displayed_columns.replace(" ", "").split(",")
|
|
||||||
|
|
||||||
hidden_columns = []
|
hidden_columns = []
|
||||||
|
|
||||||
@@ -415,34 +471,23 @@ def get_data_schema() -> dict:
|
|||||||
else:
|
else:
|
||||||
raise Exception(f"Chemin vers le schéma invalide: {path}")
|
raise Exception(f"Chemin vers le schéma invalide: {path}")
|
||||||
|
|
||||||
new_schema = {}
|
new_schema = OrderedDict()
|
||||||
|
|
||||||
for col in original_schema["fields"]:
|
for col in original_schema["fields"]:
|
||||||
new_schema[col["name"]] = col
|
new_schema[col["name"]] = col
|
||||||
|
|
||||||
new_schema["sourceDataset"] = {
|
|
||||||
"description": "Code de la source des données, avec un lien vers le fichier Open Data dont proviennent les données de ce marché public.",
|
|
||||||
"title": "Source des données",
|
|
||||||
"short_name": "Source",
|
|
||||||
}
|
|
||||||
return new_schema
|
return new_schema
|
||||||
|
|
||||||
|
|
||||||
def track_search(query):
|
def track_search(query, category):
|
||||||
if (
|
if len(query) >= 4 and not development and os.getenv("MATOMO_DOMAIN"):
|
||||||
len(query) >= 4
|
url = "https://decp.info"
|
||||||
and os.getenv("DEVELOPMENT").lower != "true"
|
|
||||||
and os.getenv("MATOMO_DOMAIN")
|
|
||||||
):
|
|
||||||
if os.getenv("DEVELOPMENT").lower() == "true":
|
|
||||||
url = "https://test.decp.info"
|
|
||||||
else:
|
|
||||||
url = "https://decp.info"
|
|
||||||
params = {
|
params = {
|
||||||
"idsite": os.getenv("MATOMO_ID_SITE"),
|
"idsite": os.getenv("MATOMO_ID_SITE"),
|
||||||
"url": url,
|
"url": url,
|
||||||
"rec": "1",
|
"rec": "1",
|
||||||
"action_name": "front_page_search",
|
"action_name": "search" if category == "home_page_search" else "filter",
|
||||||
|
"search_cat": category,
|
||||||
"rand": uuid.uuid4().hex,
|
"rand": uuid.uuid4().hex,
|
||||||
"apiv": "1",
|
"apiv": "1",
|
||||||
"h": localtime().tm_hour,
|
"h": localtime().tm_hour,
|
||||||
@@ -470,7 +515,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
|
|||||||
return dff.select(pl.lit(False).alias("matches"))
|
return dff.select(pl.lit(False).alias("matches"))
|
||||||
|
|
||||||
# Enregistrement des recherche dans Matomo
|
# Enregistrement des recherche dans Matomo
|
||||||
track_search(query)
|
track_search(query, "home_page_search")
|
||||||
|
|
||||||
# Normalize query
|
# Normalize query
|
||||||
normalized_query = unidecode(query.strip()).upper()
|
normalized_query = unidecode(query.strip()).upper()
|
||||||
@@ -515,12 +560,11 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
|
|||||||
dff = (
|
dff = (
|
||||||
dff.with_columns(token_matches + [match_score])
|
dff.with_columns(token_matches + [match_score])
|
||||||
.filter(pl.col("match_score") == len(tokens))
|
.filter(pl.col("match_score") == len(tokens))
|
||||||
.sort("Marchés", descending=True)
|
|
||||||
.drop([f"token_{token}" for token in tokens])
|
.drop([f"token_{token}" for token in tokens])
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format result
|
# Format result
|
||||||
dff = add_links(dff, target="")
|
dff = add_links(dff)
|
||||||
dff = dff.with_columns(
|
dff = dff.with_columns(
|
||||||
pl.concat_str(
|
pl.concat_str(
|
||||||
pl.col(f"{org_type}_departement_nom"),
|
pl.col(f"{org_type}_departement_nom"),
|
||||||
@@ -531,12 +575,14 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
|
|||||||
)
|
)
|
||||||
|
|
||||||
dff = dff.select(f"{org_type}_id", f"{org_type}_nom", "Département", "Marchés")
|
dff = dff.select(f"{org_type}_id", f"{org_type}_nom", "Département", "Marchés")
|
||||||
|
dff = dff.group_by(f"{org_type}_id", f"{org_type}_nom", "Département").sum()
|
||||||
|
dff = dff.sort("Marchés", descending=True)
|
||||||
|
|
||||||
return dff
|
return dff
|
||||||
|
|
||||||
|
|
||||||
def prepare_table_data(
|
def prepare_table_data(
|
||||||
data, data_timestamp, filter_query, page_current, page_size, sort_by
|
data, data_timestamp, filter_query, page_current, page_size, sort_by, source_table
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Fonction de préparation des données pour les datatables, afin de permettre une gestion fine des logiques,
|
Fonction de préparation des données pour les datatables, afin de permettre une gestion fine des logiques,
|
||||||
@@ -547,12 +593,14 @@ def prepare_table_data(
|
|||||||
:param page_current:
|
:param page_current:
|
||||||
:param page_size:
|
:param page_size:
|
||||||
:param sort_by:
|
:param sort_by:
|
||||||
:param search_params:
|
:param source_table:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if os.getenv("DEVELOPMENT").lower() == "true":
|
if os.getenv("DEVELOPMENT").lower() == "true":
|
||||||
print(" + + + + + + + + + + + + + + + + + + ")
|
logger.debug(" + + + + + + + + + + + + + + + + + + ")
|
||||||
|
|
||||||
|
trigger_cleanup = no_update
|
||||||
|
|
||||||
# Récupération des données
|
# Récupération des données
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
@@ -560,30 +608,13 @@ def prepare_table_data(
|
|||||||
else:
|
else:
|
||||||
lff: pl.LazyFrame = df.lazy() # start from the original data
|
lff: pl.LazyFrame = df.lazy() # start from the original data
|
||||||
|
|
||||||
# if search_params:
|
|
||||||
# if "filtres" in search_params:
|
|
||||||
# filter_query = search_params["filtres"][0]
|
|
||||||
#
|
|
||||||
# if "tris" in search_params:
|
|
||||||
# try:
|
|
||||||
# sort_by = json.loads(search_params["tris"][0])
|
|
||||||
# except json.JSONDecodeError:
|
|
||||||
# pass
|
|
||||||
#
|
|
||||||
# if "colonnes" in search_params:
|
|
||||||
# try:
|
|
||||||
# hidden_columns = json.loads(search_params["colonnes"][0])
|
|
||||||
# print(hidden_columns)
|
|
||||||
# lff = lff.drop(hidden_columns)
|
|
||||||
# except json.JSONDecodeError:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
# Application des filtres
|
# Application des filtres
|
||||||
if filter_query:
|
if filter_query:
|
||||||
lff = filter_table_data(lff, filter_query)
|
lff = filter_table_data(lff, filter_query, source_table)
|
||||||
|
trigger_cleanup = no_update if source_table == "tableau" else str(uuid.uuid4())
|
||||||
|
|
||||||
# Application des tris
|
# Application des tris
|
||||||
if len(sort_by) > 0:
|
if sort_by and len(sort_by) > 0:
|
||||||
lff = sort_table_data(lff, sort_by)
|
lff = sort_table_data(lff, sort_by)
|
||||||
|
|
||||||
# Matérialisation des filtres
|
# Matérialisation des filtres
|
||||||
@@ -618,7 +649,7 @@ def prepare_table_data(
|
|||||||
dff = format_values(dff)
|
dff = format_values(dff)
|
||||||
|
|
||||||
# Récupération des colonnes et tooltip
|
# Récupération des colonnes et tooltip
|
||||||
columns, tooltip = setup_table_columns(dff)
|
table_columns, tooltip = setup_table_columns(dff)
|
||||||
|
|
||||||
dicts = dff.to_dicts()
|
dicts = dff.to_dicts()
|
||||||
|
|
||||||
@@ -627,13 +658,14 @@ def prepare_table_data(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
dicts,
|
dicts,
|
||||||
columns,
|
table_columns,
|
||||||
tooltip,
|
tooltip,
|
||||||
data_timestamp + 1,
|
data_timestamp + 1,
|
||||||
nb_rows,
|
nb_rows,
|
||||||
download_disabled,
|
download_disabled,
|
||||||
download_text,
|
download_text,
|
||||||
download_title,
|
download_title,
|
||||||
|
trigger_cleanup,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -641,7 +673,7 @@ def get_button_properties(height):
|
|||||||
if height > 65000:
|
if height > 65000:
|
||||||
download_disabled = True
|
download_disabled = True
|
||||||
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
|
download_text = "Téléchargement désactivé au-delà de 65 000 lignes"
|
||||||
download_title = "Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul. Contactez-moi pour me présenter votre besoin en téléchargement afin que je puisse adapter la solution."
|
download_title = " Ajoutez des filtres pour réduire le nombre de lignes, Excel ne supporte pas d'avoir plus de 65 000 URLs dans une même feuille de calcul."
|
||||||
elif height == 0:
|
elif height == 0:
|
||||||
download_disabled = True
|
download_disabled = True
|
||||||
download_text = "Pas de données à télécharger"
|
download_text = "Pas de données à télécharger"
|
||||||
@@ -649,7 +681,7 @@ def get_button_properties(height):
|
|||||||
else:
|
else:
|
||||||
download_disabled = False
|
download_disabled = False
|
||||||
download_text = "Télécharger au format Excel"
|
download_text = "Télécharger au format Excel"
|
||||||
download_title = ""
|
download_title = "Télécharger les données telles qu'affichées au format Excel"
|
||||||
return download_disabled, download_text, download_title
|
return download_disabled, download_text, download_title
|
||||||
|
|
||||||
|
|
||||||
@@ -667,11 +699,69 @@ def invert_columns(columns):
|
|||||||
return inverted_columns
|
return inverted_columns
|
||||||
|
|
||||||
|
|
||||||
|
def make_org_jsonld(org_id, org_type, org_name=None, type_org_id="SIRET") -> dict:
|
||||||
|
org_types = {"acheteur": "GovernmentOrganization", "titulaire": "Organization"}
|
||||||
|
address = None
|
||||||
|
if type_org_id.lower() == "siret" and len(org_id) == 14:
|
||||||
|
annuaire_data = get_annuaire_data(org_id)
|
||||||
|
annuaire_address = annuaire_data["matching_etablissements"][0]
|
||||||
|
code_postal = annuaire_address["code_postal"]
|
||||||
|
commune = annuaire_address["libelle_commune"]
|
||||||
|
|
||||||
|
address = (
|
||||||
|
{
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": annuaire_address.get("adresse", "")
|
||||||
|
.replace(code_postal, "")
|
||||||
|
.replace(commune, "")
|
||||||
|
.strip(),
|
||||||
|
"addressLocality": commune,
|
||||||
|
"postalCode": code_postal,
|
||||||
|
"addressCountry": "FR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
jsonld = {
|
||||||
|
"@type": org_types[org_type],
|
||||||
|
"name": org_name,
|
||||||
|
"url": f"https://decp.info/{org_type}s/{org_id}",
|
||||||
|
"sameAs": f"https://annuaire-entreprises.data.gouv.fr/etablissement/{org_id}",
|
||||||
|
"identifier": {
|
||||||
|
"@type": "PropertyValue",
|
||||||
|
"propertyID": type_org_id.lower(),
|
||||||
|
"value": org_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if address:
|
||||||
|
jsonld["address"] = address
|
||||||
|
|
||||||
|
return jsonld
|
||||||
|
|
||||||
|
|
||||||
df: pl.DataFrame = get_decp_data()
|
df: pl.DataFrame = get_decp_data()
|
||||||
schema = df.collect_schema()
|
schema = df.collect_schema()
|
||||||
|
|
||||||
df_acheteurs = get_org_data(df, "acheteur")
|
df_acheteurs = get_org_data(df, "acheteur")
|
||||||
df_titulaires = get_org_data(df, "titulaire")
|
df_titulaires = get_org_data(df, "titulaire")
|
||||||
|
df_acheteurs_departement: pl.DataFrame = (
|
||||||
|
df_acheteurs.select(["acheteur_id", "acheteur_nom", "acheteur_departement_code"])
|
||||||
|
.unique()
|
||||||
|
.sort("acheteur_nom")
|
||||||
|
)
|
||||||
|
df_titulaires_departement: pl.DataFrame = (
|
||||||
|
df_titulaires.select(
|
||||||
|
["titulaire_id", "titulaire_nom", "titulaire_departement_code"]
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.sort("titulaire_nom")
|
||||||
|
)
|
||||||
|
df_acheteurs_marches: pl.DataFrame = (
|
||||||
|
df.select("uid", "objet", "acheteur_id").unique().sort("acheteur_id")
|
||||||
|
)
|
||||||
|
df_titulaires_marches: pl.DataFrame = (
|
||||||
|
df.select("uid", "objet", "titulaire_id").unique().sort("titulaire_id")
|
||||||
|
)
|
||||||
|
|
||||||
departements = get_departements()
|
departements = get_departements()
|
||||||
domain_name = (
|
domain_name = (
|
||||||
@@ -686,3 +776,4 @@ meta_content = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
data_schema = get_data_schema()
|
data_schema = get_data_schema()
|
||||||
|
columns = df.columns
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
import pytest
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def test_data():
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"uid": "1",
|
||||||
|
"id": "1",
|
||||||
|
"acheteur_nom": "ACHETEUR 1",
|
||||||
|
"acheteur_id": "a1",
|
||||||
|
"titulaire_nom": "TITULAIRE 1",
|
||||||
|
"titulaire_id": "t1",
|
||||||
|
"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),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
path = "tests/test.parquet"
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
print(f"Writing test data to: {path}") # <-- This will show you the real path
|
||||||
|
|
||||||
|
pl.DataFrame(data).write_parquet("tests/test.parquet")
|
||||||
|
yield path
|
||||||
|
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.unlink(path)
|
||||||
|
print(path, "deleted")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_setup_options():
|
||||||
|
options = Options()
|
||||||
|
options.add_argument("--window-size=1200,1200 ")
|
||||||
|
options.add_experimental_option(
|
||||||
|
"prefs",
|
||||||
|
{
|
||||||
|
"download.default_directory": "/home/colin/git/decp.info",
|
||||||
|
"download.prompt_for_download": False,
|
||||||
|
"download.directory_upgrade": True,
|
||||||
|
"safebrowsing.enabled": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return options
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import polars as pl
|
||||||
|
from dash.testing.composite import DashComposite
|
||||||
|
from selenium.webdriver import Keys
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.remote.webelement import WebElement
|
||||||
|
|
||||||
|
|
||||||
|
def test_001_logo_and_search(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)
|
||||||
|
assert dash_duo.find_element(".logo > h1").text == "decp.info"
|
||||||
|
|
||||||
|
for org_type in ["acheteur", "titulaire"]:
|
||||||
|
name = f"{org_type.upper()} 1"
|
||||||
|
search_bar: WebElement = dash_duo.find_element("#search")
|
||||||
|
|
||||||
|
dash_duo.clear_input(search_bar)
|
||||||
|
|
||||||
|
search_bar.send_keys(name)
|
||||||
|
search_bar.send_keys(Keys.ENTER)
|
||||||
|
|
||||||
|
dash_duo.wait_for_element(f"#results_{org_type}_datatable", timeout=2)
|
||||||
|
result_table: WebElement = dash_duo.find_element(
|
||||||
|
f"#results_{org_type}_datatable tbody"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result_table.find_elements(by=By.TAG_NAME, value="tr")) == 2, (
|
||||||
|
"The search should return only one result"
|
||||||
|
) # header row + 1 result
|
||||||
|
assert (
|
||||||
|
result_table.find_element(
|
||||||
|
by=By.CSS_SELECTOR, value=f'td[data-dash-column="{org_type}_nom"]'
|
||||||
|
).text
|
||||||
|
== name
|
||||||
|
), f"The search result should have the right {org_type} name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_002_filter_persistence(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)
|
||||||
|
|
||||||
|
def open_page_and_check_filter_input():
|
||||||
|
dash_duo.wait_for_page(f"{dash_duo.server_url}/{page}")
|
||||||
|
filter_input_selector = (
|
||||||
|
'.marches_table th[data-dash-column="uid"] input[type="text"]'
|
||||||
|
)
|
||||||
|
dash_duo.wait_for_element(filter_input_selector, timeout=2)
|
||||||
|
_filter_input: WebElement = dash_duo.find_element(filter_input_selector)
|
||||||
|
return _filter_input
|
||||||
|
|
||||||
|
for page in ["tableau", "acheteurs/a1", "titulaires/t1"]:
|
||||||
|
print("page:", page)
|
||||||
|
filter_input = open_page_and_check_filter_input()
|
||||||
|
filter_input.send_keys("11") # a UID that doesn't exist
|
||||||
|
filter_input.send_keys(Keys.ENTER)
|
||||||
|
filter_input = open_page_and_check_filter_input()
|
||||||
|
assert filter_input.get_attribute("value") == "11"
|
||||||
|
|
||||||
|
|
||||||
|
def test_003_tableau_download(dash_duo: DashComposite):
|
||||||
|
from pages.acheteur import download_acheteur_data
|
||||||
|
from pages.tableau import download_data
|
||||||
|
from pages.titulaire import download_titulaire_data
|
||||||
|
from src.app import app
|
||||||
|
|
||||||
|
# Juste pour instancier l'app
|
||||||
|
print(app.server.name)
|
||||||
|
|
||||||
|
dicts = pl.read_parquet("tests/test.parquet").to_dicts()
|
||||||
|
|
||||||
|
outputs = [
|
||||||
|
download_data(1, "", [], None),
|
||||||
|
download_acheteur_data(1, dicts, "a1", "2025"),
|
||||||
|
download_titulaire_data(1, dicts, "t1", "2025"),
|
||||||
|
]
|
||||||
|
for output in outputs:
|
||||||
|
assert isinstance(output, dict)
|
||||||
|
for f in ["content", "filename", "type", "base64"]:
|
||||||
|
assert f in output
|
||||||
|
assert isinstance(output["content"], str) and len(output["content"]) > 100
|
||||||
|
assert isinstance(output["filename"], str) and output["filename"].startswith(
|
||||||
|
"decp_"
|
||||||
|
)
|
||||||
|
assert output["type"] is None
|
||||||
|
assert output["base64"] is True
|
||||||
Reference in New Issue
Block a user