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/README.md b/README.md index a661458..f66408a 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) @@ -35,10 +34,23 @@ L'API propose de nombreux endpoints. - [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 @@ -112,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 ``` @@ -125,3 +137,139 @@ 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 + + +| 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… | ~5 400 | +| `mandats` | `/mandats` | Jointure acteur ↔ organe (appartenance + dates) | ~25 600 | +| `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/`. + +```mermaid +erDiagram + dossiers { + string uid PK + string titre + string libelleProcedure + string statut + } + documents { + string uid PK + string dossierRefUid FK + string auteurPrincipalUid FK + text titrePrincipal + string classeLibelle + bool texteLoi + string dateDepot + } + amendements { + string uid PK + string acteurRefUid FK + string groupePolitiqueRefUid FK + 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 dossierRefUid FK + string documentRefUid FK + string amendementRefUid FK + string dateScrutin + text objet + string code + int pour + int contre + int abstentions + } + groupesVotants { + string uid PK + string scrutinRefUid FK + string organeRefUid FK + string positionMajoritaire + int pour + int contre + int abstentions + } + auteursDocument { + string uid PK + string documentRefUid FK + string acteurRefUid FK + string qualite + } + coSignatairesDocument { + 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" + acteurs ||--o{ mandats : "acteurRefUid" + acteurs ||--o{ auteursDocument : "acteurRefUid" + acteurs ||--o{ coSignatairesDocument : "acteurRefUid" + organes ||--o{ acteurs : "groupeParlementaireUid" + organes ||--o{ amendements : "groupePolitiqueRefUid" + organes ||--o{ mandats : "organeRefUid" + organes ||--o{ groupesVotants : "organeRefUid" + amendements ||--o{ scrutins : "amendementRefUid" + scrutins ||--o{ amendements : "scrutinRefUid" + scrutins ||--o{ groupesVotants : "scrutinRefUid" +``` + +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 aa7bc90..9ed5e95 100644 --- a/etl/database.py +++ b/etl/database.py @@ -5,6 +5,25 @@ 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", + # Objets ajoutés pour les recoupements auteur / groupe / texte / vote agrégé. + "acteurs", + "organes", + "mandats", + "scrutins", + "groupesVotants", + "documents", + "auteursDocument", + "coSignatairesDocument", +} + def _get_db_url(): PG_USER = getenv("PG_USER") @@ -24,21 +43,33 @@ 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(): + """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/etl/download.py b/etl/download.py index 1dc0ad3..e27e75b 100644 --- a/etl/download.py +++ b/etl/download.py @@ -5,12 +5,35 @@ 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`, `groupesVotants`) : on les +# scope alors à la L17 par jointure (sur `documents` ou `scrutins`) au +# moment de l'analyse. +APIS = { + "dossiers": True, + "documents": True, + "amendements": True, + "acteurs": False, + "organes": False, + "mandats": True, + "scrutins": True, + "auteursDocument": False, + "coSignatairesDocument": False, + "groupesVotants": False, +} + BATCH_SIZE = 500 BASE_URL = "https://parlement.tricoteuses.fr/" +TIMEOUT = 90 +MAX_RETRIES = 3 def save(data, filename): @@ -19,40 +42,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..32a7a4a 100644 --- a/etl/loading.py +++ b/etl/loading.py @@ -1,12 +1,65 @@ +from sqlalchemy import tuple_ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session 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 + +# 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""" + """Load into the database the data for the given fields, in batches.""" + data = _deduplicate(table, data) 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): + session.execute(_upsert(table, data[start : start + BATCH_SIZE])) session.commit() diff --git a/models/__init__.py b/models/__init__.py index efb2b64..f9e78d1 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1 +1,10 @@ +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.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/acteur.py b/models/acteur.py new file mode 100644 index 0000000..c7a3a30 --- /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 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] + + # --- 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/amendement.py b/models/amendement.py new file mode 100644 index 0000000..86f91cf --- /dev/null +++ b/models/amendement.py @@ -0,0 +1,70 @@ +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 (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] + nombreCoSignataires: Mapped[int | None] + + # --- Objet visé dans le texte --- + divisionArticleDesignation: Mapped[str | None] + alineaDesignation: 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] + # 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] + dateSort: Mapped[str | None] + dateMaj: Mapped[str | None] + datePublication: 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/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] 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]