161 lines
8.7 KiB
Python
161 lines
8.7 KiB
Python
"""Single-profile, read-only summary probe; not an accepted ARR name mapping.
|
|
|
|
The caller owns the authorized sample budget. This module performs exactly one
|
|
HTTP attempt, with no broad search, pagination, retry or profile-detail fallback.
|
|
Raw names/IDs and the returned assessment are private data, not log messages.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import re
|
|
|
|
from . import collect_arr_source as source
|
|
|
|
|
|
OPERATION = "searchProfiles"
|
|
PATH = "/api/v1/profiles/searches"
|
|
VERSION = "arr-profile-summary-probe/v1"
|
|
COMPONENTS = {"surname": "name", "givenName": "givenName", "middleName": "middleName",
|
|
"nameTitle": "nameTitle"}
|
|
require = source.require
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PrimaryGuest:
|
|
profile_id: str = field(repr=False)
|
|
name: dict = field(repr=False)
|
|
|
|
|
|
def _profile_id(identifiers):
|
|
require(type(identifiers) is list and all(type(item) is dict for item in identifiers),
|
|
"profile_invalid_identity_list")
|
|
require(all(type(item.get("type")) is str for item in identifiers), "profile_invalid_identity_type")
|
|
matches = [item.get("id") for item in identifiers if item["type"] == "Profile"]
|
|
require(len(matches) == 1 and type(matches[0]) is str
|
|
and bool(re.fullmatch(r"[A-Za-z0-9_-]{1,128}", matches[0])), "profile_ambiguous_identity")
|
|
return matches[0]
|
|
|
|
|
|
def primary_guest(detail: dict, *, include_prefix: bool = False) -> PrimaryGuest:
|
|
source.reservation_id(detail)
|
|
guests = detail.get("reservationGuests")
|
|
require("reservationGuests" in detail, "profile_missing_guest_list")
|
|
require(type(guests) is list and all(type(item) is dict for item in guests), "profile_invalid_guest_list")
|
|
require(all("primary" not in guest or type(guest["primary"]) is bool for guest in guests),
|
|
"profile_invalid_primary_flag")
|
|
primary = [guest for guest in guests if guest.get("primary") is True]
|
|
require(len(primary) == 1, "profile_ambiguous_primary_guest")
|
|
info = primary[0].get("profileInfo")
|
|
require("profileInfo" in primary[0], "profile_missing_primary_info")
|
|
require(type(info) is dict, "profile_invalid_primary_info")
|
|
identity = _profile_id(info.get("profileIdList"))
|
|
profile = info.get("profile")
|
|
require("profile" in info, "profile_missing_person_names")
|
|
require(type(profile) is dict, "profile_invalid_primary_profile")
|
|
require("customer" in profile, "profile_missing_person_names")
|
|
customer = profile["customer"]
|
|
require(type(customer) is dict, "profile_invalid_primary_customer")
|
|
require("personName" in customer, "profile_missing_person_names")
|
|
names = customer["personName"]
|
|
require(type(names) is list and all(type(item) is dict for item in names), "profile_invalid_person_names")
|
|
require(all("nameType" not in name or type(name["nameType"]) is str for name in names),
|
|
"profile_invalid_primary_name_type")
|
|
selected = [name for name in names if name.get("nameType") == "Primary"]
|
|
require(len(selected) == 1, "profile_ambiguous_primary_name")
|
|
name = selected[0]
|
|
components = {**COMPONENTS, **({"namePrefix": "namePrefix"} if include_prefix else {})}
|
|
require(all(key not in name or type(name[key]) is str for key in components), "profile_invalid_name_component")
|
|
return PrimaryGuest(identity, {key: name[key] for key in components if key in name})
|
|
|
|
|
|
def request(profile_id: str) -> tuple[str, str, bytes]:
|
|
_profile_id([{"type": "Profile", "id": profile_id}])
|
|
return "POST", PATH, source.json_bytes({"profileIds": [profile_id], "summaryInfo": True, "limit": 1, "offset": 0})
|
|
|
|
|
|
def summary_row(raw: bytes, profile_id: str, hotel_id: str) -> dict:
|
|
"""Validate response identity/coverage independently of available name fields."""
|
|
request(profile_id)
|
|
require(type(raw) is bytes and len(raw) <= source.MAX_RESPONSE_BYTES, "profile_response_too_large")
|
|
document = source.strict_json(raw)
|
|
require(document.get("operation_id") == OPERATION and document.get("hotel_id") == hotel_id,
|
|
"profile_response_context_mismatch")
|
|
require(type(document.get("oracle_request_id")) is str and bool(document["oracle_request_id"].strip()),
|
|
"profile_missing_oracle_request_id")
|
|
source.check_warnings(document)
|
|
data = document.get("data")
|
|
summaries = data.get("profileSummaries") if type(data) is dict else None
|
|
require(type(summaries) is dict, "profile_missing_summary")
|
|
if "hasMore" in summaries:
|
|
require(summaries["hasMore"] is False, "profile_unexpected_more_results")
|
|
for field in ("limit", "count", "totalResults", "totalPages"):
|
|
if field in summaries:
|
|
require(type(summaries[field]) is int and summaries[field] == 1, "profile_inconsistent_pagination")
|
|
if "offset" in summaries:
|
|
# Oracle's schema describes an initial offset; the real single-row
|
|
# samples return1. Both supported values are bounded to this one row;
|
|
# no broader pagination convention is inferred from these samples.
|
|
require(type(summaries["offset"]) is int and summaries["offset"] in (0, 1),
|
|
"profile_inconsistent_pagination")
|
|
rows = summaries.get("profileInfo")
|
|
require(type(rows) is list and len(rows) == 1 and type(rows[0]) is dict, "profile_ambiguous_summary_count")
|
|
row = rows[0]
|
|
require(_profile_id(row.get("profileIdList")) == profile_id, "profile_identity_mismatch")
|
|
if "profile" in row:
|
|
require(type(row["profile"]) is dict, "profile_invalid_summary_profile")
|
|
if "formerName" in row["profile"]:
|
|
require(type(row["profile"]["formerName"]) is dict, "profile_invalid_former_name")
|
|
return row
|
|
|
|
|
|
def inspect_summary(raw: bytes, guest: PrimaryGuest, hotel_id: str, *, include_prefix: bool = False) -> dict:
|
|
require(type(guest) is PrimaryGuest, "profile_primary_guest_required")
|
|
row = summary_row(raw, guest.profile_id, hotel_id)
|
|
profile = row.get("profile")
|
|
require(type(profile) is dict, "profile_missing_summary_profile")
|
|
name = profile.get("formerName")
|
|
require(type(name) is dict, "profile_missing_former_name")
|
|
components = {**COMPONENTS, **({"namePrefix": "namePrefix"} if include_prefix else {})}
|
|
require(all(key not in name or type(name[key]) is str
|
|
for key in (*components.values(), "fullName", "nameType")), "profile_invalid_summary_name")
|
|
comparison = {}
|
|
for detail_key, summary_key in components.items():
|
|
if detail_key not in guest.name:
|
|
comparison[detail_key] = "both_absent" if summary_key not in name else "summary_only"
|
|
elif summary_key not in name:
|
|
comparison[detail_key] = "summary_missing"
|
|
else:
|
|
comparison[detail_key] = "equal" if guest.name[detail_key] == name[summary_key] else "different"
|
|
full_name = name.get("fullName")
|
|
return {"version": VERSION, "identity_matched": True, "profile_id": guest.profile_id,
|
|
"name_type": name.get("nameType"), "component_comparisons": comparison,
|
|
"full_name_state": "missing" if full_name is None else "present" if full_name.strip() else "explicit_blank",
|
|
"full_name": full_name, "alt_name_returned": "altName" in profile,
|
|
"report_equivalence_verified": False, "finance_ready": False}
|
|
|
|
|
|
def read_once(guest: PrimaryGuest, hotel_id: str, transport, archive: source.Archive,
|
|
index: int, *, key: str = "") -> dict:
|
|
"""One exact-ID read; capture the original response before interpreting it."""
|
|
require(type(index) is int and 1 <= index <= 3, "profile_probe_index_out_of_bounds")
|
|
source.Options("2000-01-01", hotel_id).validate()
|
|
require(type(guest) is PrimaryGuest, "profile_primary_guest_required")
|
|
method, path, payload = request(guest.profile_id)
|
|
prefix = f"profile-{index:02d}"
|
|
archive.write(prefix + ".request.json", {"operation_id": OPERATION, "method": method, "path": path,
|
|
"hotel_id": hotel_id, "body": source.strict_json(payload), "started_at": source.utc_now()})
|
|
try:
|
|
status, headers, raw = transport(method, path, payload)
|
|
except (OSError, TimeoutError, ConnectionError):
|
|
archive.write(prefix + ".meta.json", {"error": "transport_failure", "ended_at": source.utc_now()})
|
|
raise source.CollectionError("profile_transport_failure") from None
|
|
require(not key or key.encode() not in raw, "secret_in_response")
|
|
archive.write(prefix + ".response.bin", raw)
|
|
headers = {key.lower(): value for key, value in headers.items()}
|
|
archive.write(prefix + ".meta.json", {"http_status": status, "edge_request_id": headers.get("x-request-id"),
|
|
"ended_at": source.utc_now(), "oversized": len(raw) > source.MAX_RESPONSE_BYTES})
|
|
require(status == 200, "profile_http_failure")
|
|
result = inspect_summary(raw, guest, hotel_id)
|
|
archive.write(prefix + ".assessment.json", result)
|
|
return result
|