123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- """
- Provides access to the models stored in the database, via the server.
- """
- from __future__ import annotations
- import datetime
- import enum
- import logging
- from dataclasses import dataclass
- from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union
- from urllib.parse import urljoin
- import requests
- LOG = logging.getLogger(__name__)
- SERVER_URL = "http://127.0.0.1:5000"
- DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
- class NetworkError(enum.Enum):
- """Represents errors that might occur when communicating with the server."""
- HttpFailure = "http_failure"
- """Returned when the server returns a non-successful status code."""
- ConnectionFailure = "connection_failure"
- """Returned when we can't connect to the server at all."""
- InvalidData = "invalid_data"
- class ServerStatus:
- """ Provides helper classes to check whether the server is up. """
- @classmethod
- def is_server_running(cls) -> Union[bool, NetworkError]:
- try:
- req = requests.get(urljoin(SERVER_URL, "ping"))
- req.raise_for_status()
- except requests.ConnectionError as ex:
- LOG.exception(ex)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as ex:
- LOG.exception(ex)
- return NetworkError.HttpFailure
- return True
- @dataclass(frozen=True)
- class OpenConsumptions:
- amount: int
- first_timestamp: Optional[datetime.datetime]
- last_timestamp: Optional[datetime.datetime]
- @classmethod
- def unsettled_consumptions(cls) -> Union[OpenConsumptions, NetworkError]:
- try:
- req = requests.get(urljoin(SERVER_URL, "status"))
- req.raise_for_status()
- data = req.json()
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- except ValueError as e:
- LOG.exception(e)
- return NetworkError.InvalidData
- amount: int = data["unsettled"]["amount"]
- if amount == 0:
- return cls.OpenConsumptions(
- amount=0, first_timestamp=None, last_timestamp=None
- )
- first = datetime.datetime.fromisoformat(data["unsettled"]["first"])
- last = datetime.datetime.fromisoformat(data["unsettled"]["last"])
- return cls.OpenConsumptions(
- amount=amount, first_timestamp=first, last_timestamp=last
- )
- class Person(NamedTuple):
- """ Represents a Person, as retrieved from the database. """
- full_name: str
- display_name: Optional[str]
- active: bool = True
- person_id: Optional[int] = None
- consumptions: dict = {}
- @property
- def name(self) -> str:
- return self.display_name or self.full_name
- def add_consumption(self, type_id: str) -> Optional[Consumption]:
- """ Register a consumption for this Person. """
- req = requests.post(
- urljoin(SERVER_URL, f"people/{self.person_id}/add_consumption/{type_id}")
- )
- try:
- data = req.json()
- if "error" in data:
- LOG.error(
- "Could not add consumption for %s (%s): %s",
- self.person_id,
- req.status_code,
- data,
- )
- return None
- self.consumptions.update(data["person"]["consumptions"])
- return Consumption.from_dict(data["consumption"])
- except ValueError:
- LOG.error(
- "Did not get JSON on adding Consumption (%s): %s",
- req.status_code,
- req.content,
- )
- return None
- def create(self) -> Union[Person, NetworkError]:
- """ Create a new Person from the current attributes. As tuples are
- immutable, a new Person with the correct id is returned. """
- try:
- req = requests.post(
- urljoin(SERVER_URL, "people"),
- json={
- "person": {
- "full_name": self.full_name,
- "display_name": self.display_name,
- "active": True,
- }
- },
- )
- req.raise_for_status()
- data = req.json()
- return Person.from_dict(data["person"])
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- except ValueError as e:
- LOG.exception(e)
- return NetworkError.InvalidData
- def set_active(self, new_state=True) -> Optional[Person]:
- req = requests.patch(
- urljoin(SERVER_URL, f"people/{self.person_id}"),
- json={"person": {"active": new_state}},
- )
- try:
- data = req.json()
- except ValueError:
- LOG.error(
- "Did not get JSON on updating Person (%s): %s",
- req.status_code,
- req.content,
- )
- return None
- if "error" in data or req.status_code != 200:
- LOG.error("Could not update Person (%s): %s", req.status_code, data)
- return None
- return Person.from_dict(data["person"])
- @classmethod
- def get(cls, person_id: int) -> Optional[Person]:
- """ Retrieve a Person by id. """
- req = requests.get(urljoin(SERVER_URL, f"/people/{person_id}"))
- try:
- data = req.json()
- if "error" in data:
- LOG.warning(
- "Could not get person %s (%s): %s", person_id, req.status_code, data
- )
- return None
- return Person.from_dict(data["person"])
- except ValueError:
- LOG.error(
- "Did not get JSON from server on getting Person (%s): %s",
- req.status_code,
- req.content,
- )
- return None
- @classmethod
- def get_all(cls, active=None) -> Union[List[Person], NetworkError]:
- """ Get all active People. """
- params = {}
- if active is not None:
- params["active"] = int(active)
- try:
- req = requests.get(urljoin(SERVER_URL, "/people"), params=params)
- req.raise_for_status()
- data = req.json()
- return [Person.from_dict(item) for item in data["people"]]
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- except ValueError as e:
- return NetworkError.InvalidData
- @classmethod
- def from_dict(cls, data: dict) -> "Person":
- """ Reconstruct a Person object from a dict. """
- return Person(
- full_name=data["full_name"],
- display_name=data["display_name"],
- active=data["active"],
- person_id=data["person_id"],
- consumptions=data["consumptions"],
- )
- class Export(NamedTuple):
- created_at: datetime.datetime
- settlement_ids: Sequence[int]
- export_id: int
- settlements: Sequence["Settlement"] = []
- @classmethod
- def from_dict(cls, data: dict) -> "Export":
- """ Reconstruct an Export from a dict. """
- return cls(
- export_id=data["export_id"],
- created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
- settlement_ids=data["settlement_ids"],
- settlements=data.get("settlements", []),
- )
- @classmethod
- def get_all(cls) -> Optional[List[Export]]:
- """ Get a list of all existing Exports. """
- req = requests.get(urljoin(SERVER_URL, "exports"))
- try:
- data = req.json()
- except ValueError:
- LOG.error(
- "Did not get JSON on listing Exports (%s): %s",
- req.status_code,
- req.content,
- )
- return None
- if "error" in data or req.status_code != 200:
- LOG.error("Could not list Exports (%s): %s", req.status_code, data)
- return None
- return [cls.from_dict(e) for e in data["exports"]]
- @classmethod
- def get(cls, export_id: int) -> Optional[Export]:
- """ Retrieve one Export. """
- req = requests.get(urljoin(SERVER_URL, f"exports/{export_id}"))
- try:
- data = req.json()
- except ValueError:
- LOG.error(
- "Did not get JSON on getting Export (%s): %s",
- req.status_code,
- req.content,
- )
- return None
- if "error" in data or req.status_code != 200:
- LOG.error("Could not get Export (%s): %s", req.status_code, data)
- return None
- data["export"]["settlements"] = data["settlements"]
- return cls.from_dict(data["export"])
- @classmethod
- def create(cls) -> Optional[Export]:
- """ Create a new Export, containing all un-exported Settlements. """
- req = requests.post(urljoin(SERVER_URL, "exports"))
- try:
- data = req.json()
- except ValueError:
- LOG.error(
- "Did not get JSON on adding Export (%s): %s",
- req.status_code,
- req.content,
- )
- return None
- if "error" in data or req.status_code != 201:
- LOG.error("Could not create Export (%s): %s", req.status_code, data)
- return None
- data["export"]["settlements"] = data["settlements"]
- return cls.from_dict(data["export"])
- class ConsumptionType(NamedTuple):
- """ Represents a stored ConsumptionType. """
- name: str
- consumption_type_id: Optional[int] = None
- icon: Optional[str] = None
- active: bool = True
- def create(self) -> Union[ConsumptionType, NetworkError]:
- """ Create a new ConsumptionType from the current attributes. As tuples
- are immutable, a new ConsumptionType with the correct id is returned.
- """
- try:
- req = requests.post(
- urljoin(SERVER_URL, "consumption_types"),
- json={"consumption_type": {"name": self.name, "icon": self.icon}},
- )
- req.raise_for_status()
- data = req.json()
- return ConsumptionType.from_dict(data["consumption_type"])
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- except ValueError as e:
- LOG.exception(e)
- return NetworkError.InvalidData
- @classmethod
- def get(cls, consumption_type_id: int) -> Union[ConsumptionType, NetworkError]:
- """ Retrieve a ConsumptionType by id. """
- try:
- req = requests.get(
- urljoin(SERVER_URL, f"/consumption_types/{consumption_type_id}")
- )
- req.raise_for_status()
- data = req.json()
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- except ValueError as e:
- LOG.exception(e)
- return NetworkError.InvalidData
- return cls.from_dict(data["consumption_type"])
- @classmethod
- def get_all(cls, active: bool = True) -> Union[List[ConsumptionType], NetworkError]:
- """ Get the list of ConsumptionTypes. """
- try:
- req = requests.get(
- urljoin(SERVER_URL, "/consumption_types"),
- params={"active": int(active)},
- )
- req.raise_for_status()
- data = req.json()
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- except ValueError as e:
- LOG.exception(e)
- return NetworkError.InvalidData
- return [cls.from_dict(x) for x in data["consumption_types"]]
- @classmethod
- def from_dict(cls, data: dict) -> "ConsumptionType":
- """ Reconstruct a ConsumptionType from a dict. """
- return cls(
- name=data["name"],
- consumption_type_id=data["consumption_type_id"],
- icon=data.get("icon"),
- active=data["active"],
- )
- def set_active(self, active: bool) -> Union[ConsumptionType, NetworkError]:
- """Update the 'active' attribute."""
- try:
- req = requests.patch(
- urljoin(SERVER_URL, f"/consumption_types/{self.consumption_type_id}"),
- json={"consumption_type": {"active": active}},
- )
- req.raise_for_status()
- data = req.json()
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- except ValueError as e:
- LOG.exception(e)
- return NetworkError.InvalidData
- return self.from_dict(data["consumption_type"])
- class Consumption(NamedTuple):
- """ Represents a stored Consumption. """
- consumption_id: int
- person_id: int
- consumption_type_id: int
- created_at: datetime.datetime
- reversed: bool = False
- settlement_id: Optional[int] = None
- @classmethod
- def from_dict(cls, data: dict) -> "Consumption":
- """ Reconstruct a Consumption from a dict. """
- return cls(
- consumption_id=data["consumption_id"],
- person_id=data["person_id"],
- consumption_type_id=data["consumption_type_id"],
- settlement_id=data["settlement_id"],
- created_at=datetime.datetime.strptime(data["created_at"], DATETIME_FORMAT),
- reversed=data["reversed"],
- )
- def reverse(self) -> Optional[Consumption]:
- """ Reverse this consumption. """
- req = requests.delete(
- urljoin(SERVER_URL, f"/consumptions/{self.consumption_id}")
- )
- try:
- data = req.json()
- if "error" in data:
- LOG.error(
- "Could not reverse consumption %s (%s): %s",
- self.consumption_id,
- req.status_code,
- data,
- )
- return None
- return Consumption.from_dict(data["consumption"])
- except ValueError:
- LOG.error(
- "Did not get JSON on reversing Consumption (%s): %s",
- req.status_code,
- req.content,
- )
- return None
- class Settlement(NamedTuple):
- """ Represents a stored Settlement. """
- settlement_id: int
- name: str
- consumption_summary: Dict[str, Any]
- count_info: Dict[str, Any] = {}
- per_person_counts: Dict[str, Any] = {}
- @classmethod
- def from_dict(cls, data: dict) -> "Settlement":
- return Settlement(
- settlement_id=data["settlement_id"],
- name=data["name"],
- consumption_summary=data["consumption_summary"],
- count_info=data["count_info"],
- per_person_counts=data["per_person_counts"],
- )
- @classmethod
- def create(cls, name: str) -> "Settlement":
- req = requests.post(
- urljoin(SERVER_URL, "/settlements"), json={"settlement": {"name": name}}
- )
- return cls.from_dict(req.json()["settlement"])
- @classmethod
- def get(cls, settlement_id: int) -> Union[Settlement, NetworkError]:
- try:
- req = requests.get(urljoin(SERVER_URL, f"/settlements/{settlement_id}"))
- req.raise_for_status()
- data = req.json()
- except ValueError as e:
- LOG.exception(e)
- return NetworkError.InvalidData
- except requests.ConnectionError as e:
- LOG.exception(e)
- return NetworkError.ConnectionFailure
- except requests.HTTPError as e:
- LOG.exception(e)
- return NetworkError.HttpFailure
- data["settlement"]["count_info"] = data["count_info"]
- return cls.from_dict(data["settlement"])
|