Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3b57608f4 | |||
| f4f0d70864 | |||
| 10c69013f0 | |||
| c08e2fa143 | |||
| afd812c50d | |||
| 536d37d67c | |||
| f43bb14e23 | |||
| 5422f95ea7 | |||
| 29f6e5c2c2 | |||
| c311ed5f23 | |||
| 31d0a626e6 | |||
| 307e3379e0 | |||
| 1fb18bcf6f | |||
| 30e822d435 | |||
| a44aaf6df6 | |||
| 8c11a018a0 | |||
| a0d6222424 | |||
| 25115c305c | |||
| 9d9533b8a2 | |||
| e4f06acc56 | |||
| e0dc2687c4 | |||
| 274e06cde4 | |||
| f9aadede8b | |||
| d35862beb0 | |||
| ed6811266d | |||
| 06f658ccf1 | |||
| e4c6659205 | |||
| 434a659778 | |||
| e573fcccae | |||
| 84739df7cb | |||
| 1d241ca9b7 | |||
| 0264aae3b9 | |||
| fb5c37b34b | |||
| 639b342178 | |||
| e8534c8108 | |||
| f5d8026061 | |||
| 1eecc45e29 | |||
| 07bdcf232d | |||
| 6e992e0a6e | |||
| a76a14b030 | |||
| b3bd488882 | |||
| a385e999d3 | |||
| a4bda0483f | |||
| 9a1e5bee63 | |||
| 1c4fccac6e | |||
| 187feee544 | |||
| 965f24ab92 | |||
| 3aee2667d0 | |||
| 3d96a5e1b8 | |||
| 35928d7245 | |||
| ac42dde488 | |||
| ae366bf014 | |||
| bd64c397dc | |||
| 937e8be356 | |||
| e880eaff12 | |||
| 07a97293cc | |||
| ef97cea679 | |||
| c9fc2a01fc | |||
| 8002bd711a | |||
| 1ba78fe8df | |||
| f1e24794e6 | |||
| 9110b78f2a |
+14
-1
@@ -1,9 +1,22 @@
|
||||
#### 2.4.0
|
||||
#### 2.5.0 (29 janvier 2026)
|
||||
|
||||
- Refonte graphique et amélioration des textes d'aide
|
||||
- Amélioration du filtrage du tableau à partir d'une URL
|
||||
- Renforcement du SEO avec une arborescence permettant l'accès aux marchés et des snippets JSON-LD
|
||||
- Suppression de la dépendance à Google Fonts grâce à [Bunny Fonts](https://fonts.bunny.net) 🇪🇺 🇸🇮
|
||||
|
||||
##### 2.4.1 (22 janvier 2026)
|
||||
|
||||
- Meilleure gestion des colonnes absentes du schéma
|
||||
|
||||
#### 2.4.0 (22 janvier 2026)
|
||||
|
||||
- Site à peu près utilisable sur petit écran (smartphone) ([#63](https://github.com/ColinMaudry/decp.info/issues/63))
|
||||
- Ajout de nouvelles statistiques dans [/statistiques](https://decp.info/statistiques) (stats par année, doublons par source)
|
||||
- Amélioration du référencement Web (sitemap, titres, descriptions) ([#50](https://github.com/ColinMaudry/decp.info/issues/50))
|
||||
- Possibilité dans les champs non-numériques de filtrer le texte selon son début ou sa fin (`text*` et `*text`)
|
||||
- Ajout d'une table des matières dans la page [À propos](https://decp.infi/a-propos) ([#36](https://github.com/ColinMaudry/decp.info/issues/36))
|
||||
- Désactivation du bloquage des robot d'agents de LLM (robots.txt)
|
||||
|
||||
##### 2.3.1 (16 janvier 2026)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# decp.info
|
||||
|
||||
> v2.4.0
|
||||
> v2.5.0
|
||||
> Outil d'exploration et de téléchargement des données essentielles de la commande publique.
|
||||
|
||||
=> [decp.info](https://decp.info)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "decp.info"
|
||||
description = "Interface d'exploration et d'analyse des marchés publics français."
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
requires-python = ">= 3.10"
|
||||
authors = [
|
||||
{ name = "Colin Maudry", email = "colin@colmo.tech" }
|
||||
|
||||
+35
-15
@@ -5,19 +5,30 @@ import dash_bootstrap_components as dbc
|
||||
import tomllib
|
||||
from dash import Dash, Input, Output, State, dcc, html, page_container, page_registry
|
||||
from dotenv import load_dotenv
|
||||
from flask import Response, send_from_directory
|
||||
from flask import Response
|
||||
|
||||
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(
|
||||
external_stylesheets=[dbc.themes.SIMPLEX],
|
||||
title="decp.info",
|
||||
use_pages=True,
|
||||
compress=True,
|
||||
meta_tags=[
|
||||
{"name": "viewport", "content": "width=device-width, initial-scale=1"},
|
||||
],
|
||||
meta_tags=meta_tags,
|
||||
)
|
||||
|
||||
# COSMO (belle font, blue),
|
||||
# UNITED (rouge, ubuntu font),
|
||||
# LUMEN (gros séparateur, blue clair),
|
||||
@@ -27,7 +38,10 @@ app = Dash(
|
||||
# robots.txt
|
||||
@app.server.route("/robots.txt")
|
||||
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")
|
||||
@@ -101,16 +115,21 @@ navbar = dbc.Navbar(
|
||||
children=[
|
||||
dbc.NavItem(
|
||||
children=[
|
||||
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
|
||||
html.P(
|
||||
html.Div(
|
||||
[
|
||||
html.A(
|
||||
version,
|
||||
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
|
||||
)
|
||||
dcc.Link(html.H1("decp.info"), href="/", className="logo"),
|
||||
html.P(
|
||||
[
|
||||
html.A(
|
||||
version,
|
||||
href="https://github.com/ColinMaudry/decp.info/blob/main/CHANGELOG.md",
|
||||
)
|
||||
],
|
||||
className="version",
|
||||
),
|
||||
],
|
||||
className="version",
|
||||
),
|
||||
className="logo-wrapper",
|
||||
)
|
||||
],
|
||||
style={"minWidth": "230px"},
|
||||
),
|
||||
@@ -135,7 +154,8 @@ navbar = dbc.Navbar(
|
||||
)
|
||||
)
|
||||
for page in page_registry.values()
|
||||
if page["name"] not in ["Acheteur", "Titulaire", "Marché"]
|
||||
if page["name"]
|
||||
in ["Recherche", "À propos", "Tableau", "Statistiques"]
|
||||
],
|
||||
className="ms-auto",
|
||||
navbar=True,
|
||||
|
||||
Vendored
+11859
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
@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;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.dash-table-container
|
||||
.dash-spreadsheet-container
|
||||
.dash-spreadsheet-inner
|
||||
td
|
||||
div.dash-cell-value.cell-markdown {
|
||||
font-family: "Inter", sans-serif !important;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.marches_table.stuck {
|
||||
position: relative;
|
||||
right: 200px;
|
||||
}
|
||||
|
||||
.marches_table .cell-table tr:nth-child(even) td {
|
||||
background-color: rgb(255 240 240 / 40%);
|
||||
}
|
||||
|
||||
/* Column Visibility Menu */
|
||||
.column-actions {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.column-header--hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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()
|
||||
|
||||
dff = dff.select(
|
||||
["uid", f"{org_type}_id", f"{org_type}_nom", "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")
|
||||
["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", "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.cast(pl.String)
|
||||
dff_nb = dff_nb.fill_null("")
|
||||
|
||||
+51
-12
@@ -91,13 +91,21 @@ def get_yearly_statistics(statistics, today_str) -> html.Div:
|
||||
page_size=10,
|
||||
sort_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")
|
||||
|
||||
|
||||
def get_barchart_sources(df: pl.DataFrame, type_date: str):
|
||||
lf = df.lazy()
|
||||
def get_barchart_sources(df_source: pl.DataFrame, type_date: str):
|
||||
lf = df_source.lazy()
|
||||
labels = {
|
||||
"dateNotification": "notification",
|
||||
"datePublicationDonnees": "publication des données",
|
||||
@@ -169,14 +177,23 @@ def get_sources_tables(source_path) -> html.Div:
|
||||
+ pl.lit("</a>")
|
||||
).alias("nom")
|
||||
)
|
||||
df = df.drop("url")
|
||||
df = df.drop("url", "unique")
|
||||
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(
|
||||
id="source_table",
|
||||
data=df.to_dicts(),
|
||||
columns=[
|
||||
{
|
||||
"name": i,
|
||||
"name": columns[i],
|
||||
"id": i,
|
||||
"presentation": "markdown",
|
||||
"type": "text",
|
||||
@@ -196,8 +213,12 @@ def get_sources_tables(source_path) -> html.Div:
|
||||
],
|
||||
sort_action="native",
|
||||
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)
|
||||
|
||||
@@ -239,9 +260,9 @@ class DataTable(dash_table.DataTable):
|
||||
def __init__(
|
||||
self,
|
||||
dtid: str,
|
||||
hidden_columns: list = None,
|
||||
data=None,
|
||||
columns: list = None,
|
||||
hidden_columns: list[str] | None = None,
|
||||
data: list[dict[str, str | int | float | bool]] | None = None,
|
||||
columns: list[dict[str, str]] | None = None,
|
||||
page_size: int = 20,
|
||||
page_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"},
|
||||
"minWidth": "350px",
|
||||
"textAlign": "left",
|
||||
"overflow": "hidden",
|
||||
"lineHeight": "18px",
|
||||
"whiteSpace": "normal",
|
||||
},
|
||||
{
|
||||
"if": {"column_id": "acheteur_id"},
|
||||
"minWidth": "160px",
|
||||
"overflow": "hidden",
|
||||
"whiteSpace": "normal",
|
||||
},
|
||||
{
|
||||
"if": {"column_id": "acheteur_nom"},
|
||||
"minWidth": "250px",
|
||||
"textAlign": "left",
|
||||
"overflow": "hidden",
|
||||
"lineHeight": "18px",
|
||||
"whiteSpace": "normal",
|
||||
@@ -269,11 +294,22 @@ class DataTable(dash_table.DataTable):
|
||||
{
|
||||
"if": {"column_id": "titulaire_nom"},
|
||||
"minWidth": "250px",
|
||||
"textAlign": "left",
|
||||
"overflow": "hidden",
|
||||
"lineHeight": "18px",
|
||||
"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
|
||||
@@ -285,7 +321,10 @@ class DataTable(dash_table.DataTable):
|
||||
page_size=page_size,
|
||||
filter_action=filter_action,
|
||||
page_action=page_action,
|
||||
filter_options={"case": "insensitive", "placeholder_text": "Filtrer..."},
|
||||
filter_options={
|
||||
"case": "insensitive",
|
||||
"placeholder_text": "",
|
||||
},
|
||||
sort_action=sort_action,
|
||||
sort_mode="multi",
|
||||
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.
|
||||
|
||||
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",
|
||||
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.utils import (
|
||||
df,
|
||||
df_acheteurs,
|
||||
filter_table_data,
|
||||
format_number,
|
||||
get_annuaire_data,
|
||||
@@ -20,7 +21,12 @@ from src.utils import (
|
||||
|
||||
|
||||
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(
|
||||
@@ -47,7 +53,7 @@ datatable = html.Div(
|
||||
|
||||
layout = [
|
||||
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(
|
||||
children=[
|
||||
html.Div(
|
||||
@@ -152,7 +158,7 @@ layout = [
|
||||
Output(component_id="acheteur_departement", component_property="children"),
|
||||
Output(component_id="acheteur_region", component_property="children"),
|
||||
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):
|
||||
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", "children"),
|
||||
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"),
|
||||
)
|
||||
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
|
||||
) -> tuple:
|
||||
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)
|
||||
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query)
|
||||
lff = filter_table_data(lff, filter_query, "ach download")
|
||||
|
||||
if len(sort_by) > 0:
|
||||
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
|
||||
|
||||
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 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:
|
||||
@@ -25,13 +33,15 @@ register_page(
|
||||
layout = [
|
||||
dcc.Store(id="marche_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(
|
||||
className="marche_infos",
|
||||
children=[
|
||||
dbc.Row(
|
||||
dbc.Col(
|
||||
[
|
||||
html.H1(id="marche_objet", style={"fontSize": "1.5em"}),
|
||||
html.P(
|
||||
"Vous consultez un résumé des données de ce marché public"
|
||||
),
|
||||
@@ -73,7 +83,7 @@ layout = [
|
||||
@callback(
|
||||
Output("marche_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]:
|
||||
marche_uid = url.split("/")[-1]
|
||||
@@ -94,6 +104,7 @@ def get_marche_data(url) -> tuple[dict, list]:
|
||||
|
||||
|
||||
@callback(
|
||||
Output("marche_objet", "children"),
|
||||
Output("marche_infos_1", "children"),
|
||||
Output("marche_infos_2", "children"),
|
||||
Output("marche_infos_titulaires", "children"),
|
||||
@@ -101,7 +112,7 @@ def get_marche_data(url) -> tuple[dict, list]:
|
||||
Input("titulaires_data", "data"),
|
||||
)
|
||||
def update_marche_info(marche, titulaires):
|
||||
def make_parameter(col):
|
||||
def make_parameter(col, bold=True):
|
||||
column_object = data_schema.get(col)
|
||||
column_name = column_object.get("title") if column_object else col
|
||||
|
||||
@@ -145,12 +156,14 @@ def update_marche_info(marche, titulaires):
|
||||
else:
|
||||
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
|
||||
|
||||
marche_objet = make_parameter("objet", bold=False)
|
||||
|
||||
marche_infos = [
|
||||
make_parameter("id"),
|
||||
make_parameter("objet"),
|
||||
make_parameter("dateNotification"), # date
|
||||
make_parameter("nature"),
|
||||
make_parameter("acheteur_nom"), # lien
|
||||
@@ -159,6 +172,7 @@ def update_marche_info(marche, titulaires):
|
||||
make_parameter("procedure"),
|
||||
make_parameter("techniques"), # list
|
||||
make_parameter("dureeMois"),
|
||||
make_parameter("dureeRestanteMois"),
|
||||
make_parameter("offresRecues"),
|
||||
make_parameter("datePublicationDonnees"), # date
|
||||
make_parameter("formePrix"),
|
||||
@@ -184,14 +198,71 @@ def update_marche_info(marche, titulaires):
|
||||
titulaires_lines = []
|
||||
for titulaire in titulaires:
|
||||
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(
|
||||
html.A(
|
||||
href=f"/titulaires/{titulaire['titulaire_id']}",
|
||||
children=titulaire["titulaire_nom"],
|
||||
)
|
||||
[
|
||||
html.A(
|
||||
href=f"/titulaires/{titulaire['titulaire_id']}",
|
||||
children=titulaire["titulaire_nom"],
|
||||
),
|
||||
f" ({categorie}, {distance})",
|
||||
]
|
||||
)
|
||||
else:
|
||||
content = html.Li(titulaire["titulaire_nom"])
|
||||
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.utils import (
|
||||
@@ -16,7 +16,7 @@ register_page(
|
||||
path="/",
|
||||
title="Recherche de marchés publics | decp.info",
|
||||
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"],
|
||||
order=0,
|
||||
)
|
||||
@@ -26,15 +26,52 @@ layout = html.Div(
|
||||
children=[
|
||||
html.Div(
|
||||
className="tagline",
|
||||
children=html.P(
|
||||
"Exploration et téléchargement des données des marchés publics"
|
||||
),
|
||||
children=html.P("Recherchez un acheteur ou un titulaire de marché public"),
|
||||
),
|
||||
dcc.Input(
|
||||
id="search",
|
||||
type="text",
|
||||
placeholder="Nom d'acheteur/entreprise, SIREN/SIRET, code département",
|
||||
autoFocus=True,
|
||||
html.Div(
|
||||
style={
|
||||
"display": "flex",
|
||||
"justifyContent": "center",
|
||||
"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(
|
||||
# className="search_options",
|
||||
@@ -47,11 +84,14 @@ layout = html.Div(
|
||||
|
||||
@callback(
|
||||
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,
|
||||
)
|
||||
def update_search_results(query):
|
||||
if len(query) >= 1:
|
||||
def update_search_results(n_submit, n_clicks, query):
|
||||
if query and len(query) >= 1:
|
||||
content = []
|
||||
|
||||
for org_type in ["acheteur", "titulaire"]:
|
||||
@@ -88,6 +128,7 @@ def update_search_results(query):
|
||||
else html.P(f"Aucun {org_type} trouvé."),
|
||||
]
|
||||
content.extend(org_content)
|
||||
style = {"textAlign": "center", "display": "none"}
|
||||
|
||||
return content
|
||||
return html.P("")
|
||||
return content, style
|
||||
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}).
|
||||
"""),
|
||||
dcc.Graph(figure=get_map_count_marches(df)),
|
||||
html.H3(
|
||||
"Statistiques générales sur les marchés",
|
||||
id="marches",
|
||||
@@ -66,6 +65,7 @@ layout = [
|
||||
"""),
|
||||
html.H4("Statistiques par année"),
|
||||
get_yearly_statistics(statistics, today_str),
|
||||
dcc.Graph(figure=get_map_count_marches(df)),
|
||||
get_duplicate_matrix(),
|
||||
html.H3("Nombre de marchés par source dans le temps"),
|
||||
dcc.Graph(
|
||||
|
||||
+133
-24
@@ -1,10 +1,22 @@
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
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.utils import (
|
||||
@@ -12,14 +24,16 @@ from src.utils import (
|
||||
filter_table_data,
|
||||
get_default_hidden_columns,
|
||||
invert_columns,
|
||||
logger,
|
||||
meta_content,
|
||||
schema,
|
||||
sort_table_data,
|
||||
)
|
||||
from utils import prepare_table_data
|
||||
|
||||
update_date = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||
update_date = datetime.fromtimestamp(update_date).strftime("%d/%m/%Y")
|
||||
update_date_timestamp = os.path.getmtime(os.getenv("DATA_FILE_PARQUET_PATH"))
|
||||
update_date = datetime.fromtimestamp(update_date_timestamp).strftime("%d/%m/%Y")
|
||||
update_date_iso = datetime.fromtimestamp(update_date_timestamp).isoformat()
|
||||
|
||||
|
||||
name = "Tableau"
|
||||
@@ -47,40 +61,120 @@ datatable = html.Div(
|
||||
)
|
||||
|
||||
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.Details(
|
||||
children=[
|
||||
html.Summary(
|
||||
html.H3("Mode d'emploi", style={"textDecoration": "underline"}),
|
||||
html.H4("Mode d'emploi", style={"textDecoration": "underline"}),
|
||||
),
|
||||
dcc.Markdown(
|
||||
dangerously_allow_html=True,
|
||||
children="""
|
||||
children=f"""
|
||||
##### Définition des colonnes
|
||||
|
||||
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`.
|
||||
|
||||
- Champs textuels : la recherche est insensible à la casse (majuscules/minuscules) et retourne les valeurs qui contiennent
|
||||
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é.
|
||||
- Champs numériques : vous pouvez soit taper un nombre pour trouver les valeurs égales, soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
||||
- Champs date : vous pouvez également utiliser **>** ou **<**. Exemples : `< 2024-01-31` pour "avant le 31 janvier 2024",
|
||||
`2024` pour "en 2024", `> 2022` pour "à partir de 2022". Lorsque vous ouvrez une URL de vue, le format équivalent `i<` ou `i>` est utilisé.
|
||||
- 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*`).
|
||||
- Champs textuels : la recherche retourne les valeurs qui contiennent le texte recherché et n'est pas sensible à la casse (majuscules/minuscules).
|
||||
- Exemple : `rennes` retourne "RENNES METROPOLE".
|
||||
- Les guillemets simples (apostrophe du 4) doivent être prédédées d'une barre oblique (AltGr + 8). Exemple : `services d\\\'assurances`
|
||||
- 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` !
|
||||
- Champs numériques (Durée en mois, Montant, ...) : vous pouvez...
|
||||
- soit taper un nombre pour trouver les valeurs strictement égales. Exemple : `12` ne retourne que des 12
|
||||
- soit le précéder de **>** ou **<** pour filtrer les valeurs supérieures ou inférieures. Exemple pour les offres reçues : `> 4` retourne les marchés ayant reçu plus de 4 offres.
|
||||
- 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.
|
||||
|
||||
##### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -154,7 +248,7 @@ def update_table(page_current, page_size, filter_query, sort_by, data_timestamp)
|
||||
# else:
|
||||
# search_params = urllib.parse.parse_qs(search_params.lstrip("?"))
|
||||
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)
|
||||
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query)
|
||||
lff = filter_table_data(lff, filter_query, "tab download")
|
||||
|
||||
if len(sort_by) > 0:
|
||||
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", "sort_by"),
|
||||
Output("table", "hidden_columns"),
|
||||
Output("url", "search", allow_duplicate=True),
|
||||
Input("url", "search"),
|
||||
Output("tableau_url", "search", allow_duplicate=True),
|
||||
Output("filter-cleanup-trigger", "data"),
|
||||
Input("tableau_url", "search"),
|
||||
prevent_initial_call=True,
|
||||
)
|
||||
def restore_view_from_url(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("?"))
|
||||
print("params", params)
|
||||
logger.debug("params", params)
|
||||
|
||||
filter_query = no_update
|
||||
sort_by = no_update
|
||||
hidden_columns = no_update
|
||||
trigger_cleanup = no_update
|
||||
|
||||
if "filtres" in params:
|
||||
filter_query = params["filtres"][0]
|
||||
trigger_cleanup = str(uuid.uuid4())
|
||||
|
||||
if "tris" in params:
|
||||
try:
|
||||
@@ -219,7 +316,18 @@ def restore_view_from_url(search):
|
||||
verified_columns = [column for column in columns if column in schema.names()]
|
||||
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(
|
||||
@@ -228,7 +336,8 @@ def restore_view_from_url(search):
|
||||
Input("table", "filter_query"),
|
||||
Input("table", "sort_by"),
|
||||
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):
|
||||
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.utils import (
|
||||
df,
|
||||
df_titulaires,
|
||||
filter_table_data,
|
||||
format_number,
|
||||
get_annuaire_data,
|
||||
@@ -20,7 +21,12 @@ from src.utils import (
|
||||
|
||||
|
||||
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(
|
||||
@@ -47,7 +53,7 @@ datatable = html.Div(
|
||||
|
||||
layout = [
|
||||
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(
|
||||
children=[
|
||||
html.Div(
|
||||
@@ -152,7 +158,7 @@ layout = [
|
||||
Output(component_id="titulaire_departement", component_property="children"),
|
||||
Output(component_id="titulaire_region", component_property="children"),
|
||||
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):
|
||||
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", "children"),
|
||||
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"),
|
||||
)
|
||||
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
|
||||
) -> list[dict]:
|
||||
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)
|
||||
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query)
|
||||
lff = filter_table_data(lff, filter_query, "titu download")
|
||||
|
||||
if len(sort_by) > 0:
|
||||
lff = sort_table_data(lff, sort_by)
|
||||
|
||||
+105
-56
@@ -10,14 +10,17 @@ from httpx import get, post
|
||||
from polars.exceptions import ComputeError
|
||||
from unidecode import unidecode
|
||||
|
||||
logger = logging.getLogger("decp.info")
|
||||
logging.getLogger("httpx").setLevel("WARNING")
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||
level=logging.INFO,
|
||||
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):
|
||||
@@ -29,14 +32,14 @@ def split_filter_part(filter_part):
|
||||
["icontains", "contains"],
|
||||
# [" ", "contains"]
|
||||
]
|
||||
print("filter part", filter_part)
|
||||
logger.debug("filter part", filter_part)
|
||||
for operator_group in operators:
|
||||
if operator_group[0] in filter_part:
|
||||
name_part, value_part = filter_part.split(operator_group[0], 1)
|
||||
name_part = name_part.strip()
|
||||
value = value_part.strip()
|
||||
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
|
||||
|
||||
@@ -145,6 +148,14 @@ def format_number(number) -> str:
|
||||
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_montant(expr, scale=None):
|
||||
# https://stackoverflow.com/a/78636786
|
||||
@@ -182,9 +193,11 @@ def format_values(dff: pl.DataFrame) -> pl.DataFrame:
|
||||
|
||||
if "montant" in dff.columns:
|
||||
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(
|
||||
pl.col("distance").pipe(format_distance).alias("distance")
|
||||
pl.col("titulaire_distance")
|
||||
.pipe(format_distance)
|
||||
.alias("titulaire_distance")
|
||||
)
|
||||
|
||||
return dff
|
||||
@@ -267,18 +280,19 @@ def get_departement_region(code_postal):
|
||||
return code_departement, nom_departement, nom_region
|
||||
|
||||
|
||||
def filter_table_data(lff: pl.LazyFrame, filter_query: str) -> pl.LazyFrame:
|
||||
debug = os.getenv("DEVELOPMENT", "False").lower() == "true"
|
||||
schema = lff.collect_schema()
|
||||
def filter_table_data(
|
||||
lff: pl.LazyFrame, filter_query: str, filter_source: str
|
||||
) -> pl.LazyFrame:
|
||||
_schema = lff.collect_schema()
|
||||
track_search(f"{filter_source}: {filter_query}")
|
||||
filtering_expressions = filter_query.split(" && ")
|
||||
for filter_part in filtering_expressions:
|
||||
col_name, operator, filter_value = split_filter_part(filter_part)
|
||||
col_type = str(schema[col_name])
|
||||
if debug:
|
||||
print("filter_value:", filter_value)
|
||||
print("filter_value_type:", type(filter_value))
|
||||
print("operator:", operator)
|
||||
print("col_type:", col_type)
|
||||
col_type = str(_schema[col_name])
|
||||
# logger.debug("filter_value:", filter_value)
|
||||
# logger.debug("filter_value_type:", type(filter_value))
|
||||
# logger.debug("operator:", operator)
|
||||
# logger.debug("col_type:", col_type)
|
||||
|
||||
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],
|
||||
nulls_last=True,
|
||||
)
|
||||
print(sort_by)
|
||||
logger.debug(sort_by)
|
||||
return lff
|
||||
|
||||
|
||||
def setup_table_columns(
|
||||
dff, hideable: bool = True, exclude: list = None, new_columns: list = None
|
||||
) -> tuple:
|
||||
new_columns = new_columns or []
|
||||
|
||||
# Liste finale de colonnes
|
||||
markdown_exceptions = ["montant", "titulaire_distance", "distance", "dureeMois"]
|
||||
columns = []
|
||||
tooltip = {}
|
||||
for column_id in dff.columns:
|
||||
@@ -358,29 +371,30 @@ def setup_table_columns(
|
||||
if column_object:
|
||||
column_name = column_object.get("title")
|
||||
else:
|
||||
if column_id not in new_columns:
|
||||
# Si le champ n'est pas dans le schéma et pas annoncé, on le skip
|
||||
print("Champ innatendu : ")
|
||||
print(dff[column_id].head())
|
||||
# Si le champ est un champ créé par erreur lors d'une jointure, on le skip
|
||||
if column_id.endswith("_left") or column_id.endswith("_right"):
|
||||
logger.warning(f"Champ innatendu : {column_id}")
|
||||
continue
|
||||
column_name = column_id
|
||||
column_object = {"title": column_name, "description": ""}
|
||||
|
||||
presentation = "input" if column_id in markdown_exceptions else "markdown"
|
||||
|
||||
column = {
|
||||
"name": column_name,
|
||||
"id": column_id,
|
||||
"presentation": "markdown",
|
||||
"presentation": presentation,
|
||||
"type": "text",
|
||||
"format": {"nully": "N/A"},
|
||||
"hideable": hideable,
|
||||
}
|
||||
columns.append(column)
|
||||
|
||||
if column_object:
|
||||
tooltip[column_id] = {
|
||||
"value": f"""**{column_object.get("title")}** ({column_id})
|
||||
|
||||
"""
|
||||
+ column_object["description"],
|
||||
+ column_object.get("description", ""),
|
||||
"type": "markdown",
|
||||
}
|
||||
return columns, tooltip
|
||||
@@ -395,7 +409,7 @@ def get_default_hidden_columns(page):
|
||||
"titulaire_id",
|
||||
"titulaire_typeIdentifiant",
|
||||
"titulaire_nom",
|
||||
"distance",
|
||||
"titulaire_distance",
|
||||
"montant",
|
||||
"codeCPV",
|
||||
"dureeRestanteMois",
|
||||
@@ -407,7 +421,7 @@ def get_default_hidden_columns(page):
|
||||
"dateNotification",
|
||||
"acheteur_id",
|
||||
"acheteur_nom",
|
||||
"distance",
|
||||
"titulaire_distance",
|
||||
"montant",
|
||||
"codeCPV",
|
||||
"dureeRestanteMois",
|
||||
@@ -447,11 +461,6 @@ def get_data_schema() -> dict:
|
||||
for col in original_schema["fields"]:
|
||||
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
|
||||
|
||||
|
||||
@@ -563,7 +572,7 @@ def search_org(dff: pl.DataFrame, query: str, org_type: str) -> pl.DataFrame:
|
||||
|
||||
|
||||
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,
|
||||
@@ -574,12 +583,12 @@ def prepare_table_data(
|
||||
:param page_current:
|
||||
:param page_size:
|
||||
:param sort_by:
|
||||
:param search_params:
|
||||
:param source_table:
|
||||
:return:
|
||||
"""
|
||||
|
||||
if os.getenv("DEVELOPMENT").lower() == "true":
|
||||
print(" + + + + + + + + + + + + + + + + + + ")
|
||||
logger.debug(" + + + + + + + + + + + + + + + + + + ")
|
||||
|
||||
# Récupération des données
|
||||
if isinstance(data, list):
|
||||
@@ -587,27 +596,9 @@ def prepare_table_data(
|
||||
else:
|
||||
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
|
||||
if filter_query:
|
||||
lff = filter_table_data(lff, filter_query)
|
||||
lff = filter_table_data(lff, filter_query, source_table)
|
||||
|
||||
# Application des tris
|
||||
if len(sort_by) > 0:
|
||||
@@ -668,7 +659,7 @@ def get_button_properties(height):
|
||||
if height > 65000:
|
||||
download_disabled = True
|
||||
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:
|
||||
download_disabled = True
|
||||
download_text = "Pas de données à télécharger"
|
||||
@@ -694,11 +685,69 @@ def invert_columns(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()
|
||||
schema = df.collect_schema()
|
||||
|
||||
df_acheteurs = get_org_data(df, "acheteur")
|
||||
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()
|
||||
domain_name = (
|
||||
|
||||
Reference in New Issue
Block a user