_cli.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. """CLI dispatcher for room_cal sync/refresh/diff actions.
  2. Usage:
  3. room-cal fetch-reservations 2026-07-01 2026-07-31
  4. room-cal fetch-closed 2026-07-01
  5. room-cal push-closed 2026-07-01 2026-07-31
  6. room-cal fetch-user-cal 2026-07-01 2026-07-31
  7. room-cal apply-rules 2026-07-01 2026-07-31 [--pipeline-name this_month]
  8. room-cal fetch-feed 2026-07-01 2026-07-31 [--feed-name Fire] [--title "Block"]
  9. room-cal fill-prices 2026-07-01 2026-07-31 [--weekday 150] [--weekend 200]
  10. room-cal fetch-prices 2026-07-01 2026-07-31 [--service airbnb]
  11. room-cal push-prices 2026-07-01 2026-07-31
  12. room-cal apply-discounts 2026-07-01 2026-07-31
  13. room-cal push-busy 2026-07-01 2026-07-31
  14. room-cal local-sync 2026-07-01 2026-07-31
  15. room-cal local-refresh 2026-07-01 2026-07-31
  16. room-cal diff 2026-07-01 2026-07-31
  17. """
  18. import argparse
  19. import asyncio
  20. from datetime import date
  21. def _date(s: str) -> date:
  22. return date.fromisoformat(s)
  23. async def _fetch_reservations(start: date, end: date, args) -> None:
  24. from room_cal.actions.fetch_reservations import fetch_reservations
  25. result = await fetch_reservations(start, end)
  26. for r in result.data:
  27. print(f" {r['service']} {r['checkin_date']} – {r['checkout_date']}: {r['guest_name']}")
  28. print(f" {len(result.data)} reservation(s) synced" + (" (changed)" if result.changed else ""))
  29. async def _fetch_closed(start: date, end: date, args) -> None:
  30. from room_cal.actions.fetch_closed import fetch_closed
  31. result = await fetch_closed(start, end)
  32. for r in result.data:
  33. print(f" {r['date']}: {', '.join(r['closed_services'])}")
  34. print(f" {len(result.data)} closed event(s) synced" + (" (changed)" if result.changed else ""))
  35. async def _fetch_user_cal(start: date, end: date, args) -> None:
  36. from room_cal.actions.fetch_user_cal import fetch_user_cal
  37. result = await fetch_user_cal(start, end)
  38. for d in result.data['closed']:
  39. print(f" closed: {d}")
  40. print(f" {len(result.data['closed'])} date(s) closed synced from user_cal" + (" (changed)" if result.changed else ""))
  41. async def _push_closed(start: date, end: date, args) -> None:
  42. from room_cal.actions.push_closed import push_closed
  43. results = await push_closed(start, end)
  44. for r in results:
  45. print(f" {r['date']}: {r['action']}")
  46. print(f" {len(results)} date(s) changed")
  47. async def _apply_rules(start: date, end: date, args) -> None:
  48. from room_cal.actions.apply_rules import apply_rules
  49. results = await apply_rules(start, end, pipeline_name=args.pipeline_name or None)
  50. total = 0
  51. for name, dates in results.items():
  52. for d in dates:
  53. print(f" {name}: {d}")
  54. total += len(dates)
  55. print(f" {total} event(s) synced from rules")
  56. async def _fetch_feed(start: date, end: date, args) -> None:
  57. from room_cal.actions.fetch_feed import fetch_feed
  58. result = await fetch_feed(start, end, feed_name=args.feed_name, title=args.title or None)
  59. for d in result.data:
  60. print(f" {d}")
  61. print(f" {len(result.data)} date(s) marked closed" + (" (changed)" if result.changed else ""))
  62. async def _fetch_prices(start: date, end: date, args) -> None:
  63. from room_cal.actions.fetch_prices import fetch_prices
  64. result = await fetch_prices(start, end, service=args.service or None)
  65. for r in result.data:
  66. print(f" {r['date']}: net ${r['net']}")
  67. print(f" {len(result.data)} price event(s) written" + (" (changed)" if result.changed else ""))
  68. async def _push_prices(start: date, end: date, args) -> None:
  69. from room_cal.actions.push_prices import push_prices
  70. results = await push_prices(start, end)
  71. for r in results:
  72. prices_str = ' '.join(f"{s}: ${v}" for s, v in r['prices'].items())
  73. print(f" {r['date']}: {prices_str}")
  74. print(f" {len(results)} date(s) pushed")
  75. async def _fill_prices(start: date, end: date, args) -> None:
  76. from room_cal.actions.fill_prices import fill_prices
  77. results = await fill_prices(
  78. start, end,
  79. weekday=int(args.weekday) if args.weekday else None,
  80. weekend=int(args.weekend) if args.weekend else None,
  81. )
  82. for r in results:
  83. print(f" {r['date']}: net ${r['net']}")
  84. print(f" {len(results)} price event(s) created")
  85. async def _apply_discounts(start: date, end: date, args) -> None:
  86. from room_cal.actions.apply_discounts import apply_discounts
  87. results = await apply_discounts(start, end)
  88. for r in results:
  89. old = ' '.join(f"{s}: ${v}" for s, v in r['old_prices'].items() if v is not None)
  90. new = ' '.join(f"{s}: ${v}" for s, v in r['new_prices'].items())
  91. print(f" {r['date']}: {old} → {new}")
  92. print(f" {len(results)} date(s) updated")
  93. async def _push_busy(start: date, end: date, args) -> None:
  94. from room_cal.actions.push_busy import push_busy
  95. result = await push_busy(start, end)
  96. for d in result.data['busy']:
  97. print(f" busy: {d}")
  98. print(f" {len(result.data['busy'])} date(s) busy" + (" (changed)" if result.changed else ""))
  99. async def _local_sync(start: date, end: date, args) -> None:
  100. from room_cal.actions.local_sync import local_sync
  101. await local_sync(start, end)
  102. async def _local_refresh(start: date, end: date, args) -> None:
  103. from room_cal.actions.local_refresh import local_refresh
  104. await local_refresh(start, end)
  105. def _fmt_table(rows: list[list[str]], header: list[str]) -> None:
  106. all_rows = [header] + rows
  107. widths = [max(len(r[i]) for r in all_rows) for i in range(len(header))]
  108. sep = ' '
  109. fmt = sep.join(f'{{:<{w}}}' for w in widths)
  110. print(' ' + fmt.format(*header))
  111. print(' ' + sep.join('-' * w for w in widths))
  112. for row in rows:
  113. print(' ' + fmt.format(*row))
  114. def _fmt_price(local: int | None, remote: int | None) -> str:
  115. if local is None and remote is None:
  116. return '—'
  117. if local is None:
  118. return f'—→${remote}'
  119. if remote is None:
  120. return f'${local}→—'
  121. if local != remote:
  122. return f'${local}→${remote}'
  123. return f'${remote}'
  124. def _fmt_discount(d: dict | None) -> str:
  125. if d is None:
  126. return '—'
  127. if d['type'] == 'flat':
  128. return f"flat:${d['value']}"
  129. return f"pct:{d['value']}%"
  130. async def _diff(start: date, end: date, args) -> None:
  131. from room_cal.actions.diff import diff
  132. from room_cal._utils import services
  133. result = await diff(start, end)
  134. svc_keys = [s.key for s in services()]
  135. any_diff = False
  136. prices = result['prices']
  137. if prices:
  138. any_diff = True
  139. n = len(prices)
  140. print(f"\n Prices ({n} date{'s' if n != 1 else ''} differ)")
  141. header = ['date', 'local'] + svc_keys
  142. rows = []
  143. for p in prices:
  144. net_str = f"${p['net']}" if p['net'] is not None else '—'
  145. svc_cols = [
  146. _fmt_price(
  147. p['per_service'][svc]['local'] if svc in p['per_service'] else None,
  148. p['per_service'][svc]['remote'] if svc in p['per_service'] else None,
  149. )
  150. for svc in svc_keys
  151. ]
  152. rows.append([str(p['date']), net_str] + svc_cols)
  153. _fmt_table(rows, header)
  154. closed = result['closed']
  155. if closed:
  156. any_diff = True
  157. n = len(closed)
  158. print(f"\n Closed vs service_api ({n} date{'s' if n != 1 else ''} differ)")
  159. header = ['date', 'local'] + svc_keys
  160. rows = []
  161. for c in closed:
  162. local_str = ', '.join(c['local']) if c['local'] else '—'
  163. remote = c['remote'] or []
  164. svc_cols = ['closed' if svc in remote else 'open' for svc in svc_keys]
  165. rows.append([str(c['date']), local_str] + svc_cols)
  166. _fmt_table(rows, header)
  167. reservations = result['reservations']
  168. if reservations:
  169. any_diff = True
  170. n = len(reservations)
  171. print(f"\n Reservations ({n} {'change' if n == 1 else 'changes'})")
  172. for r in reservations:
  173. if r['status'] == 'added':
  174. print(f" [+] {r['service']} {r['checkin_date']} → {r['checkout_date']} {r['guest_name']} ({r['reservation_id']})")
  175. elif r['status'] == 'removed':
  176. print(f" [-] {r['service']} {r['checkin_date']} → {r['checkout_date']} {r['guest_name']} ({r['reservation_id']})")
  177. elif r['status'] == 'changed':
  178. for c in r['changes']:
  179. print(f" [~] {r['reservation_id']} {c['field']}: {c['local']!r} → {c['remote']!r}")
  180. uc_closed = result['user_cal_closed']
  181. if uc_closed:
  182. any_diff = True
  183. n = len(uc_closed)
  184. print(f"\n Closed vs user_cal ({n} date{'s' if n != 1 else ''} differ)")
  185. _fmt_table(
  186. [[str(r['date']), 'closed' if r['in_room'] else 'open', 'closed' if r['in_user_cal'] else 'open']
  187. for r in uc_closed],
  188. ['date', 'room', 'user_cal'],
  189. )
  190. ical_feeds_closed = result['ical_feeds_closed']
  191. for feed_name, rows_data in ical_feeds_closed.items():
  192. any_diff = True
  193. n = len(rows_data)
  194. print(f"\n Closed vs ical_feeds \"{feed_name}\" ({n} date{'s' if n != 1 else ''} differ)")
  195. _fmt_table(
  196. [[str(r['date']), 'closed' if r['in_room'] else 'open', 'blocked' if r['in_ical_feeds'] else 'open']
  197. for r in rows_data],
  198. ['date', 'room', 'ical_feeds'],
  199. )
  200. rule_discounts = result['rule_discounts']
  201. for pipeline_name, rows_data in rule_discounts.items():
  202. any_diff = True
  203. n = len(rows_data)
  204. print(f"\n Discounts vs rule \"{pipeline_name}\" ({n} date{'s' if n != 1 else ''} differ)")
  205. _fmt_table(
  206. [[str(d['date']), _fmt_discount(d['local']), _fmt_discount(d['rule'])] for d in rows_data],
  207. ['date', 'local', 'rule'],
  208. )
  209. rule_closed = result['rule_closed']
  210. for pipeline_name, rows_data in rule_closed.items():
  211. any_diff = True
  212. n = len(rows_data)
  213. print(f"\n Closed vs rule \"{pipeline_name}\" ({n} date{'s' if n != 1 else ''} differ)")
  214. _fmt_table(
  215. [[str(r['date']), 'closed' if r['in_room'] else 'open', 'closed' if r['in_rule'] else 'open']
  216. for r in rows_data],
  217. ['date', 'room', 'rule'],
  218. )
  219. rule_prices = result['rule_prices']
  220. for pipeline_name, rows_data in rule_prices.items():
  221. any_diff = True
  222. n = len(rows_data)
  223. print(f"\n Prices vs rule \"{pipeline_name}\" ({n} date{'s' if n != 1 else ''} differ)")
  224. _fmt_table(
  225. [[str(r['date']), f"${r['local_base']}" if r['local_base'] is not None else '(none)', f"${r['rule_min']}"]
  226. for r in rows_data],
  227. ['date', 'local_base', 'rule_min'],
  228. )
  229. orphans = result['orphans']
  230. if orphans:
  231. any_diff = True
  232. n = len(orphans)
  233. print(f"\n Orphans ({n} event{'s' if n != 1 else ''})")
  234. rows = []
  235. for o in orphans:
  236. src = o['source']
  237. src_str = f"{src['domain']}:{src.get('id','')}" if src else '(none)'
  238. rows.append([str(o['date']), o['event_type'], o['title'], src_str])
  239. _fmt_table(rows, ['date', 'type', 'title', 'source'])
  240. if not any_diff:
  241. print(" no differences")
  242. timings = result.get('_timings', {})
  243. if timings:
  244. total = timings.pop('_total', None)
  245. header = f" Timings (wall: {total:.1f}s)" if total is not None else " Timings"
  246. print(f"\n{header}")
  247. for k, v in sorted(timings.items(), key=lambda x: -x[1]):
  248. print(f" {k:<22} {v:.2f}s")
  249. _ACTIONS: dict[str, tuple] = {
  250. 'fetch-reservations': (_fetch_reservations, 'Fetch reservations from all services and write to CalDAV'),
  251. 'fetch-closed': (_fetch_closed, 'Fetch per-service blocked dates and write to CalDAV'),
  252. 'fetch-user-cal': (_fetch_user_cal, 'Sync Room Closed events from user_cal to the main calendar'),
  253. 'apply-rules': (_apply_rules, 'Sync rule-generated discounts and closed events into the main calendar'),
  254. 'push-closed': (_push_closed, 'Read local CalDAV closed events and push changes to all services'),
  255. 'fetch-feed': (_fetch_feed, 'Mark days with events on an iCal feed as closed on all services'),
  256. 'fill-prices': (_fill_prices, 'Create default price events for dates with no local price'),
  257. 'fetch-prices': (_fetch_prices, 'Fetch service API list prices and write net prices to CalDAV'),
  258. 'push-prices': (_push_prices, 'Read local CalDAV price events and push list prices to all services'),
  259. 'apply-discounts': (_apply_discounts, 'Re-apply discount events to price calendar and push changed list prices'),
  260. 'push-busy': (_push_busy, 'Sync Busy export calendar from local closed and reservation events'),
  261. 'local-sync': (_local_sync, 'Fetch all iCal feeds, apply rules, fill prices, and sync Busy — no service API calls'),
  262. 'local-refresh': (_local_refresh, 'Re-write all local Room calendar events (prices, closed, discounts, reservations)'),
  263. 'diff': (_diff, 'Compare local CalDAV data to live service APIs and report all differences (read-only)'),
  264. }
  265. def main() -> None:
  266. parser = argparse.ArgumentParser(
  267. prog='room-cal',
  268. description=__doc__,
  269. formatter_class=argparse.RawDescriptionHelpFormatter,
  270. )
  271. parser.add_argument(
  272. 'action', choices=list(_ACTIONS), metavar='ACTION',
  273. help='Action to run: ' + ', '.join(_ACTIONS),
  274. )
  275. parser.add_argument('start', type=_date, help='Start date (YYYY-MM-DD)')
  276. parser.add_argument('end', type=_date, nargs='?', help='End date inclusive (default: same as start)')
  277. parser.add_argument(
  278. '--feed-name', default='Fire', metavar='NAME',
  279. help='iCal feed name for fetch-feed (default: Fire)',
  280. )
  281. parser.add_argument(
  282. '--title', default=None, metavar='TITLE',
  283. help='Only close dates for feed events whose summary exactly matches TITLE',
  284. )
  285. parser.add_argument(
  286. '--pipeline-name', default=None, metavar='PIPELINE',
  287. help='Pipeline name for apply-rules (default: all pipelines)',
  288. )
  289. parser.add_argument(
  290. '--service', default=None, metavar='SERVICE',
  291. help='Service key to use for fetch-prices (default: first available)',
  292. )
  293. parser.add_argument(
  294. '--weekday', default=None, metavar='PRICE',
  295. help='Weekday net price override for fill-prices',
  296. )
  297. parser.add_argument(
  298. '--weekend', default=None, metavar='PRICE',
  299. help='Weekend net price override for fill-prices',
  300. )
  301. args = parser.parse_args()
  302. end = args.end or args.start
  303. if end < args.start:
  304. parser.error(f"end date {end} is before start date {args.start}")
  305. from room_cal.service_api.types import ServiceAuthError
  306. fn, _ = _ACTIONS[args.action]
  307. print(f"{args.action} {args.start} – {end}")
  308. try:
  309. asyncio.run(fn(args.start, end, args))
  310. except ServiceAuthError as e:
  311. print(f" error: {e}")
  312. raise SystemExit(1)