From 9f5f46a93accd9e0786e3b10634cea180410b751 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Tue, 28 Jul 2026 15:40:27 +0200 Subject: [PATCH] feat: donne aux immeubles un nom d'usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les comptes rendus n'identifient un immeuble que par son code de gestion (« 33689020 »), illisible partout où il s'affiche. La dénomination (« Servient ») le remplace à l'écran sans toucher au code, qui reste la clé venue des PDF. La colonne s'ajoute à une table déjà installée : elle passe donc par le rattrapage de schéma, qui accepte désormais l'absence de valeur de rattrapage. Déduire un nom d'usage du code en inventerait un. Co-Authored-By: Claude Opus 5 --- src/plesna_gerance/api/routes/analytics.py | 1 + src/plesna_gerance/api/schemas/__init__.py | 2 ++ src/plesna_gerance/api/schemas/models.py | 17 +++++++++ src/plesna_gerance/database/connection.py | 18 +++++++--- src/plesna_gerance/database/models.py | 3 ++ tests/test_schema_updates.py | 41 ++++++++++++++++++---- 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/plesna_gerance/api/routes/analytics.py b/src/plesna_gerance/api/routes/analytics.py index cb02d86..2f22373 100644 --- a/src/plesna_gerance/api/routes/analytics.py +++ b/src/plesna_gerance/api/routes/analytics.py @@ -59,6 +59,7 @@ async def list_immeubles( ImmeubleResponse( id=row.Immeuble.id, code=row.Immeuble.code, + denomination=row.Immeuble.denomination, adresse=row.Immeuble.adresse, ville=row.Immeuble.ville, code_postal=row.Immeuble.code_postal, diff --git a/src/plesna_gerance/api/schemas/__init__.py b/src/plesna_gerance/api/schemas/__init__.py index 791bd77..c62fe8d 100644 --- a/src/plesna_gerance/api/schemas/__init__.py +++ b/src/plesna_gerance/api/schemas/__init__.py @@ -6,6 +6,7 @@ from .models import ( DepenseDetail, DepensesSummary, DocumentSummary, + ImmeubleBody, ImmeubleResponse, LotReferentielResponse, LotResponse, @@ -21,6 +22,7 @@ __all__ = [ "DocumentSummary", "DepenseDetail", "DepensesSummary", + "ImmeubleBody", "ImmeubleResponse", "LotResponse", "CaracteristiquesBody", diff --git a/src/plesna_gerance/api/schemas/models.py b/src/plesna_gerance/api/schemas/models.py index 3c3ff5e..e2ecb0f 100644 --- a/src/plesna_gerance/api/schemas/models.py +++ b/src/plesna_gerance/api/schemas/models.py @@ -72,6 +72,7 @@ class ImmeubleResponse(BaseModel): id: int code: str + denomination: str | None = None adresse: str | None ville: str | None code_postal: str | None @@ -189,6 +190,20 @@ class DepensesSummary(BaseModel): # ============================================================ +class ImmeubleBody(BaseModel): + """Ce qui se saisit sur un immeuble : son nom d'usage.""" + + denomination: str | None = None + + @field_validator("denomination") + @classmethod + def _texte_vide_vaut_absent(cls, value: str | None) -> str | None: + """Effacer le nom doit rendre l'immeuble à son code, pas le nommer « ».""" + if value is None: + return None + return value.strip() or None + + class CaracteristiquesBody(BaseModel): """Caractéristiques d'un logement telles que saisies. @@ -248,6 +263,8 @@ class LotReferentielResponse(BaseModel): numero: str immeuble_id: int immeuble_code: str | None + #: Nom d'usage de l'immeuble ; le tableau retombe sur le code s'il manque. + immeuble_denomination: str | None = None #: Type de lot vu par l'extraction PDF, conservé tel quel. type_extrait: str | None diff --git a/src/plesna_gerance/database/connection.py b/src/plesna_gerance/database/connection.py index c7adb87..6e30910 100644 --- a/src/plesna_gerance/database/connection.py +++ b/src/plesna_gerance/database/connection.py @@ -96,14 +96,19 @@ def init_db(db_path: Path | None = None) -> Path: #: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de -#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table -#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce -#: rattrapage, une base installee cesserait de fonctionner apres mise a jour. +#: rattrapage pour les lignes existantes, ou None quand il n'y a rien de vrai a +#: y mettre). `create_all` ne modifie pas une table deja presente, et le projet +#: n'utilise pas d'outil de migration : sans ce rattrapage, une base installee +#: cesserait de fonctionner apres mise a jour. _ADDED_COLUMNS = { "documents": { # Les documents deja en base ont ete extraits lors de leur import. "extracted_at": ("DATETIME", "created_at"), }, + "immeubles": { + # Le nom d'usage se saisit : le deduire du code inventerait un nom. + "denomination": ("VARCHAR(100)", None), + }, } @@ -119,8 +124,11 @@ def _apply_schema_updates(engine): for name, (definition, backfill) in columns.items(): if name in existing: continue - conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")) - conn.execute(text(f"UPDATE {table} SET {name} = {backfill}")) + conn.execute( + text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}") + ) + if backfill is not None: + conn.execute(text(f"UPDATE {table} SET {name} = {backfill}")) def _seed_tags_if_empty(engine): diff --git a/src/plesna_gerance/database/models.py b/src/plesna_gerance/database/models.py index 7187ea8..de34fe7 100644 --- a/src/plesna_gerance/database/models.py +++ b/src/plesna_gerance/database/models.py @@ -65,6 +65,9 @@ class Immeuble(Base): id = Column(Integer, primary_key=True, autoincrement=True) code = Column(String(20), unique=True, nullable=False, index=True) + #: Nom d'usage ("Servient"), saisi : les PDF ne donnent qu'un code de + #: gestion, illisible partout où l'immeuble est cité. + denomination = Column(String(100), nullable=True) adresse = Column(String(255), nullable=True) ville = Column(String(100), nullable=True) code_postal = Column(String(10), nullable=True) diff --git a/tests/test_schema_updates.py b/tests/test_schema_updates.py index 6a93fbf..fbdc30f 100644 --- a/tests/test_schema_updates.py +++ b/tests/test_schema_updates.py @@ -24,11 +24,7 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch): importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc) with engine.begin() as conn: conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at")) - conn.execute( - text( - "INSERT INTO immeubles (code) VALUES ('IMM1');" - ) - ) + conn.execute(text("INSERT INTO immeubles (code) VALUES ('IMM1');")) conn.execute( text( "INSERT INTO documents (reference, date, immeuble_id, json_data," @@ -55,6 +51,37 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch): connection.reset_connection() +def test_ajoute_denomination_a_une_base_existante(tmp_path, monkeypatch): + """La colonne arrive vide : deduire un nom d'usage du code l'inventerait.""" + db_path = tmp_path / "ancienne.sqlite" + monkeypatch.setenv("PLESNA_DB_PATH", str(db_path)) + monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents")) + + connection.reset_connection() + connection.init_db(db_path) + engine = connection.get_engine(db_path) + with engine.begin() as conn: + conn.execute(text("ALTER TABLE immeubles DROP COLUMN denomination")) + conn.execute(text("INSERT INTO immeubles (code) VALUES ('33689020')")) + connection.reset_connection() + + connection.init_db(db_path) + + with connection.get_engine(db_path).begin() as conn: + colonnes = { + row[1] for row in conn.execute(text("PRAGMA table_info(immeubles)")) + } + assert "denomination" in colonnes + + denomination, code = conn.execute( + text("SELECT denomination, code FROM immeubles") + ).one() + assert denomination is None + assert code == "33689020" + + connection.reset_connection() + + def test_rattrapage_idempotent(tmp_path, monkeypatch): """Relancer init_db sur une base a jour ne doit rien casser.""" db_path = tmp_path / "a_jour.sqlite" @@ -67,7 +94,9 @@ def test_rattrapage_idempotent(tmp_path, monkeypatch): connection.init_db(db_path) with connection.get_engine(db_path).begin() as conn: - colonnes = [row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))] + colonnes = [ + row[1] for row in conn.execute(text("PRAGMA table_info(documents)")) + ] assert colonnes.count("extracted_at") == 1 connection.reset_connection()