Merge branch 'release/2.5.0'

This commit is contained in:
Colin Maudry
2026-01-29 20:26:31 +01:00
23 changed files with 13177 additions and 497 deletions
+7
View File
@@ -1,3 +1,10 @@
#### 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) ##### 2.4.1 (22 janvier 2026)
- Meilleure gestion des colonnes absentes du schéma - Meilleure gestion des colonnes absentes du schéma
+1 -1
View File
@@ -1,6 +1,6 @@
# decp.info # decp.info
> v2.4.1 > v2.5.0
> 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)
+1 -1
View File
@@ -1,7 +1,7 @@
[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.4.1" version = "2.5.0"
requires-python = ">= 3.10" requires-python = ">= 3.10"
authors = [ authors = [
{ name = "Colin Maudry", email = "colin@colmo.tech" } { name = "Colin Maudry", email = "colin@colmo.tech" }
+27 -7
View File
@@ -5,19 +5,30 @@ import dash_bootstrap_components as dbc
import tomllib import tomllib
from dash import Dash, Input, Output, State, 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 Response, send_from_directory from flask import Response
load_dotenv() load_dotenv()
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( app = Dash(
external_stylesheets=[dbc.themes.SIMPLEX],
title="decp.info", title="decp.info",
use_pages=True, use_pages=True,
compress=True, compress=True,
meta_tags=[ meta_tags=meta_tags,
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
],
) )
# 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),
@@ -27,7 +38,10 @@ 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") @app.server.route("/sitemap.xml")
@@ -101,6 +115,8 @@ navbar = dbc.Navbar(
children=[ children=[
dbc.NavItem( dbc.NavItem(
children=[ children=[
html.Div(
[
dcc.Link(html.H1("decp.info"), href="/", className="logo"), dcc.Link(html.H1("decp.info"), href="/", className="logo"),
html.P( html.P(
[ [
@@ -112,6 +128,9 @@ navbar = dbc.Navbar(
className="version", className="version",
), ),
], ],
className="logo-wrapper",
)
],
style={"minWidth": "230px"}, style={"minWidth": "230px"},
), ),
dbc.Nav( dbc.Nav(
@@ -135,7 +154,8 @@ navbar = dbc.Navbar(
) )
) )
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", className="ms-auto",
navbar=True, navbar=True,
File diff suppressed because it is too large Load Diff
+479
View File
@@ -0,0 +1,479 @@
@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;
}
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;
/* couleur thème foncée */
}
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: 36px;
}
.table-menu > * {
margin: 8px 16px 8px 0;
float: left;
}
#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 {
margin: 0;
color: white;
font-family: "Inter", sans-serif;
text-align: left;
font-weight: 600;
padding-right: 12px;
border: 1px solid rgb(179, 56, 33) !important;
background-color: rgb(179, 56, 33);
border-bottom: none !important;
height: 34px;
}
.dash-table-container
.dash-spreadsheet-container
.dash-spreadsheet-inner
table.cell-table
tr:first-of-type
th.dash-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-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;
}
.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;
}
.show-hide {
position: relative;
width: 180px;
margin: 0 0 10px 0;
}
.show-hide::before {
background: inherit;
content: "Colonnes affichées";
position: absolute;
left: 5px;
right: 5px;
}
.show-hide-menu-item > input {
margin-right: 10px;
}
/* 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;
}
}
+105
View File
@@ -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;
},
},
});
-8
View File
@@ -1,8 +0,0 @@
# START YOAST BLOCK
# Copié depuis https://next.ink/robots.txt
# ---------------------------
User-agent: *
Allow: /
# ---------------------------
# END YOAST BLOCK
-336
View File
@@ -1,336 +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 */
a.logo {
color: black;
text-decoration: none;
float: left;
}
p.version {
float: left;
margin-top: 21px;
margin-left: 12px;
}
p.version > a {
text-decoration: none;
margin-top: 10px;
}
.navbar-brand {
margin-right: 2px;
}
/* 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 table td,
.marches_table table th {
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: 25px 40px 0 60px;
font-size: 90%;
max-width: 900px;
}
/* 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 */
h3 {
margin: 36px 0 20px 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;
}
@media (max-width: 992px) {
#announcements-nav {
display: none !important;
}
}
/* CSS for the /a-propos page Layout and Table of Contents */
.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 */
}
/* Hide TOC on smaller screens */
@media (max-width: 992px) {
.a-propos-content {
max-width: 100%;
padding-right: 0;
flex: 1 1 100%;
}
.a-propos-toc {
display: none;
}
}
/* Styling for 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;
}
/* Indentation for H5 levels (Level 2 in our simplified TOC) */
/* Using specific classes if I generate them, or just generic hierarchy if nested */
.toc-level-2 {
margin-left: 15px;
font-size: 0.85em;
}
/* Header title for TOC (optional) */
.toc-header {
font-weight: bold;
margin-bottom: 10px;
display: block;
color: #333;
}
+4 -4
View File
@@ -11,11 +11,11 @@ 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("")
+51 -12
View File
@@ -91,13 +91,21 @@ def get_yearly_statistics(statistics, today_str) -> html.Div:
page_size=10, page_size=10,
sort_action="none", sort_action="none",
filter_action="none", filter_action="none",
style_cell={
"border": "solid 1px rgb(179, 56, 33)",
},
style_header={
"border": "solid 1px rgb(179, 56, 33)",
"backgroundColor": "rgb(179, 56, 33)",
"color": "white",
},
) )
return html.Div(children=table, className="marches_table") return html.Div(children=table, className="marches_table")
def get_barchart_sources(df: pl.DataFrame, type_date: str): def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
lf = df.lazy() lf = df_source.lazy()
labels = { labels = {
"dateNotification": "notification", "dateNotification": "notification",
"datePublicationDonnees": "publication des données", "datePublicationDonnees": "publication des données",
@@ -169,14 +177,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",
@@ -196,8 +213,12 @@ def get_sources_tables(source_path) -> html.Div:
], ],
sort_action="native", sort_action="native",
markdown_options={"html": True}, markdown_options={"html": True},
style_header={
"border": "solid 1px rgb(179, 56, 33)",
"backgroundColor": "rgb(179, 56, 33)",
"color": "white",
},
) )
datatable.data = df.to_dicts()
return html.Div(children=datatable) return html.Div(children=datatable)
@@ -239,9 +260,9 @@ 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",
@@ -253,15 +274,19 @@ class DataTable(dash_table.DataTable):
{ {
"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",
@@ -269,11 +294,22 @@ 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",
}, },
{
"if": {"column_id": "montant"},
"textAlign": "right",
},
{
"if": {"column_id": "dureeMois"},
"textAlign": "right",
},
{
"if": {"column_id": "titulaire_distance"},
"textAlign": "right",
},
] ]
# Initialisation de la classe parente avec les arguments # Initialisation de la classe parente avec les arguments
@@ -285,7 +321,10 @@ 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": "",
},
sort_action=sort_action, sort_action=sort_action,
sort_mode="multi", sort_mode="multi",
sort_by=[], sort_by=[],
+27 -1
View File
@@ -120,7 +120,28 @@ Cest vrai, vous navez pas eu à cliquer sur un bloc qui recouvre la moiti
Rien dexceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi daudience, correctement configurés pour respecter la vie privée, sont exemptés dautorisation préalable. Rien dexceptionnel, je respecte simplement la loi, qui dit que certains outils de suivi daudience, correctement configurés pour respecter la vie privée, sont exemptés dautorisation préalable.
Jutilise 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 mest donc impossible dassocier vos visites sur ce site à votre personne.""" Jutilise 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 mest donc impossible dassocier vos visites sur ce site à votre personne.
J'enregistre également les données suivantes, de manière anonyme, afin de mieux comprendre comment vous utilisez le site et l'améliorer :
- recherches sur la page d'accueil
- filtres appliqués aux données
"""
),
html.H5("Attributions", id="attributions"),
dcc.Markdown("""
Les polices de caractères sont distribuées par [Bunny fonts](https://fonts.bunny.net), une alternative européenne et qualitative à Google Fonts.
- 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))
- 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)
"""
), ),
], ],
), ),
@@ -173,6 +194,11 @@ Jutilise pour cela [Matomo](https://matomo.org/), un outil [libre](https://ma
href="#audience", href="#audience",
className="toc-link toc-level-2", className="toc-link toc-level-2",
), ),
html.A(
"Attributions",
href="#attributions",
className="toc-link toc-level-2",
),
] ]
), ),
], ],
+12 -6
View File
@@ -7,6 +7,7 @@ from src.callbacks import get_top_org_table
from src.figures import DataTable, point_on_map from src.figures import DataTable, point_on_map
from src.utils import ( from src.utils import (
df, df,
df_acheteurs,
filter_table_data, filter_table_data,
format_number, format_number,
get_annuaire_data, get_annuaire_data,
@@ -20,7 +21,12 @@ from src.utils import (
def get_title(acheteur_id: str = None) -> str: def get_title(acheteur_id: str = None) -> str:
return f"Acheteur {acheteur_id} | decp.info" acheteur_nom = (
df_acheteurs.filter(pl.col("acheteur_id") == acheteur_id)
.select("acheteur_nom")
.item()
)
return f"Marchés publics attribués par {acheteur_nom} | decp.info"
register_page( register_page(
@@ -47,7 +53,7 @@ datatable = html.Div(
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.Location(id="acheteur_url", refresh="callback-nav"),
html.Div( html.Div(
children=[ children=[
html.Div( html.Div(
@@ -152,7 +158,7 @@ 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]
@@ -216,7 +222,7 @@ 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:
@@ -254,7 +260,7 @@ def get_last_marches_data(
data, page_current, page_size, filter_query, sort_by, data_timestamp 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"
) )
@@ -313,7 +319,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)
View File
+78
View File
@@ -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
+25
View File
@@ -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()
]
),
]
)
+99
View File
@@ -0,0 +1,99 @@
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()
)
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
+79 -8
View File
@@ -1,3 +1,4 @@
import json
from datetime import datetime from datetime import datetime
import dash_bootstrap_components as dbc import dash_bootstrap_components as dbc
@@ -5,7 +6,14 @@ 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: def get_title(uid: str = None) -> str:
@@ -25,13 +33,15 @@ 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.Script(type="application/ld+json", id="marche_jsonld"),
dbc.Container( dbc.Container(
className="marche_infos", className="marche_infos",
children=[ children=[
dbc.Row( dbc.Row(
dbc.Col( dbc.Col(
[ [
html.H1(id="marche_objet", style={"fontSize": "1.5em"}),
html.P( html.P(
"Vous consultez un résumé des données de ce marché public" "Vous consultez un résumé des données de ce marché public"
), ),
@@ -73,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]
@@ -94,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"),
@@ -101,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
@@ -145,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
@@ -159,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"),
@@ -184,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_categorie"):
distance = str(titulaire.get("titulaire_categorie")) + " km"
else:
distance = ""
content = html.Li( content = html.Li(
[
html.A( html.A(
href=f"/titulaires/{titulaire['titulaire_id']}", href=f"/titulaires/{titulaire['titulaire_id']}",
children=titulaire["titulaire_nom"], 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)
+51 -10
View File
@@ -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 (
@@ -16,7 +16,7 @@ register_page(
path="/", path="/",
title="Recherche de marchés publics | decp.info", title="Recherche de marchés publics | decp.info",
name=name, name=name,
description="Recherchez des des acheteurs et des titulaires parmi les données essentielles de la commande publique.", 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"
),
), ),
html.Div(
style={
"display": "flex",
"justifyContent": "center",
"marginTop": "30px",
"marginBottom": "30px",
},
children=[
dcc.Input( dcc.Input(
id="search", id="search",
type="text", type="text",
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département", placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
autoFocus=True, autoFocus=True,
style={
"margin": "0",
"width": "500px",
"border": "1px solid #ccc",
"borderRight": "none",
"borderRadius": "4px 0 0 4px",
"padding": "5px 10px",
"outline": "none",
},
),
html.Button(
"🔍",
id="search-button",
style={
"border": "1px solid #ccc",
"borderRadius": "0 4px 4px 0",
"marginLeft": "0",
"backgroundColor": "#f0f0f0",
"cursor": "pointer",
"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 -1
View File
@@ -48,7 +48,6 @@ layout = [
Les statistiques publiées sur cette page ont été produites automatiquement à partir des données les plus récentes ({today_str}). 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( html.H3(
"Statistiques générales sur les marchés", "Statistiques générales sur les marchés",
id="marches", id="marches",
@@ -66,6 +65,7 @@ layout = [
"""), """),
html.H4("Statistiques par année"), html.H4("Statistiques par année"),
get_yearly_statistics(statistics, today_str), get_yearly_statistics(statistics, today_str),
dcc.Graph(figure=get_map_count_marches(df)),
get_duplicate_matrix(), get_duplicate_matrix(),
html.H3("Nombre de marchés par source dans le temps"), html.H3("Nombre de marchés par source dans le temps"),
dcc.Graph( dcc.Graph(
+133 -24
View File
@@ -1,10 +1,22 @@
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 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 src.figures import DataTable from src.figures import DataTable
from src.utils import ( from src.utils import (
@@ -12,14 +24,16 @@ from src.utils import (
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"
@@ -47,40 +61,120 @@ datatable = html.Div(
) )
layout = [ layout = [
dcc.Location(id="url", refresh=False), dcc.Location(id="tableau_url", refresh=False),
dcc.Store(id="filter-cleanup-trigger"),
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 vous permet d'appliquer un filtre sur une ou plusieurs colonnes, et ainsi produire la liste de marchés dont vous avez besoin ([exemple de filtre](/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)). Par défaut seules quelques colonnes sont affichées, mais vous pouvez en afficher jusqu'à {str(df.width)} en cliquant sur le bouton **Colonnes affichées**. 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( html.Details(
children=[ children=[
html.Summary( html.Summary(
html.H3("Mode d'emploi", style={"textDecoration": "underline"}), html.H4("Mode d'emploi", style={"textDecoration": "underline"}),
), ),
dcc.Markdown( dcc.Markdown(
dangerously_allow_html=True, dangerously_allow_html=True,
children=""" children=f"""
##### Définition des colonnes ##### Définition des colonnes
Pour voir la définition d'une colonne, passez votre souris sur son en-tête. Pour voir la définition d'une colonne, passez votre souris sur son en-tête.
##### Filtres ##### 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`. 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 - Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules).
le texte recherché. 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`. Lorsque vous ouvrez une URL de vue, le format équivalent `icontains rennes` est utilisé. - Exemple : `rennes` retourne "RENNES METROPOLE".
- 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. - Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
- Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024", - Lorsque vous ouvrez une URL de vue (voir "Partager une vue" plus bas), le format équivalent `icontains rennes` est utilisé. Mais dans vos filtres pas besoin de taper `icontains` !
`2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé. - Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
- Pour les champs textuels et dates : pour chercher du texte qui **commence par** votre texte, entez `texte*`, pour chercher du texte qui **finit par** votre texte, entez `*texte`. C'est par exemple utile pour filtrer des acheteurs ou titulaires par numéro SIREN (`123456789*`). - 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.
- lorsque vous ouvrez une URL de vue (voir "Partager une vue" plus bas), le format équivalent `i<` ou `i>` est utilisé, mais c'est un bug : vous n'avez pas besoin de taper le `i` pour appliquer ce filtre.
- 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. Vos filtres sont remis à zéro quand vous rafraîchissez la page. Vous pouvez filtrer plusieurs colonnes à la fois. Vos filtres sont remis à zéro quand vous rafraîchissez la page.
##### Tri ##### 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 : tri ascendant, tri descendant, pas de tri. 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 **Colonnes affichées** et cochez les colonnes pour les afficher.
##### Partager une vue ##### 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. Une vue est un ensemble de filtres, de tris et de choix de colonnes que vous avez appliqué. Cliquez sur l'icône <img src="/assets/copy.svg" alt="drawing" width="20"/> 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. Pratique pour partager une vue avec un·e collègue, sur les réseaux sociaux, ou la sauvegarder pour plus tard.
@@ -154,7 +248,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
# 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"
) )
@@ -174,7 +268,7 @@ 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 len(sort_by) > 0:
lff = sort_table_data(lff, sort_by) lff = sort_table_data(lff, sort_by)
@@ -190,23 +284,26 @@ def download_data(n_clicks, filter_query, sort_by, hidden_columns: list = None):
Output("table", "filter_query"), Output("table", "filter_query"),
Output("table", "sort_by"), Output("table", "sort_by"),
Output("table", "hidden_columns"), Output("table", "hidden_columns"),
Output("url", "search", allow_duplicate=True), Output("tableau_url", "search", allow_duplicate=True),
Input("url", "search"), Output("filter-cleanup-trigger", "data"),
Input("tableau_url", "search"),
prevent_initial_call=True, prevent_initial_call=True,
) )
def restore_view_from_url(search): def restore_view_from_url(search):
if not search: if not search:
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("?"))
print("params", params) logger.debug("params", params)
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())
if "tris" in params: if "tris" in params:
try: try:
@@ -219,7 +316,18 @@ def restore_view_from_url(search):
verified_columns = [column for column in columns if column in schema.names()] verified_columns = [column for column in 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
clientside_callback(
ClientsideFunction(
namespace="clientside",
function_name="clean_filters",
),
Output("filter-cleanup-trigger", "data", allow_duplicate=True),
Input("filter-cleanup-trigger", "data"),
prevent_initial_call=True,
)
@callback( @callback(
@@ -228,7 +336,8 @@ def restore_view_from_url(search):
Input("table", "filter_query"), Input("table", "filter_query"),
Input("table", "sort_by"), Input("table", "sort_by"),
Input("table", "hidden_columns"), Input("table", "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:
+18 -6
View File
@@ -7,6 +7,7 @@ from src.callbacks import get_top_org_table
from src.figures import DataTable, point_on_map from src.figures import DataTable, point_on_map
from src.utils import ( from src.utils import (
df, df,
df_titulaires,
filter_table_data, filter_table_data,
format_number, format_number,
get_annuaire_data, get_annuaire_data,
@@ -20,7 +21,12 @@ from src.utils import (
def get_title(titulaire_id: str = None) -> str: def get_title(titulaire_id: str = None) -> str:
return f"Titulaire {titulaire_id} | decp.info" titulaire_nom = (
df_titulaires.filter(pl.col("titulaire_id") == titulaire_id)
.select("titulaire_nom")
.item()
)
return f"Marchés publics remportés par {titulaire_nom} | decp.info"
register_page( register_page(
@@ -47,7 +53,7 @@ datatable = html.Div(
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.Location(id="titulaire_url", refresh="callback-nav"),
html.Div( html.Div(
children=[ children=[
html.Div( html.Div(
@@ -152,7 +158,7 @@ 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]
@@ -219,7 +225,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:
@@ -263,7 +269,13 @@ def get_last_marches_data(
data, page_current, page_size, filter_query, sort_by, data_timestamp 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",
) )
@@ -322,7 +334,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)
+103 -55
View File
@@ -10,14 +10,17 @@ from httpx import 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 +32,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("=>", name, operator_group[1], value)
return name, operator_group[1], value return name, operator_group[1], value
@@ -145,6 +148,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 +193,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
@@ -267,18 +280,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(f"{filter_source}: {filter_query}")
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())
@@ -339,16 +353,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( def setup_table_columns(
dff, hideable: bool = True, exclude: list = None, new_columns: list = None dff, hideable: bool = True, exclude: list = None, new_columns: list = None
) -> tuple: ) -> tuple:
new_columns = new_columns or []
# 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:
@@ -358,19 +371,19 @@ def setup_table_columns(
if column_object: if column_object:
column_name = column_object.get("title") column_name = column_object.get("title")
else: else:
if column_id not in new_columns: # Si le champ est un champ créé par erreur lors d'une jointure, on le skip
# Si le champ n'est pas dans le schéma et pas annoncé, on le skip if column_id.endswith("_left") or column_id.endswith("_right"):
print("Champ innatendu : ") logger.warning(f"Champ innatendu : {column_id}")
print(dff[column_id].head())
if column_id.endswith("_right") or column_id.endswith("_left"):
continue continue
column_name = column_id column_name = column_id
column_object = {"title": column_name, "description": ""} 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,
@@ -396,7 +409,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",
@@ -408,7 +421,7 @@ 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",
@@ -448,11 +461,6 @@ def get_data_schema() -> dict:
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
@@ -564,7 +572,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
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,
@@ -575,12 +583,12 @@ 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(" + + + + + + + + + + + + + + + + + + ")
# Récupération des données # Récupération des données
if isinstance(data, list): if isinstance(data, list):
@@ -588,27 +596,9 @@ 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)
# Application des tris # Application des tris
if len(sort_by) > 0: if len(sort_by) > 0:
@@ -669,7 +659,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"
@@ -695,11 +685,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 = (