""" 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 rename( self, new_full_name: Optional[str], new_display_name: Optional[str] ) -> Optional[Person]: person_payload: Dict[str, str] = {} if new_full_name is not None: person_payload["full_name"] = new_full_name if new_display_name is not None: person_payload["display_name"] = new_display_name req = requests.patch( urljoin(SERVER_URL, f"people/{self.person_id}"), json={"person": person_payload}, ) 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"]) 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: LOG.exception(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"]) @dataclass(frozen=True) class AardbeiActivity: aardbei_id: int name: str @classmethod def from_dict(cls, data: Dict[str, Any]) -> AardbeiActivity: return cls(data["activity"]["id"], data["activity"]["name"]) @classmethod def get_available( cls, token: str, endpoint: str ) -> Union[List[AardbeiActivity], NetworkError]: try: req = requests.post( urljoin(SERVER_URL, "/aardbei/get_activities"), json={"endpoint": endpoint, "token": token}, ) req.raise_for_status() return [cls.from_dict(x) for x in req.json()["activities"]] 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 apply_activity( cls, token: str, endpoint: str, activity_id: int ) -> Union[int, NetworkError]: try: req = requests.post( urljoin(SERVER_URL, "/aardbei/apply_activity"), json={"activity_id": activity_id, "token": token, "endpoint": endpoint}, ) req.raise_for_status() data = req.json() return data["activity"]["response_counts"]["present"] 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 @dataclass(frozen=True) class AardbeiPeopleDiff: altered_name: List[str] link_existing: List[str] new_people: List[str] num_changes: int @classmethod def from_dict(cls, data: Dict[str, Any]) -> AardbeiPeopleDiff: return cls(**data) @classmethod def get_diff( cls, token: str, endpoint: str ) -> Union[AardbeiPeopleDiff, NetworkError]: try: req = requests.post( urljoin(SERVER_URL, "/aardbei/diff_people"), json={"endpoint": endpoint, "token": token}, ) req.raise_for_status() data = req.json() return cls.from_dict(data) 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 sync(cls, token: str, endpoint: str) -> Union[AardbeiPeopleDiff, NetworkError]: try: req = requests.post( urljoin(SERVER_URL, "/aardbei/sync_people"), json={"endpoint": endpoint, "token": token}, ) req.raise_for_status() data = req.json() return cls.from_dict(data) 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