mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Abri tenancy signatory becomes an associated HubSpot deal contact 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d1b7e15d64
commit
78fcba45e5
5 changed files with 205 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Literal, NewType, Optional, Union
|
||||
from typing import Literal, NewType, Optional, Tuple, Union
|
||||
|
||||
PlaceRef = NewType("PlaceRef", str)
|
||||
|
||||
|
|
@ -48,3 +48,31 @@ class AppointmentAmended:
|
|||
|
||||
|
||||
AmendJobResult = Union[AppointmentAmended, AbriRequestRejected]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tenant:
|
||||
"""A tenancy signatory, minimised to the fields the HubSpot sync needs.
|
||||
|
||||
Person title, per-person references, the main-contact flag and tenancy
|
||||
dates are deliberately dropped at parse time (data minimisation); mobile
|
||||
and telephone are the single best number of each kind, pre-selected by
|
||||
priority.
|
||||
"""
|
||||
|
||||
forenames: str
|
||||
surname: str
|
||||
mobile: Optional[str]
|
||||
telephone: Optional[str]
|
||||
vulnerabilities: Tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenancyData:
|
||||
"""Tenancy signatories for a place; the reference is PII-safe."""
|
||||
|
||||
tenancy_reference: str
|
||||
tenants: Tuple[Tenant, ...]
|
||||
|
||||
|
||||
GetTenantDataResult = Union[TenancyData, AbriRequestRejected]
|
||||
|
|
|
|||
25
etl/hubspot/deal_contacts_client.py
Normal file
25
etl/hubspot/deal_contacts_client.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from typing import Dict
|
||||
|
||||
from hubspot.client import Client # type: ignore[reportMissingTypeStubs]
|
||||
|
||||
# HubSpot-defined deal -> contact association ("deal_to_contact" in v3 terms).
|
||||
DEAL_TO_CONTACT_ASSOCIATION_TYPE_ID = 3
|
||||
|
||||
|
||||
class HubspotDealContactsClient:
|
||||
"""Focused HubSpot client for the tenant-data sync flow.
|
||||
|
||||
Deliberately separate from the legacy HubspotClient: the SDK client is
|
||||
constructor-injected, and SDK errors are re-raised scrubbed of response
|
||||
bodies (HubSpot validation errors echo submitted property values, which
|
||||
here are tenant PII).
|
||||
"""
|
||||
|
||||
def __init__(self, sdk_client: Client) -> None:
|
||||
self._client: Client = sdk_client
|
||||
|
||||
def create_contact(self, properties: Dict[str, str]) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def associate_contact_to_deal(self, deal_id: str, contact_id: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
|
@ -9,9 +9,11 @@ from domain.abri.models import (
|
|||
AmendJobRequest,
|
||||
AmendJobResult,
|
||||
AppointmentAmended,
|
||||
GetTenantDataResult,
|
||||
JobLogged,
|
||||
LogJobRequest,
|
||||
LogJobResult,
|
||||
PlaceRef,
|
||||
)
|
||||
from infrastructure.abri.config import AbriConfig
|
||||
from infrastructure.abri.envelope import serialise_relay_request
|
||||
|
|
@ -73,6 +75,9 @@ class AbriClient:
|
|||
|
||||
return self._parse_appointment_amended(outcome)
|
||||
|
||||
def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def _exchange(
|
||||
self, request_type: str, parameters: List[Tuple[str, str]]
|
||||
) -> Union[ET.Element, AbriRequestRejected]:
|
||||
|
|
|
|||
37
orchestration/tenant_data_sync_orchestrator.py
Normal file
37
orchestration/tenant_data_sync_orchestrator.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Tuple, Union
|
||||
|
||||
from domain.abri.models import AbriRequestRejected, PlaceRef
|
||||
from etl.hubspot.deal_contacts_client import HubspotDealContactsClient
|
||||
from infrastructure.abri.abri_client import AbriClient
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantDataSyncSummary:
|
||||
"""PII-free record of what a sync run did: safe to log and persist."""
|
||||
|
||||
tenancy_reference: str
|
||||
contact_ids: Tuple[str, ...]
|
||||
vulnerable_contact_count: int
|
||||
|
||||
|
||||
TenantDataSyncResult = Union[TenantDataSyncSummary, AbriRequestRejected]
|
||||
|
||||
|
||||
class TenantDataSyncOrchestrator:
|
||||
"""Syncs an Abri tenancy's signatories to a HubSpot deal.
|
||||
|
||||
Tenant data flows straight from Abri to HubSpot; nothing is persisted
|
||||
in Domna's database or logs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
abri_client: AbriClient,
|
||||
hubspot: HubspotDealContactsClient,
|
||||
) -> None:
|
||||
self._abri = abri_client
|
||||
self._hubspot = hubspot
|
||||
|
||||
def run(self, place_ref: PlaceRef, deal_id: str) -> TenantDataSyncResult:
|
||||
raise NotImplementedError
|
||||
109
tests/orchestration/test_tenant_data_sync_orchestrator.py
Normal file
109
tests/orchestration/test_tenant_data_sync_orchestrator.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from hubspot.crm.associations.v4 import AssociationSpec # type: ignore[reportMissingTypeStubs]
|
||||
from hubspot.crm.contacts import SimplePublicObjectInputForCreate # type: ignore[reportMissingTypeStubs]
|
||||
|
||||
from domain.abri.models import PlaceRef
|
||||
from etl.hubspot.deal_contacts_client import HubspotDealContactsClient
|
||||
from infrastructure.abri.abri_client import AbriClient
|
||||
from infrastructure.abri.config import AbriConfig
|
||||
from orchestration.tenant_data_sync_orchestrator import (
|
||||
TenantDataSyncOrchestrator,
|
||||
TenantDataSyncSummary,
|
||||
)
|
||||
|
||||
ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key"
|
||||
PLACE_REF = PlaceRef("1004202A")
|
||||
DEAL_ID = "9876543210"
|
||||
|
||||
CONFIG = AbriConfig(
|
||||
endpoint_url=ENDPOINT_URL,
|
||||
username="DomnaWeb",
|
||||
password="",
|
||||
default_resource="NAULKH",
|
||||
)
|
||||
|
||||
|
||||
def _tenancy_response(
|
||||
tenants_xml: str, tenancy_reference: str = "10042020051017"
|
||||
) -> bytes:
|
||||
return (
|
||||
f'<Root><Tenancy end_date="" start_date="15/12/2008">'
|
||||
f"{tenancy_reference}{tenants_xml}</Tenancy></Root>"
|
||||
).encode()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def mock_session() -> MagicMock:
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sdk_client() -> MagicMock:
|
||||
sdk = MagicMock()
|
||||
sdk.crm.contacts.basic_api.create.return_value.id = "60123"
|
||||
return sdk
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def orchestrator(
|
||||
mock_session: MagicMock, sdk_client: MagicMock
|
||||
) -> TenantDataSyncOrchestrator:
|
||||
with patch(
|
||||
"infrastructure.abri.abri_client.requests.Session",
|
||||
return_value=mock_session,
|
||||
):
|
||||
abri_client = AbriClient(config=CONFIG)
|
||||
return TenantDataSyncOrchestrator(
|
||||
abri_client=abri_client,
|
||||
hubspot=HubspotDealContactsClient(sdk_client=sdk_client),
|
||||
)
|
||||
|
||||
|
||||
# --- happy path: signatories become associated deal contacts ---
|
||||
|
||||
|
||||
def test_run_creates_an_associated_deal_contact_per_signatory_and_returns_a_summary(
|
||||
orchestrator: TenantDataSyncOrchestrator,
|
||||
mock_session: MagicMock,
|
||||
sdk_client: MagicMock,
|
||||
) -> None:
|
||||
# Arrange
|
||||
mock_session.post.return_value.content = _tenancy_response(
|
||||
'<Tenant forenames="Amanjeet" main-contact="yes" person_title="Mrs"'
|
||||
' surname="Okello">1368473<Mobile priority="10">09853460810</Mobile>'
|
||||
'<Telephone priority="101">02473757484</Telephone></Tenant>'
|
||||
)
|
||||
|
||||
# Act
|
||||
result = orchestrator.run(PLACE_REF, deal_id=DEAL_ID)
|
||||
|
||||
# Assert
|
||||
assert result == TenantDataSyncSummary(
|
||||
tenancy_reference="10042020051017",
|
||||
contact_ids=("60123",),
|
||||
vulnerable_contact_count=0,
|
||||
)
|
||||
sdk_client.crm.contacts.basic_api.create.assert_called_once_with(
|
||||
simple_public_object_input_for_create=SimplePublicObjectInputForCreate(
|
||||
properties={
|
||||
"firstname": "Amanjeet",
|
||||
"lastname": "Okello",
|
||||
"mobilephone": "09853460810",
|
||||
"phone": "02473757484",
|
||||
"is_vulnerable": "false",
|
||||
}
|
||||
)
|
||||
)
|
||||
sdk_client.crm.associations.v4.basic_api.create.assert_called_once_with(
|
||||
object_type="deals",
|
||||
object_id=DEAL_ID,
|
||||
to_object_type="contacts",
|
||||
to_object_id="60123",
|
||||
association_spec=[
|
||||
AssociationSpec(
|
||||
association_category="HUBSPOT_DEFINED", association_type_id=3
|
||||
)
|
||||
],
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue