Digitale bierlijst

model.py 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  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 rename(
  127. self, new_full_name: Optional[str], new_display_name: Optional[str]
  128. ) -> Optional[Person]:
  129. person_payload: Dict[str, str] = {}
  130. if new_full_name is not None:
  131. person_payload["full_name"] = new_full_name
  132. if new_display_name is not None:
  133. person_payload["display_name"] = new_display_name
  134. req = requests.patch(
  135. urljoin(SERVER_URL, f"people/{self.person_id}"),
  136. json={"person": person_payload},
  137. )
  138. try:
  139. data = req.json()
  140. except ValueError:
  141. LOG.error(
  142. "Did not get JSON on updating Person (%s): %s",
  143. req.status_code,
  144. req.content,
  145. )
  146. return None
  147. if "error" in data or req.status_code != 200:
  148. LOG.error("Could not update Person (%s): %s", req.status_code, data)
  149. return None
  150. return Person.from_dict(data["person"])
  151. def set_active(self, new_state=True) -> Optional[Person]:
  152. req = requests.patch(
  153. urljoin(SERVER_URL, f"people/{self.person_id}"),
  154. json={"person": {"active": new_state}},
  155. )
  156. try:
  157. data = req.json()
  158. except ValueError:
  159. LOG.error(
  160. "Did not get JSON on updating Person (%s): %s",
  161. req.status_code,
  162. req.content,
  163. )
  164. return None
  165. if "error" in data or req.status_code != 200:
  166. LOG.error("Could not update Person (%s): %s", req.status_code, data)
  167. return None
  168. return Person.from_dict(data["person"])
  169. @classmethod
  170. def get(cls, person_id: int) -> Optional[Person]:
  171. """ Retrieve a Person by id. """
  172. req = requests.get(urljoin(SERVER_URL, f"/people/{person_id}"))
  173. try:
  174. data = req.json()
  175. if "error" in data:
  176. LOG.warning(
  177. "Could not get person %s (%s): %s", person_id, req.status_code, data
  178. )
  179. return None
  180. return Person.from_dict(data["person"])
  181. except ValueError:
  182. LOG.error(
  183. "Did not get JSON from server on getting Person (%s): %s",
  184. req.status_code,
  185. req.content,
  186. )
  187. return None
  188. @classmethod
  189. def get_all(cls, active=None) -> Union[List[Person], NetworkError]:
  190. """ Get all active People. """
  191. params = {}
  192. if active is not None:
  193. params["active"] = int(active)
  194. try:
  195. req = requests.get(urljoin(SERVER_URL, "/people"), params=params)
  196. req.raise_for_status()
  197. data = req.json()
  198. return [Person.from_dict(item) for item in data["people"]]
  199. except requests.ConnectionError as e:
  200. LOG.exception(e)
  201. return NetworkError.ConnectionFailure
  202. except requests.HTTPError as e:
  203. LOG.exception(e)
  204. return NetworkError.HttpFailure
  205. except ValueError as e:
  206. LOG.exception(e)
  207. return NetworkError.InvalidData
  208. @classmethod
  209. def from_dict(cls, data: dict) -> "Person":
  210. """ Reconstruct a Person object from a dict. """
  211. return Person(
  212. full_name=data["full_name"],
  213. display_name=data["display_name"],
  214. active=data["active"],
  215. person_id=data["person_id"],
  216. consumptions=data["consumptions"],
  217. )
  218. class Export(NamedTuple):
  219. created_at: datetime.datetime
  220. settlement_ids: Sequence[int]
  221. export_id: int
  222. settlements: Sequence["Settlement"] = []
  223. @classmethod
  224. def from_dict(cls, data: dict) -> "Export":
  225. """ Reconstruct an Export from a dict. """
  226. return cls(
  227. export_id=data["export_id"],
  228. created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
  229. settlement_ids=data["settlement_ids"],
  230. settlements=data.get("settlements", []),
  231. )
  232. @classmethod
  233. def get_all(cls) -> Optional[List[Export]]:
  234. """ Get a list of all existing Exports. """
  235. req = requests.get(urljoin(SERVER_URL, "exports"))
  236. try:
  237. data = req.json()
  238. except ValueError:
  239. LOG.error(
  240. "Did not get JSON on listing Exports (%s): %s",
  241. req.status_code,
  242. req.content,
  243. )
  244. return None
  245. if "error" in data or req.status_code != 200:
  246. LOG.error("Could not list Exports (%s): %s", req.status_code, data)
  247. return None
  248. return [cls.from_dict(e) for e in data["exports"]]
  249. @classmethod
  250. def get(cls, export_id: int) -> Optional[Export]:
  251. """ Retrieve one Export. """
  252. req = requests.get(urljoin(SERVER_URL, f"exports/{export_id}"))
  253. try:
  254. data = req.json()
  255. except ValueError:
  256. LOG.error(
  257. "Did not get JSON on getting Export (%s): %s",
  258. req.status_code,
  259. req.content,
  260. )
  261. return None
  262. if "error" in data or req.status_code != 200:
  263. LOG.error("Could not get Export (%s): %s", req.status_code, data)
  264. return None
  265. data["export"]["settlements"] = data["settlements"]
  266. return cls.from_dict(data["export"])
  267. @classmethod
  268. def create(cls) -> Optional[Export]:
  269. """ Create a new Export, containing all un-exported Settlements. """
  270. req = requests.post(urljoin(SERVER_URL, "exports"))
  271. try:
  272. data = req.json()
  273. except ValueError:
  274. LOG.error(
  275. "Did not get JSON on adding Export (%s): %s",
  276. req.status_code,
  277. req.content,
  278. )
  279. return None
  280. if "error" in data or req.status_code != 201:
  281. LOG.error("Could not create Export (%s): %s", req.status_code, data)
  282. return None
  283. data["export"]["settlements"] = data["settlements"]
  284. return cls.from_dict(data["export"])
  285. class ConsumptionType(NamedTuple):
  286. """ Represents a stored ConsumptionType. """
  287. name: str
  288. consumption_type_id: Optional[int] = None
  289. icon: Optional[str] = None
  290. active: bool = True
  291. def create(self) -> Union[ConsumptionType, NetworkError]:
  292. """ Create a new ConsumptionType from the current attributes. As tuples
  293. are immutable, a new ConsumptionType with the correct id is returned.
  294. """
  295. try:
  296. req = requests.post(
  297. urljoin(SERVER_URL, "consumption_types"),
  298. json={"consumption_type": {"name": self.name, "icon": self.icon}},
  299. )
  300. req.raise_for_status()
  301. data = req.json()
  302. return ConsumptionType.from_dict(data["consumption_type"])
  303. except requests.ConnectionError as e:
  304. LOG.exception(e)
  305. return NetworkError.ConnectionFailure
  306. except requests.HTTPError as e:
  307. LOG.exception(e)
  308. return NetworkError.HttpFailure
  309. except ValueError as e:
  310. LOG.exception(e)
  311. return NetworkError.InvalidData
  312. @classmethod
  313. def get(cls, consumption_type_id: int) -> Union[ConsumptionType, NetworkError]:
  314. """ Retrieve a ConsumptionType by id. """
  315. try:
  316. req = requests.get(
  317. urljoin(SERVER_URL, f"/consumption_types/{consumption_type_id}")
  318. )
  319. req.raise_for_status()
  320. data = req.json()
  321. except requests.ConnectionError as e:
  322. LOG.exception(e)
  323. return NetworkError.ConnectionFailure
  324. except requests.HTTPError as e:
  325. LOG.exception(e)
  326. return NetworkError.HttpFailure
  327. except ValueError as e:
  328. LOG.exception(e)
  329. return NetworkError.InvalidData
  330. return cls.from_dict(data["consumption_type"])
  331. @classmethod
  332. def get_all(cls, active: bool = True) -> Union[List[ConsumptionType], NetworkError]:
  333. """ Get the list of ConsumptionTypes. """
  334. try:
  335. req = requests.get(
  336. urljoin(SERVER_URL, "/consumption_types"),
  337. params={"active": int(active)},
  338. )
  339. req.raise_for_status()
  340. data = req.json()
  341. except requests.ConnectionError as e:
  342. LOG.exception(e)
  343. return NetworkError.ConnectionFailure
  344. except requests.HTTPError as e:
  345. LOG.exception(e)
  346. return NetworkError.HttpFailure
  347. except ValueError as e:
  348. LOG.exception(e)
  349. return NetworkError.InvalidData
  350. return [cls.from_dict(x) for x in data["consumption_types"]]
  351. @classmethod
  352. def from_dict(cls, data: dict) -> "ConsumptionType":
  353. """ Reconstruct a ConsumptionType from a dict. """
  354. return cls(
  355. name=data["name"],
  356. consumption_type_id=data["consumption_type_id"],
  357. icon=data.get("icon"),
  358. active=data["active"],
  359. )
  360. def set_active(self, active: bool) -> Union[ConsumptionType, NetworkError]:
  361. """Update the 'active' attribute."""
  362. try:
  363. req = requests.patch(
  364. urljoin(SERVER_URL, f"/consumption_types/{self.consumption_type_id}"),
  365. json={"consumption_type": {"active": active}},
  366. )
  367. req.raise_for_status()
  368. data = req.json()
  369. except requests.ConnectionError as e:
  370. LOG.exception(e)
  371. return NetworkError.ConnectionFailure
  372. except requests.HTTPError as e:
  373. LOG.exception(e)
  374. return NetworkError.HttpFailure
  375. except ValueError as e:
  376. LOG.exception(e)
  377. return NetworkError.InvalidData
  378. return self.from_dict(data["consumption_type"])
  379. class Consumption(NamedTuple):
  380. """ Represents a stored Consumption. """
  381. consumption_id: int
  382. person_id: int
  383. consumption_type_id: int
  384. created_at: datetime.datetime
  385. reversed: bool = False
  386. settlement_id: Optional[int] = None
  387. @classmethod
  388. def from_dict(cls, data: dict) -> "Consumption":
  389. """ Reconstruct a Consumption from a dict. """
  390. return cls(
  391. consumption_id=data["consumption_id"],
  392. person_id=data["person_id"],
  393. consumption_type_id=data["consumption_type_id"],
  394. settlement_id=data["settlement_id"],
  395. created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
  396. reversed=data["reversed"],
  397. )
  398. def reverse(self) -> Optional[Consumption]:
  399. """ Reverse this consumption. """
  400. req = requests.delete(
  401. urljoin(SERVER_URL, f"/consumptions/{self.consumption_id}")
  402. )
  403. try:
  404. data = req.json()
  405. if "error" in data:
  406. LOG.error(
  407. "Could not reverse consumption %s (%s): %s",
  408. self.consumption_id,
  409. req.status_code,
  410. data,
  411. )
  412. return None
  413. return Consumption.from_dict(data["consumption"])
  414. except ValueError:
  415. LOG.error(
  416. "Did not get JSON on reversing Consumption (%s): %s",
  417. req.status_code,
  418. req.content,
  419. )
  420. return None
  421. class Settlement(NamedTuple):
  422. """ Represents a stored Settlement. """
  423. settlement_id: int
  424. name: str
  425. consumption_summary: Dict[str, Any]
  426. count_info: Dict[str, Any] = {}
  427. per_person_counts: Dict[str, Any] = {}
  428. @classmethod
  429. def from_dict(cls, data: dict) -> "Settlement":
  430. return Settlement(
  431. settlement_id=data["settlement_id"],
  432. name=data["name"],
  433. consumption_summary=data["consumption_summary"],
  434. count_info=data["count_info"],
  435. per_person_counts=data["per_person_counts"],
  436. )
  437. @classmethod
  438. def create(cls, name: str) -> "Settlement":
  439. req = requests.post(
  440. urljoin(SERVER_URL, "/settlements"), json={"settlement": {"name": name}}
  441. )
  442. return cls.from_dict(req.json()["settlement"])
  443. @classmethod
  444. def get(cls, settlement_id: int) -> Union[Settlement, NetworkError]:
  445. try:
  446. req = requests.get(urljoin(SERVER_URL, f"/settlements/{settlement_id}"))
  447. req.raise_for_status()
  448. data = req.json()
  449. except ValueError as e:
  450. LOG.exception(e)
  451. return NetworkError.InvalidData
  452. except requests.ConnectionError as e:
  453. LOG.exception(e)
  454. return NetworkError.ConnectionFailure
  455. except requests.HTTPError as e:
  456. LOG.exception(e)
  457. return NetworkError.HttpFailure
  458. data["settlement"]["count_info"] = data["count_info"]
  459. return cls.from_dict(data["settlement"])
  460. @dataclass(frozen=True)
  461. class AardbeiActivity:
  462. aardbei_id: int
  463. name: str
  464. @classmethod
  465. def from_dict(cls, data: Dict[str, Any]) -> AardbeiActivity:
  466. return cls(data["activity"]["id"], data["activity"]["name"])
  467. @classmethod
  468. def get_available(
  469. cls, token: str, endpoint: str
  470. ) -> Union[List[AardbeiActivity], NetworkError]:
  471. try:
  472. req = requests.post(
  473. urljoin(SERVER_URL, "/aardbei/get_activities"),
  474. json={"endpoint": endpoint, "token": token},
  475. )
  476. req.raise_for_status()
  477. return [cls.from_dict(x) for x in req.json()["activities"]]
  478. except requests.ConnectionError as e:
  479. LOG.exception(e)
  480. return NetworkError.ConnectionFailure
  481. except requests.HTTPError as e:
  482. LOG.exception(e)
  483. return NetworkError.HttpFailure
  484. except ValueError as e:
  485. LOG.exception(e)
  486. return NetworkError.InvalidData
  487. @classmethod
  488. def apply_activity(
  489. cls, token: str, endpoint: str, activity_id: int
  490. ) -> Union[int, NetworkError]:
  491. try:
  492. req = requests.post(
  493. urljoin(SERVER_URL, "/aardbei/apply_activity"),
  494. json={"activity_id": activity_id, "token": token, "endpoint": endpoint},
  495. )
  496. req.raise_for_status()
  497. data = req.json()
  498. return data["activity"]["response_counts"]["present"]
  499. except requests.ConnectionError as e:
  500. LOG.exception(e)
  501. return NetworkError.ConnectionFailure
  502. except requests.HTTPError as e:
  503. LOG.exception(e)
  504. return NetworkError.HttpFailure
  505. except ValueError as e:
  506. LOG.exception(e)
  507. return NetworkError.InvalidData
  508. @dataclass(frozen=True)
  509. class AardbeiPeopleDiff:
  510. altered_name: List[str]
  511. link_existing: List[str]
  512. new_people: List[str]
  513. num_changes: int
  514. @classmethod
  515. def from_dict(cls, data: Dict[str, Any]) -> AardbeiPeopleDiff:
  516. return cls(**data)
  517. @classmethod
  518. def get_diff(
  519. cls, token: str, endpoint: str
  520. ) -> Union[AardbeiPeopleDiff, NetworkError]:
  521. try:
  522. req = requests.post(
  523. urljoin(SERVER_URL, "/aardbei/diff_people"),
  524. json={"endpoint": endpoint, "token": token},
  525. )
  526. req.raise_for_status()
  527. data = req.json()
  528. return cls.from_dict(data)
  529. except requests.ConnectionError as e:
  530. LOG.exception(e)
  531. return NetworkError.ConnectionFailure
  532. except requests.HTTPError as e:
  533. LOG.exception(e)
  534. return NetworkError.HttpFailure
  535. except ValueError as e:
  536. LOG.exception(e)
  537. return NetworkError.InvalidData
  538. @classmethod
  539. def sync(cls, token: str, endpoint: str) -> Union[AardbeiPeopleDiff, NetworkError]:
  540. try:
  541. req = requests.post(
  542. urljoin(SERVER_URL, "/aardbei/sync_people"),
  543. json={"endpoint": endpoint, "token": token},
  544. )
  545. req.raise_for_status()
  546. data = req.json()
  547. return cls.from_dict(data)
  548. except requests.ConnectionError as e:
  549. LOG.exception(e)
  550. return NetworkError.ConnectionFailure
  551. except requests.HTTPError as e:
  552. LOG.exception(e)
  553. return NetworkError.HttpFailure
  554. except ValueError as e:
  555. LOG.exception(e)
  556. return NetworkError.InvalidData