_utils.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import asyncio
  2. import json
  3. import os
  4. from dataclasses import dataclass, field
  5. from datetime import date, datetime, timedelta
  6. from importlib.resources import files
  7. from pathlib import Path
  8. from typing import Generic, TypeVar
  9. from zoneinfo import ZoneInfo
  10. _T = TypeVar('_T')
  11. @dataclass
  12. class FetchResult(Generic[_T]):
  13. """Return type for fetch_* actions: the synced data plus a changed flag."""
  14. data: _T
  15. changed: bool
  16. def __bool__(self) -> bool:
  17. return self.changed
  18. _source_registry: dict[str, 'EventSource'] = {}
  19. class EventSource:
  20. """Base class for event-producing objects. Registers the instance by domain name on init."""
  21. def __init__(self, name: str) -> None:
  22. self._name = name
  23. _source_registry[name] = self
  24. def _domain(self) -> str:
  25. return self._name
  26. def _ephemeral(self) -> bool:
  27. return False
  28. def is_ephemeral_domain(name: str | None) -> bool:
  29. """Return True if the named domain was registered as ephemeral (e.g. 'api')."""
  30. if not name:
  31. return False
  32. source = _source_registry.get(name)
  33. return source._ephemeral() if source else False
  34. def registered_domains() -> frozenset[str]:
  35. """Return the set of all registered source domain names."""
  36. return frozenset(_source_registry)
  37. def safe_json_loads(s: str | None) -> dict | None:
  38. if not s:
  39. return None
  40. try:
  41. return json.loads(s)
  42. except (ValueError, TypeError):
  43. return None
  44. def iter_dates(start: date, end: date):
  45. d = start
  46. while d <= end:
  47. yield d
  48. d += timedelta(days=1)
  49. def event_dates(event, start_date: date, end_date: date):
  50. """Yield dates covered by an EventRecord, clamped to [start_date, end_date]."""
  51. ev_start = event.start.date()
  52. ev_end = event.end.date()
  53. last_day = (ev_end - timedelta(days=1)) if event.all_day else ev_end
  54. for d in iter_dates(ev_start, last_day):
  55. if start_date <= d <= end_date:
  56. yield d
  57. async def drop_stale_events(
  58. events: list,
  59. covered: set[date],
  60. *,
  61. domain: str,
  62. id_: str,
  63. cal,
  64. ) -> None:
  65. """Delete events whose source domain/id match but whose date is no longer covered."""
  66. to_delete = []
  67. for ev in events:
  68. xd = safe_json_loads(ev.x_data)
  69. stored = xd.get('source', {}) if xd else {}
  70. if not isinstance(stored, dict):
  71. continue
  72. ev_date = ev.start.date()
  73. if ev_date not in covered and stored.get('domain') == domain and stored.get('id') == id_:
  74. to_delete.append(ev)
  75. if to_delete:
  76. await asyncio.gather(*[cal.delete(ev) for ev in to_delete])
  77. def filter_events(
  78. events: list,
  79. prefix: str,
  80. *,
  81. all_day: bool | None = None,
  82. require_x_data: bool = True,
  83. ) -> list:
  84. return [
  85. e for e in events
  86. if e.title.startswith(prefix)
  87. and (all_day is None or e.all_day == all_day)
  88. and (not require_x_data or e.x_data)
  89. ]
  90. def data_home() -> Path:
  91. override = os.environ.get('ROOM_CAL_DATA_HOME')
  92. return Path(override) if override else Path.home() / '.local' / 'room_cal'
  93. def _deep_merge(base: dict, override: dict) -> dict:
  94. result = dict(base)
  95. for k, v in override.items():
  96. if k in result and isinstance(result[k], dict) and isinstance(v, dict):
  97. result[k] = _deep_merge(result[k], v)
  98. else:
  99. result[k] = v
  100. return result
  101. # ── Config dataclasses ─────────────────────────────────────────────────────────
  102. @dataclass
  103. class ServiceFees:
  104. commission: float = 0.0
  105. client_fee: float = 0.0
  106. @classmethod
  107. def from_dict(cls, d: dict) -> 'ServiceFees':
  108. return cls(commission=d.get('commission', 0.0), client_fee=d.get('client_fee', 0.0))
  109. @dataclass
  110. class ServiceTaxes:
  111. wrong: float | None = None
  112. flat_tax: int = 0
  113. @classmethod
  114. def from_dict(cls, d: dict) -> 'ServiceTaxes':
  115. return cls(wrong=d.get('wrong'), flat_tax=d.get('flat_tax', 0))
  116. @dataclass
  117. class ServiceConfig:
  118. key: str
  119. name: str
  120. listing_id: str = ''
  121. hotel_id: str = ''
  122. hotel_account_id: str = ''
  123. host_account_id: str = ''
  124. room_id: str = ''
  125. rate_id: str = ''
  126. fees: ServiceFees = field(default_factory=ServiceFees)
  127. taxes: ServiceTaxes = field(default_factory=ServiceTaxes)
  128. @classmethod
  129. def from_dict(cls, key: str, d: dict) -> 'ServiceConfig':
  130. return cls(
  131. key=key,
  132. name=d['name'],
  133. listing_id=d.get('listing_id', ''),
  134. hotel_id=str(d.get('hotel_id', '')),
  135. hotel_account_id=str(d.get('hotel_account_id', '')),
  136. host_account_id=str(d.get('host_account_id', '')),
  137. room_id=str(d.get('room_id', '')),
  138. rate_id=str(d.get('rate_id', '')),
  139. fees=ServiceFees.from_dict(d.get('fees', {})),
  140. taxes=ServiceTaxes.from_dict(d.get('taxes', {})),
  141. )
  142. @dataclass
  143. class GlobalTaxes:
  144. sales: float = 0.0
  145. @classmethod
  146. def from_dict(cls, d: dict) -> 'GlobalTaxes':
  147. return cls(sales=d.get('sales', 0.0))
  148. @dataclass
  149. class DefaultPrices:
  150. weekday: int = 62
  151. weekend: int = 72
  152. @classmethod
  153. def from_dict(cls, d: dict) -> 'DefaultPrices':
  154. return cls(weekday=d.get('weekday', 62), weekend=d.get('weekend', 72))
  155. @dataclass
  156. class PropertyConfig:
  157. timezone: str = 'America/Los_Angeles'
  158. checkin_hour: int = 14
  159. checkout_hour: int = 12
  160. @classmethod
  161. def from_dict(cls, d: dict) -> 'PropertyConfig':
  162. return cls(
  163. timezone=d.get('timezone', 'America/Los_Angeles'),
  164. checkin_hour=d.get('checkin_hour', 14),
  165. checkout_hour=d.get('checkout_hour', 12),
  166. )
  167. @dataclass
  168. class GlobalConfig:
  169. taxes: GlobalTaxes = field(default_factory=GlobalTaxes)
  170. property: PropertyConfig = field(default_factory=PropertyConfig)
  171. prices: DefaultPrices = field(default_factory=DefaultPrices)
  172. reservation_window_days: int = 90
  173. busy_calendar_name: str = 'Busy'
  174. @classmethod
  175. def from_dict(cls, d: dict) -> 'GlobalConfig':
  176. return cls(
  177. taxes=GlobalTaxes.from_dict(d.get('taxes', {})),
  178. property=PropertyConfig.from_dict(d.get('property', {})),
  179. prices=DefaultPrices.from_dict(d.get('prices', {})),
  180. reservation_window_days=d.get('reservation_window_days', 90),
  181. busy_calendar_name=d.get('busy_calendar_name', 'Busy'),
  182. )
  183. @dataclass
  184. class PipelineConfig:
  185. type: str
  186. rules: list[str]
  187. @classmethod
  188. def from_dict(cls, d: dict) -> 'PipelineConfig':
  189. return cls(type=d['type'], rules=d.get('rules', []))
  190. @dataclass
  191. class FutureBlockRule:
  192. days_out: int = 150
  193. @classmethod
  194. def from_dict(cls, d: dict) -> 'FutureBlockRule':
  195. return cls(days_out=d.get('days_out', 150))
  196. @dataclass
  197. class ShortNoticeRule:
  198. max_days: int = 14
  199. ramp_days: int = 7
  200. @classmethod
  201. def from_dict(cls, d: dict) -> 'ShortNoticeRule':
  202. return cls(max_days=d.get('max_days', 14), ramp_days=d.get('ramp_days', 7))
  203. @dataclass
  204. class FillMonthRule:
  205. discount_pct: int = 10
  206. max_days: int = 30
  207. ramp_days: int = 7
  208. @classmethod
  209. def from_dict(cls, d: dict) -> 'FillMonthRule':
  210. return cls(
  211. discount_pct=d.get('discount_pct', 10),
  212. max_days=d.get('max_days', 30),
  213. ramp_days=d.get('ramp_days', 7),
  214. )
  215. @dataclass
  216. class FireRule:
  217. day_of: int = 314
  218. day_before: int = 111
  219. day_after: int = 222
  220. feed_name: str = 'Fire'
  221. @classmethod
  222. def from_dict(cls, d: dict) -> 'FireRule':
  223. return cls(
  224. day_of=d.get('day_of', 314),
  225. day_before=d.get('day_before', 111),
  226. day_after=d.get('day_after', 222),
  227. feed_name=d.get('feed_name', 'Fire'),
  228. )
  229. @dataclass
  230. class RulesConfig:
  231. future_block: FutureBlockRule = field(default_factory=FutureBlockRule)
  232. short_notice: ShortNoticeRule = field(default_factory=ShortNoticeRule)
  233. fill_month: FillMonthRule = field(default_factory=FillMonthRule)
  234. fire: FireRule = field(default_factory=FireRule)
  235. pipelines: dict[str, PipelineConfig] = field(default_factory=dict)
  236. @classmethod
  237. def from_dict(cls, d: dict) -> 'RulesConfig':
  238. return cls(
  239. future_block=FutureBlockRule.from_dict(d.get('future_block', {})),
  240. short_notice=ShortNoticeRule.from_dict(d.get('short_notice', {})),
  241. fill_month=FillMonthRule.from_dict(d.get('fill_month', {})),
  242. fire=FireRule.from_dict(d.get('fire', {})),
  243. pipelines={k: PipelineConfig.from_dict(v) for k, v in d.get('pipelines', {}).items()},
  244. )
  245. @dataclass
  246. class CalendarEntry:
  247. name: str
  248. url: str | None = None
  249. caldav: str | None = None
  250. past_days: int | None = None
  251. future_days: int | None = None
  252. @classmethod
  253. def from_dict(cls, d: dict) -> 'CalendarEntry':
  254. return cls(
  255. name=d['name'],
  256. url=d.get('url'),
  257. caldav=d.get('caldav'),
  258. past_days=d.get('past_days'),
  259. future_days=d.get('future_days'),
  260. )
  261. @dataclass
  262. class CaldavStoreConfig:
  263. url: str = ''
  264. username: str = ''
  265. password: str = ''
  266. calendar_name: str = 'Room'
  267. @classmethod
  268. def from_dict(cls, d: dict) -> 'CaldavStoreConfig':
  269. return cls(
  270. url=d.get('url', ''),
  271. username=d.get('username', ''),
  272. password=d.get('password', ''),
  273. calendar_name=d.get('calendar_name', 'Room'),
  274. )
  275. @dataclass
  276. class SqliteStoreConfig:
  277. db_path: str = 'store.db'
  278. @classmethod
  279. def from_dict(cls, d: dict) -> 'SqliteStoreConfig':
  280. return cls(db_path=d.get('db_path', 'store.db'))
  281. @dataclass
  282. class StoreConfig:
  283. active: str = 'caldav'
  284. caldav: CaldavStoreConfig = field(default_factory=CaldavStoreConfig)
  285. sqlite: SqliteStoreConfig = field(default_factory=SqliteStoreConfig)
  286. @classmethod
  287. def from_dict(cls, d: dict) -> 'StoreConfig':
  288. return cls(
  289. active=d.get('active', 'caldav'),
  290. caldav=CaldavStoreConfig.from_dict(d.get('caldav', {})),
  291. sqlite=SqliteStoreConfig.from_dict(d.get('sqlite', {})),
  292. )
  293. @dataclass
  294. class Config:
  295. global_: GlobalConfig = field(default_factory=GlobalConfig)
  296. services: dict[str, ServiceConfig] = field(default_factory=dict)
  297. rules: RulesConfig = field(default_factory=RulesConfig)
  298. calendars: list[CalendarEntry] = field(default_factory=list)
  299. api_url: str | None = None
  300. store: StoreConfig = field(default_factory=StoreConfig)
  301. @classmethod
  302. def from_dict(cls, d: dict) -> 'Config':
  303. store_d = d.get('store', {})
  304. return cls(
  305. global_=GlobalConfig.from_dict(d.get('global', {})),
  306. services={k: ServiceConfig.from_dict(k, v) for k, v in d.get('services', {}).items()},
  307. rules=RulesConfig.from_dict(d.get('rules', {})),
  308. calendars=[CalendarEntry.from_dict(e) for e in d.get('calendars', [])],
  309. api_url=d.get('api_url'),
  310. store=StoreConfig.from_dict(store_d),
  311. )
  312. # ── Config loading ─────────────────────────────────────────────────────────────
  313. def get_config() -> Config:
  314. """Load room_cal config.
  315. Always starts from the bundled default config.json, then deep-merges
  316. data_home()/config.json on top. Dict keys are merged recursively; lists
  317. and scalar values in the user config replace their defaults entirely.
  318. """
  319. default = json.loads(files('room_cal').joinpath('config.json').read_text(encoding='utf-8'))
  320. override_path = data_home() / 'config.json'
  321. if not override_path.exists():
  322. return Config.from_dict(default)
  323. override = json.loads(override_path.read_text(encoding='utf-8'))
  324. return Config.from_dict(_deep_merge(default, override))
  325. def local_today() -> date:
  326. """Return today's date in the configured property timezone."""
  327. tz = ZoneInfo(get_config().global_.property.timezone)
  328. return datetime.now(tz).date()
  329. def services() -> list[ServiceConfig]:
  330. """Return list of ServiceConfig for all configured services."""
  331. return list(get_config().services.values())
  332. def service_name(key: str) -> str:
  333. """Return the display name for a service key, falling back to key.capitalize()."""
  334. svc = get_config().services.get(key)
  335. return svc.name if svc else key.capitalize()
  336. def source_line(source: dict) -> str:
  337. """Format a source dict {name, id} into a human-readable string."""
  338. name = source.get('name', '')
  339. id_ = source.get('id', '')
  340. return f"{name} {id_}".strip()
  341. def read_env(path: Path) -> dict[str, str]:
  342. env: dict[str, str] = {}
  343. try:
  344. for line in path.read_text().splitlines():
  345. line = line.strip()
  346. if not line or line.startswith('#') or '=' not in line:
  347. continue
  348. k, _, v = line.partition('=')
  349. env[k.strip()] = v.strip().strip("'\"")
  350. except FileNotFoundError:
  351. pass
  352. return env