From 74eb581bfe1a894a0e4f911eb4db7132d6ead08d Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Sat, 11 Jul 2026 18:25:26 +0200 Subject: [PATCH 01/11] Add Amendement model and preserve analysis tables on rebuild Introduce the raw `amendements` table (fields sourced directly from the Tricoteuses /amendements endpoint, grouped into identity, text, author, attachment, status and dates). Column names mirror the JSON keys so the existing schema-driven ETL loads them without changes. Scope the rebuild to ETL-managed tables (ETL_TABLES) so future analysis tables are neither dropped by `create_db` nor treated as source files by the ETL loop. This lets analysis results survive a re-download without introducing Alembic. Add `.env.example` wiring the ETL to the local docker-compose Postgres. --- .env.example | 11 +++++++++ etl/database.py | 26 ++++++++++++++++--- models/__init__.py | 1 + models/amendement.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 models/amendement.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f731d9c --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Connexion PostgreSQL utilisée par l'ETL (etl/database.py). +# Ces valeurs correspondent à l'instance locale définie dans docker-compose.yml. +# Copier ce fichier en `.env` : `cp .env.example .env` +PG_USER=postgres +PG_PWD=postgres +PG_DB=ipolitics +PG_HOST=localhost +PG_PORT=5432 + +# Mettre à True pour logguer toutes les requêtes SQL émises par SQLAlchemy. +PG_ECHO=False diff --git a/etl/database.py b/etl/database.py index aa7bc90..bd13010 100644 --- a/etl/database.py +++ b/etl/database.py @@ -5,6 +5,13 @@ import models # noqa: F401 # pyright: ignore[reportUnusedImport] # registers all ORM models with Base.metadata from models.base import Base +# Tables alimentées par l'ETL depuis les fichiers JSON de ./data. +# Seules ces tables sont détruites lors d'un rebuild et parcourues par l'ETL. +# Les tables d'analyse (ajoutées plus tard) en sont volontairement exclues afin +# que leurs résultats survivent à un rebuild et ne soient pas traitées comme des +# fichiers source à charger. +ETL_TABLES = {"dossiers", "amendements"} + def _get_db_url(): PG_USER = getenv("PG_USER") @@ -29,16 +36,27 @@ def get_engine(): return create_engine(pg_url, poolclass=pool.NullPool, echo=bool(PG_ECHO)) +def _get_etl_tables(): + """Return the schema definitions of the ETL-managed tables only.""" + return [table for table in Base.metadata.sorted_tables if table.name in ETL_TABLES] + + def create_db(): - """Drop the current DB and recreate from the schema.""" + """Rebuild the ETL-managed tables from the schema. + + Only the tables listed in ETL_TABLES are dropped and recreated. Analysis + tables are left untouched so their results survive a rebuild; create_all is + idempotent and (re)creates any missing table without altering existing ones. + """ print("Creating DB") engine = get_engine() - print(Base.metadata.tables) - Base.metadata.drop_all(engine) + etl_tables = _get_etl_tables() + print(etl_tables) + Base.metadata.drop_all(engine, tables=etl_tables) Base.metadata.create_all(engine) print("Db was created") return Base.metadata.tables def get_tables_definition(): - return Base.metadata.sorted_tables + return _get_etl_tables() diff --git a/models/__init__.py b/models/__init__.py index ddca4b6..d82f96b 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1 +1,2 @@ +from models.amendement import Amendement # noqa: F401 from models.dossier import User # noqa: F401 diff --git a/models/amendement.py b/models/amendement.py new file mode 100644 index 0000000..723183d --- /dev/null +++ b/models/amendement.py @@ -0,0 +1,59 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Amendement(Base): + """Amendement brut tel que renvoyé par l'API des tricoteuses (endpoint /amendements). + + Les noms d'attributs sont en camelCase afin de correspondre exactement aux clés + du JSON source : l'ETL (etl/extraction.py) s'appuie sur cette correspondance + 1:1 entre nom de colonne et nom de champ JSON. + + Seul `uid` est non nullable (clé primaire) ; les autres champs restent nullable + car les amendements sont hétérogènes (budgétaires, sous-amendements...) et + n'exposent pas toujours l'ensemble des champs. + """ + + __tablename__ = "amendements" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + numeroLong: Mapped[str | None] + numeroOrdreDepot: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + dataset: Mapped[int | None] + + # --- Texte (contenu analysé) --- + exposeSommaire: Mapped[str | None] = mapped_column(Text) + dispositif: Mapped[str | None] = mapped_column(Text) + + # --- Auteur / signataires --- + acteurRefUid: Mapped[str | None] + groupePolitiqueRefUid: Mapped[str | None] + organeRefUid: Mapped[str | None] + typeAuteur: Mapped[str | None] + nomRepresentation: Mapped[str | None] + signatairesLibelle: Mapped[str | None] + nombreCoSignataires: Mapped[int | None] + + # --- Rattachement (références molles vers d'autres entités de l'API) --- + dossierRefUid: Mapped[str | None] + documentRefUid: Mapped[str | None] + etapeLegislativeRefUid: Mapped[str | None] + codeEtape: Mapped[str | None] + + # --- Statut / sort --- + sortAmendement: Mapped[str | None] + etatCode: Mapped[str | None] + etatLibelle: Mapped[str | None] + triAmendement: Mapped[str | None] + + # --- Dates (conservées en texte : l'ETL insère les valeurs JSON brutes) --- + dateDepot: Mapped[str | None] + dateSort: Mapped[str | None] + dateMaj: Mapped[str | None] + datePublication: Mapped[str | None] + datePremierAjout: Mapped[str | None] From 85f3026bfb674088f052b47441af264ced35d907 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Sat, 11 Jul 2026 19:44:26 +0200 Subject: [PATCH 02/11] Fix PG_ECHO always enabling SQL echo getenv returns a string, so bool(getenv("PG_ECHO", False)) was truthy for any non-empty value including "False", leaving SQL echo permanently on. Parse the flag explicitly instead. --- etl/database.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/etl/database.py b/etl/database.py index bd13010..47904c3 100644 --- a/etl/database.py +++ b/etl/database.py @@ -31,9 +31,10 @@ def _get_db_url(): def get_engine(): """Return a configured SQLAlchemy engine""" - PG_ECHO = getenv("PG_ECHO", False) + # getenv renvoie une chaîne : bool("False") vaudrait True, d'où la comparaison explicite. + pg_echo = getenv("PG_ECHO", "").strip().lower() == "true" pg_url = _get_db_url() - return create_engine(pg_url, poolclass=pool.NullPool, echo=bool(PG_ECHO)) + return create_engine(pg_url, poolclass=pool.NullPool, echo=pg_echo) def _get_etl_tables(): From 4d44082c9d8efce9e68790bd400e42df9f4d27e5 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Thu, 16 Jul 2026 21:01:24 +0200 Subject: [PATCH 03/11] =?UTF-8?q?Ajoute=20les=20objets=20parlementaires=20?= =?UTF-8?q?=C3=A0=20l'ETL=20(acteurs,=20organes,=20votes=20agr=C3=A9g?= =?UTF-8?q?=C3=A9s,=20textes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Charge depuis l'API des tricoteuses les objets permettant les recoupements « qui propose / porte quel texte, via quel groupe, et comment c'est voté » : - acteurs, organes, mandats (référentiels + jointure acteur↔groupe) - scrutins (résultat agrégé des votes, lien natif amendementRefUid) - documents (textes de loi, lien dossierRefUid) - auteursDocument, coSignatairesDocument (qui dépose/co-signe un texte) Modèles typés d'après le profilage des types JSON réels, colonnes RefUid indexées pour les jointures. download.py devient robuste (retries, timeout long, plus de cap de pages) et gère un filtre legislature configurable par endpoint. loading.py insère par lots pour rester sous la limite de 65 535 paramètres de Postgres (indispensable pour mandats/coSignatairesDocument). Périmètre législature 17 ; votes nominatifs individuels laissés de côté (pas de filtre legislature côté API, résultat agrégé par scrutin suffisant). --- README.md | 50 +++++++++++++++++++++ etl/database.py | 13 +++++- etl/download.py | 77 +++++++++++++++++++++++---------- etl/loading.py | 13 ++++-- models/__init__.py | 7 +++ models/acteur.py | 64 +++++++++++++++++++++++++++ models/auteur_document.py | 28 ++++++++++++ models/cosignataire_document.py | 31 +++++++++++++ models/document.py | 74 +++++++++++++++++++++++++++++++ models/mandat.py | 58 +++++++++++++++++++++++++ models/organe.py | 71 ++++++++++++++++++++++++++++++ models/scrutin.py | 68 +++++++++++++++++++++++++++++ 12 files changed, 527 insertions(+), 27 deletions(-) create mode 100644 models/acteur.py create mode 100644 models/auteur_document.py create mode 100644 models/cosignataire_document.py create mode 100644 models/document.py create mode 100644 models/mandat.py create mode 100644 models/organe.py create mode 100644 models/scrutin.py diff --git a/README.md b/README.md index a661458..a887512 100644 --- a/README.md +++ b/README.md @@ -125,3 +125,53 @@ class User(Base): Le champ doit porter le même nom sinon l'ETL ne sera pas capable de le trouver. 2. Exécuter `just all` + +## Objets parlementaires chargés + +Au-delà des `dossiers` et `amendements`, l'ETL charge les objets de l'API des tricoteuses +nécessaires aux recoupements « qui propose / porte quel texte, via quel groupe, et comment +c'est voté » : + +| Table | Endpoint | Contenu | Volume (législature 17) | +|---|---|---|---| +| `acteurs` | `/acteurs` | Députés / sénateurs (référentiel trans-législature) | ~3 100 | +| `organes` | `/organes` | Groupes politiques, commissions, assemblées… | ~6 100 | +| `mandats` | `/mandats` | Jointure acteur ↔ organe (appartenance + dates) | ~25 600 | +| `scrutins` | `/scrutins` | Scrutins publics et résultat agrégé (pour/contre/abstentions) | ~8 300 | +| `documents` | `/documents` | Textes (projets/propositions de loi, rapports…) | ~4 500 | +| `auteursDocument` | `/auteursDocument` | Auteur(s) d'un document | ~20 000 | +| `coSignatairesDocument` | `/coSignatairesDocument` | Co-signataires d'un document | ~116 000 | + +Les jointures se font par les colonnes `…RefUid` (références molles, indexées) : + +``` +amendements → acteurRefUid / groupePolitiqueRefUid + ↓ ↓ + acteurs ←── mandats ──→ organes (groupe politique) + ↓ +documents (dossierRefUid → dossiers) → auteursDocument / coSignatairesDocument → acteurs + ↓ +scrutins (amendementRefUid → amendements ; documentRefUid → documents) : résultat du vote +``` + +Le lien **amendement ↔ scrutin** est natif : `scrutins.amendementRefUid` pointe vers +`amendements.uid` (quand `scrutins.typeObjet = 'amendement'`). Seule une minorité d'amendements +passe par un scrutin public (les autres sont tranchés à main levée), la jointure est donc +volontairement clairsemée. + +### Filtre de législature + +`etl/download.py` associe à chaque endpoint un booléen indiquant s'il faut filtrer par +`legislature=17`. Trois cas : + +- **Filtré L17** : `dossiers`, `documents`, `amendements`, `scrutins`, `mandats` — l'API + supporte le filtre et il est pertinent de se limiter à la 17ᵉ législature. +- **Non filtré, référentiel complet** : `acteurs` (renvoie une 500 avec le filtre), `organes` + (partagé entre législatures) — gardés entiers pour éviter des références orphelines. +- **Non filtré faute de paramètre** : `auteursDocument`, `coSignatairesDocument` — l'endpoint + n'expose pas de filtre `legislature` ; on les scope à la L17 par jointure sur + `documents.legislature = 17` au moment de l'analyse. + +> Note : l'endpoint `/votes` (votes nominatifs individuels) n'expose **pas** de filtre +> `legislature` et mélange plusieurs législatures ; il n'est volontairement pas chargé pour +> l'instant. Le résultat agrégé par scrutin (`scrutins`) suffit à la plupart des recoupements. diff --git a/etl/database.py b/etl/database.py index 47904c3..9712091 100644 --- a/etl/database.py +++ b/etl/database.py @@ -10,7 +10,18 @@ # Les tables d'analyse (ajoutées plus tard) en sont volontairement exclues afin # que leurs résultats survivent à un rebuild et ne soient pas traitées comme des # fichiers source à charger. -ETL_TABLES = {"dossiers", "amendements"} +ETL_TABLES = { + "dossiers", + "amendements", + # Objets ajoutés pour les recoupements auteur / groupe / texte / vote agrégé. + "acteurs", + "organes", + "mandats", + "scrutins", + "documents", + "auteursDocument", + "coSignatairesDocument", +} def _get_db_url(): diff --git a/etl/download.py b/etl/download.py index 1dc0ad3..494b54e 100644 --- a/etl/download.py +++ b/etl/download.py @@ -5,12 +5,33 @@ import httpx LEGISLATURE = 17 -# Liste des apis à télécharger -APIS = ["dossiers", "documents", "amendements"] -MAX_PAGE = 1000 +# APIs à télécharger, avec pour chacune : faut-il filtrer par législature ? +# True -> ajoute &legislature=17. Réservé aux endpoints qui exposent ce filtre +# dans l'API ET pour lesquels il est pertinent de se limiter à la L17. +# False -> pas de filtre. Nécessaire pour : +# - les référentiels trans-législature (`acteurs` renvoie même une 500 avec +# le filtre ; `organes` est partagé et on le veut complet pour éviter des +# références orphelines) ; +# - les endpoints qui n'exposent pas de paramètre `legislature` +# (`auteursDocument`, `coSignatairesDocument`) : on les scope alors à la +# L17 par jointure sur `documents` au moment de l'analyse. +APIS = { + "dossiers": True, + "documents": True, + "amendements": True, + "acteurs": False, + "organes": False, + "mandats": True, + "scrutins": True, + "auteursDocument": False, + "coSignatairesDocument": False, +} + BATCH_SIZE = 500 BASE_URL = "https://parlement.tricoteuses.fr/" +TIMEOUT = 90 +MAX_RETRIES = 3 def save(data, filename): @@ -19,40 +40,50 @@ def save(data, filename): json.dump(data, f) -def get(page, base_url): - params = f"?page={page}&perPage={BATCH_SIZE}&legislature={LEGISLATURE}" +def get(page, base_url, with_legislature): + """Récupère une page, avec quelques tentatives : l'API ferme parfois la + connexion en cours de route sur les gros volumes.""" + params = f"?page={page}&perPage={BATCH_SIZE}" + if with_legislature: + params += f"&legislature={LEGISLATURE}" url = base_url + params - try: - response = httpx.get(url, timeout=20) - response.raise_for_status() - return response - except Exception as e: - print(f"Error in Download: {e}") - return None + for attempt in range(1, MAX_RETRIES + 1): + try: + response = httpx.get(url, timeout=TIMEOUT) + response.raise_for_status() + return response + except Exception as e: + print(f"\tpage {page} tentative {attempt}/{MAX_RETRIES}: {e}") + sleep(3 * attempt) + return None -def get_api_data(api): +def get_api_data(api, with_legislature): base_url = BASE_URL + api + "/json" data = [] - for page in range(1, MAX_PAGE): - print("\tpage: ", page) - response = get(page, base_url) + page = 1 + while True: + response = get(page, base_url, with_legislature) if response is None: - break + # On abandonne l'endpoint plutôt que de sauvegarder un fichier tronqué. + raise RuntimeError( + f"Abandon de {api} à la page {page} après {MAX_RETRIES} tentatives" + ) - current_batch_data = response.json() - if len(current_batch_data["data"]) == 0: + current_batch_data = response.json()["data"] + if len(current_batch_data) == 0: break - data.extend(current_batch_data["data"]) - + data.extend(current_batch_data) + print(f"\t{api} page {page}: +{len(current_batch_data)} (total {len(data)})") + page += 1 sleep(0.3) return data def run_download(): - for api in APIS: + for api, with_legislature in APIS.items(): print("Fetching ", api) - data = get_api_data(api) + data = get_api_data(api, with_legislature) save(data, api) diff --git a/etl/loading.py b/etl/loading.py index 99b7500..68327e0 100644 --- a/etl/loading.py +++ b/etl/loading.py @@ -3,10 +3,17 @@ from etl.database import get_engine +# Nombre de lignes par INSERT. Le protocole Postgres plafonne à 65 535 paramètres +# par requête : avec ~40 colonnes, 1 000 lignes restent largement sous la limite. +# Indispensable pour les tables volumineuses (mandats, coSignatairesDocument…) qui +# dépasseraient sinon la limite en un seul INSERT. +BATCH_SIZE = 1000 + def load(table, data): - """Load into the database the data for the given_fields""" + """Load into the database the data for the given fields, in batches.""" with Session(get_engine()) as session: - insert_statement = insert(table).values(data).on_conflict_do_nothing() - session.execute(insert_statement) + for start in range(0, len(data), BATCH_SIZE): + batch = data[start : start + BATCH_SIZE] + session.execute(insert(table).values(batch).on_conflict_do_nothing()) session.commit() diff --git a/models/__init__.py b/models/__init__.py index 187aec0..29a628f 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,2 +1,9 @@ +from models.acteur import Acteur # noqa: F401 from models.amendement import Amendement # noqa: F401 +from models.auteur_document import AuteurDocument # noqa: F401 +from models.cosignataire_document import CoSignataireDocument # noqa: F401 +from models.document import Document # noqa: F401 from models.dossier import Dossier # noqa: F401 +from models.mandat import Mandat # noqa: F401 +from models.organe import Organe # noqa: F401 +from models.scrutin import Scrutin # noqa: F401 diff --git a/models/acteur.py b/models/acteur.py new file mode 100644 index 0000000..2ab5cfe --- /dev/null +++ b/models/acteur.py @@ -0,0 +1,64 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Acteur(Base): + """Acteur parlementaire (député / sénateur) tel que renvoyé par l'API des + tricoteuses (endpoint /acteurs). + + Référentiel trans-législature : `uid` (PA…) est stable d'une législature à + l'autre, le rattachement à un groupe/commission passe par la table `mandats`. + Noms de colonnes en camelCase = clés JSON (voir Amendement pour la convention). + """ + + __tablename__ = "acteurs" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + prenom: Mapped[str | None] + nom: Mapped[str | None] + civ: Mapped[str | None] + slug: Mapped[str | None] + chambre: Mapped[str | None] + actif: Mapped[bool | None] + + # --- État civil --- + dateNais: Mapped[str | None] + dateDeces: Mapped[str | None] + villeNais: Mapped[str | None] + depNais: Mapped[str | None] + paysNais: Mapped[str | None] + + # --- Profession --- + profession: Mapped[str | None] + catSocPro: Mapped[str | None] + famSocPro: Mapped[str | None] + + # --- Rattachements (références molles) --- + groupeParlementaireUid: Mapped[str | None] + mandatPrincipalUid: Mapped[str | None] + circonscriptionUid: Mapped[str | None] + commissionPermanenteRefUid: Mapped[str | None] + fonctionCommissionPermanente: Mapped[str | None] + placeHemicycle: Mapped[str | None] + + # --- Divers --- + uriHatvp: Mapped[str | None] + urlImage: Mapped[str | None] + compteTwitter: Mapped[str | None] + senatMatricule: Mapped[str | None] + + # --- Compteurs d'activité --- + nombreAmendements: Mapped[int | None] + nombreAmendementsAdoptes: Mapped[int | None] + nombreInterventions: Mapped[int | None] + nombreQuestions: Mapped[int | None] + nombreQuestionsRepondues: Mapped[int | None] + nombreDocumentsPublies: Mapped[int | None] + nombreMandats: Mapped[int | None] + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/auteur_document.py b/models/auteur_document.py new file mode 100644 index 0000000..e9e1c8d --- /dev/null +++ b/models/auteur_document.py @@ -0,0 +1,28 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class AuteurDocument(Base): + """Auteur d'un document parlementaire (endpoint /auteursDocument). + + Relie un acteur (ou un organe) à un document (texte de loi, rapport…) avec sa + `qualite` (auteur, rapporteur…). Sert à savoir qui dépose / porte un texte + (`acteurRefUid` → acteurs, `documentRefUid` → documents). + """ + + __tablename__ = "auteursDocument" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + qualite: Mapped[str | None] + + # --- Rattachement (références molles indexées) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/cosignataire_document.py b/models/cosignataire_document.py new file mode 100644 index 0000000..f47fcae --- /dev/null +++ b/models/cosignataire_document.py @@ -0,0 +1,31 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class CoSignataireDocument(Base): + """Co-signataire d'un document parlementaire (endpoint /coSignatairesDocument). + + Relie un acteur à un document qu'il co-signe, avec les dates de (co)signature et + d'éventuel retrait. Complète `auteursDocument` pour reconstituer l'ensemble des + soutiens d'un texte. + """ + + __tablename__ = "coSignatairesDocument" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + edite: Mapped[bool | None] + etApparentes: Mapped[bool | None] + + # --- Rattachement (références molles indexées) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) + + # --- Dates --- + dateCosignature: Mapped[str | None] + dateRetraitCosignature: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/document.py b/models/document.py new file mode 100644 index 0000000..eb51b6e --- /dev/null +++ b/models/document.py @@ -0,0 +1,74 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Document(Base): + """Document parlementaire (endpoint /documents) : les textes eux-mêmes — projets + et propositions de loi, rapports, accords internationaux… + + Se rattache au dossier législatif par `dossierRefUid` et porte l'auteur principal + (`auteurPrincipalUid`). L'exposé des motifs (`exposeMotifsTexte`) est l'équivalent, + côté texte, de l'exposé sommaire d'un amendement. + """ + + __tablename__ = "documents" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + numNotice: Mapped[str | None] + texteLoi: Mapped[bool | None] + amendable: Mapped[bool | None] + estDisponible: Mapped[bool | None] + adoptionConforme: Mapped[bool | None] + + # --- Titres / contenu --- + titrePrincipal: Mapped[str | None] = mapped_column(Text) + titrePrincipalCourt: Mapped[str | None] = mapped_column(Text) + formule: Mapped[str | None] = mapped_column(Text) + exposeMotifsTexte: Mapped[str | None] = mapped_column(Text) + exposeMotifsHtml: Mapped[str | None] = mapped_column(Text) + denominationStructurelle: Mapped[str | None] + + # --- Classification --- + classeCode: Mapped[str | None] + classeLibelle: Mapped[str | None] + typeCode: Mapped[str | None] + typeLibelle: Mapped[str | None] + sousTypeCode: Mapped[str | None] + sousTypeLibelle: Mapped[str | None] + sousTypeLibelleEdition: Mapped[str | None] + especeCode: Mapped[str | None] + especeLibelle: Mapped[str | None] + depotCode: Mapped[str | None] + depotLibelle: Mapped[str | None] + provenance: Mapped[str | None] + statutAdoption: Mapped[str | None] + niveauCorrection: Mapped[str | None] + typeCorrection: Mapped[str | None] + xsiType: Mapped[str | None] + + # --- Rattachement (références molles indexées) --- + dossierRefUid: Mapped[str | None] = mapped_column(index=True) + documentParentRefUid: Mapped[str | None] = mapped_column(index=True) + auteurPrincipalUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + etapeLegislativePrincipaleRefUid: Mapped[str | None] + + # --- Divers --- + nbPage: Mapped[str | None] + prix: Mapped[str | None] + isbn: Mapped[str | None] + pdfUrl: Mapped[str | None] + + # --- Dates --- + dateCreation: Mapped[str | None] + dateDepot: Mapped[str | None] + datePublication: Mapped[str | None] + datePublicationWeb: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/mandat.py b/models/mandat.py new file mode 100644 index 0000000..80308dd --- /dev/null +++ b/models/mandat.py @@ -0,0 +1,58 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Mandat(Base): + """Mandat parlementaire (endpoint /mandats) : table de jointure entre un acteur + et un organe (appartenance à un groupe, une commission, une délégation…), avec + la qualité occupée et les dates de début/fin. + + C'est le pivot pour rattacher un acteur à son groupe politique à une date donnée + (`acteurRefUid` → acteurs, `organeRefUid` → organes). + """ + + __tablename__ = "mandats" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + typeOrgane: Mapped[str | None] + xsiType: Mapped[str | None] + actif: Mapped[bool | None] + nominPrincipale: Mapped[bool | None] + + # --- Rattachement (références molles indexées pour les jointures) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + mandatRemplaceRefUid: Mapped[str | None] + missionPrecedenteRefUid: Mapped[str | None] + + # --- Qualité --- + codeQualite: Mapped[str | None] + libQualite: Mapped[str | None] + libQualiteSex: Mapped[str | None] + libelle: Mapped[str | None] + causeMandat: Mapped[str | None] + causeFin: Mapped[str | None] + + # --- Circonscription / territoire --- + refCirconscription: Mapped[str | None] + region: Mapped[str | None] + regionType: Mapped[str | None] + departement: Mapped[str | None] + numDepartement: Mapped[int | None] + numCirco: Mapped[int | None] + placeHemicycle: Mapped[str | None] + preseance: Mapped[int | None] + premiereElection: Mapped[str | None] + + # --- Dates --- + dateDebut: Mapped[str | None] + dateFin: Mapped[str | None] + datePriseFonction: Mapped[str | None] + datePublication: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/organe.py b/models/organe.py new file mode 100644 index 0000000..f0cf015 --- /dev/null +++ b/models/organe.py @@ -0,0 +1,71 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Organe(Base): + """Organe parlementaire (endpoint /organes) : groupes politiques, commissions, + assemblées, missions, délégations… + + Le `codeType` distingue la nature de l'organe (ex. GP = groupe politique, + COMPER = commission permanente). Les groupes votants et les mandats pointent + vers cet identifiant `uid` (PO…) via leur `organeRefUid`. + """ + + __tablename__ = "organes" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + codeType: Mapped[str | None] + type: Mapped[str | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + actif: Mapped[bool | None] + regime: Mapped[str | None] + regimeJuridique: Mapped[str | None] + xsiType: Mapped[str | None] + + # --- Libellés --- + libelle: Mapped[str | None] = mapped_column(Text) + libelleEdition: Mapped[str | None] = mapped_column(Text) + libelleAbrege: Mapped[str | None] + libelleAbrev: Mapped[str | None] + libelleTronque: Mapped[str | None] + + # --- Caractérisation politique (groupes) --- + positionPolitique: Mapped[str | None] + couleurAssociee: Mapped[str | None] + poids: Mapped[int | None] + preseance: Mapped[int | None] + cohesion: Mapped[int | None] + + # --- Rattachement / contacts --- + organeParentRefUid: Mapped[str | None] = mapped_column(index=True) + secretaire01: Mapped[str | None] + secretaire02: Mapped[str | None] + siteInternet: Mapped[str | None] = mapped_column(Text) + urlImage: Mapped[str | None] + senatCode: Mapped[str | None] + numCirco: Mapped[str | None] + numDepartement: Mapped[str | None] + + # --- Compteurs d'activité --- + nombreMembres: Mapped[int | None] + nombreReunionsAnnuelles: Mapped[int | None] + auditionsRealisees: Mapped[int | None] + dossiersLoiTraites: Mapped[int | None] + missionsDemarrees: Mapped[int | None] + nombreAmendementsProposes: Mapped[int | None] + nombreInterventions: Mapped[int | None] + nombreQuestions: Mapped[int | None] + nombreTextesLoisDeposes: Mapped[int | None] + rapportsPublies: Mapped[int | None] + + # --- Dates --- + dateDebut: Mapped[str | None] + dateFin: Mapped[str | None] + dateAgrement: Mapped[str | None] + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] diff --git a/models/scrutin.py b/models/scrutin.py new file mode 100644 index 0000000..096f933 --- /dev/null +++ b/models/scrutin.py @@ -0,0 +1,68 @@ +from sqlalchemy import Text +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class Scrutin(Base): + """Scrutin public (endpoint /scrutins) : un vote solennel de l'hémicycle sur un + objet donné (amendement, article, ensemble d'un texte, motion…), avec le résultat + agrégé (pour / contre / abstentions). + + Les références molles permettent de relier le scrutin à ce qui était voté : + `dossierRefUid`, `documentRefUid`, `amendementRefUid`. Le détail par groupe est + dans `groupesVotants`, le détail nominatif dans `votes`. + """ + + __tablename__ = "scrutins" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + legislature: Mapped[int | None] + chambre: Mapped[str | None] + numero: Mapped[str | None] + dateScrutin: Mapped[str | None] + demandeur: Mapped[str | None] + + # --- Objet voté --- + objet: Mapped[str | None] = mapped_column(Text) + titre: Mapped[str | None] = mapped_column(Text) + typeObjet: Mapped[str | None] + numeroTypeObjet: Mapped[str | None] + + # --- Type de scrutin / résultat --- + codeTypeVote: Mapped[str | None] + libelleTypeVote: Mapped[str | None] + modePublicationDesVotes: Mapped[str | None] + typeMajorite: Mapped[str | None] + code: Mapped[str | None] + libelle: Mapped[str | None] + annonce: Mapped[str | None] + + # --- Décompte --- + pour: Mapped[int | None] + contre: Mapped[int | None] + abstentions: Mapped[int | None] + nonVotants: Mapped[int | None] + nonVotantsVolontaires: Mapped[int | None] + nombreVotants: Mapped[int | None] + suffragesExprimes: Mapped[int | None] + nbrSuffragesRequis: Mapped[int | None] + + # --- Rattachement (références molles indexées) --- + organeRefUid: Mapped[str | None] = mapped_column(index=True) + dossierRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) + amendementRefUid: Mapped[str | None] = mapped_column(index=True) + articleRefUid: Mapped[str | None] + seanceRefUid: Mapped[str | None] + pointOdjRefUid: Mapped[str | None] + acteLegislatifRefUid: Mapped[str | None] + etapeLegislativeRefUid: Mapped[str | None] + codeEtape: Mapped[str | None] + quantiemeJourSeance: Mapped[str | None] + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] From 71a19ca312c8e1c4ed759c3d59a9e76ba42ddc23 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Thu, 16 Jul 2026 21:46:57 +0200 Subject: [PATCH 04/11] Remplace le graphe de jointures ASCII par un diagramme Mermaid --- README.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a887512..8a95778 100644 --- a/README.md +++ b/README.md @@ -142,16 +142,25 @@ c'est voté » : | `auteursDocument` | `/auteursDocument` | Auteur(s) d'un document | ~20 000 | | `coSignatairesDocument` | `/coSignatairesDocument` | Co-signataires d'un document | ~116 000 | -Les jointures se font par les colonnes `…RefUid` (références molles, indexées) : - -``` -amendements → acteurRefUid / groupePolitiqueRefUid - ↓ ↓ - acteurs ←── mandats ──→ organes (groupe politique) - ↓ -documents (dossierRefUid → dossiers) → auteursDocument / coSignatairesDocument → acteurs - ↓ -scrutins (amendementRefUid → amendements ; documentRefUid → documents) : résultat du vote +Les jointures se font par les colonnes `…RefUid` (références molles, indexées). Chaque flèche +`A -->|colonne| B` se lit : la colonne `colonne` de **A** référence l'`uid` de **B**. + +```mermaid +flowchart LR + amendements -->|acteurRefUid| acteurs + amendements -->|groupePolitiqueRefUid| organes + amendements -->|dossierRefUid| dossiers + amendements -->|documentRefUid| documents + scrutins -->|amendementRefUid| amendements + scrutins -->|documentRefUid| documents + documents -->|dossierRefUid| dossiers + documents -->|auteurPrincipalUid| acteurs + auteursDocument -->|documentRefUid| documents + auteursDocument -->|acteurRefUid| acteurs + coSignatairesDocument -->|documentRefUid| documents + coSignatairesDocument -->|acteurRefUid| acteurs + mandats -->|acteurRefUid| acteurs + mandats -->|organeRefUid| organes ``` Le lien **amendement ↔ scrutin** est natif : `scrutins.amendementRefUid` pointe vers From 38807fbeb90a19680a1a1dfec2d6359e27935850 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Thu, 16 Jul 2026 22:46:07 +0200 Subject: [PATCH 05/11] =?UTF-8?q?Utilise=20un=20diagramme=20entit=C3=A9-re?= =?UTF-8?q?lation=20pour=20le=20sch=C3=A9ma=20de=20la=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 94 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8a95778..4b52643 100644 --- a/README.md +++ b/README.md @@ -142,25 +142,85 @@ c'est voté » : | `auteursDocument` | `/auteursDocument` | Auteur(s) d'un document | ~20 000 | | `coSignatairesDocument` | `/coSignatairesDocument` | Co-signataires d'un document | ~116 000 | -Les jointures se font par les colonnes `…RefUid` (références molles, indexées). Chaque flèche -`A -->|colonne| B` se lit : la colonne `colonne` de **A** référence l'`uid` de **B**. +Les jointures se font par les colonnes `…RefUid` (références molles, nullable, indexées). Schéma +entité-relation ci-dessous — les boîtes ne montrent que les colonnes clés (PK + FK + quelques +champs parlants) ; la liste complète est dans les modèles `models/`. ```mermaid -flowchart LR - amendements -->|acteurRefUid| acteurs - amendements -->|groupePolitiqueRefUid| organes - amendements -->|dossierRefUid| dossiers - amendements -->|documentRefUid| documents - scrutins -->|amendementRefUid| amendements - scrutins -->|documentRefUid| documents - documents -->|dossierRefUid| dossiers - documents -->|auteurPrincipalUid| acteurs - auteursDocument -->|documentRefUid| documents - auteursDocument -->|acteurRefUid| acteurs - coSignatairesDocument -->|documentRefUid| documents - coSignatairesDocument -->|acteurRefUid| acteurs - mandats -->|acteurRefUid| acteurs - mandats -->|organeRefUid| organes +erDiagram + dossiers { + string uid PK + string titre + } + documents { + string uid PK + string dossierRefUid FK + string auteurPrincipalUid FK + string classeLibelle + bool texteLoi + } + amendements { + string uid PK + string acteurRefUid FK + string groupePolitiqueRefUid FK + string dossierRefUid FK + string documentRefUid FK + string sortAmendement + } + acteurs { + string uid PK + string nom + string prenom + string chambre + } + organes { + string uid PK + string codeType + string libelleAbrev + } + mandats { + string uid PK + string acteurRefUid FK + string organeRefUid FK + string typeOrgane + string dateDebut + string dateFin + } + scrutins { + string uid PK + string amendementRefUid FK + string documentRefUid FK + string typeObjet + string code + int pour + int contre + } + auteursDocument { + string uid PK + string documentRefUid FK + string acteurRefUid FK + string qualite + } + coSignatairesDocument { + string uid PK + string documentRefUid FK + string acteurRefUid FK + } + + dossiers ||--o{ documents : "dossierRefUid" + dossiers ||--o{ amendements : "dossierRefUid" + documents ||--o{ amendements : "documentRefUid" + acteurs ||--o{ amendements : "acteurRefUid" + organes ||--o{ amendements : "groupePolitiqueRefUid" + acteurs ||--o{ mandats : "acteurRefUid" + organes ||--o{ mandats : "organeRefUid" + acteurs ||--o{ documents : "auteurPrincipalUid" + documents ||--o{ auteursDocument : "documentRefUid" + acteurs ||--o{ auteursDocument : "acteurRefUid" + documents ||--o{ coSignatairesDocument : "documentRefUid" + acteurs ||--o{ coSignatairesDocument : "acteurRefUid" + amendements ||--o{ scrutins : "amendementRefUid" + documents ||--o{ scrutins : "documentRefUid" ``` Le lien **amendement ↔ scrutin** est natif : `scrutins.amendementRefUid` pointe vers From ab416527ee4f7b92becfeee55132a6ea1ec2935b Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Fri, 17 Jul 2026 09:46:32 +0200 Subject: [PATCH 06/11] =?UTF-8?q?All=C3=A8ge=20le=20README=20en=20retirant?= =?UTF-8?q?=20quelques=20phrases=20superflues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/README.md b/README.md index 4b52643..fff2807 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ Voici les différents endpoint: * députés => `https://parlement.tricoteuses.fr/acteurs` * votes => `https://parlement.tricoteuses.fr/scrutins` -L'API propose de nombreux endpoints. [Information sur le chemin d'une loi](https://www.assemblee-nationale.fr/dyn/actualites-accueil-hub/le-parcours-de-la-loi) @@ -128,9 +127,6 @@ Le champ doit porter le même nom sinon l'ETL ne sera pas capable de le trouver. ## Objets parlementaires chargés -Au-delà des `dossiers` et `amendements`, l'ETL charge les objets de l'API des tricoteuses -nécessaires aux recoupements « qui propose / porte quel texte, via quel groupe, et comment -c'est voté » : | Table | Endpoint | Contenu | Volume (législature 17) | |---|---|---|---| @@ -227,20 +223,3 @@ Le lien **amendement ↔ scrutin** est natif : `scrutins.amendementRefUid` point `amendements.uid` (quand `scrutins.typeObjet = 'amendement'`). Seule une minorité d'amendements passe par un scrutin public (les autres sont tranchés à main levée), la jointure est donc volontairement clairsemée. - -### Filtre de législature - -`etl/download.py` associe à chaque endpoint un booléen indiquant s'il faut filtrer par -`legislature=17`. Trois cas : - -- **Filtré L17** : `dossiers`, `documents`, `amendements`, `scrutins`, `mandats` — l'API - supporte le filtre et il est pertinent de se limiter à la 17ᵉ législature. -- **Non filtré, référentiel complet** : `acteurs` (renvoie une 500 avec le filtre), `organes` - (partagé entre législatures) — gardés entiers pour éviter des références orphelines. -- **Non filtré faute de paramètre** : `auteursDocument`, `coSignatairesDocument` — l'endpoint - n'expose pas de filtre `legislature` ; on les scope à la L17 par jointure sur - `documents.legislature = 17` au moment de l'analyse. - -> Note : l'endpoint `/votes` (votes nominatifs individuels) n'expose **pas** de filtre -> `legislature` et mélange plusieurs législatures ; il n'est volontairement pas chargé pour -> l'instant. Le résultat agrégé par scrutin (`scrutins`) suffit à la plupart des recoupements. From c80f1e167dd8d58a6eecd7f72c22a148240501e2 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Fri, 17 Jul 2026 09:58:41 +0200 Subject: [PATCH 07/11] =?UTF-8?q?Documente=20le=20setup=20.env=20/=20Postg?= =?UTF-8?q?reSQL=20et=20corrige=20l'exemple=20de=20mod=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fff2807..db8cb1b 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,23 @@ Voici les différents endpoint: - [Installation d'UV](https://docs.astral.sh/uv/) ### Setup + +1. Installer les dépendances : ```bash uv sync ``` +2. Créer le fichier `.env` à partir de l'exemple, puis l'adapter si besoin : +```bash +cp .env.example .env +``` + +3. Démarrer PostgreSQL (instance locale définie dans `docker-compose.yml`, sur le port `5432`) : +```bash +docker compose up -d db +``` +Les valeurs par défaut de `.env.example` correspondent à ce conteneur (`postgres`/`postgres`, base `ipolitics`). + ### Usages #### Exécuter des commandes @@ -111,12 +124,12 @@ Voici une partie du fichier `./data/dossiers.json` Je veux rajouter le champ `chambre` dans la DB et faire en sorte que l'ETL l'ajoute de lui-même. 1. Rajouter le champ dans le modèle -``` -class User(Base): +```python +class Dossier(Base): __tablename__ = "dossiers" uid: Mapped[str] = mapped_column(primary_key=True) - titre: Mapped[str] = mapped_column(String(500)) + titre: Mapped[str] = mapped_column(String(1000)) dataset: Mapped[int] chambre: Mapped[str] = mapped_column(String(5)) # <-------- nouvelle colonne qui porte le même nom que le champ du fichier json ``` From 997b0b027e0fe8a6c7a44aa3c38b3e0f54993f58 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Fri, 17 Jul 2026 13:09:39 +0200 Subject: [PATCH 08/11] =?UTF-8?q?D=C3=A9duplique=20et=20met=20=C3=A0=20jou?= =?UTF-8?q?r=20les=20lignes=20modifi=C3=A9es=20au=20chargement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etl/loading.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/etl/loading.py b/etl/loading.py index 68327e0..32a7a4a 100644 --- a/etl/loading.py +++ b/etl/loading.py @@ -1,3 +1,4 @@ +from sqlalchemy import tuple_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session @@ -9,11 +10,56 @@ # dépasseraient sinon la limite en un seul INSERT. BATCH_SIZE = 1000 +# Colonnes ignorées pour décider si une ligne a changé. `dateMaj` est l'horodatage +# du lot d'export des tricoteuses, pas une date de modification de la ligne : sur +# 7 tables sur 9 il porte la même valeur pour toutes les lignes et change à chaque +# téléchargement. Le comparer reviendrait à réécrire l'intégralité de ces tables à +# chaque exécution. +IGNORED_FOR_COMPARISON = {"dateMaj"} + + +def _get_primary_key(table): + return [column.name for column in table.primary_key.columns] + + +def _deduplicate(table, data): + """Ne garder qu'une ligne par clé primaire. + + La pagination de l'API sert parfois deux fois la même entrée (~640 doublons + sur organes, ~77 sur scrutins). Postgres interdit qu'un ON CONFLICT DO UPDATE + touche deux fois la même ligne dans une seule commande : sans déduplication, + l'INSERT échoue. + """ + keys = _get_primary_key(table) + unique = {tuple(row[key] for key in keys): row for row in data} + return list(unique.values()) + + +def _upsert(table, batch): + """INSERT ... ON CONFLICT DO UPDATE n'écrivant que les lignes réellement modifiées. + + Le WHERE compare la ligne existante à celle proposée : sans lui, chaque + exécution réécrirait toutes les lignes. Or un UPDATE Postgres n'est jamais + fait sur place (nouvelle version du tuple, index et WAL mis à jour), ce qui + ferait gonfler la base pour rien. + """ + statement = insert(table).values(batch) + keys = _get_primary_key(table) + updatable = [column.name for column in table.columns if column.name not in keys] + compared = [name for name in updatable if name not in IGNORED_FOR_COMPARISON] + return statement.on_conflict_do_update( + index_elements=keys, + set_={name: statement.excluded[name] for name in updatable}, + where=tuple_(*(table.c[name] for name in compared)).is_distinct_from( + tuple_(*(statement.excluded[name] for name in compared)) + ), + ) + def load(table, data): """Load into the database the data for the given fields, in batches.""" + data = _deduplicate(table, data) with Session(get_engine()) as session: for start in range(0, len(data), BATCH_SIZE): - batch = data[start : start + BATCH_SIZE] - session.execute(insert(table).values(batch).on_conflict_do_nothing()) + session.execute(_upsert(table, data[start : start + BATCH_SIZE])) session.commit() From 5460e899343bfc23b6030f882cd35fd2e18cff79 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Fri, 17 Jul 2026 13:09:39 +0200 Subject: [PATCH 09/11] =?UTF-8?q?Ajoute=20les=20champs=20demand=C3=A9s=20e?= =?UTF-8?q?t=20la=20ventilation=20des=20votes=20par=20groupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +++++++++++++++++++++++++------ etl/database.py | 1 + etl/download.py | 6 ++++-- models/__init__.py | 1 + models/amendement.py | 11 +++++++++++ models/dossier.py | 17 +++++++++++++++++ models/groupe_votant.py | 37 +++++++++++++++++++++++++++++++++++++ 7 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 models/groupe_votant.py diff --git a/README.md b/README.md index db8cb1b..998741d 100644 --- a/README.md +++ b/README.md @@ -144,13 +144,18 @@ Le champ doit porter le même nom sinon l'ETL ne sera pas capable de le trouver. | Table | Endpoint | Contenu | Volume (législature 17) | |---|---|---|---| | `acteurs` | `/acteurs` | Députés / sénateurs (référentiel trans-législature) | ~3 100 | -| `organes` | `/organes` | Groupes politiques, commissions, assemblées… | ~6 100 | +| `organes` | `/organes` | Groupes politiques, commissions, assemblées… | ~5 400 | | `mandats` | `/mandats` | Jointure acteur ↔ organe (appartenance + dates) | ~25 600 | -| `scrutins` | `/scrutins` | Scrutins publics et résultat agrégé (pour/contre/abstentions) | ~8 300 | +| `scrutins` | `/scrutins` | Scrutins publics et résultat agrégé (pour/contre/abstentions) | ~8 200 | +| `groupesVotants` | `/groupesVotants` | Résultat d'un scrutin ventilé par groupe politique | ~121 500 * | | `documents` | `/documents` | Textes (projets/propositions de loi, rapports…) | ~4 500 | | `auteursDocument` | `/auteursDocument` | Auteur(s) d'un document | ~20 000 | | `coSignatairesDocument` | `/coSignatairesDocument` | Co-signataires d'un document | ~116 000 | +\* `groupesVotants` n'expose pas de filtre `legislature` : la table couvre toutes les législatures +(~98 300 lignes se rattachent à un scrutin de la L17, soit 12 groupes pour chacun des 8 192 +scrutins concernés). Se scoper par jointure sur `scrutins`. + Les jointures se font par les colonnes `…RefUid` (références molles, nullable, indexées). Schéma entité-relation ci-dessous — les boîtes ne montrent que les colonnes clés (PK + FK + quelques champs parlants) ; la liste complète est dans les modèles `models/`. @@ -160,6 +165,8 @@ erDiagram dossiers { string uid PK string titre + string libelleProcedure + string statut } documents { string uid PK @@ -174,6 +181,7 @@ erDiagram string groupePolitiqueRefUid FK string dossierRefUid FK string documentRefUid FK + string scrutinRefUid FK string sortAmendement } acteurs { @@ -204,6 +212,14 @@ erDiagram int pour int contre } + groupesVotants { + string uid PK + string scrutinRefUid FK + string organeRefUid FK + string positionMajoritaire + int pour + int contre + } auteursDocument { string uid PK string documentRefUid FK @@ -230,9 +246,12 @@ erDiagram acteurs ||--o{ coSignatairesDocument : "acteurRefUid" amendements ||--o{ scrutins : "amendementRefUid" documents ||--o{ scrutins : "documentRefUid" + scrutins ||--o{ groupesVotants : "scrutinRefUid" + organes ||--o{ groupesVotants : "organeRefUid" ``` -Le lien **amendement ↔ scrutin** est natif : `scrutins.amendementRefUid` pointe vers -`amendements.uid` (quand `scrutins.typeObjet = 'amendement'`). Seule une minorité d'amendements -passe par un scrutin public (les autres sont tranchés à main levée), la jointure est donc -volontairement clairsemée. +Le lien **amendement ↔ scrutin** est natif, et dans les deux sens : `scrutins.amendementRefUid` +pointe vers l'amendement tranché par le scrutin, et `amendements.scrutinRefUid` vers le scrutin +qui a tranché l'amendement. Le second est le plus large (~11 600 amendements contre ~6 800), +un même scrutin pouvant trancher plusieurs amendements identiques. La jointure reste clairsemée : +la plupart des amendements sont tranchés à main levée, sans scrutin public. diff --git a/etl/database.py b/etl/database.py index 9712091..9ed5e95 100644 --- a/etl/database.py +++ b/etl/database.py @@ -18,6 +18,7 @@ "organes", "mandats", "scrutins", + "groupesVotants", "documents", "auteursDocument", "coSignatairesDocument", diff --git a/etl/download.py b/etl/download.py index 494b54e..e27e75b 100644 --- a/etl/download.py +++ b/etl/download.py @@ -14,8 +14,9 @@ # le filtre ; `organes` est partagé et on le veut complet pour éviter des # références orphelines) ; # - les endpoints qui n'exposent pas de paramètre `legislature` -# (`auteursDocument`, `coSignatairesDocument`) : on les scope alors à la -# L17 par jointure sur `documents` au moment de l'analyse. +# (`auteursDocument`, `coSignatairesDocument`, `groupesVotants`) : on les +# scope alors à la L17 par jointure (sur `documents` ou `scrutins`) au +# moment de l'analyse. APIS = { "dossiers": True, "documents": True, @@ -26,6 +27,7 @@ "scrutins": True, "auteursDocument": False, "coSignatairesDocument": False, + "groupesVotants": False, } BATCH_SIZE = 500 diff --git a/models/__init__.py b/models/__init__.py index 29a628f..f9e78d1 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -4,6 +4,7 @@ from models.cosignataire_document import CoSignataireDocument # noqa: F401 from models.document import Document # noqa: F401 from models.dossier import Dossier # noqa: F401 +from models.groupe_votant import GroupeVotant # noqa: F401 from models.mandat import Mandat # noqa: F401 from models.organe import Organe # noqa: F401 from models.scrutin import Scrutin # noqa: F401 diff --git a/models/amendement.py b/models/amendement.py index 723183d..d931882 100644 --- a/models/amendement.py +++ b/models/amendement.py @@ -39,17 +39,28 @@ class Amendement(Base): signatairesLibelle: Mapped[str | None] nombreCoSignataires: Mapped[int | None] + # --- Objet visé dans le texte --- + divisionArticleDesignation: Mapped[str | None] + alineaDesignation: Mapped[str | None] + # --- Rattachement (références molles vers d'autres entités de l'API) --- dossierRefUid: Mapped[str | None] documentRefUid: Mapped[str | None] etapeLegislativeRefUid: Mapped[str | None] codeEtape: Mapped[str | None] + seanceRefUid: Mapped[str | None] + # Renseigné quand l'amendement a été tranché par un scrutin public. Couvre deux + # fois plus d'amendements que le lien inverse `scrutins.amendementRefUid`, un même + # scrutin pouvant trancher plusieurs amendements identiques. + scrutinRefUid: Mapped[str | None] = mapped_column(index=True) # --- Statut / sort --- sortAmendement: Mapped[str | None] etatCode: Mapped[str | None] etatLibelle: Mapped[str | None] triAmendement: Mapped[str | None] + # L'API renvoie ici les chaînes "true"/"false" et non un booléen JSON. + soumisArticle40: Mapped[str | None] # --- Dates (conservées en texte : l'ETL insère les valeurs JSON brutes) --- dateDepot: Mapped[str | None] diff --git a/models/dossier.py b/models/dossier.py index f46927a..6ae850d 100644 --- a/models/dossier.py +++ b/models/dossier.py @@ -5,8 +5,25 @@ class Dossier(Base): + """Dossier législatif (endpoint /dossiers) : le fil qui suit un texte tout au long + de la procédure (commission, séance, navette avec le Sénat, amendements...). + + L'endpoint expose bien plus de champs que ceux repris ici ; on ne charge que ceux + utiles aux recoupements. + """ + __tablename__ = "dossiers" + # --- Identité --- uid: Mapped[str] = mapped_column(primary_key=True) titre: Mapped[str] = mapped_column(String(1000)) dataset: Mapped[int] + legislature: Mapped[int | None] + + # --- Procédure --- + codeProcedure: Mapped[str | None] + libelleProcedure: Mapped[str | None] + + # --- Avancement --- + statut: Mapped[str | None] + dateDernierActe: Mapped[str | None] diff --git a/models/groupe_votant.py b/models/groupe_votant.py new file mode 100644 index 0000000..b0b5479 --- /dev/null +++ b/models/groupe_votant.py @@ -0,0 +1,37 @@ +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import Base + + +class GroupeVotant(Base): + """Résultat d'un scrutin ventilé par groupe politique (endpoint /groupesVotants). + + Une ligne par couple (scrutin, groupe) : le décompte du groupe et sa position + majoritaire. Le détail nominatif (endpoint /votes) n'est pas chargé. + + L'endpoint n'expose pas de filtre `legislature` : la table couvre toutes les + législatures et se scope à la 17ᵉ par jointure sur `scrutins`. + """ + + __tablename__ = "groupesVotants" + + # --- Identité --- + uid: Mapped[str] = mapped_column(primary_key=True) + dataset: Mapped[int | None] + + # --- Rattachement (références molles indexées) --- + scrutinRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) + + # --- Décompte du groupe --- + positionMajoritaire: Mapped[str | None] + nombreMembresGroupe: Mapped[int | None] + pour: Mapped[int | None] + contre: Mapped[int | None] + abstentions: Mapped[int | None] + nonVotants: Mapped[int | None] + nonVotantsVolontaires: Mapped[int | None] + + # --- Dates --- + dateMaj: Mapped[str | None] + datePremierAjout: Mapped[str | None] From 085c7debdd795b56c0d6a1bcd2b83bbcfcfbd599 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Fri, 17 Jul 2026 14:07:53 +0200 Subject: [PATCH 10/11] =?UTF-8?q?Resserre=20le=20sch=C3=A9ma=20de=20la=20b?= =?UTF-8?q?ase=20sur=20les=20champs=20les=20plus=20parlants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 998741d..f66408a 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,10 @@ erDiagram string uid PK string dossierRefUid FK string auteurPrincipalUid FK + text titrePrincipal string classeLibelle bool texteLoi + string dateDepot } amendements { string uid PK @@ -182,35 +184,46 @@ erDiagram string dossierRefUid FK string documentRefUid FK string scrutinRefUid FK + string numeroLong + string divisionArticleDesignation + text exposeSommaire string sortAmendement + string dateDepot } acteurs { string uid PK + string groupeParlementaireUid FK string nom string prenom string chambre + bool actif } organes { string uid PK string codeType string libelleAbrev + string positionPolitique } mandats { string uid PK string acteurRefUid FK string organeRefUid FK string typeOrgane + string libQualite string dateDebut string dateFin } scrutins { string uid PK - string amendementRefUid FK + string dossierRefUid FK string documentRefUid FK - string typeObjet + string amendementRefUid FK + string dateScrutin + text objet string code int pour int contre + int abstentions } groupesVotants { string uid PK @@ -219,6 +232,7 @@ erDiagram string positionMajoritaire int pour int contre + int abstentions } auteursDocument { string uid PK @@ -230,24 +244,28 @@ erDiagram string uid PK string documentRefUid FK string acteurRefUid FK + string dateCosignature } dossiers ||--o{ documents : "dossierRefUid" dossiers ||--o{ amendements : "dossierRefUid" + dossiers ||--o{ scrutins : "dossierRefUid" documents ||--o{ amendements : "documentRefUid" + documents ||--o{ scrutins : "documentRefUid" + documents ||--o{ auteursDocument : "documentRefUid" + documents ||--o{ coSignatairesDocument : "documentRefUid" + acteurs ||--o{ documents : "auteurPrincipalUid" acteurs ||--o{ amendements : "acteurRefUid" - organes ||--o{ amendements : "groupePolitiqueRefUid" acteurs ||--o{ mandats : "acteurRefUid" - organes ||--o{ mandats : "organeRefUid" - acteurs ||--o{ documents : "auteurPrincipalUid" - documents ||--o{ auteursDocument : "documentRefUid" acteurs ||--o{ auteursDocument : "acteurRefUid" - documents ||--o{ coSignatairesDocument : "documentRefUid" acteurs ||--o{ coSignatairesDocument : "acteurRefUid" + organes ||--o{ acteurs : "groupeParlementaireUid" + organes ||--o{ amendements : "groupePolitiqueRefUid" + organes ||--o{ mandats : "organeRefUid" + organes ||--o{ groupesVotants : "organeRefUid" amendements ||--o{ scrutins : "amendementRefUid" - documents ||--o{ scrutins : "documentRefUid" + scrutins ||--o{ amendements : "scrutinRefUid" scrutins ||--o{ groupesVotants : "scrutinRefUid" - organes ||--o{ groupesVotants : "organeRefUid" ``` Le lien **amendement ↔ scrutin** est natif, et dans les deux sens : `scrutins.amendementRefUid` From 7904fde83ee94a8c5244a149133d1b857b336dc9 Mon Sep 17 00:00:00 2001 From: "david.noel@withpigment.com" Date: Sat, 18 Jul 2026 14:57:33 +0200 Subject: [PATCH 11/11] =?UTF-8?q?Indexe=20les=20r=C3=A9f=C3=A9rences=20de?= =?UTF-8?q?=20jointure=20des=20amendements=20et=20acteurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/acteur.py | 10 +++++----- models/amendement.py | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/models/acteur.py b/models/acteur.py index 2ab5cfe..c7a3a30 100644 --- a/models/acteur.py +++ b/models/acteur.py @@ -36,11 +36,11 @@ class Acteur(Base): catSocPro: Mapped[str | None] famSocPro: Mapped[str | None] - # --- Rattachements (références molles) --- - groupeParlementaireUid: Mapped[str | None] - mandatPrincipalUid: Mapped[str | None] - circonscriptionUid: Mapped[str | None] - commissionPermanenteRefUid: Mapped[str | None] + # --- Rattachements (références molles indexées) --- + groupeParlementaireUid: Mapped[str | None] = mapped_column(index=True) + mandatPrincipalUid: Mapped[str | None] = mapped_column(index=True) + circonscriptionUid: Mapped[str | None] = mapped_column(index=True) + commissionPermanenteRefUid: Mapped[str | None] = mapped_column(index=True) fonctionCommissionPermanente: Mapped[str | None] placeHemicycle: Mapped[str | None] diff --git a/models/amendement.py b/models/amendement.py index d931882..86f91cf 100644 --- a/models/amendement.py +++ b/models/amendement.py @@ -30,10 +30,10 @@ class Amendement(Base): exposeSommaire: Mapped[str | None] = mapped_column(Text) dispositif: Mapped[str | None] = mapped_column(Text) - # --- Auteur / signataires --- - acteurRefUid: Mapped[str | None] - groupePolitiqueRefUid: Mapped[str | None] - organeRefUid: Mapped[str | None] + # --- Auteur / signataires (références molles indexées) --- + acteurRefUid: Mapped[str | None] = mapped_column(index=True) + groupePolitiqueRefUid: Mapped[str | None] = mapped_column(index=True) + organeRefUid: Mapped[str | None] = mapped_column(index=True) typeAuteur: Mapped[str | None] nomRepresentation: Mapped[str | None] signatairesLibelle: Mapped[str | None] @@ -43,9 +43,9 @@ class Amendement(Base): divisionArticleDesignation: Mapped[str | None] alineaDesignation: Mapped[str | None] - # --- Rattachement (références molles vers d'autres entités de l'API) --- - dossierRefUid: Mapped[str | None] - documentRefUid: Mapped[str | None] + # --- Rattachement (références molles indexées vers d'autres entités de l'API) --- + dossierRefUid: Mapped[str | None] = mapped_column(index=True) + documentRefUid: Mapped[str | None] = mapped_column(index=True) etapeLegislativeRefUid: Mapped[str | None] codeEtape: Mapped[str | None] seanceRefUid: Mapped[str | None]