client.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import asyncio
  2. import logging
  3. from datetime import date
  4. from ._airbnb import _AirbnbClient
  5. from ._booking import _BookingClient
  6. from ._protocols import ChatClient, ServiceClient
  7. from .types import DayInfo, ServiceAuthError
  8. _log = logging.getLogger(__name__)
  9. _instance: 'ServiceAPI | None' = None
  10. class ServiceAPI:
  11. """Unified async client for all booking platform services.
  12. Credentials are read from <service>.env files in the data directory
  13. (~/.local/room_cal/ or ROOM_CAL_DATA_HOME).
  14. All date arguments are native datetime.date objects.
  15. Pass a custom `clients` dict to substitute stubs for testing.
  16. """
  17. _CLIENT_CLASSES: dict[str, type] = {
  18. 'airbnb': _AirbnbClient,
  19. 'booking': _BookingClient,
  20. }
  21. @classmethod
  22. def get(cls) -> 'ServiceAPI':
  23. """Return the process-wide singleton, creating it on first call."""
  24. global _instance
  25. if _instance is None:
  26. _instance = cls()
  27. return _instance
  28. @classmethod
  29. def set(cls, instance: 'ServiceAPI') -> None:
  30. """Replace the singleton — use in tests to install a stub."""
  31. global _instance
  32. _instance = instance
  33. def __init__(self, clients: dict[str, ServiceClient] | None = None) -> None:
  34. if clients is not None:
  35. self._clients: dict[str, ServiceClient] = clients
  36. self._disabled: dict[str, str] = {}
  37. return
  38. self._clients = {}
  39. self._disabled: dict[str, str] = {}
  40. for key, cls in self._CLIENT_CLASSES.items():
  41. try:
  42. self._clients[key] = cls.from_env()
  43. except Exception as e:
  44. _log.warning('service %s failed to load: %s', key, e)
  45. self._disabled[key] = str(e)
  46. def keys(self) -> list[str]:
  47. """Return the keys of all successfully loaded services."""
  48. return list(self._clients.keys())
  49. def service_name(self, key: str) -> str:
  50. """Return the human-readable display name for a service key."""
  51. cls = self._CLIENT_CLASSES.get(key)
  52. return getattr(cls, 'service_name', key.capitalize()) if cls else key.capitalize()
  53. async def refresh_credentials(self, key: str) -> None:
  54. """Trigger credential refresh for the named service. Raises ServiceAuthError on failure."""
  55. cls = self._CLIENT_CLASSES.get(key)
  56. if cls is None or not hasattr(cls, 'refresh_credentials'):
  57. from .types import ServiceAuthError
  58. raise ServiceAuthError(f'No automatic refresh handler for service: {key}')
  59. loop = asyncio.get_running_loop()
  60. await loop.run_in_executor(None, cls.refresh_credentials)
  61. def client(self, key: str) -> ServiceClient:
  62. """Return the platform client for `key`, typed as ServiceClient."""
  63. try:
  64. return self._clients[key]
  65. except KeyError:
  66. raise KeyError(f'unknown service: {key!r}') from None
  67. def chat_client(self, key: str) -> ChatClient:
  68. """Return the platform client for `key` if it supports chat; raise TypeError otherwise."""
  69. c = self.client(key)
  70. if not isinstance(c, ChatClient):
  71. raise TypeError(f'{key!r} does not support chat')
  72. return c # type: ignore[return-value]
  73. async def _gather(self, coros: dict[str, object]) -> dict[str, object]:
  74. """Gather per-service results; isolate ServiceAuthError so one failed service can't kill others."""
  75. keys = list(coros.keys())
  76. raw = await asyncio.gather(*coros.values(), return_exceptions=True) # type: ignore[arg-type]
  77. out: dict[str, object] = {}
  78. for key, result in zip(keys, raw):
  79. if isinstance(result, BaseException):
  80. _log.error('service %s error (skipping): %s', key, result, exc_info=result)
  81. out[key] = None
  82. else:
  83. out[key] = result
  84. return out
  85. async def get_prices_range(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
  86. """Return {date_str: {service_key: price}} for all dates in the range.
  87. Uses each client's bulk get_calendar call so the whole range is fetched
  88. in one pass (month-by-month internally). Price is None for booked or
  89. unavailable dates where the platform does not report a price.
  90. """
  91. ds_from = start_date.isoformat()
  92. ds_to = end_date.isoformat()
  93. cals = await self._gather({
  94. key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
  95. })
  96. all_dates: set[str] = set()
  97. for cal in cals.values():
  98. if cal is not None:
  99. all_dates.update(d for d in cal if ds_from <= d <= ds_to) # type: ignore[union-attr]
  100. result: dict[str, dict[str, int | None]] = {}
  101. for d in sorted(all_dates):
  102. result[d] = {
  103. key: cal[d].price if (cal is not None and d in cal) else None # type: ignore[index]
  104. for key, cal in cals.items()
  105. }
  106. return result
  107. async def get_booked_nets(self, start_date: date, end_date: date) -> dict[str, dict[str, int | None]]:
  108. """Return {date_str: {service_key: net}} for booked dates where per-night net is available.
  109. Only services that implement get_booked_nets contribute data; others are absent from the result.
  110. Dispatch is via hasattr so new clients opt in by implementing the method.
  111. """
  112. ds_from = start_date.isoformat()
  113. ds_to = end_date.isoformat()
  114. per_service = await self._gather({
  115. key: client.get_booked_nets(ds_from, ds_to) # type: ignore[attr-defined]
  116. for key, client in self._clients.items()
  117. if hasattr(client, 'get_booked_nets')
  118. })
  119. result: dict[str, dict[str, int | None]] = {}
  120. for key, nets in per_service.items():
  121. if nets is None:
  122. continue
  123. for d, net in nets.items(): # type: ignore[union-attr]
  124. result.setdefault(d, {})[key] = net
  125. return result
  126. async def get_prices(self, d: date) -> dict[str, int | None]:
  127. """Return {service_key: price} for all services on a single date."""
  128. ds = d.isoformat()
  129. cals = await self._gather({
  130. key: client.get_calendar(ds, ds) for key, client in self._clients.items()
  131. })
  132. return {
  133. key: cal[ds].price if (cal is not None and ds in cal) else None # type: ignore[index]
  134. for key, cal in cals.items()
  135. }
  136. async def set_prices(self, d: date, prices: dict[str, int]) -> None:
  137. """Set price on each specified service; skips booked dates."""
  138. ds = d.isoformat()
  139. _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
  140. cals = await self._gather({
  141. key: self._clients[key].get_calendar(ds, ds) for key in prices if key in self._clients
  142. })
  143. to_set = {
  144. key: price
  145. for (key, price) in prices.items()
  146. if cals.get(key) is not None and not cals[key].get(ds, _fallback).booked # type: ignore[union-attr]
  147. }
  148. if to_set:
  149. await self._gather({
  150. key: self._clients[key].set_price(ds, price) for key, price in to_set.items()
  151. })
  152. async def get_available(self, d: date) -> bool:
  153. """Return True only when all active (non-failed) services have the date open."""
  154. ds = d.isoformat()
  155. _closed = DayInfo(date=ds, price=None, available=False, booked=False, rooms_to_sell=None)
  156. cals = await self._gather({
  157. key: client.get_calendar(ds, ds) for key, client in self._clients.items()
  158. })
  159. return all(
  160. cal.get(ds, _closed).available # type: ignore[union-attr]
  161. for cal in cals.values() if cal is not None
  162. )
  163. async def set_available(self, d: date, available: bool) -> None:
  164. """Open or block a date on all services; skips booked dates."""
  165. ds = d.isoformat()
  166. _fallback = DayInfo(date=ds, price=None, available=True, booked=False, rooms_to_sell=None)
  167. cals = await self._gather({
  168. key: client.get_calendar(ds, ds) for key, client in self._clients.items()
  169. })
  170. to_set = {
  171. key: self._clients[key]
  172. for key, cal in cals.items()
  173. if cal is not None and not cal.get(ds, _fallback).booked # type: ignore[union-attr]
  174. }
  175. if to_set:
  176. await self._gather({
  177. key: client.set_availability(ds, available) for key, client in to_set.items()
  178. })
  179. async def get_closed_by_date(self, start_date: date, end_date: date) -> dict[str, list[str]]:
  180. """Return {date_str: [service_keys]} for dates blocked (not available, not booked) on at least one service."""
  181. ds_from = start_date.isoformat()
  182. ds_to = end_date.isoformat()
  183. cals = await self._gather({
  184. key: client.get_calendar(ds_from, ds_to) for key, client in self._clients.items()
  185. })
  186. all_dates: set[str] = set()
  187. for cal in cals.values():
  188. if cal is not None:
  189. all_dates.update(cal.keys()) # type: ignore[union-attr]
  190. result: dict[str, list[str]] = {}
  191. for d in sorted(all_dates):
  192. if not (ds_from <= d <= ds_to):
  193. continue
  194. closed = [
  195. key for key, cal in cals.items()
  196. if cal is not None and (day := cal.get(d)) and not day.available and not day.booked # type: ignore[union-attr]
  197. ]
  198. if closed:
  199. result[d] = closed
  200. return result
  201. async def get_reservations(self, start_date: date, end_date: date) -> list[dict]:
  202. """Return reservations from all services, sorted by start_date.
  203. Each entry: {service, start_date, end_date, id, guest_name}
  204. """
  205. ds_from = start_date.isoformat()
  206. ds_to = end_date.isoformat()
  207. all_res = await self._gather({
  208. key: client.get_reservations(ds_from, ds_to) for key, client in self._clients.items()
  209. })
  210. result: list[dict] = []
  211. for key, reservations in all_res.items():
  212. if reservations is None:
  213. continue
  214. for r in reservations: # type: ignore[union-attr]
  215. result.append({
  216. 'service': key,
  217. 'start_date': date.fromisoformat(r.check_in),
  218. 'end_date': date.fromisoformat(r.check_out),
  219. 'id': r.reservation_id,
  220. 'guest_name': r.guest_name,
  221. 'service_data': r.service_data or {},
  222. 'chat_id': r.chat_id,
  223. 'door_code': r.door_code,
  224. })
  225. result.sort(key=lambda x: x['start_date'])
  226. return result
  227. async def diff_remote_sync(self, start_date: date, end_date: date) -> dict[str, list[dict]]:
  228. """Return per-service integrity issues without making any changes."""
  229. ds_from, ds_to = start_date.isoformat(), end_date.isoformat()
  230. out: dict[str, list[dict]] = {}
  231. for key, client in self._clients.items():
  232. if hasattr(client, 'diff_remote_sync'):
  233. result = await client.diff_remote_sync(ds_from, ds_to)
  234. if result:
  235. out[key] = result
  236. return out
  237. async def remote_sync(self, start_date: date, end_date: date) -> dict[str, list[dict]]:
  238. """Fix per-service integrity issues. Returns per-service list of corrections made."""
  239. ds_from, ds_to = start_date.isoformat(), end_date.isoformat()
  240. out: dict[str, list[dict]] = {}
  241. for key, client in self._clients.items():
  242. if hasattr(client, 'remote_sync'):
  243. result = await client.remote_sync(ds_from, ds_to)
  244. if result:
  245. out[key] = result
  246. return out