Digitale bierlijst

model.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. """
  2. Provides access to the models stored in the database, via the server.
  3. """
  4. from __future__ import annotations
  5. import datetime
  6. import enum
  7. import logging
  8. from dataclasses import dataclass
  9. from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
  10. from urllib.parse import urljoin
  11. import requests
  12. LOG = logging.getLogger(__name__)
  13. SERVER_URL = "http://127.0.0.1:5000"
  14. DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
  15. class NetworkError(enum.Enum):
  16. """Represents errors that might occur when communicating with the server."""
  17. HttpFailure = "http_failure"
  18. """Returned when the server returns a non-successful status code."""
  19. ConnectionFailure = "connection_failure"
  20. """Returned when we can't connect to the server at all."""
  21. InvalidData = "invalid_data"
  22. class ServerStatus:
  23. """ Provides helper classes to check whether the server is up. """
  24. @classmethod
  25. def is_server_running(cls) -> Union[bool, NetworkError]:
  26. try:
  27. req = requests.get(urljoin(SERVER_URL, "ping"))
  28. req.raise_for_status()
  29. except requests.ConnectionError as ex:
  30. LOG.exception(ex)
  31. return NetworkError.ConnectionFailure
  32. except requests.HTTPError as ex:
  33. LOG.exception(ex)
  34. return NetworkError.HttpFailure
  35. return True
  36. @dataclass(frozen=True)
  37. class OpenConsumptions:
  38. amount: int
  39. first_timestamp: Optional[datetime.datetime]
  40. last_timestamp: Optional[datetime.datetime]
  41. @classmethod
  42. def unsettled_consumptions(cls) -> Union[OpenConsumptions, NetworkError]:
  43. try:
  44. req = requests.get(urljoin(SERVER_URL, "status"))
  45. req.raise_for_status()
  46. data = req.json()
  47. except requests.ConnectionError as e:
  48. LOG.exception(e)
  49. return NetworkError.ConnectionFailure
  50. except requests.HTTPError as e:
  51. LOG.exception(e)
  52. return NetworkError.HttpFailure
  53. except ValueError as e:
  54. LOG.exception(e)
  55. return NetworkError.InvalidData
  56. amount: int = data["unsettled"]["amount"]
  57. if amount == 0:
  58. return cls.OpenConsumptions(
  59. amount=0, first_timestamp=None, last_timestamp=None
  60. )
  61. first = datetime.datetime.fromisoformat(data["unsettled"]["first"])
  62. last = datetime.datetime.fromisoformat(data["unsettled"]["last"])
  63. return cls.OpenConsumptions(
  64. amount=amount, first_timestamp=first, last_timestamp=last
  65. )
  66. class Person(NamedTuple):
  67. """ Represents a Person, as retrieved from the database. """
  68. full_name: str
  69. display_name: Optional[str]
  70. active: bool = True
  71. person_id: Optional[int] = None
  72. consumptions: dict = {}
  73. @property
  74. def name(self) -> str:
  75. return self.display_name or self.full_name
  76. def add_consumption(self, type_id: str) -> Optional[Consumption]:
  77. """ Register a consumption for this Person. """
  78. req = requests.post(
  79. urljoin(SERVER_URL, f"people/{self.person_id}/add_consumption/{type_id}")
  80. )
  81. try:
  82. data = req.json()
  83. if "error" in data:
  84. LOG.error(
  85. "Could not add consumption for %s (%s): %s",
  86. self.person_id,
  87. req.status_code,
  88. data,
  89. )
  90. return None
  91. self.consumptions.update(data["person"]["consumptions"])
  92. return Consumption.from_dict(data["consumption"])
  93. except ValueError:
  94. LOG.error(
  95. "Did not get JSON on adding Consumption (%s): %s",
  96. req.status_code,
  97. req.content,
  98. )
  99. return None
  100. def create(self) -> Union[Person, NetworkError]:
  101. """ Create a new Person from the current attributes. As tuples are
  102. immutable, a new Person with the correct id is returned. """
  103. try:
  104. req = requests.post(
  105. urljoin(SERVER_URL, "people"),
  106. json={
  107. "person": {
  108. "full_name": self.full_name,
  109. "display_name": self.display_name,
  110. "active": True,
  111. }
  112. },
  113. )
  114. req.raise_for_status()
  115. data = req.json()
  116. return Person.from_dict(data["person"])
  117. except requests.ConnectionError as e:
  118. LOG.exception(e)
  119. return NetworkError.ConnectionFailure
  120. except requests.HTTPError as e:
  121. LOG.exception(e)
  122. return NetworkError.HttpFailure
  123. except ValueError as e:
  124. LOG.exception(e)
  125. return NetworkError.InvalidData
  126. def set_active(self, new_state=True) -> Optional[Person]:
  127. req = requests.patch(
  128. urljoin(SERVER_URL, f"people/{self.person_id}"),
  129. json={"person": {"active": new_state}},
  130. )
  131. try:
  132. data = req.json()
  133. except ValueError:
  134. LOG.error(
  135. "Did not get JSON on updating Person (%s): %s",
  136. req.status_code,
  137. req.content,
  138. )
  139. return None
  140. if "error" in data or req.status_code != 200:
  141. LOG.error("Could not update Person (%s): %s", req.status_code, data)
  142. return None
  143. return Person.from_dict(data["person"])
  144. @classmethod
  145. def get(cls, person_id: int) -> Optional[Person]:
  146. """ Retrieve a Person by id. """
  147. req = requests.get(urljoin(SERVER_URL, f"/people/{person_id}"))
  148. try:
  149. data = req.json()
  150. if "error" in data:
  151. LOG.warning(
  152. "Could not get person %s (%s): %s", person_id, req.status_code, data
  153. )
  154. return None
  155. return Person.from_dict(data["person"])
  156. except ValueError:
  157. LOG.error(
  158. "Did not get JSON from server on getting Person (%s): %s",
  159. req.status_code,
  160. req.content,
  161. )
  162. return None
  163. @classmethod
  164. def get_all(cls, active=None) -> Union[List[Person], NetworkError]:
  165. """ Get all active People. """
  166. params = {}
  167. if active is not None:
  168. params["active"] = int(active)
  169. try:
  170. req = requests.get(urljoin(SERVER_URL, "/people"), params=params)
  171. req.raise_for_status()
  172. data = req.json()
  173. return [Person.from_dict(item) for item in data["people"]]
  174. except requests.ConnectionError as e:
  175. LOG.exception(e)
  176. return NetworkError.ConnectionFailure
  177. except requests.HTTPError as e:
  178. LOG.exception(e)
  179. return NetworkError.HttpFailure
  180. except ValueError as e:
  181. LOG.exception(e)
  182. return NetworkError.InvalidData
  183. @classmethod
  184. def from_dict(cls, data: dict) -> "Person":
  185. """ Reconstruct a Person object from a dict. """
  186. return Person(
  187. full_name=data["full_name"],
  188. display_name=data["display_name"],
  189. active=data["active"],
  190. person_id=data["person_id"],
  191. consumptions=data["consumptions"],
  192. )
  193. class Export(NamedTuple):
  194. created_at: datetime.datetime
  195. settlement_ids: Sequence[int]
  196. export_id: int
  197. settlements: Sequence["Settlement"] = []
  198. @classmethod
  199. def from_dict(cls, data: dict) -> "Export":
  200. """ Reconstruct an Export from a dict. """
  201. return cls(
  202. export_id=data["export_id"],
  203. created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
  204. settlement_ids=data["settlement_ids"],
  205. settlements=data.get("settlements", []),
  206. )
  207. @classmethod
  208. def get_all(cls) -> Optional[List[Export]]:
  209. """ Get a list of all existing Exports. """
  210. req = requests.get(urljoin(SERVER_URL, "exports"))
  211. try:
  212. data = req.json()
  213. except ValueError:
  214. LOG.error(
  215. "Did not get JSON on listing Exports (%s): %s",
  216. req.status_code,
  217. req.content,
  218. )
  219. return None
  220. if "error" in data or req.status_code != 200:
  221. LOG.error("Could not list Exports (%s): %s", req.status_code, data)
  222. return None
  223. return [cls.from_dict(e) for e in data["exports"]]
  224. @classmethod
  225. def get(cls, export_id: int) -> Optional[Export]:
  226. """ Retrieve one Export. """
  227. req = requests.get(urljoin(SERVER_URL, f"exports/{export_id}"))
  228. try:
  229. data = req.json()
  230. except ValueError:
  231. LOG.error(
  232. "Did not get JSON on getting Export (%s): %s",
  233. req.status_code,
  234. req.content,
  235. )
  236. return None
  237. if "error" in data or req.status_code != 200:
  238. LOG.error("Could not get Export (%s): %s", req.status_code, data)
  239. return None
  240. data["export"]["settlements"] = data["settlements"]
  241. return cls.from_dict(data["export"])
  242. @classmethod
  243. def create(cls) -> Optional[Export]:
  244. """ Create a new Export, containing all un-exported Settlements. """
  245. req = requests.post(urljoin(SERVER_URL, "exports"))
  246. try:
  247. data = req.json()
  248. except ValueError:
  249. LOG.error(
  250. "Did not get JSON on adding Export (%s): %s",
  251. req.status_code,
  252. req.content,
  253. )
  254. return None
  255. if "error" in data or req.status_code != 201:
  256. LOG.error("Could not create Export (%s): %s", req.status_code, data)
  257. return None
  258. data["export"]["settlements"] = data["settlements"]
  259. return cls.from_dict(data["export"])
  260. class ConsumptionType(NamedTuple):
  261. """ Represents a stored ConsumptionType. """
  262. name: str
  263. consumption_type_id: Optional[int] = None
  264. icon: Optional[str] = None
  265. active: bool = True
  266. def create(self) -> Union[ConsumptionType, NetworkError]:
  267. """ Create a new ConsumptionType from the current attributes. As tuples
  268. are immutable, a new ConsumptionType with the correct id is returned.
  269. """
  270. try:
  271. req = requests.post(
  272. urljoin(SERVER_URL, "consumption_types"),
  273. json={"consumption_type": {"name": self.name, "icon": self.icon}},
  274. )
  275. req.raise_for_status()
  276. data = req.json()
  277. return ConsumptionType.from_dict(data["consumption_type"])
  278. except requests.ConnectionError as e:
  279. LOG.exception(e)
  280. return NetworkError.ConnectionFailure
  281. except requests.HTTPError as e:
  282. LOG.exception(e)
  283. return NetworkError.HttpFailure
  284. except ValueError as e:
  285. LOG.exception(e)
  286. return NetworkError.InvalidData
  287. @classmethod
  288. def get(cls, consumption_type_id: int) -> Union[ConsumptionType, NetworkError]:
  289. """ Retrieve a ConsumptionType by id. """
  290. try:
  291. req = requests.get(
  292. urljoin(SERVER_URL, f"/consumption_types/{consumption_type_id}")
  293. )
  294. req.raise_for_status()
  295. data = req.json()
  296. except requests.ConnectionError as e:
  297. LOG.exception(e)
  298. return NetworkError.ConnectionFailure
  299. except requests.HTTPError as e:
  300. LOG.exception(e)
  301. return NetworkError.HttpFailure
  302. except ValueError as e:
  303. LOG.exception(e)
  304. return NetworkError.InvalidData
  305. return cls.from_dict(data["consumption_type"])
  306. @classmethod
  307. def get_all(cls, active: bool = True) -> Union[List[ConsumptionType], NetworkError]:
  308. """ Get the list of ConsumptionTypes. """
  309. try:
  310. req = requests.get(
  311. urljoin(SERVER_URL, "/consumption_types"),
  312. params={"active": int(active)},
  313. )
  314. req.raise_for_status()
  315. data = req.json()
  316. except requests.ConnectionError as e:
  317. LOG.exception(e)
  318. return NetworkError.ConnectionFailure
  319. except requests.HTTPError as e:
  320. LOG.exception(e)
  321. return NetworkError.HttpFailure
  322. except ValueError as e:
  323. LOG.exception(e)
  324. return NetworkError.InvalidData
  325. return [cls.from_dict(x) for x in data["consumption_types"]]
  326. @classmethod
  327. def from_dict(cls, data: dict) -> "ConsumptionType":
  328. """ Reconstruct a ConsumptionType from a dict. """
  329. return cls(
  330. name=data["name"],
  331. consumption_type_id=data["consumption_type_id"],
  332. icon=data.get("icon"),
  333. active=data["active"],
  334. )
  335. def set_active(self, active: bool) -> Union[ConsumptionType, NetworkError]:
  336. """Update the 'active' attribute."""
  337. try:
  338. req = requests.patch(
  339. urljoin(SERVER_URL, f"/consumption_types/{self.consumption_type_id}"),
  340. json={"consumption_type": {"active": active}},
  341. )
  342. req.raise_for_status()
  343. data = req.json()
  344. except requests.ConnectionError as e:
  345. LOG.exception(e)
  346. return NetworkError.ConnectionFailure
  347. except requests.HTTPError as e:
  348. LOG.exception(e)
  349. return NetworkError.HttpFailure
  350. except ValueError as e:
  351. LOG.exception(e)
  352. return NetworkError.InvalidData
  353. return self.from_dict(data["consumption_type"])
  354. class Consumption(NamedTuple):
  355. """ Represents a stored Consumption. """
  356. consumption_id: int
  357. person_id: int
  358. consumption_type_id: int
  359. created_at: datetime.datetime
  360. reversed: bool = False
  361. settlement_id: Optional[int] = None
  362. @classmethod
  363. def from_dict(cls, data: dict) -> "Consumption":
  364. """ Reconstruct a Consumption from a dict. """
  365. return cls(
  366. consumption_id=data["consumption_id"],
  367. person_id=data["person_id"],
  368. consumption_type_id=data["consumption_type_id"],
  369. settlement_id=data["settlement_id"],
  370. created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
  371. reversed=data["reversed"],
  372. )
  373. def reverse(self) -> Optional[Consumption]:
  374. """ Reverse this consumption. """
  375. req = requests.delete(
  376. urljoin(SERVER_URL, f"/consumptions/{self.consumption_id}")
  377. )
  378. try:
  379. data = req.json()
  380. if "error" in data:
  381. LOG.error(
  382. "Could not reverse consumption %s (%s): %s",
  383. self.consumption_id,
  384. req.status_code,
  385. data,
  386. )
  387. return None
  388. return Consumption.from_dict(data["consumption"])
  389. except ValueError:
  390. LOG.error(
  391. "Did not get JSON on reversing Consumption (%s): %s",
  392. req.status_code,
  393. req.content,
  394. )
  395. return None
  396. class Settlement(NamedTuple):
  397. """ Represents a stored Settlement. """
  398. settlement_id: int
  399. name: str
  400. consumption_summary: Dict[str, Any]
  401. count_info: Dict[str, Any] = {}
  402. per_person_counts: Dict[str, Any] = {}
  403. @classmethod
  404. def from_dict(cls, data: dict) -> "Settlement":
  405. return Settlement(
  406. settlement_id=data["settlement_id"],
  407. name=data["name"],
  408. consumption_summary=data["consumption_summary"],
  409. count_info=data["count_info"],
  410. per_person_counts=data["per_person_counts"],
  411. )
  412. @classmethod
  413. def create(cls, name: str) -> "Settlement":
  414. req = requests.post(
  415. urljoin(SERVER_URL, "/settlements"), json={"settlement": {"name": name}}
  416. )
  417. return cls.from_dict(req.json()["settlement"])
  418. @classmethod
  419. def get(cls, settlement_id: int) -> Union[Settlement, NetworkError]:
  420. try:
  421. req = requests.get(urljoin(SERVER_URL, f"/settlements/{settlement_id}"))
  422. req.raise_for_status()
  423. data = req.json()
  424. except ValueError as e:
  425. LOG.exception(e)
  426. return NetworkError.InvalidData
  427. except requests.ConnectionError as e:
  428. LOG.exception(e)
  429. return NetworkError.ConnectionFailure
  430. except requests.HTTPError as e:
  431. LOG.exception(e)
  432. return NetworkError.HttpFailure
  433. data["settlement"]["count_info"] = data["count_info"]
  434. return cls.from_dict(data["settlement"])
  435. @dataclass(frozen=True)
  436. class AardbeiActivity:
  437. aardbei_id: int
  438. name: str
  439. @classmethod
  440. def from_dict(cls, data: Dict[str, Any]) -> AardbeiActivity:
  441. return cls(data["activity"]["id"], data["activity"]["name"])
  442. @classmethod
  443. def get_available(
  444. cls, token: str, endpoint: str
  445. ) -> Union[List[AardbeiActivity], NetworkError]:
  446. try:
  447. req = requests.post(
  448. urljoin(SERVER_URL, "/aardbei/get_activities"),
  449. json={"endpoint": endpoint, "token": token},
  450. )
  451. req.raise_for_status()
  452. return [cls.from_dict(x) for x in req.json()["activities"]]
  453. except requests.ConnectionError as e:
  454. LOG.exception(e)
  455. return NetworkError.ConnectionFailure
  456. except requests.HTTPError as e:
  457. LOG.exception(e)
  458. return NetworkError.HttpFailure
  459. except ValueError as e:
  460. LOG.exception(e)
  461. return NetworkError.InvalidData
  462. @classmethod
  463. def apply_activity(
  464. cls, token: str, endpoint: str, activity_id: int
  465. ) -> Union[int, NetworkError]:
  466. try:
  467. req = requests.post(
  468. urljoin(SERVER_URL, "/aardbei/apply_activity"),
  469. json={"activity_id": activity_id, "token": token, "endpoint": endpoint},
  470. )
  471. req.raise_for_status()
  472. data = req.json()
  473. return data["activity"]["response_counts"]["present"]
  474. except requests.ConnectionError as e:
  475. LOG.exception(e)
  476. return NetworkError.ConnectionFailure
  477. except requests.HTTPError as e:
  478. LOG.exception(e)
  479. return NetworkError.HttpFailure
  480. except ValueError as e:
  481. LOG.exception(e)
  482. return NetworkError.InvalidData
  483. @dataclass(frozen=True)
  484. class AardbeiPeopleDiff:
  485. altered_name: List[str]
  486. link_existing: List[str]
  487. new_people: List[str]
  488. num_changes: int
  489. @classmethod
  490. def from_dict(cls, data: Dict[str, Any]) -> AardbeiPeopleDiff:
  491. return cls(**data)
  492. @classmethod
  493. def get_diff(cls, token: str, endpoint: str) -> Union[AardbeiPeopleDiff, NetworkError]:
  494. try:
  495. req = requests.post(
  496. urljoin(SERVER_URL, "/aardbei/diff_people"),
  497. json={"endpoint": endpoint, "token": token},
  498. )
  499. req.raise_for_status()
  500. data = req.json()
  501. return cls.from_dict(data)
  502. except requests.ConnectionError as e:
  503. LOG.exception(e)
  504. return NetworkError.ConnectionFailure
  505. except requests.HTTPError as e:
  506. LOG.exception(e)
  507. return NetworkError.HttpFailure
  508. except ValueError as e:
  509. LOG.exception(e)
  510. return NetworkError.InvalidData
  511. @classmethod
  512. def sync(cls, token: str, endpoint: str) -> Union[AardbeiPeopleDiff, NetworkError]:
  513. try:
  514. req = requests.post(
  515. urljoin(SERVER_URL, "/aardbei/sync_people"),
  516. json={"endpoint": endpoint, "token": token},
  517. )
  518. req.raise_for_status()
  519. data = req.json()
  520. return cls.from_dict(data)
  521. except requests.ConnectionError as e:
  522. LOG.exception(e)
  523. return NetworkError.ConnectionFailure
  524. except requests.HTTPError as e:
  525. LOG.exception(e)
  526. return NetworkError.HttpFailure
  527. except ValueError as e:
  528. LOG.exception(e)
  529. return NetworkError.InvalidData