Files
ARR-2.0-0918/integrations/ohip/room_calendar_evidence.py

251 lines
13 KiB
Python

"""Read one bound room-calendar response without selecting an ARR display room.
No network, pagination or time-zone conversion. Missing collections are not
empty collections. All occurrences of each internal reservation ID survive;
neither a returned200 nor a room count establishes complete coverage.
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime
import hashlib
import re
from urllib.parse import parse_qsl, urlsplit
from . import collect_arr_source as source
VERSION = "arr-room-calendar-evidence/v1"
OPERATION = "getRoomCalendar"
PATH = "/api/v1/reservations/room-calendar"
FLAGS = {"complete_calendar": False, "display_room_selected": False,
"report_equivalence_verified": False, "complete_arr_output": False, "finance_ready": False}
require = source.require
def _day(value):
require(type(value) is str and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value), "calendar_invalid_date")
try:
return date.fromisoformat(value)
except ValueError:
raise source.CollectionError("calendar_invalid_date") from None
def _id(value):
require(type(value) is str and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value), "calendar_invalid_reservation_id")
return value
def _observation(document, key, *, timestamp=False):
if key not in document:
return {"state": "missing"}
value = document[key]
if value is None:
return {"state": "null"}
require(type(value) is str, "calendar_invalid_text")
if timestamp and value:
require(re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}", value), "calendar_invalid_timestamp")
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
raise source.CollectionError("calendar_invalid_timestamp") from None
return {"state": "present" if value else "explicit_blank", "value": value}
def _collection(document, key):
if key not in document:
return "missing", []
if document[key] is None:
return "null", []
values = document[key]
require(type(values) is list and len(values) <= 4000 and all(type(v) is dict for v in values),
"calendar_invalid_collection")
return "present" if values else "explicit_empty", values
def _flag(document, key):
if key not in document:
return {"state": "missing"}
if document[key] is None:
return {"state": "null"}
require(type(document[key]) is bool, "calendar_invalid_room_flag")
return {"state": "present", "value": document[key]}
def _query(request, report_date):
require(type(request) is dict and request.get("operation_id") == OPERATION
and request.get("method") == "GET" and request.get("body") is None, "calendar_request_mismatch")
require(type(request.get("path")) is str, "calendar_request_mismatch")
url = urlsplit(request["path"])
require(not url.scheme and not url.netloc and not url.fragment and url.path == PATH, "calendar_request_mismatch")
try:
pairs = parse_qsl(url.query, keep_blank_values=True, strict_parsing=True, max_num_fields=5000)
except ValueError:
raise source.CollectionError("calendar_invalid_query") from None
query = {}
allowed = {"startDate", "endDate", "pageIndex", "recordsPerPage", "includeRoomMoveHistory",
"showRoomMoveSegments", "roomId", "assignedRooms", "unassignedRooms"}
for key, value in pairs:
require(key in allowed, "calendar_unsupported_query_filter")
if key == "roomId":
require(bool(value) and len(value) <= 20, "calendar_invalid_room_filter")
query.setdefault(key, []).append(value)
else:
require(key not in query, "calendar_duplicate_query")
query[key] = value
require(len(query.get("roomId", [])) <= 4000, "calendar_room_filter_limit_exceeded")
start, end, target = _day(query.get("startDate")), _day(query.get("endDate")), _day(report_date)
require(start <= target <= end and (end - start).days <= 366, "calendar_query_date_mismatch")
for key in ("includeRoomMoveHistory", "showRoomMoveSegments"):
require(query.get(key) == "true", "calendar_history_not_requested")
for key in ("assignedRooms", "unassignedRooms"):
require(key not in query or query[key] in ("Y", "N"), "calendar_invalid_assignment_filter")
for key in ("pageIndex", "recordsPerPage"):
if key in query:
require(re.fullmatch(r"[0-9]{1,5}", query[key]) is not None, "calendar_invalid_page_query")
require(0 <= int(query[key]) <= 10000 and (key != "recordsPerPage" or int(query[key]) > 0),
"calendar_invalid_page_query")
# Some private probes also stored an explicit query object. Check it rather
# than trusting a contradictory friendly representation of the actual URL.
if "query" in request:
require(source.json_bytes(request["query"]) == source.json_bytes(query), "calendar_query_record_mismatch")
return query
def _reservation(entry):
state, identifiers = _collection(entry, "reservationIdList")
if state != "present":
return None
require(all(type(item.get("type")) is str for item in identifiers), "calendar_invalid_identity_type")
ids = [item.get("id") for item in identifiers if item["type"] == "Reservation"]
require(len(ids) <= 1, "calendar_ambiguous_reservation_id")
return _id(ids[0]) if ids else None
def _moves(entry, identity, hotel_id, pointer):
state, moves = _collection(entry, "roomMoveHistory")
result = []
for index, move in enumerate(moves):
matched = False
if "hotelId" in move:
require(move["hotelId"] == hotel_id, "calendar_move_hotel_mismatch")
if "reservationId" in move:
value = move["reservationId"]
require(type(value) is dict and value.get("type") == "Reservation"
and _id(value.get("id")) == identity, "calendar_move_reservation_mismatch")
matched = move.get("hotelId") == hotel_id
result.append({"pointer": f"{pointer}/roomMoveHistory/{index}", "identity_matched": matched,
"date": _observation(move, "date", timestamp=True), "date_zone": "database",
"from_room": _observation(move, "fromRoom"), "to_room": _observation(move, "toRoom")})
return state, result
def inspect(request, http_status, raw, *, hotel_id, report_date, reservation_ids):
"""Return private facts. Caller must authenticate the supplied original files.
This is one-page evidence, not a complete-history validator. The target IDs
must come from an already verified base capture, never names or array order.
"""
source.Options(report_date, hotel_id).validate()
query = _query(request, report_date)
require("hotel_id" not in request or request["hotel_id"] == hotel_id, "calendar_request_hotel_mismatch")
require(type(http_status) is int and http_status == 200, "calendar_http_failure")
require(type(raw) is bytes and len(raw) <= source.MAX_RESPONSE_BYTES, "calendar_response_too_large")
require(type(reservation_ids) in (list, tuple) and 1 <= len(reservation_ids) <= 10000, "calendar_invalid_targets")
targets = [_id(value) for value in reservation_ids]
require(len(set(targets)) == len(targets), "calendar_duplicate_target")
document = source.strict_json(raw)
require(document.get("operation_id") == OPERATION and document.get("hotel_id") == hotel_id,
"calendar_response_context_mismatch")
require(type(document.get("oracle_request_id")) is str and bool(document["oracle_request_id"].strip()),
"calendar_missing_oracle_request_id")
source.check_warnings(document)
data = document.get("data")
require(type(data) is dict, "calendar_invalid_data")
result = {"version": VERSION, "hotel_id": hotel_id, "report_date": report_date, **FLAGS,
"request_sha256": hashlib.sha256(source.json_bytes(request)).hexdigest(),
"response_sha256": hashlib.sha256(raw).hexdigest(),
"request_window": {key: query[key] for key in ("startDate", "endDate")},
"calendar_state": "missing", "room_collection_state": "not_available", "returned_rooms": None,
"pagination": {}, "inner_hotel_state": "not_available", "unbound_reservation_entries": 0,
"schedule_collection_states": {}, "targets": [{"reservation_id": key, "occurrences": []} for key in targets]}
if "roomCalendar" not in data:
return result
calendar = data["roomCalendar"]
if calendar is None:
result["calendar_state"] = "null"
return result
require(type(calendar) is dict, "calendar_invalid_container")
result["calendar_state"] = "present"
result["inner_hotel_state"] = "present" if "hotelId" in calendar else "missing"
if "hotelId" in calendar:
require(calendar["hotelId"] == hotel_id, "calendar_inner_hotel_mismatch")
for key in ("pageIndex", "recordsPerPage", "totalRooms"):
if key in calendar:
value = calendar[key]
require(type(value) is int and value >= 0 and (key != "recordsPerPage" or value > 0),
"calendar_invalid_page_metadata")
if key in query:
require(value == int(query[key]), "calendar_page_echo_mismatch")
result["pagination"][key] = value
state, rooms = _collection(calendar, "room")
result["room_collection_state"] = state
if state in {"present", "explicit_empty"}:
result["returned_rooms"] = len(rooms)
if rooms:
require(calendar.get("hotelId") == hotel_id, "calendar_missing_inner_hotel")
if "totalRooms" in calendar:
require(calendar["totalRooms"] >= len(rooms), "calendar_inconsistent_room_total")
if "recordsPerPage" in calendar:
require(calendar["recordsPerPage"] >= len(rooms), "calendar_page_size_exceeded")
by_id = {row["reservation_id"]: row["occurrences"] for row in result["targets"]}
schedule_states, count = Counter(), 0
for ri, room in enumerate(rooms):
room_number = _observation(room, "roomId")
pseudo, component = _flag(room, "pseudo"), _flag(room, "componentSuite")
state, schedules = _collection(room, "roomSchedule")
schedule_states[state] += 1
for si, schedule in enumerate(schedules):
_, entries = _collection(schedule, "roomCalendarResList")
for ei, entry in enumerate(entries):
count += 1
require(count <= 100000, "calendar_entry_budget_exceeded")
identity = _reservation(entry)
if identity is None:
result["unbound_reservation_entries"] += 1
continue
if identity not in by_id:
continue
pointer = f"/data/roomCalendar/room/{ri}/roomSchedule/{si}/roomCalendarResList/{ei}"
span = entry.get("dateTimeSpan", {})
require(type(span) is dict, "calendar_invalid_time_span")
move_state, moves = _moves(entry, identity, hotel_id, pointer)
by_id[identity].append({"pointer": pointer, "room": room_number,
"pseudo_room": pseudo, "component_suite": component,
"schedule_category": _observation(schedule, "roomScheduleCategory"),
"schedule_start": _observation(schedule, "start", timestamp=True),
"schedule_end": _observation(schedule, "end", timestamp=True),
"stay_start": _observation(span, "startDateTime", timestamp=True),
"stay_end": _observation(span, "endDateTime", timestamp=True),
"segment_start": _observation(entry, "segmentStartDateTime", timestamp=True),
"segment_end": _observation(entry, "segmentEndDateTime", timestamp=True),
"span_zone": "hotel", "reservation_status": _observation(entry, "reservationStatus"),
"move_collection_state": move_state, "moves": moves})
result["schedule_collection_states"] = dict(sorted(schedule_states.items()))
return result
def summary(evidence):
"""Fixed keys/counts only; room numbers, guest data and internal IDs stay private."""
targets = evidence["targets"]
occurrences = [item for row in targets for item in row["occurrences"]]
moves = [move for item in occurrences for move in item["moves"]]
return {"version": VERSION, **FLAGS, "calendar_state": evidence["calendar_state"],
"room_collection_state": evidence["room_collection_state"], "returned_rooms": evidence["returned_rooms"],
"inner_hotel_state": evidence["inner_hotel_state"], "pagination": evidence["pagination"],
"targets": len(targets), "targets_observed": sum(bool(row["occurrences"]) for row in targets),
"targets_with_multiple_occurrences": sum(len(row["occurrences"]) > 1 for row in targets),
"unbound_reservation_entries": evidence["unbound_reservation_entries"],
"matched_occurrences": len(occurrences), "observed_moves": len(moves),
"identity_matched_moves": sum(move["identity_matched"] for move in moves),
"response_sha256": evidence["response_sha256"], "network_calls": 0}