python-easyverein 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
easyverein/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Middleware for FastAPI that supports authenticating users against Keycloak
3
+ """
4
+
5
+ __version__ = "0.0.1"
6
+
7
+ # Export EasyVerein API directly
8
+ from .api import EasyvereinAPI # noqa: F401
easyverein/api.py ADDED
@@ -0,0 +1,43 @@
1
+ """
2
+ Main EasyVerein API class
3
+ """
4
+ import logging
5
+
6
+ from .core.client import EasyvereinClient
7
+ from .modules.contact_details import ContactDetailsMixin
8
+ from .modules.custom_field import CustomFieldMixin
9
+ from .modules.invoice import InvoiceMixin
10
+ from .modules.invoice_item import InvoiceItemMixin
11
+ from .modules.member import MemberMixin
12
+ from .modules.member_custom_field import MemberCustomFieldMixin
13
+
14
+
15
+ class EasyvereinAPI:
16
+ def __init__(
17
+ self,
18
+ api_key,
19
+ api_version="v1.7",
20
+ base_url: str = "https://hexa.easyverein.com/api/",
21
+ logger: logging.Logger = None,
22
+ ):
23
+ """
24
+ Constructor setting API key and logger. Test
25
+ """
26
+
27
+ super().__init__()
28
+
29
+ if logger:
30
+ self.logger = logger
31
+ else:
32
+ self.logger = logging.getLogger("easyverein")
33
+
34
+ self.c = EasyvereinClient(api_key, api_version, base_url, self.logger, self)
35
+
36
+ # Add methods
37
+
38
+ self.contact_details = ContactDetailsMixin(self.c, self.logger)
39
+ self.custom_field = CustomFieldMixin(self.c, self.logger)
40
+ self.invoice = InvoiceMixin(self.c, self.logger)
41
+ self.invoice_item = InvoiceItemMixin(self.c, self.logger)
42
+ self.member = MemberMixin(self.c, self.logger)
43
+ self.member_custom_field = MemberCustomFieldMixin(self.c, self.logger)
File without changes
@@ -0,0 +1,314 @@
1
+ """
2
+ Main EasyVerein API class
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, TypeVar
9
+
10
+ import requests
11
+ from pydantic import BaseModel
12
+
13
+ from .exceptions import EasyvereinAPIException, EasyvereinAPITooManyRetriesException
14
+
15
+ if TYPE_CHECKING:
16
+ from .. import EasyvereinAPI
17
+
18
+ T = TypeVar("T")
19
+
20
+
21
+ class EasyvereinClient:
22
+ """
23
+ Class encapsulating common function used by all API methods
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ api_key,
29
+ api_version,
30
+ base_url,
31
+ logger: logging.Logger,
32
+ instance: EasyvereinAPI,
33
+ ):
34
+ """
35
+ Constructor setting API key and logger
36
+ """
37
+ self.api_key = api_key
38
+ self.base_url = base_url
39
+ self.api_version = api_version
40
+ self.logger = logger
41
+ self.api_instance = instance
42
+
43
+ def _get_header(self):
44
+ """
45
+ Constructs a header for the API request
46
+ """
47
+ return {"Authorization": "Bearer " + self.api_key}
48
+
49
+ def get_url(self, path: str, url_params: dict = None) -> str:
50
+ """
51
+ Constructs a URL for the API request.
52
+
53
+ :param path: Base path of the request
54
+ :param url_params: additional path parameters to append
55
+ """
56
+ url = f"{self.base_url}{self.api_version}{path}"
57
+
58
+ self.logger.debug(f"Base URL is {url}")
59
+
60
+ if url_params:
61
+ for key, value in url_params.items():
62
+ if not value:
63
+ continue
64
+ self.logger.debug(f"Adding {key}={value} path parameter to URL")
65
+ if "?" not in url:
66
+ url += f"?{key}={value}"
67
+ else:
68
+ url += f"&{key}={value}"
69
+
70
+ self.logger.debug(f"Final constructed URL is {url}")
71
+
72
+ return url
73
+
74
+ def _do_request( # noqa: PLR0913
75
+ self, method, url, data=None, headers=None, files=None
76
+ ):
77
+ """
78
+ Helper method that performs an actual call against the API,
79
+ fetching the most common errors
80
+ """
81
+ self.logger.debug("Performing %s request to %s", method, url)
82
+ if data:
83
+ self.logger.debug("Request data: %s", data)
84
+ if headers:
85
+ self.logger.debug("Provided request headers: %s", headers)
86
+
87
+ # Merge auth header with custom headers
88
+ final_headers = self._get_header() | (headers or {})
89
+
90
+ self.logger.debug("Final request headers: %s", final_headers)
91
+
92
+ func = getattr(requests, method)
93
+ if data:
94
+ res = func(url, headers=final_headers, json=data, files=files or {})
95
+ else:
96
+ res = func(url, headers=final_headers, files=files)
97
+
98
+ self.logger.debug("Request returned status code %d", res.status_code)
99
+
100
+ if res.status_code == 429:
101
+ retry_after = res.headers("Retry-After")
102
+ self.logger.warning(
103
+ "Request returned status code 429, too many requests. Wait %d seconds",
104
+ retry_after,
105
+ )
106
+ raise EasyvereinAPITooManyRetriesException(
107
+ retry_after,
108
+ f"Too many requests, please wait {retry_after} seconds and try again.",
109
+ )
110
+
111
+ if res.status_code == 404:
112
+ self.logger.warning("Request returned status code 404, resource not found")
113
+ raise EasyvereinAPIException("Requested resource not found")
114
+
115
+ # In some cases (for example on 204 delete) the response is empty
116
+ if res.content == b"":
117
+ return res.status_code, None
118
+
119
+ # Try to parse response as JSON and return it for further processing
120
+ try:
121
+ content = res.json()
122
+ except ValueError:
123
+ self.logger.error("Unable to parse response content as JSON")
124
+ self.logger.debug("Response content: %s", res.content)
125
+ content = None
126
+
127
+ return res.status_code, content
128
+
129
+ def create(
130
+ self,
131
+ url,
132
+ data: BaseModel = None,
133
+ return_model: type[T] = None,
134
+ status_code: int = 201,
135
+ ) -> T:
136
+ """
137
+ Method to create an object in the API
138
+ """
139
+ return self._handle_response(
140
+ self._do_request(
141
+ "post",
142
+ url,
143
+ data=data.model_dump(
144
+ exclude_none=True, exclude_unset=True, by_alias=True
145
+ ),
146
+ ),
147
+ return_model,
148
+ status_code,
149
+ )
150
+
151
+ def delete(self, url, status_code: int = 204):
152
+ """
153
+ Method to delete an object in the API
154
+ """
155
+ return self._handle_response(
156
+ self._do_request("delete", url), expected_status_code=status_code
157
+ )
158
+
159
+ def update(
160
+ self, url, data: BaseModel = None, model: type[T] = None, status_code: int = 200
161
+ ) -> T:
162
+ """
163
+ Method to update an object in the API
164
+ """
165
+ return self._handle_response(
166
+ self._do_request(
167
+ "patch",
168
+ url,
169
+ data=data.model_dump(
170
+ exclude_none=True, exclude_unset=True, by_alias=True
171
+ ),
172
+ ),
173
+ model,
174
+ expected_status_code=status_code,
175
+ )
176
+
177
+ def upload(
178
+ self,
179
+ url: str,
180
+ field_name: str,
181
+ file: Path,
182
+ model: type[T] = None,
183
+ status_code: int = 200,
184
+ ) -> T:
185
+ """
186
+ This method uploads a file to a certain endpoint.
187
+
188
+ Only tested with invoices so far
189
+ """
190
+ # Check that path is a file and it exists
191
+ if not file.exists() or not file.is_file():
192
+ self.logger.error("File does not exist or is not a file.")
193
+ raise FileNotFoundError("File does not exist")
194
+
195
+ files = {field_name: open(file, "rb")}
196
+ headers = {"Content-Disposition": f'name="file"; filename="{file.name}"'}
197
+
198
+ return self._handle_response(
199
+ self._do_request(
200
+ "patch",
201
+ url,
202
+ headers=headers,
203
+ files=files,
204
+ ),
205
+ model,
206
+ status_code,
207
+ )
208
+
209
+ def fetch(self, url, model: type[T] = None) -> list[T]:
210
+ """
211
+ Helper method that fetches a result from an API call
212
+
213
+ Only supports GET endpoints
214
+ """
215
+ res = self._do_request("get", url)
216
+ return self._handle_response(res, model, 200)
217
+
218
+ def fetch_one(self, url, model: type[T] = None) -> T | None:
219
+ """
220
+ Helper method that fetches a result from an API call
221
+
222
+ Only supports GET endpoints
223
+ """
224
+ reply = self.fetch(url, model)
225
+ if isinstance(reply, list):
226
+ if len(reply) == 0:
227
+ return None
228
+
229
+ self.logger.warning(
230
+ "One object was requested, but multiple objects were returned. Returning first."
231
+ )
232
+ self.logger.debug(f"In total {len(reply)} objects where returned.")
233
+ return reply[0]
234
+
235
+ return reply
236
+
237
+ def fetch_paginated(self, url, model: type[T] = None, limit=100) -> list[T]:
238
+ """
239
+ Helper method that fetches all pages of a paginated API call
240
+
241
+ Only supports GET endpoints
242
+ """
243
+
244
+ self.logger.debug("Fetching paginated API call %s, limit is %d", url, limit)
245
+
246
+ # Add limit parameter to URL
247
+ if "?" not in url:
248
+ url += f"?limit={limit}"
249
+ else:
250
+ url += f"&limit={limit}"
251
+
252
+ resources = []
253
+ status_code: int = 0
254
+
255
+ while url is not None:
256
+ self.logger.debug("Fetching page of paginated API call %s", url)
257
+
258
+ status_code, result = self._do_request("get", url)
259
+ self.logger.debug("Request returned status code %d", status_code)
260
+
261
+ if not status_code == 200:
262
+ self.logger.error(
263
+ "Could not fetch paginated API %s, status code %d", url, status_code
264
+ )
265
+ self.logger.debug("API response: %s", result)
266
+ raise EasyvereinAPIException(
267
+ f"Could not fetch paginated API {url}, "
268
+ f"status code {status_code}. API response: {result}"
269
+ )
270
+
271
+ resources.extend(result["results"])
272
+ url = result["next"]
273
+
274
+ return self._handle_response((status_code, resources), model, 200)
275
+
276
+ def _handle_response(
277
+ self,
278
+ res: tuple[int, list | dict],
279
+ model: type[T] = None,
280
+ expected_status_code=200,
281
+ ) -> T | list[T]:
282
+ """
283
+ Helper method that handles API responses
284
+ """
285
+ status_code, data = res
286
+ if status_code != expected_status_code:
287
+ raise EasyvereinAPIException(
288
+ f"API returned status code {status_code}. API response: {data}"
289
+ )
290
+ else:
291
+ self.logger.debug("API returned status code %d", status_code)
292
+
293
+ # if no data is expected return raw data (usually None)
294
+ if not model:
295
+ self.logger.debug("No model provided. Returning raw data: %s", data)
296
+ return data
297
+
298
+ self.logger.debug("Received raw data: %s", data)
299
+
300
+ # if data is a list, parse each entry
301
+ # fetch_paginated returns a list of result entries instead of raw data, this is why this case is here.
302
+ if isinstance(data, list):
303
+ objects = []
304
+ for obj in data:
305
+ objects.append(model.model_validate(obj))
306
+ elif isinstance(data, dict) and "results" in data:
307
+ objects = []
308
+ for obj in data["results"]:
309
+ objects.append(model.model_validate(obj))
310
+ else:
311
+ # Handle the case when data is not a list
312
+ objects = model.model_validate(data)
313
+
314
+ return objects
@@ -0,0 +1,18 @@
1
+ """
2
+ This module contains exceptions used by the library.
3
+ """
4
+
5
+
6
+ class EasyvereinAPIException(Exception):
7
+ """
8
+ Exception describing an error that occurred while interacting
9
+ with the easyVerein API
10
+ """
11
+
12
+
13
+ class EasyvereinAPITooManyRetriesException(EasyvereinAPIException):
14
+ """
15
+ Exception if the API returns a 429 Too Many Requests error
16
+ """
17
+
18
+ retry_after = 0
@@ -0,0 +1,29 @@
1
+ import logging
2
+ from typing import Protocol, Type, TypeVar
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from .client import EasyvereinClient
7
+
8
+
9
+ # noinspection PyPropertyDefinition
10
+ class IsEVClientProtocol(Protocol):
11
+ @property
12
+ def logger(self) -> logging.Logger:
13
+ ...
14
+
15
+ @property
16
+ def c(self) -> EasyvereinClient:
17
+ ...
18
+
19
+ @property
20
+ def endpoint_name(self) -> str:
21
+ ...
22
+
23
+ @property
24
+ def model_class(self) -> TypeVar:
25
+ ...
26
+
27
+ @property
28
+ def return_type(self) -> Type[BaseModel]:
29
+ ...
@@ -0,0 +1,41 @@
1
+ """
2
+ Custom types used for model validation
3
+ """
4
+ import datetime
5
+ import json
6
+ from typing import Annotated
7
+
8
+ from pydantic import BeforeValidator, EmailStr, Field, PlainSerializer, UrlConstraints
9
+ from pydantic_core import Url
10
+
11
+ from .validators import empty_string_to_none, parse_json_string
12
+
13
+ AnyHttpURL = Annotated[
14
+ Url,
15
+ UrlConstraints(allowed_schemes=["http", "https"]),
16
+ PlainSerializer(lambda x: str(x), return_type=str),
17
+ ]
18
+ EasyVereinReference = Annotated[
19
+ int | AnyHttpURL | None, BeforeValidator(empty_string_to_none)
20
+ ]
21
+ PositiveIntWithZero = Annotated[int, Field(ge=0)]
22
+ Date = Annotated[
23
+ datetime.date, PlainSerializer(lambda x: x.strftime("%Y-%m-%d"), return_type=str)
24
+ ]
25
+ DateTime = Annotated[
26
+ datetime.datetime,
27
+ PlainSerializer(lambda x: x.strftime("%Y-%m-%dT%H:%M:%S"), return_type=str),
28
+ ]
29
+ OptionsField = Annotated[
30
+ list[str] | None,
31
+ PlainSerializer(lambda x: json.dumps(x), return_type=str),
32
+ BeforeValidator(parse_json_string),
33
+ BeforeValidator(empty_string_to_none),
34
+ ]
35
+ HexColor = Annotated[
36
+ str | None,
37
+ Field(min_length=7, max_length=7),
38
+ BeforeValidator(empty_string_to_none),
39
+ ]
40
+
41
+ Email = Annotated[EmailStr | None, BeforeValidator(empty_string_to_none)]
@@ -0,0 +1,14 @@
1
+ import json
2
+ from typing import Any
3
+
4
+
5
+ def empty_string_to_none(v: Any) -> Any:
6
+ if isinstance(v, str) and v == "":
7
+ return None
8
+ return v
9
+
10
+
11
+ def parse_json_string(v: Any) -> Any:
12
+ if isinstance(v, str):
13
+ return json.loads(v)
14
+ return v
@@ -0,0 +1,18 @@
1
+ # noqa: F401
2
+ from .contact_details import ContactDetails, ContactDetailsCreate, ContactDetailsUpdate
3
+ from .custom_field import CustomField, CustomFieldCreate, CustomFieldUpdate
4
+ from .invoice import Invoice, InvoiceCreate, InvoiceUpdate
5
+ from .invoice_item import InvoiceItem, InvoiceItemCreate, InvoiceItemUpdate
6
+ from .member import Member, MemberCreate, MemberUpdate
7
+ from .member_custom_field import (
8
+ MemberCustomField,
9
+ MemberCustomFieldCreate,
10
+ MemberCustomFieldUpdate,
11
+ )
12
+
13
+ ContactDetails.model_rebuild()
14
+ CustomField.model_rebuild()
15
+ Invoice.model_rebuild()
16
+ InvoiceItem.model_rebuild()
17
+ Member.model_rebuild()
18
+ MemberCustomField.model_rebuild()
@@ -0,0 +1,17 @@
1
+ from pydantic import BaseModel, Field, PositiveInt
2
+
3
+ from ..core.types import DateTime, EasyVereinReference
4
+
5
+
6
+ class EasyVereinBase(BaseModel):
7
+ """
8
+ Base class encapsulating common fields for all models
9
+ """
10
+
11
+ id: PositiveInt | None = None
12
+ org: EasyVereinReference | None = None
13
+ # TODO: Add reference to Organization once implemented
14
+ deleteAfterDate: DateTime | None = Field(default=None, alias="_deleteAfterDate")
15
+ """Alias for `_deleteAfterDate` field. See [Pydantic Models](../usage.md#pydantic-models) for details."""
16
+ deletedBy: str | None = Field(default=None, alias="_deletedBy")
17
+ """Alias for `_deletedBy` field. See [Pydantic Models](../usage.md#pydantic-models) for details."""
@@ -0,0 +1,137 @@
1
+ """
2
+ Contact Details related models
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import Field
9
+
10
+ from ..core.types import Date, Email
11
+ from .base import EasyVereinBase
12
+ from .mixins.required_attributes import required_mixin
13
+
14
+
15
+ class ContactDetails(EasyVereinBase):
16
+ """
17
+ | Representative Model Class | Update Model Class | Create Model Class |
18
+ | --- | --- | --- |
19
+ | `ContactDetails` | `ContactDetailsUpdate` | `ContactDetailsCreate` |
20
+
21
+ !!! info "Contact Details and Members"
22
+ Note that contact details can be created standalone (independently of members), but members
23
+ are required to have a contact details object linked.
24
+ """
25
+
26
+ isCompany: bool | None = Field(default=None, alias="_isCompany")
27
+ """Alias for `_isCompany` field. See [Pydantic Models](../usage.md#pydantic-models) for details."""
28
+ salutation: Literal["", "Herr", "Frau"] | None = None
29
+ firstName: str | None = Field(default=None, max_length=128)
30
+ familyName: str | None = Field(default=None, max_length=128)
31
+ nameAffix: str | None = Field(default=None, max_length=100)
32
+ dateOfBirth: Date | None = None
33
+ internalNote: str | None = None
34
+ privateEmail: Email | None = None
35
+ companyEmail: Email | None = None
36
+ companyEmailInvoice: Email | None = None
37
+ primaryEmail: str | None = "email"
38
+ preferredEmailField: Literal[0, 1, 2] | None = Field(
39
+ default=None, alias="_preferredEmailField"
40
+ )
41
+ """
42
+ Alias for `_preferredEmailField` field. See [Pydantic Models](../usage.md#pydantic-models) for details.
43
+
44
+ Possible values:
45
+
46
+ - 0: same as login
47
+ - 1: private
48
+ - 2: company
49
+ """
50
+ # Hint: 0 = mail, 1 = phone, 3 = no communication
51
+ preferredCommunicationWay: Literal[0, 1, 2] | None = None
52
+ companyName: str | None = None
53
+ invoiceCompany: bool | None = None
54
+ sendInvoiceCompanyMail: bool | None = None
55
+ addressCompany: bool | None = None
56
+ privatePhone: str | None = Field(default=None, max_length=100)
57
+ companyPhone: str | None = Field(default=None, max_length=100)
58
+ mobilePhone: str | None = Field(default=None, max_length=100)
59
+ street: str | None = Field(default=None, max_length=128)
60
+ city: str | None = Field(default=None, max_length=100)
61
+ state: str | None = Field(default=None, max_length=64)
62
+ additionalAdressInfo: str | None = Field(
63
+ default=None, max_length=128
64
+ ) # Intentionally written wrong, as per API
65
+ zip: str | None = Field(default=None, max_length=20)
66
+ country: str | None = Field(default=None, max_length=50)
67
+ companyStreet: str | None = Field(default=None, max_length=100)
68
+ companyCity: str | None = Field(default=None, max_length=64)
69
+ companyState: str | None = Field(default=None, max_length=100)
70
+ companyZip: str | None = Field(default=None, max_length=20)
71
+ companyCountry: str | None = Field(default=None, max_length=50)
72
+ professionalRole: str | None = Field(default=None, max_length=500)
73
+ balance: float | None = None
74
+ iban: str | None = Field(default=None, max_length=50)
75
+ bic: str | None = Field(default=None, max_length=100)
76
+ bankAccountOwner: str | None = Field(default=None, max_length=128)
77
+ sepaMandate: str | None = Field(default=None, max_length=60)
78
+ sepaDate: Date | None = None
79
+ methodOfPayment: int | None = None
80
+ """
81
+ Defines the method of payment preferred by the user.
82
+
83
+ Possible values:
84
+
85
+ - 0: not selected
86
+ - 1: direct debit
87
+ - 2: bank transfer
88
+ - 3: cash
89
+ - 4: other
90
+ """
91
+ datevAccountNumber: int | None = None
92
+ # TODO: Refine once available from API description
93
+ copiedFromParent: Any | None = Field(default=None, alias="_copiedFromParent")
94
+ """
95
+ Alias for `_copiedFromParent` field. See [Pydantic Models](../usage.md#pydantic-models) for details.
96
+ """
97
+ # TODO: Refine once available from API description
98
+ copiedFromParentStartDate: Any | None = Field(
99
+ default=None,
100
+ alias="_copiedFromParentStartDate",
101
+ )
102
+ """
103
+ Alias for `_copiedFromParentStartDate` field. See [Pydantic Models](../usage.md#pydantic-models) for details.
104
+ """
105
+ # TODO: Refine once available from API description
106
+ copiedFromParentEndDate: Any | None = Field(
107
+ default=None,
108
+ alias="_copiedFromParentEndDate",
109
+ )
110
+ """
111
+ Alias for `_copiedFromParentEndDate` field. See [Pydantic Models](../usage.md#pydantic-models) for details.
112
+ """
113
+ # TODO: Refine once available from API description
114
+ copiedFromParentEndDateAction: Any | None = Field(
115
+ default=None,
116
+ alias="_copiedFromParentEndDateAction",
117
+ )
118
+ """
119
+ Alias for `_copiedFromParentEndDateAction` field. See [Pydantic Models](../usage.md#pydantic-models) for details.
120
+ """
121
+
122
+
123
+ class ContactDetailsUpdate(ContactDetails):
124
+ """
125
+ Pydantic model used to update contact details
126
+ """
127
+
128
+ isCompany: bool | None = Field(default=None, serialization_alias="_isCompany")
129
+ preferredEmailField: Literal[0, 1, 2] | None = Field(
130
+ default=None, alias="_preferredEmailField"
131
+ )
132
+
133
+
134
+ class ContactDetailsCreate(ContactDetailsUpdate, required_mixin(["isCompany"])):
135
+ """
136
+ Pydantic model for creating new contact details
137
+ """