import xml.etree.ElementTree as ET from datetime import date from typing import Dict, List, Tuple, Union import requests from domain.abri.models import ( AbriRequestRejected, AmendJobRequest, AmendJobResult, AppointmentAmended, GetTenantDataResult, 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 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) 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_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)