|
|
@@ -1,12 +1,14 @@
|
|
|
import asyncio
|
|
|
+import logging
|
|
|
from datetime import date
|
|
|
|
|
|
from ._airbnb import _AirbnbClient
|
|
|
from ._booking import _BookingClient
|
|
|
from ._homestay import _HomestayClient
|
|
|
from ._protocols import ChatClient, ServiceClient
|
|
|
-from .types import DayInfo
|
|
|
+from .types import DayInfo, ServiceAuthError
|
|
|
|
|
|
+_log = logging.getLogger(__name__)
|
|
|
|
|
|
class ServiceAPI:
|
|
|
"""Unified async client for all booking platform services.
|
|
|
@@ -19,11 +21,21 @@ class ServiceAPI:
|
|
|
"""
|
|
|
|
|
|
def __init__(self, clients: dict[str, ServiceClient] | None = None) -> None:
|
|
|
- self._clients: dict[str, ServiceClient] = clients if clients is not None else {
|
|
|
- 'airbnb': _AirbnbClient.from_env(),
|
|
|
- 'booking': _BookingClient.from_env(),
|
|
|
- 'homestay': _HomestayClient.from_env(),
|
|
|
- }
|
|
|
+ if clients is not None:
|
|
|
+ self._clients: dict[str, ServiceClient] = clients
|
|
|
+ return
|
|
|
+ self._clients = {}
|
|
|
+ self._disabled: dict[str, str] = {}
|
|
|
+ for key, factory in {
|
|
|
+ 'airbnb': _AirbnbClient.from_env,
|
|
|
+ 'booking': _BookingClient.from_env,
|
|
|
+ 'homestay': _HomestayClient.from_env,
|
|
|
+ }.items():
|
|
|
+ try:
|
|
|
+ self._clients[key] = factory()
|
|
|
+ except Exception as e:
|
|
|
+ _log.warning('service %s disabled: %s', key, e)
|
|
|
+ self._disabled[key] = str(e)
|
|
|
|
|
|
def client(self, key: str) -> ServiceClient:
|
|
|
"""Return the platform client for `key`, typed as ServiceClient."""
|
|
|
@@ -39,6 +51,19 @@ class ServiceAPI:
|
|
|
raise TypeError(f'{key!r} does not support chat')
|
|
|
return c # type: ignore[return-value]
|
|
|
|
|
|
+ async def _gather(self, coros: dict[str, object]) -> dict[str, object]:
|
|
|
+ """Gather per-service results; isolate ServiceAuthError so one failed service can't kill others."""
|
|
|
+ keys = list(coros.keys())
|
|
|
+ raw = await asyncio.gather(*coros.values(), return_exceptions=True) # type: ignore[arg-type]
|
|
|
+ out: dict[str, object] = {}
|
|
|
+ for key, result in zip(keys, raw):
|
|
|
+ if isinstance(result, BaseException):
|
|
|
+ _log.error('service %s error (skipping): %s', key, result, exc_info=result)
|
|
|
+ out[key] = None
|
|
|
+ else:
|
|
|
+ out[key] = result
|
|
|
+ return out
|
|
|
+
|
|
|
async def get_prices_range(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
|
|
|
"""Return {date_str: {service_key: price}} for all dates in the range.
|
|
|
|
|
|
@@ -48,79 +73,97 @@ class ServiceAPI:
|
|
|
"""
|
|
|
ds_from = start_date.isoformat()
|
|
|
ds_to = end_date.isoformat()
|
|
|
- keys = list(self._clients.keys())
|
|
|
- cals = await asyncio.gather(*[
|
|
|
- client.get_calendar(ds_from, ds_to) for client in self._clients.values()
|
|
|
- ])
|
|
|
+ cals = await self._gather({
|
|
|
+ key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
|
|
|
+ })
|
|
|
all_dates: set[str] = set()
|
|
|
- for cal in cals:
|
|
|
- all_dates.update(d for d in cal if ds_from <= d <= ds_to)
|
|
|
+ for cal in cals.values():
|
|
|
+ if cal is not None:
|
|
|
+ all_dates.update(d for d in cal if ds_from <= d <= ds_to) # type: ignore[union-attr]
|
|
|
result: dict[str, dict[str, int | None]] = {}
|
|
|
for d in sorted(all_dates):
|
|
|
- result[d] = {key: cal[d].price if d in cal else None for key, cal in zip(keys, cals)}
|
|
|
+ result[d] = {
|
|
|
+ key: cal[d].price if (cal is not None and d in cal) else None # type: ignore[index]
|
|
|
+ for key, cal in cals.items()
|
|
|
+ }
|
|
|
return result
|
|
|
|
|
|
async def get_prices(self, d: date) -> dict[str, int | None]:
|
|
|
"""Return {service_key: price} for all services on a single date."""
|
|
|
ds = d.isoformat()
|
|
|
- keys = list(self._clients.keys())
|
|
|
- cals = await asyncio.gather(*[
|
|
|
- client.get_calendar(ds, ds) for client in self._clients.values()
|
|
|
- ])
|
|
|
- return {key: cal[ds].price if ds in cal else None for key, cal in zip(keys, cals)}
|
|
|
+ cals = await self._gather({
|
|
|
+ key: client.get_calendar(ds, ds) for key, client in self._clients.items()
|
|
|
+ })
|
|
|
+ return {
|
|
|
+ key: cal[ds].price if (cal is not None and ds in cal) else None # type: ignore[index]
|
|
|
+ for key, cal in cals.items()
|
|
|
+ }
|
|
|
|
|
|
async def set_prices(self, d: date, prices: dict[str, int]) -> None:
|
|
|
"""Set price on each specified service; skips booked dates."""
|
|
|
ds = d.isoformat()
|
|
|
_fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
|
|
|
- cals = await asyncio.gather(*[self._clients[key].get_calendar(ds, ds) for key in prices])
|
|
|
+ cals = await self._gather({
|
|
|
+ key: self._clients[key].get_calendar(ds, ds) for key in prices if key in self._clients
|
|
|
+ })
|
|
|
to_set = {
|
|
|
key: price
|
|
|
- for (key, price), cal in zip(prices.items(), cals)
|
|
|
- if not cal.get(ds, _fallback).booked
|
|
|
+ for (key, price) in prices.items()
|
|
|
+ if cals.get(key) is not None and not cals[key].get(ds, _fallback).booked # type: ignore[union-attr]
|
|
|
}
|
|
|
if to_set:
|
|
|
- await asyncio.gather(*[self._clients[key].set_price(ds, price) for key, price in to_set.items()])
|
|
|
+ await self._gather({
|
|
|
+ key: self._clients[key].set_price(ds, price) for key, price in to_set.items()
|
|
|
+ })
|
|
|
|
|
|
async def get_available(self, d: date) -> bool:
|
|
|
- """Return True only when all services have the date open."""
|
|
|
+ """Return True only when all active (non-failed) services have the date open."""
|
|
|
ds = d.isoformat()
|
|
|
- keys = list(self._clients.keys())
|
|
|
- cals = await asyncio.gather(*[
|
|
|
- client.get_calendar(ds, ds) for client in self._clients.values()
|
|
|
- ])
|
|
|
_closed = DayInfo(date=ds, price=None, available=False, booked=False, rooms_to_sell=None)
|
|
|
- return all(cal.get(ds, _closed).available for cal in cals)
|
|
|
+ cals = await self._gather({
|
|
|
+ key: client.get_calendar(ds, ds) for key, client in self._clients.items()
|
|
|
+ })
|
|
|
+ return all(
|
|
|
+ cal.get(ds, _closed).available # type: ignore[union-attr]
|
|
|
+ for cal in cals.values() if cal is not None
|
|
|
+ )
|
|
|
|
|
|
async def set_available(self, d: date, available: bool) -> None:
|
|
|
"""Open or block a date on all services; skips booked dates."""
|
|
|
ds = d.isoformat()
|
|
|
_fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
|
|
|
- keys = list(self._clients.keys())
|
|
|
- cals = await asyncio.gather(*[client.get_calendar(ds, ds) for client in self._clients.values()])
|
|
|
- to_set = [
|
|
|
- client for key, client, cal in zip(keys, self._clients.values(), cals)
|
|
|
- if not cal.get(ds, _fallback).booked
|
|
|
- ]
|
|
|
+ cals = await self._gather({
|
|
|
+ key: client.get_calendar(ds, ds) for key, client in self._clients.items()
|
|
|
+ })
|
|
|
+ to_set = {
|
|
|
+ key: self._clients[key]
|
|
|
+ for key, cal in cals.items()
|
|
|
+ if cal is not None and not cal.get(ds, _fallback).booked # type: ignore[union-attr]
|
|
|
+ }
|
|
|
if to_set:
|
|
|
- await asyncio.gather(*[client.set_availability(ds, available) for client in to_set])
|
|
|
+ await self._gather({
|
|
|
+ key: client.set_availability(ds, available) for key, client in to_set.items()
|
|
|
+ })
|
|
|
|
|
|
async def get_closed_by_date(self, start_date: date, end_date: date) -> dict[str, list[str]]:
|
|
|
"""Return {date_str: [service_keys]} for dates blocked (not available, not booked) on at least one service."""
|
|
|
ds_from = start_date.isoformat()
|
|
|
ds_to = end_date.isoformat()
|
|
|
- keys = list(self._clients.keys())
|
|
|
- cals = await asyncio.gather(*[
|
|
|
- client.get_calendar(ds_from, ds_to) for client in self._clients.values()
|
|
|
- ])
|
|
|
+ cals = await self._gather({
|
|
|
+ key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
|
|
|
+ })
|
|
|
all_dates: set[str] = set()
|
|
|
- for cal in cals:
|
|
|
- all_dates.update(cal.keys())
|
|
|
+ for cal in cals.values():
|
|
|
+ if cal is not None:
|
|
|
+ all_dates.update(cal.keys()) # type: ignore[union-attr]
|
|
|
result: dict[str, list[str]] = {}
|
|
|
for d in sorted(all_dates):
|
|
|
if not (ds_from <= d <= ds_to):
|
|
|
continue
|
|
|
- closed = [key for key, cal in zip(keys, cals) if (day := cal.get(d)) and not day.available and not day.booked]
|
|
|
+ closed = [
|
|
|
+ key for key, cal in cals.items()
|
|
|
+ if cal is not None and (day := cal.get(d)) and not day.available and not day.booked # type: ignore[union-attr]
|
|
|
+ ]
|
|
|
if closed:
|
|
|
result[d] = closed
|
|
|
return result
|
|
|
@@ -130,14 +173,16 @@ class ServiceAPI:
|
|
|
|
|
|
Each entry: {service, start_date, end_date, id, guest_name}
|
|
|
"""
|
|
|
- keys = list(self._clients.keys())
|
|
|
- all_res = await asyncio.gather(*[
|
|
|
- client.get_reservations(start_date.isoformat(), end_date.isoformat())
|
|
|
- for client in self._clients.values()
|
|
|
- ])
|
|
|
+ ds_from = start_date.isoformat()
|
|
|
+ ds_to = end_date.isoformat()
|
|
|
+ all_res = await self._gather({
|
|
|
+ key: client.get_reservations(ds_from, ds_to) for key, client in self._clients.items()
|
|
|
+ })
|
|
|
result: list[dict] = []
|
|
|
- for key, reservations in zip(keys, all_res):
|
|
|
- for r in reservations:
|
|
|
+ for key, reservations in all_res.items():
|
|
|
+ if reservations is None:
|
|
|
+ continue
|
|
|
+ for r in reservations: # type: ignore[union-attr]
|
|
|
result.append({
|
|
|
'service': key,
|
|
|
'start_date': date.fromisoformat(r.check_in),
|