251 lines
13 KiB
Python
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}
|