mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
301 lines
10 KiB
Python
301 lines
10 KiB
Python
import xml.etree.ElementTree as ET
|
|
from datetime import date
|
|
from typing import Dict, List, Tuple, Union
|
|
|
|
import requests
|
|
|
|
from domain.abri.models import (
|
|
AbandonJobRequest,
|
|
AbandonJobResult,
|
|
AbriRequestRejected,
|
|
AmendJobRequest,
|
|
AmendJobResult,
|
|
AppointmentAmended,
|
|
GetTenantDataResult,
|
|
JobAbandoned,
|
|
JobLogged,
|
|
LogJobRequest,
|
|
LogJobResult,
|
|
PlaceRef,
|
|
TenancyData,
|
|
Tenant,
|
|
)
|
|
from domain.abri.phone_selection import PhoneEntry, PhoneKind, select_phone_numbers
|
|
from infrastructure.abri.config import AbriConfig
|
|
from infrastructure.abri.envelope import serialise_relay_request
|
|
from infrastructure.abri.errors import AbriResponseParseError, AbriTransportError
|
|
|
|
STD_JOB_CODE = "SCSEXT"
|
|
CLIENT_CODE = "HSG"
|
|
RESOURCE_GROUP = "Surveyors"
|
|
|
|
|
|
def _format_appointment_date(appointment_date: date) -> str:
|
|
return appointment_date.strftime("%d/%m/%Y")
|
|
|
|
|
|
# Wire tags -> the domain's phone kinds; selection policy lives in the domain.
|
|
_PHONE_KINDS: Dict[str, PhoneKind] = {"Mobile": "mobile", "Telephone": "telephone"}
|
|
|
|
|
|
def _phone_entries(tenant_element: ET.Element) -> List[PhoneEntry]:
|
|
return [
|
|
PhoneEntry(
|
|
kind=_PHONE_KINDS[child.tag],
|
|
number=child.text or "",
|
|
priority=child.get("priority"),
|
|
)
|
|
for child in tenant_element
|
|
if child.tag in _PHONE_KINDS
|
|
]
|
|
|
|
|
|
class AbriClient:
|
|
def __init__(self, config: AbriConfig) -> None:
|
|
self._config = config
|
|
self._session = requests.Session()
|
|
|
|
def log_job(self, request: LogJobRequest) -> LogJobResult:
|
|
outcome = self._exchange(
|
|
request_type="logjob",
|
|
parameters=[
|
|
("place_ref", request.place_ref),
|
|
("std_job_code", STD_JOB_CODE),
|
|
("client", CLIENT_CODE),
|
|
("short_description", request.short_description),
|
|
("long_description", request.long_description),
|
|
("client_ref", request.client_ref),
|
|
("appointment_date", _format_appointment_date(request.appointment_date)),
|
|
("appointment_time", request.appointment_time),
|
|
("resource", self._config.default_resource),
|
|
("resource_group", RESOURCE_GROUP),
|
|
],
|
|
)
|
|
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
return outcome
|
|
|
|
return self._parse_job_logged(outcome)
|
|
|
|
def amend_job(self, request: AmendJobRequest) -> AmendJobResult:
|
|
parameters = [
|
|
("job_no", request.job_no),
|
|
("action", "amend"),
|
|
("appointment_date", _format_appointment_date(request.appointment_date)),
|
|
("appointment_time", request.appointment_time),
|
|
]
|
|
if request.resource is not None:
|
|
parameters.append(("resource", request.resource))
|
|
|
|
outcome = self._exchange(
|
|
request_type="amendoptiappt",
|
|
parameters=parameters,
|
|
)
|
|
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
return outcome
|
|
|
|
return self._parse_appointment_amended(outcome)
|
|
|
|
def abandon_job(self, request: AbandonJobRequest) -> AbandonJobResult:
|
|
outcome = self._exchange(
|
|
request_type="canceljob",
|
|
parameters=[
|
|
("job_no", request.job_no),
|
|
("abandon_reason", request.reason.value),
|
|
("date_abandoned", _format_appointment_date(request.date_abandoned)),
|
|
],
|
|
)
|
|
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
return outcome
|
|
|
|
return self._parse_abandon_result(outcome)
|
|
|
|
def get_tenant_data(self, place_ref: PlaceRef) -> GetTenantDataResult:
|
|
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]]
|
|
) -> Union[ET.Element, AbriRequestRejected]:
|
|
envelope = serialise_relay_request(
|
|
request_type=request_type,
|
|
parameters=parameters,
|
|
username=self._config.username,
|
|
password=self._config.password,
|
|
)
|
|
|
|
reply = self._parse_reply(self._post(envelope))
|
|
|
|
if self._is_failure_document(reply):
|
|
return self._parse_rejection(reply)
|
|
|
|
return reply
|
|
|
|
def _post(self, envelope: bytes) -> bytes:
|
|
try:
|
|
response = self._session.post(
|
|
self._config.endpoint_url,
|
|
data=envelope,
|
|
headers={"Content-Type": "application/xml"},
|
|
)
|
|
response.raise_for_status()
|
|
except requests.RequestException as error:
|
|
raise AbriTransportError(str(error)) from error
|
|
|
|
return response.content
|
|
|
|
@staticmethod
|
|
def _parse_reply(body: bytes) -> ET.Element:
|
|
try:
|
|
return ET.fromstring(body)
|
|
except ET.ParseError as error:
|
|
raise AbriResponseParseError(str(error)) from error
|
|
|
|
@staticmethod
|
|
def _is_failure_document(reply: ET.Element) -> bool:
|
|
return reply.tag == "response"
|
|
|
|
@staticmethod
|
|
def _parse_job_logged(root: ET.Element) -> JobLogged:
|
|
job_logged = root.find("Jobs/Job_logged")
|
|
|
|
if job_logged is None:
|
|
raise AbriResponseParseError(
|
|
"Job_logged element missing from relay response"
|
|
)
|
|
|
|
job_no = job_logged.get("job_no")
|
|
logged_info = job_logged.get("logged_info")
|
|
|
|
if job_no is None or logged_info is None:
|
|
raise AbriResponseParseError(
|
|
"Job_logged element missing job_no or logged_info"
|
|
)
|
|
|
|
return JobLogged(job_no=job_no, logged_info=logged_info)
|
|
|
|
@staticmethod
|
|
def _parse_appointment_amended(root: ET.Element) -> AppointmentAmended:
|
|
amended = root.find("AmendOptiAppt")
|
|
|
|
if amended is None:
|
|
raise AbriResponseParseError(
|
|
"AmendOptiAppt element missing from relay response"
|
|
)
|
|
|
|
if amended.get("success") != "Y":
|
|
raise AbriResponseParseError(
|
|
"AmendOptiAppt element did not confirm success"
|
|
)
|
|
|
|
job_no = amended.get("job_no")
|
|
appointment_date = amended.get("appointment_date")
|
|
appointment_time = amended.get("appointment_time")
|
|
|
|
if job_no is None or appointment_date is None or appointment_time is None:
|
|
raise AbriResponseParseError(
|
|
"AmendOptiAppt element missing job_no, appointment_date "
|
|
"or appointment_time"
|
|
)
|
|
|
|
return AppointmentAmended(
|
|
job_no=job_no,
|
|
appointment_date=appointment_date,
|
|
appointment_time=appointment_time,
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_abandon_result(root: ET.Element) -> AbandonJobResult:
|
|
# canceljob diverges from the other flows: failure can arrive as a
|
|
# result document whose cancellation collection is empty and which
|
|
# carries an ErrorDetails element, rather than the shared failure
|
|
# envelope. A present JobCancelled is success; a present ErrorDetails
|
|
# is a business rejection. Neither present is genuinely ambiguous and
|
|
# must stay retriable — never silently a success or a rejection.
|
|
job_cancelled = root.find(".//JobCancelled")
|
|
if job_cancelled is not None:
|
|
job_no = job_cancelled.get("job_no")
|
|
if job_no is None:
|
|
raise AbriResponseParseError("JobCancelled element missing job_no")
|
|
return JobAbandoned(job_no=job_no)
|
|
|
|
error_details = root.find(".//ErrorDetails")
|
|
if error_details is not None:
|
|
return AbriClient._rejection_from_error_details(error_details)
|
|
|
|
raise AbriResponseParseError(
|
|
"canceljob response has neither JobCancelled nor ErrorDetails"
|
|
)
|
|
|
|
@staticmethod
|
|
def _rejection_from_error_details(error_details: ET.Element) -> AbriRequestRejected:
|
|
code = error_details.get("code")
|
|
message = error_details.get("message")
|
|
|
|
if code is None or message is None:
|
|
raise AbriResponseParseError(
|
|
"canceljob ErrorDetails missing code or message"
|
|
)
|
|
|
|
return AbriRequestRejected(code=code, message=message)
|
|
|
|
@staticmethod
|
|
def _parse_tenancy_data(root: ET.Element) -> TenancyData:
|
|
tenancies = root.findall("Tenancy")
|
|
|
|
if len(tenancies) != 1:
|
|
raise AbriResponseParseError(
|
|
f"expected exactly one Tenancy element, found {len(tenancies)}"
|
|
)
|
|
|
|
tenancy = tenancies[0]
|
|
tenancy_reference = (tenancy.text or "").strip()
|
|
|
|
if not tenancy_reference:
|
|
raise AbriResponseParseError("Tenancy element missing its reference")
|
|
|
|
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.
|
|
numbers = select_phone_numbers(_phone_entries(element))
|
|
return Tenant(
|
|
forenames=(element.get("forenames") or "").strip(),
|
|
surname=(element.get("surname") or "").strip(),
|
|
mobile=numbers.mobile,
|
|
telephone=numbers.telephone,
|
|
secondary_number=numbers.secondary,
|
|
vulnerabilities=tuple(
|
|
text
|
|
for vulnerability in element.findall("Vulnerability")
|
|
if (text := (vulnerability.text or "").strip())
|
|
),
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_rejection(root: ET.Element) -> AbriRequestRejected:
|
|
success = root.findtext("success")
|
|
code = root.findtext("code")
|
|
message = root.findtext("message")
|
|
|
|
if success != "false" or code is None or message is None:
|
|
raise AbriResponseParseError("malformed relay failure response")
|
|
|
|
return AbriRequestRejected(code=code, message=message)
|