Digitale bierlijst

model.py 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  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, 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) -> Optional[Person]:
  101. """ Create a new Person from the current attributes. As tuples are
  102. immutable, a new Person with the correct id is returned. """
  103. req = requests.post(
  104. urljoin(SERVER_URL, "people"),
  105. json={"person": {"name": self.name, "active": True}},
  106. )
  107. try:
  108. data = req.json()
  109. except ValueError:
  110. LOG.error(
  111. "Did not get JSON on adding Person (%s): %s",
  112. req.status_code,
  113. req.content,
  114. )
  115. return None
  116. if "error" in data or req.status_code != 201:
  117. LOG.error("Could not create Person (%s): %s", req.status_code, data)
  118. return None
  119. return Person.from_dict(data["person"])
  120. def set_active(self, new_state=True) -> Optional[Person]:
  121. req = requests.patch(
  122. urljoin(SERVER_URL, f"people/{self.person_id}"),
  123. json={"person": {"active": new_state}},
  124. )
  125. try:
  126. data = req.json()
  127. except ValueError:
  128. LOG.error(
  129. "Did not get JSON on updating Person (%s): %s",
  130. req.status_code,
  131. req.content,
  132. )
  133. return None
  134. if "error" in data or req.status_code != 200:
  135. LOG.error("Could not update Person (%s): %s", req.status_code, data)
  136. return None
  137. return Person.from_dict(data["person"])
  138. @classmethod
  139. def get(cls, person_id: int) -> Optional[Person]:
  140. """ Retrieve a Person by id. """
  141. req = requests.get(urljoin(SERVER_URL, f"/people/{person_id}"))
  142. try:
  143. data = req.json()
  144. if "error" in data:
  145. LOG.warning(
  146. "Could not get person %s (%s): %s", person_id, req.status_code, data
  147. )
  148. return None
  149. return Person.from_dict(data["person"])
  150. except ValueError:
  151. LOG.error(
  152. "Did not get JSON from server on getting Person (%s): %s",
  153. req.status_code,
  154. req.content,
  155. )
  156. return None
  157. @classmethod
  158. def get_all(cls, active=None) -> Optional[List[Person]]:
  159. """ Get all active People. """
  160. params = {}
  161. if active is not None:
  162. params["active"] = int(active)
  163. req = requests.get(urljoin(SERVER_URL, "/people"), params=params)
  164. try:
  165. data = req.json()
  166. if "error" in data:
  167. LOG.warning("Could not get people (%s): %s", req.status_code, data)
  168. return [Person.from_dict(item) for item in data["people"]]
  169. except ValueError:
  170. LOG.error(
  171. "Did not get JSON from server on getting People (%s): %s",
  172. req.status_code,
  173. req.content,
  174. )
  175. return None
  176. @classmethod
  177. def from_dict(cls, data: dict) -> "Person":
  178. """ Reconstruct a Person object from a dict. """
  179. return Person(
  180. full_name=data["full_name"],
  181. display_name=data["display_name"],
  182. active=data["active"],
  183. person_id=data["person_id"],
  184. consumptions=data["consumptions"],
  185. )
  186. class Export(NamedTuple):
  187. created_at: datetime.datetime
  188. settlement_ids: Sequence[int]
  189. export_id: int
  190. settlements: Sequence["Settlement"] = []
  191. @classmethod
  192. def from_dict(cls, data: dict) -> "Export":
  193. """ Reconstruct an Export from a dict. """
  194. return cls(
  195. export_id=data["export_id"],
  196. created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
  197. settlement_ids=data["settlement_ids"],
  198. settlements=data.get("settlements", []),
  199. )
  200. @classmethod
  201. def get_all(cls) -> Optional[List[Export]]:
  202. """ Get a list of all existing Exports. """
  203. req = requests.get(urljoin(SERVER_URL, "exports"))
  204. try:
  205. data = req.json()
  206. except ValueError:
  207. LOG.error(
  208. "Did not get JSON on listing Exports (%s): %s",
  209. req.status_code,
  210. req.content,
  211. )
  212. return None
  213. if "error" in data or req.status_code != 200:
  214. LOG.error("Could not list Exports (%s): %s", req.status_code, data)
  215. return None
  216. return [cls.from_dict(e) for e in data["exports"]]
  217. @classmethod
  218. def get(cls, export_id: int) -> Optional[Export]:
  219. """ Retrieve one Export. """
  220. req = requests.get(urljoin(SERVER_URL, f"exports/{export_id}"))
  221. try:
  222. data = req.json()
  223. except ValueError:
  224. LOG.error(
  225. "Did not get JSON on getting Export (%s): %s",
  226. req.status_code,
  227. req.content,
  228. )
  229. return None
  230. if "error" in data or req.status_code != 200:
  231. LOG.error("Could not get Export (%s): %s", req.status_code, data)
  232. return None
  233. data["export"]["settlements"] = data["settlements"]
  234. return cls.from_dict(data["export"])
  235. @classmethod
  236. def create(cls) -> Optional[Export]:
  237. """ Create a new Export, containing all un-exported Settlements. """
  238. req = requests.post(urljoin(SERVER_URL, "exports"))
  239. try:
  240. data = req.json()
  241. except ValueError:
  242. LOG.error(
  243. "Did not get JSON on adding Export (%s): %s",
  244. req.status_code,
  245. req.content,
  246. )
  247. return None
  248. if "error" in data or req.status_code != 201:
  249. LOG.error("Could not create Export (%s): %s", req.status_code, data)
  250. return None
  251. data["export"]["settlements"] = data["settlements"]
  252. return cls.from_dict(data["export"])
  253. class ConsumptionType(NamedTuple):
  254. """ Represents a stored ConsumptionType. """
  255. name: str
  256. consumption_type_id: Optional[int] = None
  257. icon: Optional[str] = None
  258. def create(self) -> Optional[ConsumptionType]:
  259. """ Create a new ConsumptionType from the current attributes. As tuples
  260. are immutable, a new ConsumptionType with the correct id is returned.
  261. """
  262. req = requests.post(
  263. urljoin(SERVER_URL, "consumption_types"),
  264. json={"consumption_type": {"name": self.name, "icon": self.icon}},
  265. )
  266. try:
  267. data = req.json()
  268. except ValueError:
  269. LOG.error(
  270. "Did not get JSON on adding ConsumptionType (%s): %s",
  271. req.status_code,
  272. req.content,
  273. )
  274. return None
  275. if "error" in data or req.status_code != 201:
  276. LOG.error(
  277. "Could not create ConsumptionType (%s): %s", req.status_code, data
  278. )
  279. return None
  280. return ConsumptionType.from_dict(data["consumption_type"])
  281. @classmethod
  282. def get(cls, consumption_type_id: int) -> Optional[ConsumptionType]:
  283. """ Retrieve a ConsumptionType by id. """
  284. req = requests.get(
  285. urljoin(SERVER_URL, f"/consumption_types/{consumption_type_id}")
  286. )
  287. try:
  288. data = req.json()
  289. if "error" in data:
  290. LOG.warning(
  291. "Could not get consumption type %s (%s): %s",
  292. consumption_type_id,
  293. req.status_code,
  294. data,
  295. )
  296. return None
  297. return cls.from_dict(data["consumption_type"])
  298. except ValueError:
  299. LOG.error(
  300. "Did not get JSON from server on getting consumption type (%s): %s",
  301. req.status_code,
  302. req.content,
  303. )
  304. return None
  305. @classmethod
  306. def get_all(cls) -> Optional[List[ConsumptionType]]:
  307. """ Get all active ConsumptionTypes. """
  308. req = requests.get(urljoin(SERVER_URL, "/consumption_types"))
  309. try:
  310. data = req.json()
  311. if "error" in data:
  312. LOG.warning(
  313. "Could not get consumption types (%s): %s", req.status_code, data
  314. )
  315. return [cls.from_dict(item) for item in data["consumption_types"]]
  316. except ValueError:
  317. LOG.error(
  318. "Did not get JSON from server on getting ConsumptionTypes (%s): %s",
  319. req.status_code,
  320. req.content,
  321. )
  322. return None
  323. @classmethod
  324. def from_dict(cls, data: dict) -> "ConsumptionType":
  325. """ Reconstruct a ConsumptionType from a dict. """
  326. return cls(
  327. name=data["name"],
  328. consumption_type_id=data["consumption_type_id"],
  329. icon=data.get("icon"),
  330. )
  331. class Consumption(NamedTuple):
  332. """ Represents a stored Consumption. """
  333. consumption_id: int
  334. person_id: int
  335. consumption_type_id: int
  336. created_at: datetime.datetime
  337. reversed: bool = False
  338. settlement_id: Optional[int] = None
  339. @classmethod
  340. def from_dict(cls, data: dict) -> "Consumption":
  341. """ Reconstruct a Consumption from a dict. """
  342. return cls(
  343. consumption_id=data["consumption_id"],
  344. person_id=data["person_id"],
  345. consumption_type_id=data["consumption_type_id"],
  346. settlement_id=data["settlement_id"],
  347. created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
  348. reversed=data["reversed"],
  349. )
  350. def reverse(self) -> Optional[Consumption]:
  351. """ Reverse this consumption. """
  352. req = requests.delete(
  353. urljoin(SERVER_URL, f"/consumptions/{self.consumption_id}")
  354. )
  355. try:
  356. data = req.json()
  357. if "error" in data:
  358. LOG.error(
  359. "Could not reverse consumption %s (%s): %s",
  360. self.consumption_id,
  361. req.status_code,
  362. data,
  363. )
  364. return None
  365. return Consumption.from_dict(data["consumption"])
  366. except ValueError:
  367. LOG.error(
  368. "Did not get JSON on reversing Consumption (%s): %s",
  369. req.status_code,
  370. req.content,
  371. )
  372. return None
  373. class Settlement(NamedTuple):
  374. """ Represents a stored Settlement. """
  375. settlement_id: int
  376. name: str
  377. consumption_summary: dict
  378. count_info: dict = {}
  379. @classmethod
  380. def from_dict(cls, data: dict) -> "Settlement":
  381. return Settlement(
  382. settlement_id=data["settlement_id"],
  383. name=data["name"],
  384. consumption_summary=data["consumption_summary"],
  385. count_info=data.get("count_info", {}),
  386. )
  387. @classmethod
  388. def create(cls, name: str) -> "Settlement":
  389. req = requests.post(
  390. urljoin(SERVER_URL, "/settlements"), json={"settlement": {"name": name}}
  391. )
  392. return cls.from_dict(req.json()["settlement"])
  393. @classmethod
  394. def get(cls, settlement_id: int) -> Optional[Settlement]:
  395. req = requests.get(urljoin(SERVER_URL, f"/settlements/{settlement_id}"))
  396. try:
  397. data = req.json()
  398. except ValueError:
  399. LOG.error(
  400. "Did not get JSON on retrieving Settlement (%s): %s",
  401. req.status_code,
  402. req.content,
  403. )
  404. return None
  405. if "error" in data or req.status_code != 200:
  406. LOG.error("Could not get Export (%s): %s", req.status_code, data)
  407. return None
  408. data["settlement"]["count_info"] = data["count_info"]
  409. return cls.from_dict(data["settlement"])