From 76d0b4bac999bb0f76bebd8bd424ef8612c38592 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 6 Jul 2026 13:24:47 +0000 Subject: [PATCH] =?UTF-8?q?Abri=20tenancy=20signatory=20becomes=20an=20ass?= =?UTF-8?q?ociated=20HubSpot=20deal=20contact=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/deal_contacts_client.py | 22 +++++++- infrastructure/abri/abri_client.py | 53 ++++++++++++++++++- .../tenant_data_sync_orchestrator.py | 38 +++++++++++-- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/etl/hubspot/deal_contacts_client.py b/etl/hubspot/deal_contacts_client.py index df8a8f0ec..6765b5da5 100644 --- a/etl/hubspot/deal_contacts_client.py +++ b/etl/hubspot/deal_contacts_client.py @@ -1,6 +1,8 @@ from typing import Dict from hubspot.client import Client # type: ignore[reportMissingTypeStubs] +from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs] +from hubspot.crm.contacts import SimplePublicObjectInputForCreate # type: ignore[reportMissingTypeStubs] # HubSpot-defined deal -> contact association ("deal_to_contact" in v3 terms). DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID = 3 @@ -19,7 +21,23 @@ class HubspotDealContactsClient: self._client: Client = sdk_client def create_contact(self, properties: Dict[str, str]) -> str: - raise NotImplementedError + contact = self._client.crm.contacts.basic_api.create( # type: ignore[reportUnknownMemberType] + simple_public_object_input_for_create=SimplePublicObjectInputForCreate( + properties=properties + ) + ) + return str(contact.id) # type: ignore[reportUnknownMemberType] def associate_contact_to_deal(self, deal_id: str, contact_id: str) -> None: - raise NotImplementedError + self._client.crm.associations.v4.basic_api.create( # type: ignore[reportUnknownMemberType] + object_type="deals", + object_id=deal_id, + to_object_type="contacts", + to_object_id=contact_id, + association_spec=[ + AssociationSpec( + association_category="HUBSPOT_DEFINED", + association_type_id=DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID, + ) + ], + ) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 6c9cc883d..16f74e17b 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -1,6 +1,6 @@ import xml.etree.ElementTree as ET from datetime import date -from typing import List, Tuple, Union +from typing import List, Optional, Tuple, Union import requests @@ -14,6 +14,8 @@ from domain.abri.models import ( LogJobRequest, LogJobResult, PlaceRef, + TenancyData, + Tenant, ) from infrastructure.abri.config import AbriConfig from infrastructure.abri.envelope import serialise_relay_request @@ -28,6 +30,14 @@ def _format_appointment_date(appointment_date: date) -> str: return appointment_date.strftime("%d/%m/%Y") +def _select_phone_number(entries: List[ET.Element]) -> Optional[str]: + for entry in entries: + number = (entry.text or "").strip() + if number: + return number + return None + + class AbriClient: def __init__(self, config: AbriConfig) -> None: self._config = config @@ -76,7 +86,15 @@ class AbriClient: return self._parse_appointment_amended(outcome) def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult: - raise NotImplementedError + outcome = self._exchange( + request_type="getSCSTenantData", + parameters=[("place_ref", place_ref)], + ) + + if isinstance(outcome, AbriRequestRejected): + return outcome + + return self._parse_tenancy_data(outcome) def _exchange( self, request_type: str, parameters: List[Tuple[str, str]] @@ -164,6 +182,37 @@ class AbriClient: appointment_time=appointment_time, ) + @staticmethod + def _parse_tenancy_data(root: ET.Element) -> TenancyData: + tenancy = root.find("Tenancy") + + if tenancy is None: + raise AbriResponseParseError( + "Tenancy element missing from relay response" + ) + + tenancy_reference = (tenancy.text or "").strip() + + return TenancyData( + tenancy_reference=tenancy_reference, + tenants=tuple( + AbriClient._parse_tenant(tenant) + for tenant in tenancy.findall("Tenant") + ), + ) + + @staticmethod + def _parse_tenant(element: ET.Element) -> Tenant: + # Data minimisation: person_title, main-contact and the per-person + # reference (the element text) are deliberately not read. + return Tenant( + forenames=(element.get("forenames") or "").strip(), + surname=(element.get("surname") or "").strip(), + mobile=_select_phone_number(element.findall("Mobile")), + telephone=_select_phone_number(element.findall("Telephone")), + vulnerabilities=(), + ) + @staticmethod def _parse_rejection(root: ET.Element) -> AbriRequestRejected: success = root.findtext("success") diff --git a/orchestration/tenant_data_sync_orchestrator.py b/orchestration/tenant_data_sync_orchestrator.py index 18790f9d1..038489ea7 100644 --- a/orchestration/tenant_data_sync_orchestrator.py +++ b/orchestration/tenant_data_sync_orchestrator.py @@ -1,11 +1,24 @@ from dataclasses import dataclass -from typing import Tuple, Union +from typing import Dict, List, Optional, Tuple, Union -from domain.abri.models import AbriRequestRejected, PlaceRef +from domain.abri.models import AbriRequestRejected, PlaceRef, Tenant from etl.hubspot.deal_contacts_client import HubspotDealContactsClient from infrastructure.abri.abri_client import AbriClient +def _contact_properties(tenant: Tenant) -> Dict[str, str]: + candidates: Dict[str, Optional[str]] = { + "firstname": tenant.forenames, + "lastname": tenant.surname, + "mobilephone": tenant.mobile, + "phone": tenant.telephone, + } + properties = {name: value for name, value in candidates.items() if value} + # Always written, so "not vulnerable" is distinguishable from "not synced". + properties["is_vulnerable"] = "true" if tenant.vulnerabilities else "false" + return properties + + @dataclass(frozen=True) class TenantDataSyncSummary: """PII-free record of what a sync run did: safe to log and persist.""" @@ -34,4 +47,23 @@ class TenantDataSyncOrchestrator: self._hubspot = hubspot def run(self, place_ref: PlaceRef, deal_id: str) -> TenantDataSyncResult: - raise NotImplementedError + tenancy = self._abri.get_tenant_data(place_ref) + + if isinstance(tenancy, AbriRequestRejected): + return tenancy + + contact_ids: List[str] = [] + for tenant in tenancy.tenants: + contact_id = self._hubspot.create_contact(_contact_properties(tenant)) + self._hubspot.associate_contact_to_deal( + deal_id=deal_id, contact_id=contact_id + ) + contact_ids.append(contact_id) + + return TenantDataSyncSummary( + tenancy_reference=tenancy.tenancy_reference, + contact_ids=tuple(contact_ids), + vulnerable_contact_count=sum( + 1 for tenant in tenancy.tenants if tenant.vulnerabilities + ), + )