Coverage for website/moneybirdsynchronization/administration.py: 38.18%
143 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1"""The MoneyBird API.
3This code is largely based on moneybird-python by Jan-Jelle Kester,
4licensed under the MIT license. The source code of moneybird-python
5can be found on GitHub: https://github.com/jjkester/moneybird-python.
6"""
8import functools
9import logging
10import time
11from abc import ABC, abstractmethod
12from datetime import datetime
13from functools import reduce
14from urllib.parse import urljoin
16from django.conf import settings
18import requests
20logger = logging.getLogger(__name__)
23class Administration(ABC):
24 """A MoneyBird administration."""
26 administration_id = None
28 def __init__(self, administration_id: int):
29 self.administration_id = administration_id
31 @abstractmethod
32 def get(self, resource_path: str, params: dict | None = None):
33 """Do a GET on the Moneybird administration."""
35 @abstractmethod
36 def post(self, resource_path: str, data: dict):
37 """Do a POST request on the Moneybird administration."""
39 @abstractmethod
40 def patch(self, resource_path: str, data: dict):
41 """Do a PATCH request on the Moneybird administration."""
43 @abstractmethod
44 def delete(self, resource_path: str, data: dict | None = None):
45 """Do a DELETE request on the Moneybird administration."""
47 class InvalidResourcePath(Exception):
48 """The given resource path is invalid."""
50 class Error(Exception):
51 """An exception that can be thrown while using the administration."""
53 def __init__(self, status_code: int, description: str | None = None):
54 """Create a new administration error."""
55 msg = f"API error {status_code}"
56 if description: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 msg += f": {description}"
59 self.status_code = status_code
60 self.description = description
62 super().__init__(msg)
64 class Unauthorized(Error):
65 """The client has insufficient authorization."""
67 class NotFound(Error):
68 """The client requested a resource that could not be found."""
70 class InvalidData(Error):
71 """The client sent invalid data."""
73 class Throttled(Error):
74 """The client sent too many requests."""
76 retry_after: int
78 class ServerError(Error):
79 """An error happened on the server."""
81 @abstractmethod
82 def _create_session(self) -> requests.Session:
83 """Create a new session."""
85 def _build_url(self, resource_path: str) -> str:
86 if resource_path.startswith("/"):
87 raise Administration.InvalidResourcePath(
88 "The resource path must not start with a slash."
89 )
91 api_base_url = "https://moneybird.com/api/v2/"
92 url_parts = [
93 api_base_url,
94 f"{self.administration_id}/",
95 f"{resource_path}.json",
96 ]
97 return reduce(urljoin, url_parts)
99 def _process_response(self, response: requests.Response) -> dict | None:
100 logger.debug(f"Response {response.status_code}: {response.text}")
102 if response.next:
103 logger.debug(f"Received paginated response: {response.next}")
105 good_codes = {200, 201, 204}
106 bad_codes = {
107 400: Administration.InvalidData,
108 401: Administration.Unauthorized,
109 403: Administration.Unauthorized,
110 404: Administration.NotFound,
111 406: Administration.InvalidData,
112 422: Administration.InvalidData,
113 429: Administration.Throttled,
114 500: Administration.ServerError,
115 }
117 code = response.status_code
119 code_is_known: bool = code in good_codes | bad_codes.keys()
121 if not code_is_known:
122 logger.warning(f"Unknown response code {code}")
123 raise Administration.Error(
124 code, "API response contained unknown status code"
125 )
127 if code in bad_codes:
128 error = bad_codes[code]
129 if error == Administration.Throttled:
130 e = Administration.Throttled(code, "Throttled")
131 e.retry_after = response.headers.get("Retry-After")
132 e.rate_limit_remaining = response.headers.get("RateLimit-Remaining")
133 e.rate_limit_limit = response.headers.get("RateLimit-Limit")
134 e.rate_limit_reset = response.headers.get("RateLimit-Reset")
135 error_description = f"retry after {e.retry_after}"
136 else:
137 try:
138 error_description = response.json()["error"]
139 except (AttributeError, TypeError, KeyError, ValueError):
140 error_description = None
142 e = error(code, error_description)
144 logger.warning(f"API error {code}: {e}")
146 raise e
148 if code == 204:
149 return {}
151 if response.text == "200":
152 return {}
154 return response.json()
157def _retry_if_throttled():
158 max_retries = 3
160 def decorator_retry(func):
161 @functools.wraps(func)
162 def wrapper(*args, **kwargs):
163 retries = 0
164 while retries < max_retries:
165 try:
166 return func(*args, **kwargs)
167 except HttpsAdministration.Throttled as e:
168 retries += 1
169 retry_after = datetime.fromtimestamp(float(e.retry_after))
170 now = datetime.now()
171 sleep_seconds = int(retry_after.timestamp() - now.timestamp()) + 1
172 if retries < max_retries:
173 logger.info(f"Retrying in {sleep_seconds} seconds...")
174 time.sleep(sleep_seconds)
175 else:
176 logger.warning("Max retries reached. Giving up.")
177 return None
179 return wrapper
181 return decorator_retry
184class HttpsAdministration(Administration):
185 """The HTTPS implementation of the MoneyBird Administration interface."""
187 def __init__(self, key: str, administration_id: int):
188 """Create a new MoneyBird administration connection."""
189 super().__init__(administration_id)
190 self.key = key
191 self.session = self._create_session()
193 def _create_session(self) -> requests.Session:
194 session = requests.Session()
195 session.headers.update({"Authorization": f"Bearer {self.key}"})
196 return session
198 @_retry_if_throttled()
199 def get(self, resource_path: str, params: dict | None = None):
200 """Do a GET on the Moneybird administration."""
201 url = self._build_url(resource_path)
202 logger.debug(f"GET {url} {params}")
203 response = self.session.get(url, params=params)
204 return self._process_response(response)
206 @_retry_if_throttled()
207 def post(self, resource_path: str, data: dict):
208 """Do a POST request for json data on the Moneybird administration."""
209 url = self._build_url(resource_path)
210 logger.debug(f"POST {url} with {data}")
211 response = self.session.post(url, json=data)
212 return self._process_response(response)
214 @_retry_if_throttled()
215 def post_files(self, resource_path: str, files: dict[str, bytes]):
216 """Do a POST request for a file or other data on the Moneybird administration."""
217 url = self._build_url(resource_path)
218 logger.debug(f"POST {url} with {files}")
219 response = self.session.post(url, files=files)
220 return self._process_response(response)
222 @_retry_if_throttled()
223 def patch(self, resource_path: str, data: dict):
224 """Do a PATCH request on the Moneybird administration."""
225 url = self._build_url(resource_path)
226 logger.debug(f"PATCH {url} with {data}")
227 response = self.session.patch(url, json=data)
228 return self._process_response(response)
230 @_retry_if_throttled()
231 def delete(self, resource_path: str, data: dict | None = None):
232 """Do a DELETE on the Moneybird administration."""
233 url = self._build_url(resource_path)
234 logger.debug(f"DELETE {url}")
235 response = self.session.delete(url, json=data)
236 return self._process_response(response)
239class MoneybirdNotConfiguredError(RuntimeError):
240 pass
243def get_moneybird_administration():
244 if settings.MONEYBIRD_ADMINISTRATION_ID and settings.MONEYBIRD_API_KEY:
245 return HttpsAdministration(
246 settings.MONEYBIRD_API_KEY, settings.MONEYBIRD_ADMINISTRATION_ID
247 )
248 raise MoneybirdNotConfiguredError()