mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
294 lines
10 KiB
Python
294 lines
10 KiB
Python
from dataclasses import dataclass
|
|
from datetime import date
|
|
from typing import Dict, List, Optional, Protocol, Tuple, Union
|
|
|
|
from domain.abri.descriptions import build_job_descriptions
|
|
from domain.abri.models import (
|
|
AbriRequestRejected,
|
|
AmendJobRequest,
|
|
AppointmentAmended,
|
|
LogJobRequest,
|
|
PlaceRef,
|
|
Tenant,
|
|
)
|
|
from domain.abri.slots import slot_for_confirmed_time
|
|
from infrastructure.abri.abri_client import AbriClient
|
|
from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
|
|
from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient
|
|
from infrastructure.hubspot.errors import HubspotRequestError
|
|
|
|
# The HubSpot deal property that carries the OpenHousing job number; the
|
|
# property ID is fixed externally, the domain and database call it job_no.
|
|
JOB_NO_DEAL_PROPERTY = "client_booking_reference"
|
|
|
|
|
|
def _vulnerability_description(tenant: Tenant) -> Optional[str]:
|
|
"""The tenant's distinct vulnerabilities, verbatim and newline-joined.
|
|
|
|
Case-insensitively distinct within the tenant, keeping the first-seen
|
|
casing and document order.
|
|
"""
|
|
distinct: List[str] = []
|
|
seen: set[str] = set()
|
|
for vulnerability in tenant.vulnerabilities:
|
|
if vulnerability.lower() in seen:
|
|
continue
|
|
seen.add(vulnerability.lower())
|
|
distinct.append(vulnerability)
|
|
return "\n".join(distinct) if distinct else None
|
|
|
|
|
|
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,
|
|
"secondary_phone_number": tenant.secondary_number,
|
|
"vulnerability_description": _vulnerability_description(tenant),
|
|
}
|
|
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."""
|
|
|
|
tenancy_reference: str
|
|
contact_ids: Tuple[str, ...]
|
|
vulnerable_contact_count: int
|
|
|
|
|
|
TenantDataSyncResult = Union[TenantDataSyncSummary, AbriRequestRejected]
|
|
|
|
|
|
class TenantDataSyncError(Exception):
|
|
"""A sync that stopped at the first failed HubSpot write.
|
|
|
|
Carries a PII-free progress report so the operator knows the deal's
|
|
half-state before cleaning up and re-running; contacts created but not
|
|
associated are orphaned in HubSpot and need manual cleanup.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
deal_id: str,
|
|
tenant_index: int,
|
|
contact_ids_created: Tuple[str, ...],
|
|
contact_ids_associated: Tuple[str, ...],
|
|
) -> None:
|
|
self.deal_id = deal_id
|
|
self.tenant_index = tenant_index
|
|
self.contact_ids_created = contact_ids_created
|
|
self.contact_ids_associated = contact_ids_associated
|
|
super().__init__(
|
|
f"{message} (deal: {deal_id}, tenant index: {tenant_index}, "
|
|
f"contacts created: {list(contact_ids_created)}, "
|
|
f"associated: {list(contact_ids_associated)})"
|
|
)
|
|
|
|
@property
|
|
def orphaned_contact_ids(self) -> Tuple[str, ...]:
|
|
return tuple(
|
|
contact_id
|
|
for contact_id in self.contact_ids_created
|
|
if contact_id not in self.contact_ids_associated
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConfirmedSurveyBooking:
|
|
"""The deal fields a confirmed survey booking contributes to a LogJob."""
|
|
|
|
deal_id: str
|
|
place_ref: PlaceRef
|
|
deal_name: str
|
|
confirmed_survey_date: date
|
|
confirmed_survey_time: Optional[str]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LogJobSummary:
|
|
"""PII-free record of a logged job: safe to log and persist."""
|
|
|
|
deal_id: str
|
|
job_no: str
|
|
|
|
|
|
LogJobOrchestrationResult = Union[LogJobSummary, AbriRequestRejected]
|
|
|
|
|
|
class DealDatabaseGateway(Protocol):
|
|
def record_job_no(self, deal_id: str, job_no: str) -> None: ...
|
|
|
|
def job_no_for_deal(self, deal_id: str) -> Optional[str]: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AppointmentChange:
|
|
"""The deal fields a changed survey booking contributes to an AmendJob."""
|
|
|
|
deal_id: str
|
|
confirmed_survey_date: date
|
|
confirmed_survey_time: Optional[str]
|
|
|
|
|
|
AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected]
|
|
|
|
|
|
class JobNoNotYetRecordedError(Exception):
|
|
"""An amendment arrived before the deal's job_no landed in the database.
|
|
|
|
Retriable: the message should be redelivered until the log flow's
|
|
write-back lands (or it dead-letters), so a rapid log-then-amend
|
|
sequence eventually converges.
|
|
"""
|
|
|
|
def __init__(self, deal_id: str) -> None:
|
|
self.deal_id = deal_id
|
|
super().__init__(
|
|
f"no job_no recorded yet for deal {deal_id}; cannot amend its appointment"
|
|
)
|
|
|
|
|
|
class LogJobWriteBackError(Exception):
|
|
"""A job logged in OpenHousing whose job_no could not be written back.
|
|
|
|
Carries the orphaned job_no so an operator can paste it into HubSpot
|
|
manually; the run must never retry log_job, which would log a duplicate
|
|
job in OpenHousing.
|
|
"""
|
|
|
|
def __init__(self, message: str, deal_id: str, job_no: str) -> None:
|
|
self.deal_id = deal_id
|
|
self.job_no = job_no
|
|
super().__init__(f"{message} (deal: {deal_id}, orphaned job_no: {job_no})")
|
|
|
|
|
|
def client_ref_for_deal(deal_id: str) -> str:
|
|
"""The client_ref sent to OpenHousing: the HubSpot deal ID, for now.
|
|
|
|
UNCONFIRMED with Abri: whether client_ref must be unique per job (a deal
|
|
re-logging after a cancellation would repeat the bare deal ID) is an open
|
|
question on issue #1478. Swap the derivation here once Abri answers.
|
|
"""
|
|
return deal_id
|
|
|
|
|
|
class AbriOrchestrator:
|
|
"""Runs the Abri OpenHousing flows for a HubSpot deal, one method each.
|
|
|
|
Tenant data flows straight from Abri to HubSpot; nothing tenant-related
|
|
is persisted in Domna's database or logs. For job logging, HubSpot is
|
|
written first (it is the source of truth); the database second, so a
|
|
failed database write self-heals on the next scrape.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
abri_client: AbriClient,
|
|
deal_contacts: HubspotDealContactsClient,
|
|
deal_properties: HubspotDealPropertiesClient,
|
|
deal_database: DealDatabaseGateway,
|
|
) -> None:
|
|
self._abri = abri_client
|
|
self._deal_contacts = deal_contacts
|
|
self._deal_properties = deal_properties
|
|
self._deal_database = deal_database
|
|
|
|
def sync_tenant_data(
|
|
self, place_ref: PlaceRef, deal_id: str
|
|
) -> TenantDataSyncResult:
|
|
tenancy = self._abri.get_tenant_data(place_ref)
|
|
|
|
if isinstance(tenancy, AbriRequestRejected):
|
|
return tenancy
|
|
|
|
contact_ids: List[str] = []
|
|
associated_ids: List[str] = []
|
|
for tenant_index, tenant in enumerate(tenancy.tenants):
|
|
try:
|
|
contact_id = self._deal_contacts.create_contact(
|
|
_contact_properties(tenant)
|
|
)
|
|
contact_ids.append(contact_id)
|
|
self._deal_contacts.associate_contact_to_deal(
|
|
deal_id=deal_id, contact_id=contact_id
|
|
)
|
|
associated_ids.append(contact_id)
|
|
except HubspotRequestError as error:
|
|
# Fail fast at the first failed write; `error` is already
|
|
# scrubbed, so chaining it keeps tracebacks PII-free.
|
|
raise TenantDataSyncError(
|
|
message=str(error),
|
|
deal_id=deal_id,
|
|
tenant_index=tenant_index,
|
|
contact_ids_created=tuple(contact_ids),
|
|
contact_ids_associated=tuple(associated_ids),
|
|
) from error
|
|
|
|
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
|
|
),
|
|
)
|
|
|
|
def amend_job(self, change: AppointmentChange) -> AmendJobOrchestrationResult:
|
|
job_no = self._deal_database.job_no_for_deal(change.deal_id)
|
|
if job_no is None:
|
|
raise JobNoNotYetRecordedError(deal_id=change.deal_id)
|
|
|
|
return self._abri.amend_job(
|
|
AmendJobRequest(
|
|
job_no=job_no,
|
|
appointment_date=change.confirmed_survey_date,
|
|
appointment_time=slot_for_confirmed_time(
|
|
change.confirmed_survey_time
|
|
),
|
|
)
|
|
)
|
|
|
|
def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult:
|
|
descriptions = build_job_descriptions(booking.deal_name)
|
|
outcome = self._abri.log_job(
|
|
LogJobRequest(
|
|
client_ref=client_ref_for_deal(booking.deal_id),
|
|
place_ref=booking.place_ref,
|
|
appointment_date=booking.confirmed_survey_date,
|
|
appointment_time=slot_for_confirmed_time(
|
|
booking.confirmed_survey_time
|
|
),
|
|
short_description=descriptions.short_description,
|
|
long_description=descriptions.long_description,
|
|
)
|
|
)
|
|
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
return outcome
|
|
|
|
try:
|
|
self._deal_properties.write_deal_property(
|
|
deal_id=booking.deal_id,
|
|
property_name=JOB_NO_DEAL_PROPERTY,
|
|
value=outcome.job_no,
|
|
)
|
|
except HubspotRequestError as error:
|
|
# The job now exists in OpenHousing; never re-run log_job (it
|
|
# would log a duplicate). `error` is already scrubbed, so
|
|
# chaining it keeps tracebacks free of the response body.
|
|
raise LogJobWriteBackError(
|
|
message=str(error),
|
|
deal_id=booking.deal_id,
|
|
job_no=outcome.job_no,
|
|
) from error
|
|
self._deal_database.record_job_no(
|
|
deal_id=booking.deal_id, job_no=outcome.job_no
|
|
)
|
|
|
|
return LogJobSummary(deal_id=booking.deal_id, job_no=outcome.job_no)
|