Merge branch 'release/2.5.0'
This commit is contained in:
@@ -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,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
@@ -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" }
|
||||||
|
|||||||
+35
-15
@@ -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,16 +115,21 @@ navbar = dbc.Navbar(
|
|||||||
children=[
|
children=[
|
||||||
dbc.NavItem(
|
dbc.NavItem(
|
||||||
children=[
|
children=[
|
||||||
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
|
html.Div(
|
||||||
html.P(
|
|
||||||
[
|
[
|
||||||
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",
|
[
|
||||||
)
|
html.A(
|
||||||
|
version,
|
||||||
|
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
className="version",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
className="version",
|
className="logo-wrapper",
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
style={"minWidth": "230px"},
|
style={"minWidth": "230px"},
|
||||||
),
|
),
|
||||||
@@ -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,
|
||||||
|
|||||||
Vendored
+11859
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +0,0 @@
|
|||||||
# START YOAST BLOCK
|
|
||||||
# Copié depuis https://next.ink/robots.txt
|
|
||||||
# ---------------------------
|
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# END YOAST BLOCK
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -120,7 +120,28 @@ 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.
|
||||||
|
|
||||||
|
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 @@ J’utilise 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
@@ -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)
|
||||||
|
|||||||
@@ -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,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
|
||||||
+82
-11
@@ -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(
|
[
|
||||||
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)
|
||||||
|
|||||||
+56
-15
@@ -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"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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": "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"}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
+104
-56
@@ -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())
|
continue
|
||||||
if column_id.endswith("_right") or column_id.endswith("_left"):
|
|
||||||
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 = (
|
||||||
|
|||||||
Reference in New Issue
Block a user