From 84fb9884d6c02013eb60e40e4c320a0cdacc9d33 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 11:32:10 +0000 Subject: [PATCH] =?UTF-8?q?No=20filters=20resolves=20the=20whole=20portfol?= =?UTF-8?q?io=20minus=20deletions=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/app/modelling/__init__.py | 0 backend/app/modelling/property_filters.py | 44 +++++++++++++++++ infrastructure/postgres/property_table.py | 8 +++ tests/backend/__init__.py | 0 tests/backend/app/__init__.py | 0 tests/backend/app/modelling/__init__.py | 0 .../app/modelling/test_property_filters.py | 49 +++++++++++++++++++ 7 files changed, 101 insertions(+) create mode 100644 backend/app/modelling/__init__.py create mode 100644 backend/app/modelling/property_filters.py create mode 100644 tests/backend/__init__.py create mode 100644 tests/backend/app/__init__.py create mode 100644 tests/backend/app/modelling/__init__.py create mode 100644 tests/backend/app/modelling/test_property_filters.py diff --git a/backend/app/modelling/__init__.py b/backend/app/modelling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/modelling/property_filters.py b/backend/app/modelling/property_filters.py new file mode 100644 index 000000000..819ba3420 --- /dev/null +++ b/backend/app/modelling/property_filters.py @@ -0,0 +1,44 @@ +"""Resolve a Modelling Run's property-group filters to a concrete property set. + +The resolution rule is a shared contract with the app's preview (ADR-0056): +the "N properties will be modelled" count the user approves is computed by the +Next.js app from this same rule, so any change here must land in both +codebases and amend the ADR. +""" + +from dataclasses import dataclass +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from sqlmodel import Session + + +class PropertyGroupFilters(BaseModel): + """The filters key of a trigger-run request. An absent key is + unconstrained; present keys combine with AND.""" + + model_config = ConfigDict(frozen=True) + + postcodes: Optional[list[str]] = None + property_types: Optional[list[str]] = None + built_forms: Optional[list[str]] = None + + +@dataclass(frozen=True) +class FilteredProperty: + """One property the filters resolved: the id to model and the postcode the + distributor batches by (postcode grouping keeps the workers' prediction + cohort cache effective).""" + + property_id: int + postcode: Optional[str] + + +def resolve_filtered_property_ids( + session: Session, portfolio_id: int, filters: PropertyGroupFilters +) -> list[FilteredProperty]: + """Resolve *filters* against *portfolio_id* per ADR-0056: base set is the + portfolio's rows with ``marked_for_deletion = false``; property_type / + built_form resolve override → lodged EPC → predicted EPC → legacy columns + → "Unknown".""" + raise NotImplementedError diff --git a/infrastructure/postgres/property_table.py b/infrastructure/postgres/property_table.py index 56d0b2fa7..c3cf497c2 100644 --- a/infrastructure/postgres/property_table.py +++ b/infrastructure/postgres/property_table.py @@ -56,3 +56,11 @@ class PropertyRow(SQLModel, table=True): # `updated_at >= 2026-06-01`, the cutoff the old pipeline predates). has_recommendations: Optional[bool] = Field(default=None) updated_at: Optional[datetime] = Field(default=None) + + # Columns the Modelling Run filter resolution reads (ADR-0056): the base-set + # exclusion flag and the legacy type/form columns (the last resort before + # "Unknown" in the resolution precedence). NOT NULL / defaulted in the FE + # schema; nullable mirrors are safe — prod enforces the real constraints. + marked_for_deletion: Optional[bool] = Field(default=None) + property_type: Optional[str] = Field(default=None) + built_form: Optional[str] = Field(default=None) diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/backend/app/__init__.py b/tests/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/backend/app/modelling/__init__.py b/tests/backend/app/modelling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/backend/app/modelling/test_property_filters.py b/tests/backend/app/modelling/test_property_filters.py new file mode 100644 index 000000000..613200dba --- /dev/null +++ b/tests/backend/app/modelling/test_property_filters.py @@ -0,0 +1,49 @@ +from sqlalchemy import Engine +from sqlmodel import Session + +from backend.app.modelling.property_filters import ( + FilteredProperty, + PropertyGroupFilters, + resolve_filtered_property_ids, +) +from infrastructure.postgres.property_table import PropertyRow + + +def _seed_property( + session: Session, + *, + portfolio_id: int, + postcode: str = "B93 8SU", + marked_for_deletion: bool = False, +) -> int: + row = PropertyRow( + portfolio_id=portfolio_id, + postcode=postcode, + marked_for_deletion=marked_for_deletion, + ) + session.add(row) + session.commit() + session.refresh(row) + assert row.id is not None + return row.id + + +def test_no_filters_resolves_the_whole_portfolio_minus_deletions( + db_engine: Engine, +) -> None: + # arrange + with Session(db_engine) as session: + kept = _seed_property(session, portfolio_id=814, postcode="B93 8SU") + deleted = _seed_property( + session, portfolio_id=814, postcode="M20 4TF", marked_for_deletion=True + ) + _other_portfolio = _seed_property(session, portfolio_id=999) + + # act + resolved = resolve_filtered_property_ids( + session, portfolio_id=814, filters=PropertyGroupFilters() + ) + + # assert + assert resolved == [FilteredProperty(property_id=kept, postcode="B93 8SU")] + assert deleted not in [p.property_id for p in resolved]