Compare commits

..

No commits in common. "326ec2485de3a2dbafa40e7f440f11ff9248618f" and "082bbcf22e366ede23db6f3646c69839f39cec2f" have entirely different histories.

204 changed files with 123 additions and 3446 deletions

View file

@ -6,7 +6,7 @@ backend/.idea/*
backend/.env
recommendations/tests/*
model_data/tests/*
deployment/*
infrastructure/*
data_collection/*
node_modules/*
conservation_areas/*

View file

@ -40,8 +40,6 @@ on:
required: false
EPC_AUTH_TOKEN:
required: false
OPEN_EPC_API_TOKEN:
required: false
jobs:
build:
@ -52,7 +50,6 @@ jobs:
DEV_DB_PORT: ${{ secrets.DEV_DB_PORT }}
DEV_DB_NAME: ${{ secrets.DEV_DB_NAME }}
EPC_AUTH_TOKEN: ${{ secrets.EPC_AUTH_TOKEN }}
OPEN_EPC_API_TOKEN: ${{ secrets.OPEN_EPC_API_TOKEN }}
outputs:
image_digest: ${{ steps.digest.outputs.image_digest }}

View file

@ -62,20 +62,20 @@ jobs:
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
working-directory: deployment/terraform/shared
working-directory: infrastructure/terraform/shared
run: terraform init -reconfigure
- name: Terraform Workspace
working-directory: deployment/terraform/shared
working-directory: infrastructure/terraform/shared
run: terraform workspace select ${STAGE} || terraform workspace new ${STAGE}
- name: Terraform Plan
working-directory: deployment/terraform/shared
working-directory: infrastructure/terraform/shared
run: terraform plan -var-file=${STAGE}.tfvars -out=tfplan
- name: Terraform Apply
if: env.TERRAFORM_APPLY == 'true'
working-directory: deployment/terraform/shared
working-directory: infrastructure/terraform/shared
run: terraform apply -auto-approve tfplan
# ============================================================
@ -101,7 +101,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: ara_engine
lambda_path: deployment/terraform/lambda/engine
lambda_path: infrastructure/terraform/lambda/engine
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: engine-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.ara_engine_image.outputs.image_digest }}
@ -133,7 +133,6 @@ jobs:
DEV_DB_PORT=$DEV_DB_PORT
DEV_DB_NAME=$DEV_DB_NAME
EPC_AUTH_TOKEN=$EPC_AUTH_TOKEN
OPEN_EPC_API_TOKEN=$OPEN_EPC_API_TOKEN
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
@ -142,7 +141,6 @@ jobs:
DEV_DB_PORT: ${{ secrets.DEV_DB_PORT }}
DEV_DB_NAME: ${{ secrets.DEV_DB_NAME }}
EPC_AUTH_TOKEN: ${{ secrets.DEV_EPC_AUTH_TOKEN }}
OPEN_EPC_API_TOKEN: ${{ secrets.DEV_OPEN_EPC_API_TOKEN }}
# ============================================================
# Deploy Address 2 UPRN Lambda
@ -152,7 +150,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: address2uprn
lambda_path: deployment/terraform/lambda/address2UPRN
lambda_path: infrastructure/terraform/lambda/address2UPRN
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: address2uprn-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.address2uprn_image.outputs.image_digest }}
@ -171,7 +169,7 @@ jobs:
uses: ./.github/workflows/_build_image.yml
with:
ecr_repo: postcode_splitter-${{ needs.determine_stage.outputs.stage }}
dockerfile_path: applications/postcode_splitter/Dockerfile
dockerfile_path: backend/postcode_splitter/handler/Dockerfile
build_context: .
build_args: |
DEV_DB_HOST=$DEV_DB_HOST
@ -193,7 +191,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: postcodeSplitter
lambda_path: deployment/terraform/lambda/postcodeSplitter
lambda_path: infrastructure/terraform/lambda/postcodeSplitter
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: postcode_splitter-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.postcodeSplitter_image.outputs.image_digest }}
@ -233,7 +231,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: bulk_address2uprn_combiner
lambda_path: deployment/terraform/lambda/bulk_address2uprn_combiner
lambda_path: infrastructure/terraform/lambda/bulk_address2uprn_combiner
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: bulk_address2uprn_combiner-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.bulk_address2uprn_combiner_image.outputs.image_digest }}
@ -273,7 +271,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: condition-etl
lambda_path: deployment/terraform/lambda/condition-etl
lambda_path: infrastructure/terraform/lambda/condition-etl
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: condition-etl-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.condition_etl_image.outputs.image_digest }}
@ -313,7 +311,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: categorisation
lambda_path: deployment/terraform/lambda/categorisation
lambda_path: infrastructure/terraform/lambda/categorisation
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: categorisation-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.categorisation_image.outputs.image_digest }}
@ -353,7 +351,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: ordnanceSurvey
lambda_path: deployment/terraform/lambda/ordnanceSurvey
lambda_path: infrastructure/terraform/lambda/ordnanceSurvey
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: ordnance-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.ordnanceSurvey_image.outputs.image_digest }}
@ -388,7 +386,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: pashub_to_ara
lambda_path: deployment/terraform/lambda/pashub_to_ara
lambda_path: infrastructure/terraform/lambda/pashub_to_ara
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: pashub_to_ara-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.pashub_to_ara_image.outputs.image_digest }}
@ -421,7 +419,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: ara_fast_api
lambda_path: deployment/terraform/lambda/fast-api
lambda_path: infrastructure/terraform/lambda/fast-api
stage: ${{ needs.determine_stage.outputs.stage }}
terraform_apply: ${{ needs.determine_stage.outputs.terraform_apply }}
secrets:
@ -460,17 +458,17 @@ jobs:
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
working-directory: deployment/terraform/cdn_certificate
working-directory: infrastructure/terraform/cdn_certificate
run: terraform init -reconfigure
- name: Terraform Workspace
working-directory: deployment/terraform/cdn_certificate
working-directory: infrastructure/terraform/cdn_certificate
run: |
terraform workspace select $STAGE \
|| terraform workspace new $STAGE
- name: Terraform Plan
working-directory: deployment/terraform/cdn_certificate
working-directory: infrastructure/terraform/cdn_certificate
run: |
terraform plan \
-var="stage=${STAGE}" \
@ -478,7 +476,7 @@ jobs:
- name: Terraform Apply
if: env.TERRAFORM_APPLY == 'true'
working-directory: deployment/terraform/cdn_certificate
working-directory: infrastructure/terraform/cdn_certificate
run: terraform apply -auto-approve tfplan
@ -505,17 +503,17 @@ jobs:
- uses: hashicorp/setup-terraform@v3
- name: Terraform Init
working-directory: deployment/terraform/cdn
working-directory: infrastructure/terraform/cdn
run: terraform init -reconfigure
- name: Terraform Workspace
working-directory: deployment/terraform/cdn
working-directory: infrastructure/terraform/cdn
run: |
terraform workspace select $STAGE \
|| terraform workspace new $STAGE
- name: Terraform Plan
working-directory: deployment/terraform/cdn
working-directory: infrastructure/terraform/cdn
run: |
terraform plan \
-var="stage=${STAGE}" \
@ -523,7 +521,7 @@ jobs:
- name: Terraform Apply
if: env.TERRAFORM_APPLY == 'true'
working-directory: deployment/terraform/cdn
working-directory: infrastructure/terraform/cdn
run: terraform apply -auto-approve tfplan
# ============================================================
@ -564,7 +562,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: magic_plan
lambda_path: deployment/terraform/lambda/magic_plan
lambda_path: infrastructure/terraform/lambda/magic_plan
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: magic-plan-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.magic_plan_image.outputs.image_digest }}
@ -587,7 +585,7 @@ jobs:
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: hubspot-etl-to-ara
lambda_path: deployment/terraform/lambda/hubspot_deal_etl
lambda_path: infrastructure/terraform/lambda/hubspot_deal_etl
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: hubspot-etl-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.hubspot_etl_image.outputs.image_digest }}

View file

@ -36,13 +36,6 @@ jobs:
build_context: .
service_name: postcode-splitter
postcode_splitter_ddd_smoke_test:
uses: ./.github/workflows/_smoke_test_lambda.yml
with:
dockerfile_path: applications/postcode_splitter/Dockerfile
build_context: .
service_name: postcode-splitter-ddd
# ============================================================
# Bulk Address2UPRN Combiner
# ============================================================

View file

@ -60,15 +60,3 @@ jobs:
-e DB_PASSWORD=test \
-e DB_PORT=5432 \
model-test pytest -vv -m 'not integration'
# The DDD rewrite (tests/) defines SQLModel table classes that map to the
# same physical tables as the legacy backend models. Both sets share the
# one global SQLModel.metadata, so they cannot be imported into the same
# pytest process. It runs as a separate invocation until the legacy
# models are retired. Its DB is spawned in-process by pytest-postgresql,
# so no DB service or env is required.
- name: Run DDD tests
run: |
docker run --rm \
--network host \
model-test pytest -vv tests/

1
.gitignore vendored
View file

@ -121,7 +121,6 @@ celerybeat.pid
# Environments
.env
.env.local
.venv
env/
venv/

29
AGENTS.md Normal file
View file

@ -0,0 +1,29 @@
<!-- BACKLOG.MD MCP GUIDELINES START -->
<CRITICAL_INSTRUCTION>
## BACKLOG WORKFLOW INSTRUCTIONS
This project uses Backlog.md MCP for all task and project management activities.
**CRITICAL GUIDANCE**
- If your client supports MCP resources, read `backlog://workflow/overview` to understand when and how to use Backlog for this project.
- If your client only supports tools or the above request fails, call `backlog.get_backlog_instructions()` to load the tool-oriented overview. Use the `instruction` selector when you need `task-creation`, `task-execution`, or `task-finalization`.
- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow
- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)")
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
These guides cover:
- Decision framework for when to create tasks
- Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization
- MCP tools reference
You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here.
</CRITICAL_INSTRUCTION>
<!-- BACKLOG.MD MCP GUIDELINES END -->

View file

@ -1,4 +1,33 @@
<!-- BACKLOG.MD MCP GUIDELINES START -->
<CRITICAL_INSTRUCTION>
## BACKLOG WORKFLOW INSTRUCTIONS
This project uses Backlog.md MCP for all task and project management activities.
**CRITICAL GUIDANCE**
- If your client supports MCP resources, read `backlog://workflow/overview` to understand when and how to use Backlog for this project.
- If your client only supports tools or the above request fails, call `backlog.get_backlog_instructions()` to load the tool-oriented overview. Use the `instruction` selector when you need `task-creation`, `task-execution`, or `task-finalization`.
- **First time working here?** Read the overview resource IMMEDIATELY to learn the workflow
- **Already familiar?** You should have the overview cached ("## Backlog.md Overview (MCP)")
- **When to read it**: BEFORE creating tasks, or when you're unsure whether to track work
These guides cover:
- Decision framework for when to create tasks
- Search-first workflow to avoid duplicates
- Links to detailed guides for task creation, execution, and finalization
- MCP tools reference
You MUST read the overview resource to understand the complete workflow. The information is NOT summarized here.
</CRITICAL_INSTRUCTION>
<!-- BACKLOG.MD MCP GUIDELINES END -->
## Available Skills
Five Claude Code skills are installed in this repo's dev container. Each maps to a phase of the feature lifecycle.

View file

@ -4,7 +4,7 @@ model_data/local_data/
backend/node_modules/
backend/.idea/
backend/.env
deployment/
infrastructure/
data_collection/
node_modules/
conservation_areas/

View file

@ -23,7 +23,7 @@ Invoke `/ubiquitous-language` in any session to extract new terms from the conve
|------|------------|------------------|
| **UPRN** | Unique Property Reference Number — the government-issued permanent identifier for a physical address in the UK. | "property ID", "address ID", "code" |
| **Postcode** | A UK postal code used to group nearby addresses; the primary search key for finding EPC records. | "zip code", "postal code" |
| **User Address** | A structured dataclass (`domain.addresses.user_address.UserAddress`) capturing a customer-supplied address: a free-text `user_address` line, a canonical `postcode` (sanitised on construction), and an optional `internal_reference`. The bare string sense -- the raw free-text address line as it arrives from upstream ingestion, before being wrapped -- remains valid when discussing CSV columns, API payloads, or other upstream contexts; in domain code, prefer the dataclass. | "user input", "raw address", "user_inputed_address" |
| **User Address** | A free-text address string provided by a user or imported from a customer dataset, before any normalisation or matching. | "user input", "raw address", "user_inputed_address" |
| **Dwelling** | A single residential unit that can hold an EPC — a house, flat, or maisonette. | "property", "unit", "home" |
## Address Matching
@ -72,7 +72,7 @@ Invoke `/ubiquitous-language` in any session to extract new terms from the conve
## Flagged ambiguities
- **"address"** appears as both the raw **User Address** (free-text from customer data, or the structured `UserAddress` dataclass that wraps it) and a structured field on an **EPC Search Result** (normalised address lines). Always qualify: "user address" vs "EPC address" or "address line 1". Within `domain/`, **User Address** specifically means the `UserAddress` dataclass; in upstream ingestion contexts (CSV columns, SQS payloads) it can still mean the raw string sense.
- **"address"** appears as both the raw **User Address** (free-text from customer data) and a structured field on an **EPC Search Result** (normalised address lines). Always qualify: "user address" vs "EPC address" or "address line 1".
- **"score"** is used for the `AddressMatch.score()` function output, the `lexiscore` DataFrame column, and informally in conversation. Prefer **Lexiscore** in domain discussions; reserve "score" for method-level code comments.
- **"user_inputed_address"** in `backend/address2UPRN/main.py` is a misspelling and a synonym for **User Address** — the canonical term. New code should use `user_address`.
- **"EPC"** is overloaded as both the document (an Energy Performance Certificate) and the rating band letter. Use **EPC** for the document and **EPC Band** for the letter.

View file

@ -1,34 +0,0 @@
FROM public.ecr.aws/lambda/python:3.11
# Postgres host/port/database are baked into the image at build time from
# the deploy workflow's --build-arg values (GitHub Actions DEV_DB_* secrets),
# mirroring backend/postcode_splitter/handler/Dockerfile. They map onto the
# POSTGRES_* names PostgresConfig.from_env reads. Username/password are NOT
# baked in -- Terraform injects those as Lambda env vars from Secrets Manager.
ARG DEV_DB_HOST
ARG DEV_DB_PORT
ARG DEV_DB_NAME
ENV POSTGRES_HOST=${DEV_DB_HOST}
ENV POSTGRES_PORT=${DEV_DB_PORT}
ENV POSTGRES_DATABASE=${DEV_DB_NAME}
WORKDIR /var/task
COPY applications/postcode_splitter/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the layered source the handler imports from. The new splitter pulls
# only DDD-shaped packages — no pandas, no legacy backend/.
COPY domain/ domain/
COPY infrastructure/ infrastructure/
COPY orchestration/ orchestration/
COPY repositories/ repositories/
COPY utilities/ utilities/
COPY applications/ applications/
# Place the handler at the Lambda task root so the runtime can resolve
# ``main.handler`` without an extra package prefix.
COPY applications/postcode_splitter/handler.py /var/task/main.py
CMD ["main.handler"]

View file

@ -1,52 +0,0 @@
from __future__ import annotations
import os
from typing import Any
import boto3
from applications.postcode_splitter.postcode_splitter_trigger_body import (
PostcodeSplitterTriggerBody,
)
from infrastructure.address2uprn_queue_client import Address2UprnQueueClient
from infrastructure.csv_s3_client import CsvS3Client
from orchestration.postcode_splitter_orchestrator import PostcodeSplitterOrchestrator
from orchestration.task_orchestrator import TaskOrchestrator
from repositories.user_address.user_address_csv_s3_repository import (
UserAddressCsvS3Repository,
)
from utilities.aws_lambda.subtask_handler import subtask_handler
@subtask_handler()
def handler(
body: dict[str, Any], context: Any, task_orchestrator: TaskOrchestrator
) -> dict[str, list[str]]:
trigger = PostcodeSplitterTriggerBody.model_validate(body)
bucket = os.environ["S3_BUCKET_NAME"]
queue_url = os.environ["ADDRESS2UPRN_QUEUE_URL"]
# boto3.client is overloaded per-service in the installed stubs; cast
# to Any so the strict-mode checker treats it as opaque.
boto3_client: Any = boto3.client # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
boto_s3: Any = boto3_client("s3")
boto_sqs: Any = boto3_client("sqs")
csv_client = CsvS3Client(boto_s3, bucket)
user_address_repo = UserAddressCsvS3Repository(csv_client, bucket)
queue_client = Address2UprnQueueClient(boto_sqs, queue_url)
splitter = PostcodeSplitterOrchestrator(
task_orchestrator=task_orchestrator,
user_address_repo=user_address_repo,
queue_client=queue_client,
)
child_ids = splitter.split_and_dispatch(
parent_task_id=trigger.task_id,
parent_subtask_id=trigger.sub_task_id,
input_s3_uri=trigger.s3_uri,
)
return {"child_subtask_ids": [str(cid) for cid in child_ids]}

View file

@ -1,34 +0,0 @@
# Local-test environment for the postcode_splitter Lambda.
#
# cp .env.local.example .env.local then fill in the values below.
#
# .env.local is gitignored. The container hits REAL AWS and a REAL Postgres,
# so every value here points at infrastructure that actually exists.
#
# NOTE: the new DDD code uses different env var names than the repo root
# .env. The mapping (root .env name -> var here) is given per section.
# Keep comments on their own lines — docker-compose's env_file parser folds a
# trailing "# ..." into the value.
# --- Postgres (orchestration/default_orchestrator -> PostgresConfig.from_env) ---
# POSTGRES_HOST <- DB_HOST, PORT <- DB_PORT, USERNAME <- DB_USERNAME,
# PASSWORD <- DB_PASSWORD, DATABASE <- DB_NAME.
POSTGRES_HOST=
POSTGRES_PORT=5432
POSTGRES_USERNAME=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
# POSTGRES_DRIVER=psycopg2 (optional; defaults to psycopg2)
# --- Handler config (applications/postcode_splitter/handler.py) ---
# S3_BUCKET_NAME: bucket holding the input address CSV (root .env: DATA_BUCKET).
# ADDRESS2UPRN_QUEUE_URL: SQS queue the splitter fans batches out to; not in
# the root .env (Terraform sets it in prod).
S3_BUCKET_NAME=
ADDRESS2UPRN_QUEUE_URL=
# --- AWS credentials for boto3 (S3 + SQS clients) ---
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=eu-west-2
# AWS_SESSION_TOKEN= (only if using temporary/SSO credentials)

View file

@ -1,9 +0,0 @@
services:
postcode-splitter:
build:
context: ../../../
dockerfile: applications/postcode_splitter/Dockerfile
ports:
- "9001:8080"
env_file:
- .env.local

View file

@ -1,28 +0,0 @@
#!/usr/bin/env python3
import json
import requests
HOST = "localhost"
PORT = "9001"
LAMBDA_URL = f"http://{HOST}:{PORT}/2015-03-31/functions/function/invocations"
payload = {
"Records": [
{
"body": json.dumps(
{
"task_id": "f4b3332f-c0cc-481f-96a5-d39860a647cf",
"sub_task_id": "14c042de-40c4-473b-8cd8-72c983a94a8d",
"s3_uri": "s3://retrofit-data-dev/ara_raw_inputs/calico/Calico Homes Full list EPC Properties(Sheet2) (1) (1).csv",
}
)
}
]
}
response = requests.post(LAMBDA_URL, json=payload)
print("Status code:", response.status_code)
print("Response:")
print(response.text)

View file

@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [ ! -f .env.local ]; then
cp .env.local.example .env.local
echo "Created .env.local from the template — fill it in, then re-run." >&2
exit 1
fi
docker compose build --no-cache
docker compose up --force-recreate

View file

@ -1,11 +0,0 @@
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class PostcodeSplitterTriggerBody(BaseModel):
model_config = ConfigDict(extra="allow")
task_id: UUID
sub_task_id: UUID
s3_uri: str

View file

@ -1,4 +0,0 @@
boto3
pydantic
sqlmodel
psycopg2-binary

View file

@ -79,23 +79,23 @@ def app():
"""
data_folder = "/workspaces/model/asset_list"
data_filename = "lincs_address_list.xlsx"
sheet_name = "Sheet1"
postcode_column = "Postcode"
address1_column = "Deal Name"
data_filename = "input.xlsx"
sheet_name = "Handovers"
postcode_column = "POSTCODE"
address1_column = "Full Addres"
address1_method = None
fulladdress_column = "Deal Name"
fulladdress_column = "Full Addres"
address_cols_to_concat = []
missing_postcodes_method = None
landlord_year_built = None
landlord_os_uprn = None
landlord_property_type = None # Good to include if landlord gave
landlord_built_form = None # Good to include if landlord gave
landlord_os_uprn = "domna_found_uprn"
landlord_property_type = "PROPERTY TYPE" # Good to include if landlord gave
landlord_built_form = "Type Description" # Good to include if landlord gave
landlord_wall_construction = None
landlord_roof_construction = None
landlord_heating_system = None
landlord_existing_pv = None
landlord_property_id = "landlord_id"
landlord_property_id = "PROP REF"
landlord_sap = None
outcomes_filename = None
outcomes_sheetname = None
@ -468,3 +468,9 @@ def app():
asset_list.duplicated_addresses.to_excel(
writer, sheet_name="Duplicate Properties", index=False
)
for key,value in dict.items():
lsakjfldsa

View file

@ -6,13 +6,11 @@ ARG DEV_DB_HOST
ARG DEV_DB_PORT
ARG DEV_DB_NAME
ARG EPC_AUTH_TOKEN
ARG OPEN_EPC_API_TOKEN
ENV DB_HOST=${DEV_DB_HOST}
ENV DB_PORT=${DEV_DB_PORT}
ENV DB_NAME=${DEV_DB_NAME}
ENV EPC_AUTH_TOKEN=${EPC_AUTH_TOKEN}
ENV OPEN_EPC_API_TOKEN=${OPEN_EPC_API_TOKEN}
# Set working directory (Lambda task root)

View file

@ -8,5 +8,4 @@ boto3==1.35.44
sqlmodel
sqlalchemy==2.0.36
psycopg2-binary==2.9.10
pydantic-settings==2.6.0
httpx
pydantic-settings==2.6.0

View file

View file

@ -1,51 +0,0 @@
from __future__ import annotations
from collections.abc import Iterable, Iterator
from domain.addresses.user_address import UserAddress
from domain.postcode import Postcode
def iter_postcode_grouped_batches(
addresses: Iterable[UserAddress],
*,
max_batch_size: int = 500,
) -> Iterator[list[UserAddress]]:
if max_batch_size < 1:
raise ValueError("max_batch_size must be >= 1")
groups = _group_by_postcode_in_order(addresses)
buffer: list[UserAddress] = []
for group in groups.values():
group_len = len(group)
# Oversize single-Postcode group: flush buffer first, then dispatch
# the group as its own batch. Mirrors the legacy
# ``if group_len >= batch_size`` branch.
if group_len >= max_batch_size:
if buffer:
yield buffer
buffer = []
yield group
continue
# Adding this group would overflow: flush buffer before appending.
if len(buffer) + group_len > max_batch_size:
yield buffer
buffer = []
buffer.extend(group)
# Final flush.
if buffer:
yield buffer
def _group_by_postcode_in_order(
addresses: Iterable[UserAddress],
) -> dict[Postcode, list[UserAddress]]:
groups: dict[Postcode, list[UserAddress]] = {}
for address in addresses:
groups.setdefault(address.postcode, []).append(address)
return groups

View file

@ -1,18 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from domain.postcode import Postcode
def _empty_source_row() -> dict[str, str]:
return {}
@dataclass(frozen=True)
class UserAddress:
user_address: str
postcode: Postcode
internal_reference: Optional[str] = None
source_row: dict[str, str] = field(default_factory=_empty_source_row, compare=False)

View file

@ -1,15 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Postcode:
value: str
def __post_init__(self) -> None:
# Frozen dataclass: bypass the descriptor with object.__setattr__.
object.__setattr__(self, "value", "".join(self.value.split()).upper())
def __str__(self) -> str:
return self.value

View file

@ -1,55 +0,0 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Optional
from uuid import UUID, uuid4
class SubTaskStatus(str, Enum):
WAITING = "waiting"
IN_PROGRESS = "in progress"
COMPLETE = "complete"
FAILED = "failed"
@dataclass
class SubTask:
id: UUID
task_id: UUID
status: SubTaskStatus = SubTaskStatus.WAITING
inputs: Optional[dict[str, Any]] = None
outputs: Optional[dict[str, Any]] = None
cloud_logs_url: Optional[str] = None
job_started: Optional[datetime] = None
job_completed: Optional[datetime] = None
@classmethod
def create(
cls, *, task_id: UUID, inputs: Optional[dict[str, Any]] = None
) -> "SubTask":
return cls(
id=uuid4(),
task_id=task_id,
status=SubTaskStatus.WAITING,
inputs=inputs,
)
def start(self, cloud_logs_url: Optional[str] = None) -> None:
if self.status not in (SubTaskStatus.WAITING, SubTaskStatus.IN_PROGRESS):
raise ValueError(f"cannot start subtask in status {self.status}")
if self.job_started is None:
self.job_started = datetime.now(timezone.utc)
self.status = SubTaskStatus.IN_PROGRESS
if cloud_logs_url is not None:
self.cloud_logs_url = cloud_logs_url
def complete(self, result: Any = None) -> None:
self.status = SubTaskStatus.COMPLETE
self.job_completed = datetime.now(timezone.utc)
if result is not None:
self.outputs = {"result": result}
def fail(self, error: BaseException) -> None:
self.status = SubTaskStatus.FAILED
self.job_completed = datetime.now(timezone.utc)
self.outputs = {"error": str(error)}

View file

@ -1,94 +0,0 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4
from domain.tasks.subtasks import SubTaskStatus
class TaskStatus(str, Enum):
WAITING = "waiting"
IN_PROGRESS = "in progress"
COMPLETE = "complete"
FAILED = "failed"
class Source(str, Enum):
PORTFOLIO = "portfolio_id"
HUBSPOT_DEAL = "hubspot_deal_id"
@dataclass
class Task:
id: UUID
task_source: str
status: TaskStatus = TaskStatus.WAITING
service: Optional[str] = None
source: Optional[Source] = None
source_id: Optional[str] = None
job_started: Optional[datetime] = None
job_completed: Optional[datetime] = None
@classmethod
def create(
cls,
*,
task_source: str,
service: Optional[str] = None,
source: Optional[Source] = None,
source_id: Optional[str] = None,
) -> "Task":
if not task_source.strip():
raise ValueError("task_source must be non-empty")
return cls(
id=uuid4(),
task_source=task_source,
service=service,
source=source,
source_id=source_id,
status=TaskStatus.WAITING,
job_started=datetime.now(timezone.utc),
)
def start(self) -> None:
if self.status not in (TaskStatus.WAITING, TaskStatus.IN_PROGRESS):
raise ValueError(f"cannot start task in status {self.status}")
if self.job_started is None:
self.job_started = datetime.now(timezone.utc)
self.status = TaskStatus.IN_PROGRESS
def complete(self) -> None:
self.status = TaskStatus.COMPLETE
self.job_completed = datetime.now(timezone.utc)
def fail(self) -> None:
self.status = TaskStatus.FAILED
self.job_completed = datetime.now(timezone.utc)
def recalculate_from_subtasks(self, statuses: list[SubTaskStatus]) -> None:
"""Recompute Task.status from its SubTasks' statuses.
Rule (preserved from legacy _update_task_progress):
- any FAILED FAILED
- all COMPLETE COMPLETE
- any IN_PROGRESS IN_PROGRESS
- otherwise WAITING
Empty list is a no-op (newly-created task with no subtasks).
"""
if not statuses:
return
now = datetime.now(timezone.utc)
if SubTaskStatus.FAILED in statuses:
self.status = TaskStatus.FAILED
self.job_completed = now
elif all(s is SubTaskStatus.COMPLETE for s in statuses):
self.status = TaskStatus.COMPLETE
self.job_completed = now
elif SubTaskStatus.IN_PROGRESS in statuses:
self.status = TaskStatus.IN_PROGRESS
self.job_completed = None
else:
self.status = TaskStatus.WAITING
self.job_completed = None

View file

@ -1,20 +0,0 @@
from uuid import UUID
from infrastructure.sqs_client import SqsClient
class Address2UprnQueueClient(SqsClient):
def publish(
self,
*,
parent_task_id: UUID,
child_subtask_id: UUID,
s3_uri: str,
) -> str:
return self.send(
{
"task_id": str(parent_task_id),
"sub_task_id": str(child_subtask_id),
"s3_uri": s3_uri,
}
)

View file

@ -1,28 +0,0 @@
import csv
from io import StringIO
from infrastructure.s3_client import S3Client
from infrastructure.s3_uri import parse_s3_uri
class CsvS3Client(S3Client):
def read_rows(self, s3_uri: str) -> list[dict[str, str]]:
bucket, key = parse_s3_uri(s3_uri)
if bucket != self.bucket:
raise ValueError(
f"s3_uri bucket {bucket!r} does not match client bucket {self.bucket!r}"
)
raw = self.get_object(key)
text = raw.decode("utf-8-sig")
reader = csv.DictReader(StringIO(text))
return [dict(row) for row in reader]
def save_rows(self, rows: list[dict[str, str]], key: str) -> str:
if not rows:
raise ValueError("Cannot save an empty rows list: header is unknown")
buffer = StringIO()
fieldnames = list(rows[0].keys())
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return self.put_object(key, buffer.getvalue().encode("utf-8"))

View file

@ -1,33 +0,0 @@
from dataclasses import dataclass
from typing import Mapping
@dataclass(frozen=True)
class PostgresConfig:
host: str
port: int
username: str
password: str
database: str
driver: str = "psycopg2"
pool_size: int = 3
max_overflow: int = 5
pool_pre_ping: bool = True
pool_recycle: int = 300
def url(self) -> str:
return (
f"postgresql+{self.driver}://"
f"{self.username}:{self.password}@{self.host}:{self.port}/{self.database}"
)
@classmethod
def from_env(cls, env: Mapping[str, str]) -> "PostgresConfig":
return cls(
host=env["POSTGRES_HOST"],
port=int(env["POSTGRES_PORT"]),
username=env["POSTGRES_USERNAME"],
password=env["POSTGRES_PASSWORD"],
database=env["POSTGRES_DATABASE"],
driver=env.get("POSTGRES_DRIVER", "psycopg2"),
)

View file

@ -1,18 +0,0 @@
from sqlalchemy.engine import Engine
from sqlmodel import Session, create_engine
from infrastructure.postgres.config import PostgresConfig
def make_engine(config: PostgresConfig) -> Engine:
return create_engine(
config.url(),
pool_size=config.pool_size,
max_overflow=config.max_overflow,
pool_pre_ping=config.pool_pre_ping,
pool_recycle=config.pool_recycle,
)
def make_session(engine: Engine) -> Session:
return Session(engine)

View file

@ -1,21 +0,0 @@
from datetime import datetime, timezone
from typing import ClassVar, Optional
from uuid import UUID, uuid4
from sqlmodel import Field, SQLModel
class SubTaskRow(SQLModel, table=True):
__tablename__: ClassVar[str] = "sub_task" # pyright: ignore[reportIncompatibleVariableOverride]
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True)
task_id: UUID = Field(foreign_key="tasks.id")
job_started: Optional[datetime] = None
job_completed: Optional[datetime] = None
status: str = Field(default="waiting")
inputs: Optional[str] = None
outputs: Optional[str] = None
cloud_logs_url: Optional[str] = None
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)

View file

@ -1,36 +0,0 @@
from datetime import datetime, timezone
from typing import ClassVar, Optional
from uuid import UUID, uuid4
from sqlalchemy import Column
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, SQLModel
from domain.tasks.tasks import Source
class TaskRow(SQLModel, table=True):
__tablename__: ClassVar[str] = "tasks" # pyright: ignore[reportIncompatibleVariableOverride]
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True)
task_source: str
job_started: Optional[datetime] = None
job_completed: Optional[datetime] = None
status: str = Field(default="waiting")
service: Optional[str] = None
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
source: Optional[Source] = Field(
default=None,
sa_column=Column(
SAEnum(
Source,
name="source",
values_callable=lambda cls: [m.value for m in cls], # pyright: ignore[reportUnknownLambdaType, reportUnknownMemberType, reportUnknownVariableType]
),
nullable=True,
),
)
source_id: Optional[str] = None

View file

@ -1,22 +0,0 @@
from typing import Any
class S3Client:
def __init__(self, boto_s3_client: Any, bucket: str) -> None:
self._client = boto_s3_client
self._bucket = bucket
@property
def bucket(self) -> str:
return self._bucket
def get_object(self, key: str) -> bytes:
response: dict[str, Any] = self._client.get_object(
Bucket=self._bucket, Key=key
)
body: bytes = response["Body"].read()
return body
def put_object(self, key: str, body: bytes) -> str:
self._client.put_object(Bucket=self._bucket, Key=key, Body=body)
return f"s3://{self._bucket}/{key}"

View file

@ -1,25 +0,0 @@
from urllib.parse import unquote
def parse_s3_uri(s3_uri: str) -> tuple[str, str]:
if s3_uri.startswith("s3://"):
parts = s3_uri[len("s3://") :].split("/", 1)
if len(parts) < 2 or not parts[0] or not parts[1]:
raise ValueError("S3 URI must include both a bucket and a key")
return parts[0], parts[1]
if "?" not in s3_uri:
raise ValueError(f"Not an s3:// URI and has no query string: {s3_uri!r}")
base, query = s3_uri.split("?", 1)
if "/s3/object/" not in base:
raise ValueError(f"Console URL has no '/s3/object/' segment: {s3_uri!r}")
bucket = base.split("/s3/object/", 1)[1]
params: dict[str, str] = {}
for item in query.split("&"):
if "=" in item:
name, value = item.split("=", 1)
params[name] = value
key = unquote(params.get("prefix", ""))
return bucket, key

View file

@ -1,20 +0,0 @@
import json
from typing import Any
class SqsClient:
def __init__(self, boto_sqs_client: Any, queue_url: str) -> None:
self._client = boto_sqs_client
self._queue_url = queue_url
@property
def queue_url(self) -> str:
return self._queue_url
def send(self, body: dict[str, Any]) -> str:
response: dict[str, Any] = self._client.send_message(
QueueUrl=self._queue_url,
MessageBody=json.dumps(body),
)
message_id: str = response["MessageId"]
return message_id

View file

@ -10,7 +10,7 @@
### 2. Add infrastructure prerequisites (shared stack)
- Add a new ECR repository in:
deployment/terraform/shared/main.tf
infrastructure/terraform/shared/main.tf
- Create a PR to deploy this to main then dev in order to deploy the shared stack

View file

@ -38,8 +38,22 @@ module "lambda" {
{
STAGE = var.stage
LOG_LEVEL = "info"
POSTGRES_USERNAME = local.db_credentials.db_assessment_model_username
POSTGRES_PASSWORD = local.db_credentials.db_assessment_model_password
DB_USERNAME = local.db_credentials.db_assessment_model_username
DB_PASSWORD = local.db_credentials.db_assessment_model_password
GOOGLE_SOLAR_API_KEY = "test"
SAP_PREDICTIONS_BUCKET = "test"
CARBON_PREDICTIONS_BUCKET = "test"
HEAT_PREDICTIONS_BUCKET = "test"
HEATING_KWH_PREDICTIONS_BUCKET = "test"
HOTWATER_KWH_PREDICTIONS_BUCKET = "test"
API_KEY = "test"
ENVIRONMENT = "test"
SECRET_KEY = "test"
PLAN_TRIGGER_BUCKET = "test"
DATA_BUCKET = "test"
EPC_AUTH_TOKEN = "test"
ENGINE_SQS_URL = "test"
ENERGY_ASSESSMENTS_BUCKET = "test"
ADDRESS2UPRN_QUEUE_URL = data.terraform_remote_state.address2uprn.outputs.address2uprn_queue_url
S3_BUCKET_NAME = data.terraform_remote_state.shared.outputs.retrofit_sap_data_bucket_name
},

Some files were not shown because too many files have changed in this diff Show more