From a5afab547c4bfc3dcaa709cff1df36683ac725d8 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:40:36 +0100 Subject: [PATCH 01/12] add `integrationLsbSport` and `integrationLsbGender`, specify types for DOSB fields --- easyverein/models/member.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/easyverein/models/member.py b/easyverein/models/member.py index b7c8a0d..3959032 100644 --- a/easyverein/models/member.py +++ b/easyverein/models/member.py @@ -63,7 +63,6 @@ class MemberBase(EasyVereinBase): paymentIntervallMonths: PositiveInt | Literal[-1] = 1 useBalanceForMembershipFee: bool | None = None bulletinBoardNewPostNotification: bool | None = None - integrationDosbGender: Literal["m", "w", "d"] | None = None isApplication: bool | None = Field(default=None, alias="_isApplication") """ Alias for `_isApplication` field. See [Pydantic Models](../usage.md#pydantic-models) for details. @@ -83,8 +82,10 @@ class MemberBase(EasyVereinBase): Alias for `_editableByRelatedMembers` field. See [Pydantic Models](../usage.md#pydantic-models) for details. """ sepaMandateFile: AnyHttpURL | str | None = None - # TODO: exact type is not specified in API docs - integrationDosbSport: list | None = None + integrationLsbSport: list[EasyVereinReference] | None = None + integrationLsbGender: Literal["m", "f", "d"] | None = None + integrationDosbSport: list[EasyVereinReference] | None = None + integrationDosbGender: Literal["m", "w", "d"] | None = None customFields: list[EasyVereinReference] | list[MemberCustomField] | None = None memberGroups: list[EasyVereinReference] | list[MemberMemberGroup] | None = None From fc3901aeb85e25fb8b33ccd8ac3971f7841049f4 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:31:31 +0100 Subject: [PATCH 02/12] extend `integrationLsbSport` and `integrationDosbGender` types; add `LsbDosbSport` model --- easyverein/models/base.py | 9 +++++++++ easyverein/models/member.py | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/easyverein/models/base.py b/easyverein/models/base.py index 46a6e8b..62e96cc 100644 --- a/easyverein/models/base.py +++ b/easyverein/models/base.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field, PositiveInt +from .mixins.empty_strings_mixin import EmptyStringsToNone from ..core.types import DateTime, EasyVereinReference @@ -15,3 +16,11 @@ class EasyVereinBase(BaseModel): """Alias for `_deleteAfterDate` field. See [Pydantic Models](../usage.md#pydantic-models) for details.""" deletedBy: str | None = Field(default=None, alias="_deletedBy") """Alias for `_deletedBy` field. See [Pydantic Models](../usage.md#pydantic-models) for details.""" + + + + +class LsbDosbSport(EasyVereinBase, EmptyStringsToNone): + title: str | None = None + sportNumber: str | None = None + federationNumber: str | None = None \ No newline at end of file diff --git a/easyverein/models/member.py b/easyverein/models/member.py index 3959032..a5b2c15 100644 --- a/easyverein/models/member.py +++ b/easyverein/models/member.py @@ -16,7 +16,7 @@ FilterIntList, FilterStrList, ) -from .base import EasyVereinBase +from .base import EasyVereinBase, LsbDosbSport from .mixins.empty_strings_mixin import EmptyStringsToNone from .mixins.required_attributes import required_mixin @@ -82,10 +82,10 @@ class MemberBase(EasyVereinBase): Alias for `_editableByRelatedMembers` field. See [Pydantic Models](../usage.md#pydantic-models) for details. """ sepaMandateFile: AnyHttpURL | str | None = None - integrationLsbSport: list[EasyVereinReference] | None = None + integrationLsbSport: list[EasyVereinReference] | list[LsbDosbSport] | None = None integrationLsbGender: Literal["m", "f", "d"] | None = None integrationDosbSport: list[EasyVereinReference] | None = None - integrationDosbGender: Literal["m", "w", "d"] | None = None + integrationDosbGender: Literal["m", "w", "d"] | list[LsbDosbSport] | None = None customFields: list[EasyVereinReference] | list[MemberCustomField] | None = None memberGroups: list[EasyVereinReference] | list[MemberMemberGroup] | None = None From a33ca0a88100dfc2535b1e46056b17bb84764489 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:43:00 +0100 Subject: [PATCH 03/12] format & lint --- easyverein/models/base.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/easyverein/models/base.py b/easyverein/models/base.py index 62e96cc..ba3dc3c 100644 --- a/easyverein/models/base.py +++ b/easyverein/models/base.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, Field, PositiveInt -from .mixins.empty_strings_mixin import EmptyStringsToNone from ..core.types import DateTime, EasyVereinReference +from .mixins.empty_strings_mixin import EmptyStringsToNone class EasyVereinBase(BaseModel): @@ -18,9 +18,7 @@ class EasyVereinBase(BaseModel): """Alias for `_deletedBy` field. See [Pydantic Models](../usage.md#pydantic-models) for details.""" - - class LsbDosbSport(EasyVereinBase, EmptyStringsToNone): title: str | None = None sportNumber: str | None = None - federationNumber: str | None = None \ No newline at end of file + federationNumber: str | None = None From d8d74317cf0177b118153e1765f2bbf0c8ec2fe8 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:48:28 +0100 Subject: [PATCH 04/12] Update `LsbDosbSport.title` to be non-optional --- easyverein/models/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easyverein/models/base.py b/easyverein/models/base.py index ba3dc3c..b7af7be 100644 --- a/easyverein/models/base.py +++ b/easyverein/models/base.py @@ -19,6 +19,6 @@ class EasyVereinBase(BaseModel): class LsbDosbSport(EasyVereinBase, EmptyStringsToNone): - title: str | None = None + title: str sportNumber: str | None = None federationNumber: str | None = None From caecba53a4df0005609518705ab255910ee5ff42 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:50:35 +0100 Subject: [PATCH 05/12] add docstring to `LsbDosbSport` model --- easyverein/models/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/easyverein/models/base.py b/easyverein/models/base.py index b7af7be..6b6a9c0 100644 --- a/easyverein/models/base.py +++ b/easyverein/models/base.py @@ -19,6 +19,10 @@ class EasyVereinBase(BaseModel): class LsbDosbSport(EasyVereinBase, EmptyStringsToNone): + """ + Pydantic Model for a LSB/DOSB Sport. + """ + title: str sportNumber: str | None = None federationNumber: str | None = None From 96b78637c2d73d094df15d25ffba913772ffa360 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:22:24 +0100 Subject: [PATCH 06/12] Update `integrationLsbGender` type: replace 'f' with 'w' --- easyverein/models/member.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/easyverein/models/member.py b/easyverein/models/member.py index a5b2c15..732dd18 100644 --- a/easyverein/models/member.py +++ b/easyverein/models/member.py @@ -83,7 +83,7 @@ class MemberBase(EasyVereinBase): """ sepaMandateFile: AnyHttpURL | str | None = None integrationLsbSport: list[EasyVereinReference] | list[LsbDosbSport] | None = None - integrationLsbGender: Literal["m", "f", "d"] | None = None + integrationLsbGender: Literal["m", "w", "d"] | None = None integrationDosbSport: list[EasyVereinReference] | None = None integrationDosbGender: Literal["m", "w", "d"] | list[LsbDosbSport] | None = None customFields: list[EasyVereinReference] | list[MemberCustomField] | None = None From e9901a26181583499b7e6c090ccec5b179a72e16 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:09:11 +0100 Subject: [PATCH 07/12] Add methods to set LSB/DOSB sports; introduce related models --- easyverein/models/__init__.py | 2 +- easyverein/models/member.py | 21 +++++++++++++++++++++ easyverein/modules/member.py | 20 +++++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/easyverein/models/__init__.py b/easyverein/models/__init__.py index c8fa722..960ef53 100644 --- a/easyverein/models/__init__.py +++ b/easyverein/models/__init__.py @@ -19,7 +19,7 @@ InvoiceItemFilter, InvoiceItemUpdate, ) -from .member import Member, MemberCreate, MemberFilter, MemberUpdate +from .member import Member, MemberCreate, MemberFilter, MemberSetLsb, MemberUpdate from .member_custom_field import ( MemberCustomField, MemberCustomFieldCreate, diff --git a/easyverein/models/member.py b/easyverein/models/member.py index 732dd18..d23ccdb 100644 --- a/easyverein/models/member.py +++ b/easyverein/models/member.py @@ -190,6 +190,27 @@ class MemberFilter(BaseModel): search: str | None = None +class MemberSetLsb(BaseModel): + """ + Pydantic model used to set LSB sports for a member + + Does not match the documentation, but works (with v2.0 in january 2026 at least) + """ + + lsbSport: list[str] + + +class MemberSetDosb(BaseModel): + """ + Pydantic model used to set LSB sports for a member + + Does not match the documentation, but works (with v2.0 in january 2026 at least) + And for some reason, has another format than the set-lsb endpoint + """ + + dosb_sport: list[str] + + from .contact_details import ContactDetails # noqa: E402 from .member_custom_field import MemberCustomField # noqa: E402 from .member_member_group import MemberMemberGroup # noqa: E402 diff --git a/easyverein/modules/member.py b/easyverein/modules/member.py index a6a3756..e7f058a 100644 --- a/easyverein/modules/member.py +++ b/easyverein/modules/member.py @@ -8,8 +8,10 @@ from easyverein.modules.member_member_group import MemberMemberGroupMixin from ..core.client import EasyvereinClient -from ..models import Member, MemberCreate, MemberFilter, MemberUpdate +from ..models import Member, MemberCreate, MemberFilter, MemberSetLsb, MemberUpdate +from ..models.member import MemberSetDosb from .mixins.crud import CRUDMixin +from .mixins.helper import get_id from .mixins.recycle_bin import RecycleBinMixin @@ -25,3 +27,19 @@ def custom_field(self, member_id: int) -> MemberCustomFieldMixin: def member_group(self, member_id: int) -> MemberMemberGroupMixin: return MemberMemberGroupMixin(self.c, self.logger, member_id) + + def set_lsb(self, target: Member | int, data: MemberSetLsb) -> None: + obj_id = get_id(target) + + self.logger.info(f"Setting LSB sports for member {obj_id}") + + url = self.c.get_url(f"/{self.endpoint_name}/{obj_id}/set-lsb") + self.c.update(url, data, status_code=204) + + def set_dosb(self, target: Member | int, data: MemberSetDosb) -> None: + obj_id = get_id(target) + + self.logger.info(f"Setting DOSB sports for member {obj_id}") + + url = self.c.get_url(f"/{self.endpoint_name}/{obj_id}/set-dosb") + self.c.update(url, data, status_code=204) From 12e52cb5b06e236fe5a91630b89c1909b2e0f1ab Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:01:14 +0100 Subject: [PATCH 08/12] Add methods to set LSB/DOSB sports; introduce related models --- easyverein/models/__init__.py | 2 +- easyverein/models/base.py | 2 +- easyverein/models/member.py | 6 ++--- tests/test_member.py | 42 ++++++++++++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/easyverein/models/__init__.py b/easyverein/models/__init__.py index 960ef53..7281527 100644 --- a/easyverein/models/__init__.py +++ b/easyverein/models/__init__.py @@ -19,7 +19,7 @@ InvoiceItemFilter, InvoiceItemUpdate, ) -from .member import Member, MemberCreate, MemberFilter, MemberSetLsb, MemberUpdate +from .member import Member, MemberCreate, MemberFilter, MemberSetDosb, MemberSetLsb, MemberUpdate from .member_custom_field import ( MemberCustomField, MemberCustomFieldCreate, diff --git a/easyverein/models/base.py b/easyverein/models/base.py index 6b6a9c0..1fddd7d 100644 --- a/easyverein/models/base.py +++ b/easyverein/models/base.py @@ -23,6 +23,6 @@ class LsbDosbSport(EasyVereinBase, EmptyStringsToNone): Pydantic Model for a LSB/DOSB Sport. """ - title: str + title: str | None = None sportNumber: str | None = None federationNumber: str | None = None diff --git a/easyverein/models/member.py b/easyverein/models/member.py index d23ccdb..ee52cec 100644 --- a/easyverein/models/member.py +++ b/easyverein/models/member.py @@ -84,8 +84,8 @@ class MemberBase(EasyVereinBase): sepaMandateFile: AnyHttpURL | str | None = None integrationLsbSport: list[EasyVereinReference] | list[LsbDosbSport] | None = None integrationLsbGender: Literal["m", "w", "d"] | None = None - integrationDosbSport: list[EasyVereinReference] | None = None - integrationDosbGender: Literal["m", "w", "d"] | list[LsbDosbSport] | None = None + integrationDosbSport: list[EasyVereinReference] | list[LsbDosbSport] | None = None + integrationDosbGender: Literal["m", "w", "d"] | None = None customFields: list[EasyVereinReference] | list[MemberCustomField] | None = None memberGroups: list[EasyVereinReference] | list[MemberMemberGroup] | None = None @@ -202,7 +202,7 @@ class MemberSetLsb(BaseModel): class MemberSetDosb(BaseModel): """ - Pydantic model used to set LSB sports for a member + Pydantic model used to set DOSB sports for a member Does not match the documentation, but works (with v2.0 in january 2026 at least) And for some reason, has another format than the set-lsb endpoint diff --git a/tests/test_member.py b/tests/test_member.py index 208b7d2..bedd4fb 100644 --- a/tests/test_member.py +++ b/tests/test_member.py @@ -2,7 +2,7 @@ from easyverein import EasyvereinAPI from easyverein.core.exceptions import EasyvereinAPINotFoundException from easyverein.models.contact_details import ContactDetails -from easyverein.models.member import Member, MemberUpdate +from easyverein.models.member import Member, MemberSetDosb, MemberSetLsb, MemberUpdate class TestMember: @@ -75,3 +75,43 @@ def test_related_members(self, ev_connection: EasyvereinAPI): assert isinstance(reset_member, Member) assert isinstance(reset_member.relatedMembers, list) assert reset_member.relatedMembers == [] + + +class TestMemberSetLsb: + def test_set_lsb(self, ev_connection: EasyvereinAPI, example_member: Member): + assert example_member.id + + # Set a LSB sport (assume ID "1" exists for testing) + ev_connection.member.set_lsb(example_member.id, MemberSetLsb(lsbSport=["1"])) + + # Verify it's set + member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationLsbSport{id}}") + assert member.integrationLsbSport is not None + assert len(member.integrationLsbSport) > 0 + + # Unset it again + ev_connection.member.set_lsb(example_member.id, MemberSetLsb(lsbSport=[])) + + # Verify it's unset + member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationLsbSport{id}}") + assert member.integrationLsbSport == [] + + +class TestMemberSetDosb: + def test_set_dosb(self, ev_connection: EasyvereinAPI, example_member: Member): + assert example_member.id + + # Set a DOSB sport (assume ID "1" exists for testing) + ev_connection.member.set_dosb(example_member.id, MemberSetDosb(dosb_sport=["1"])) + + # Verify it's set + member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationDosbSport{id}}") + assert member.integrationDosbSport is not None + assert len(member.integrationDosbSport) > 0 + + # Unset it again + ev_connection.member.set_dosb(example_member.id, MemberSetDosb(dosb_sport=[])) + + # Verify it's unset + member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationDosbSport{id}}") + assert member.integrationDosbSport == [] From 66d739de12b0fe9a91e7a42eec69763be2c59f2b Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:48:01 +0100 Subject: [PATCH 09/12] fix copy/paste errors in documentation --- docs/api/endpoints/billing_account.md | 2 +- docs/api/endpoints/booking.md | 4 ++-- docs/api/endpoints/contact_details.md | 2 +- docs/api/endpoints/custom_field.md | 2 +- docs/api/endpoints/invoice_item.md | 2 +- docs/api/endpoints/member.md | 2 +- docs/api/endpoints/member_custom_field.md | 2 +- docs/api/endpoints/member_group.md | 6 +++--- docs/api/endpoints/member_member_group.md | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/api/endpoints/billing_account.md b/docs/api/endpoints/billing_account.md index b411c6f..8ffeb12 100644 --- a/docs/api/endpoints/billing_account.md +++ b/docs/api/endpoints/billing_account.md @@ -8,7 +8,7 @@ None ## Generic Model Mappings -| Generic Model | Invoice Model | +| Generic Model | BillingAccount Model | |-------------------|------------------------| | `ModelType` | `BillingAccount` | | `CreateModelType` | `BillingAccountCreate` | diff --git a/docs/api/endpoints/booking.md b/docs/api/endpoints/booking.md index ac019e2..28978d6 100644 --- a/docs/api/endpoints/booking.md +++ b/docs/api/endpoints/booking.md @@ -8,8 +8,8 @@ None ## Generic Model Mappings -| Generic Model | Invoice Model | -|-------------------|------------------------| +| Generic Model | Booking Model | +|-------------------|-----------------| | `ModelType` | `Booking` | | `CreateModelType` | `BookingCreate` | | `UpdateModelType` | `BookingUpdate` | diff --git a/docs/api/endpoints/contact_details.md b/docs/api/endpoints/contact_details.md index 0ddb3b8..c91ed28 100644 --- a/docs/api/endpoints/contact_details.md +++ b/docs/api/endpoints/contact_details.md @@ -8,7 +8,7 @@ None ## Generic Model Mappings -| Generic Model | Invoice Model | +| Generic Model | ContactDetails Model | |-------------------|------------------------| | `ModelType` | `ContactDetails` | | `CreateModelType` | `ContactDetailsCreate` | diff --git a/docs/api/endpoints/custom_field.md b/docs/api/endpoints/custom_field.md index a79c109..ceaabc5 100644 --- a/docs/api/endpoints/custom_field.md +++ b/docs/api/endpoints/custom_field.md @@ -8,7 +8,7 @@ None ## Generic Model Mappings -| Generic Model | Invoice Model | +| Generic Model | CustomField Model | |-------------------|---------------------| | `ModelType` | `CustomField` | | `CreateModelType` | `CustomFieldCreate` | diff --git a/docs/api/endpoints/invoice_item.md b/docs/api/endpoints/invoice_item.md index 1449c6a..7e295db 100644 --- a/docs/api/endpoints/invoice_item.md +++ b/docs/api/endpoints/invoice_item.md @@ -8,7 +8,7 @@ None ## Generic Model Mappings -| Generic Model | Invoice Model | +| Generic Model | InvoiceItem Model | |-------------------|---------------------| | `ModelType` | `InvoiceItem` | | `CreateModelType` | `InvoiceItemCreate` | diff --git a/docs/api/endpoints/member.md b/docs/api/endpoints/member.md index 4f3689d..afeab9e 100644 --- a/docs/api/endpoints/member.md +++ b/docs/api/endpoints/member.md @@ -8,7 +8,7 @@ None ## Generic Model Mappings -| Generic Model | Invoice Model | +| Generic Model | Member Model | |-------------------|----------------| | `ModelType` | `Member` | | `CreateModelType` | `MemberCreate` | diff --git a/docs/api/endpoints/member_custom_field.md b/docs/api/endpoints/member_custom_field.md index 9eb5432..891191f 100644 --- a/docs/api/endpoints/member_custom_field.md +++ b/docs/api/endpoints/member_custom_field.md @@ -13,7 +13,7 @@ requires the member as constructor argument, to avoid passing it again to all en ## Generic Model Mappings -| Generic Model | Invoice Model | +| Generic Model | MemberCustomField Model | |-------------------|---------------------------| | `ModelType` | `MemberCustomField` | | `CreateModelType` | `MemberCustomFieldCreate` | diff --git a/docs/api/endpoints/member_group.md b/docs/api/endpoints/member_group.md index 922f48c..cf78163 100644 --- a/docs/api/endpoints/member_group.md +++ b/docs/api/endpoints/member_group.md @@ -8,9 +8,9 @@ None ## Generic Model Mappings -| Generic Model | Invoice Model | -|-------------------|----------------| -| `ModelType` | `MemberGroup` | +| Generic Model | Invoice Model | +|-------------------|---------------------| +| `ModelType` | `MemberGroup` | | `CreateModelType` | `MemberGroupCreate` | | `UpdateModelType` | `MemberGroupUpdate` | diff --git a/docs/api/endpoints/member_member_group.md b/docs/api/endpoints/member_member_group.md index 3757ad2..ff0cc43 100644 --- a/docs/api/endpoints/member_member_group.md +++ b/docs/api/endpoints/member_member_group.md @@ -13,7 +13,7 @@ requires the member as constructor argument, to avoid passing it again to all en ## Generic Model Mappings -| Generic Model | Invoice Model | +| Generic Model | MemberMemberGroup Model | |-------------------|---------------------------| | `ModelType` | `MemberMemberGroup` | | `CreateModelType` | `MemberMemberGroupCreate` | From 235ff48a2cfb1f47c5fe0440314f6736f08a6f21 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:56:07 +0100 Subject: [PATCH 10/12] shorten comments :) --- tests/test_member.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_member.py b/tests/test_member.py index bedd4fb..3f42be7 100644 --- a/tests/test_member.py +++ b/tests/test_member.py @@ -81,10 +81,10 @@ class TestMemberSetLsb: def test_set_lsb(self, ev_connection: EasyvereinAPI, example_member: Member): assert example_member.id - # Set a LSB sport (assume ID "1" exists for testing) + # Set a LSB sport ev_connection.member.set_lsb(example_member.id, MemberSetLsb(lsbSport=["1"])) - # Verify it's set + # Verify member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationLsbSport{id}}") assert member.integrationLsbSport is not None assert len(member.integrationLsbSport) > 0 @@ -92,7 +92,7 @@ def test_set_lsb(self, ev_connection: EasyvereinAPI, example_member: Member): # Unset it again ev_connection.member.set_lsb(example_member.id, MemberSetLsb(lsbSport=[])) - # Verify it's unset + # Verify member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationLsbSport{id}}") assert member.integrationLsbSport == [] @@ -101,17 +101,17 @@ class TestMemberSetDosb: def test_set_dosb(self, ev_connection: EasyvereinAPI, example_member: Member): assert example_member.id - # Set a DOSB sport (assume ID "1" exists for testing) + # Set a DOSB sport ev_connection.member.set_dosb(example_member.id, MemberSetDosb(dosb_sport=["1"])) - # Verify it's set + # Verify member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationDosbSport{id}}") assert member.integrationDosbSport is not None assert len(member.integrationDosbSport) > 0 - # Unset it again + # Unset ev_connection.member.set_dosb(example_member.id, MemberSetDosb(dosb_sport=[])) - # Verify it's unset + # Verify member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationDosbSport{id}}") assert member.integrationDosbSport == [] From 090824460020664562420e4a0a206945937cb932 Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:22:20 +0100 Subject: [PATCH 11/12] Add bulk create and update mixins; extend client methods for bulk operations --- easyverein/core/client.py | 32 +++++++++++++++ easyverein/modules/mixins/crud.py | 66 +++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/easyverein/core/client.py b/easyverein/core/client.py index 1f8de45..40590e8 100644 --- a/easyverein/core/client.py +++ b/easyverein/core/client.py @@ -204,6 +204,38 @@ def update(self, url, data: BaseModel, status_code: int = 200, exclude_none: boo expected_status_code=status_code, ) + def bulk_create(self, url, data: list[BaseModel], status_code: int = 201) -> ResponseSchema: + """ + Method to create multiple objects in the API + """ + return self._handle_response( + self._do_request( + "post", + url, + data={"entries": [d.model_dump(exclude_none=True, exclude_unset=True, by_alias=True) for d in data]}, + ), + expected_status_code=status_code, + ) + + def bulk_update( + self, url, data: list[BaseModel], status_code: int = 200, exclude_none: bool = True + ) -> ResponseSchema: + """ + Method to update multiple objects in the API + """ + return self._handle_response( + self._do_request( + "patch", + url, + data={ + "entries": [ + d.model_dump(exclude_none=exclude_none, exclude_unset=True, by_alias=True) for d in data + ] + }, + ), + expected_status_code=status_code, + ) + def upload( self, url: str, diff --git a/easyverein/modules/mixins/crud.py b/easyverein/modules/mixins/crud.py index cb3bc71..c901728 100644 --- a/easyverein/modules/mixins/crud.py +++ b/easyverein/modules/mixins/crud.py @@ -194,3 +194,69 @@ def delete( self.logger.info(f"Deleting object of type {self.endpoint_name} with id {obj_id} from wastebasket") purge: Callable = getattr(self, "purge") purge(obj_id) + + +class BulkUpdateCreateMixin(Generic[ModelType, CreateModelType, UpdateModelType]): + """ + Mixin providing bulk create and update functionality for endpoints that support it. + Currently only supported for the `member` and `contact-details` endpoints. + """ + + def bulk_create(self: EVClientProtocol[ModelType], data: list[CreateModelType]) -> list[bool]: + """ + Creates multiple objects in a single API request and returns the created objects. + + **Example**: + + ```py + from easyverein import EasyvereinAPI + + ev_client = EasyvereinAPI("your_api_key") + + contact_details = ev_client.contact_details.bulk_create([ + ContactDetailsCreate(fistName="example1", lastName="Example1", isCompany=False), + ContactDetailsCreate(fistName="example2", lastName="Example2", isCompany=False), + ]) + ``` + + Args: + data: List of Pydantic models containing the data for the objects to be created. + """ + self.logger.info(f"Creating object of type {self.endpoint_name}") + + url = self.c.get_url(f"/{self.endpoint_name}/bulk-create") + response = self.c.bulk_create(url, data) + return [r["data"]["success"] for r in response.result] # type: ignore + + def bulk_update( + self: EVClientProtocol[ModelType], + data: list[UpdateModelType], + exclude_none: bool = True, + ) -> list[bool]: + """ + Updates multiple objects in a single API request and returns the updated objects. + + Note that the update models must include the `id` of the objects to be updated. + + **Example**: + + ```py + from easyverein import EasyvereinAPI + + ev_client = EasyvereinAPI("your_api_key") + + updated_members = ev_client.member.bulk_update([ + MemberUpdate(id=1, membershipNumber="M1"), + MemberUpdate(id=2, membershipNumber="M2"), + ]) + ``` + + Args: + data: List of Pydantic models containing the data to update. + exclude_none: If True, fields with None values will be excluded from the update. + """ + self.logger.info(f"Bulk updating objects of type {self.endpoint_name}") + + url = self.c.get_url(f"/{self.endpoint_name}/bulk-update") + response = self.c.bulk_update(url, data, exclude_none=exclude_none) + return [r["success"] for r in response.result] # type: ignore From d2b6c37e52267c97bef87f7778e830a831a9748d Mon Sep 17 00:00:00 2001 From: maribue <127690431+Blumenkind111@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:11:44 +0100 Subject: [PATCH 12/12] Implemented Bulk-Endpoints in the modules and created tests --- easyverein/modules/booking.py | 3 +- easyverein/modules/contact_details.py | 3 +- easyverein/modules/invoice.py | 3 +- easyverein/modules/member.py | 8 ++- easyverein/modules/mixins/crud.py | 2 +- tests/conftest.py | 4 +- tests/test_booking.py | 93 +++++++++++++++++++++++++++ tests/test_contact_details.py | 56 ++++++++++++++++ tests/test_filters.py | 5 +- tests/test_invoice.py | 73 +++++++++++++++++++++ tests/test_member.py | 73 ++++++++++++++++++++- 11 files changed, 312 insertions(+), 11 deletions(-) create mode 100644 tests/test_booking.py diff --git a/easyverein/modules/booking.py b/easyverein/modules/booking.py index b01f907..1560b07 100644 --- a/easyverein/modules/booking.py +++ b/easyverein/modules/booking.py @@ -11,12 +11,13 @@ BookingFilter, BookingUpdate, ) -from .mixins.crud import CRUDMixin +from .mixins.crud import BulkUpdateCreateMixin, CRUDMixin from .mixins.recycle_bin import RecycleBinMixin class BookingMixin( CRUDMixin[Booking, BookingCreate, BookingUpdate, BookingFilter], + BulkUpdateCreateMixin[Booking, BookingCreate, BookingUpdate], RecycleBinMixin[Booking], ): def __init__(self, client: EasyvereinClient, logger: logging.Logger): diff --git a/easyverein/modules/contact_details.py b/easyverein/modules/contact_details.py index f8e1120..447a86f 100644 --- a/easyverein/modules/contact_details.py +++ b/easyverein/modules/contact_details.py @@ -7,12 +7,13 @@ from ..core.client import EasyvereinClient from ..models import ContactDetails, ContactDetailsCreate, ContactDetailsUpdate from ..models.contact_details import ContactDetailsFilter -from .mixins.crud import CRUDMixin +from .mixins.crud import BulkUpdateCreateMixin, CRUDMixin from .mixins.recycle_bin import RecycleBinMixin class ContactDetailsMixin( CRUDMixin[ContactDetails, ContactDetailsCreate, ContactDetailsUpdate, ContactDetailsFilter], + BulkUpdateCreateMixin[ContactDetails, ContactDetailsCreate, ContactDetailsUpdate], RecycleBinMixin[ContactDetails], ): def __init__(self, client: EasyvereinClient, logger: logging.Logger): diff --git a/easyverein/modules/invoice.py b/easyverein/modules/invoice.py index f1328ac..de5e287 100644 --- a/easyverein/modules/invoice.py +++ b/easyverein/modules/invoice.py @@ -11,12 +11,13 @@ from ..core.exceptions import EasyvereinAPIException from ..models.invoice import Invoice, InvoiceCreate, InvoiceFilter, InvoiceUpdate from ..models.invoice_item import InvoiceItemCreate -from .mixins.crud import CRUDMixin +from .mixins.crud import BulkUpdateCreateMixin, CRUDMixin from .mixins.recycle_bin import RecycleBinMixin class InvoiceMixin( CRUDMixin[Invoice, InvoiceCreate, InvoiceUpdate, InvoiceFilter], + BulkUpdateCreateMixin[Invoice, InvoiceCreate, InvoiceUpdate], RecycleBinMixin[Invoice], ): def __init__(self, client: EasyvereinClient, logger: logging.Logger): diff --git a/easyverein/modules/member.py b/easyverein/modules/member.py index e7f058a..28aeba2 100644 --- a/easyverein/modules/member.py +++ b/easyverein/modules/member.py @@ -10,12 +10,16 @@ from ..core.client import EasyvereinClient from ..models import Member, MemberCreate, MemberFilter, MemberSetLsb, MemberUpdate from ..models.member import MemberSetDosb -from .mixins.crud import CRUDMixin +from .mixins.crud import BulkUpdateCreateMixin, CRUDMixin from .mixins.helper import get_id from .mixins.recycle_bin import RecycleBinMixin -class MemberMixin(CRUDMixin[Member, MemberCreate, MemberUpdate, MemberFilter], RecycleBinMixin[Member]): +class MemberMixin( + CRUDMixin[Member, MemberCreate, MemberUpdate, MemberFilter], + BulkUpdateCreateMixin[Member, MemberCreate, MemberUpdate], + RecycleBinMixin[Member], +): def __init__(self, client: EasyvereinClient, logger: logging.Logger): self.endpoint_name = "member" self.c = client diff --git a/easyverein/modules/mixins/crud.py b/easyverein/modules/mixins/crud.py index c901728..58e3cd2 100644 --- a/easyverein/modules/mixins/crud.py +++ b/easyverein/modules/mixins/crud.py @@ -259,4 +259,4 @@ def bulk_update( url = self.c.get_url(f"/{self.endpoint_name}/bulk-update") response = self.c.bulk_update(url, data, exclude_none=exclude_none) - return [r["success"] for r in response.result] # type: ignore + return [r["data"]["success"] for r in response.result] # type: ignore diff --git a/tests/conftest.py b/tests/conftest.py index a3a6f77..379262c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,7 @@ def ev_connection(): @pytest.fixture(scope="module", autouse=True) def _clear_wastebaskets(ev_connection: EasyvereinAPI): - for module in ["member_group", "custom_field", "contact_details", "billing_account"]: + for module in ["member", "member_group", "custom_field", "contact_details", "billing_account"]: deleted, _ = getattr(ev_connection, module).get_deleted() for d in deleted: getattr(ev_connection, module).purge(d) @@ -26,7 +26,7 @@ def _clear_wastebaskets(ev_connection: EasyvereinAPI): @pytest.fixture(scope="function") def random_string(): - return "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(16)) + return "test_" + "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(16)) @pytest.fixture(scope="module") diff --git a/tests/test_booking.py b/tests/test_booking.py new file mode 100644 index 0000000..45aa4c6 --- /dev/null +++ b/tests/test_booking.py @@ -0,0 +1,93 @@ +import datetime +from typing import Generator + +import pytest +from easyverein import EasyvereinAPI +from easyverein.models import Booking, BookingCreate, BookingUpdate + + +@pytest.fixture(scope="module", autouse=True) +def _remove_test_bookings(ev_connection: EasyvereinAPI) -> Generator[None, None, None]: + yield + for b in ev_connection.booking.get_all(query="{id,receiver}"): + if b.receiver and b.receiver.lower().startswith("test_"): + ev_connection.booking.delete(b, delete_from_recycle_bin=True) + + +class TestBooking: + def test_get_bookings(self, ev_connection: EasyvereinAPI): + bookings, total_count = ev_connection.booking.get() + assert isinstance(bookings, list) + + def test_create_booking_minimal(self, ev_connection: EasyvereinAPI, random_string: str): + booking_model = BookingCreate( + receiver=f"test_{random_string}", + date=datetime.date.today(), + amount=100.0, + ) + + booking = ev_connection.booking.create(booking_model) + assert isinstance(booking, Booking) + assert booking.receiver == booking_model.receiver + + ev_connection.booking.delete(booking, delete_from_recycle_bin=True) + + +class TestBookingBulk: + def test_bulk_create(self, ev_connection: EasyvereinAPI, random_string: str): + name = random_string + booking_data = [ + BookingCreate( + receiver=f"test_{name}1", + date=datetime.date.today(), + amount=100.0, + ), + BookingCreate( + receiver=f"test_{name}2", + date=datetime.date.today(), + amount=200.0, + ), + ] + + successes = ev_connection.booking.bulk_create(booking_data) + assert successes == [True, True] + + created_bookings = [ + b + for b in ev_connection.booking.get_all(query="{id,receiver}") + if b.receiver and b.receiver.startswith(f"test_{name}") + ] + + assert len(created_bookings) == 2 + + def test_bulk_update(self, ev_connection: EasyvereinAPI, random_string: str): + # Create two bookings + name = random_string + b1 = ev_connection.booking.create( + BookingCreate( + receiver=f"test_{name}3", + date=datetime.date.today(), + amount=300.0, + ) + ) + b2 = ev_connection.booking.create( + BookingCreate( + receiver=f"test_{name}4", + date=datetime.date.today(), + amount=400.0, + ) + ) + + update_data = [ + BookingUpdate(id=b1.id, description="Updated Description 1"), + BookingUpdate(id=b2.id, description="Updated Description 2"), + ] + + successes = ev_connection.booking.bulk_update(update_data) + assert successes == [True, True] + + updated_b1 = ev_connection.booking.get_by_id(b1.id) # type: ignore + updated_b2 = ev_connection.booking.get_by_id(b2.id) # type: ignore + + assert updated_b1.description == "Updated Description 1" + assert updated_b2.description == "Updated Description 2" diff --git a/tests/test_contact_details.py b/tests/test_contact_details.py index 795ff54..3943849 100644 --- a/tests/test_contact_details.py +++ b/tests/test_contact_details.py @@ -1,7 +1,18 @@ +from typing import Generator + +import pytest from easyverein import EasyvereinAPI from easyverein.models import ContactDetails, ContactDetailsCreate, ContactDetailsUpdate +@pytest.fixture(scope="module", autouse=True) +def _remove_test_contacts(ev_connection: EasyvereinAPI) -> Generator[None, None, None]: + yield + for c in ev_connection.contact_details.get_all(query="{id,firstName}"): + if c.firstName and c.firstName.lower().startswith("test_"): + ev_connection.contact_details.delete(c, delete_from_recycle_bin=True) + + class TestContactDetails: def test_get_contact_details(self, ev_connection: EasyvereinAPI): contact_details, total_count = ev_connection.contact_details.get() @@ -69,3 +80,48 @@ def test_create_minimal_personal_contact_details(self, ev_connection: Easyverein # Delete the contact details again ev_connection.contact_details.delete(contact_details, delete_from_recycle_bin=True) + + +class TestContactDetailsBulk: + def test_bulk_create(self, ev_connection: EasyvereinAPI, random_string: str): + name = random_string + contact_details_data = [ + ContactDetailsCreate(firstName=f"test_{name}1", familyName="Person", isCompany=False), + ContactDetailsCreate(firstName=f"test_{name}2", familyName="Person", isCompany=False), + ] + + successes = ev_connection.contact_details.bulk_create(contact_details_data) + assert successes == [True, True] + + created_contacts = [ + c + for c in ev_connection.contact_details.get_all(query="{id,firstName}") + if c.firstName and c.firstName.startswith(f"test_{name}") + ] + + assert isinstance(created_contacts, list) + assert len(created_contacts) == 2 + + def test_bulk_update(self, ev_connection: EasyvereinAPI, random_string: str): + # We need some contacts to update. Let's create two. + name = random_string + c1 = ev_connection.contact_details.create( + ContactDetailsCreate(firstName=f"test_{name}3", familyName="Person", isCompany=False) + ) + c2 = ev_connection.contact_details.create( + ContactDetailsCreate(firstName=f"test_{name}4", familyName="Person", isCompany=False) + ) + + update_data = [ + ContactDetailsUpdate(id=c1.id, street="Test Street 1"), + ContactDetailsUpdate(id=c2.id, street="Test Street 2"), + ] + + successes = ev_connection.contact_details.bulk_update(update_data) + assert successes == [True, True] + + updated_c1 = ev_connection.contact_details.get_by_id(c1.id) # type: ignore + updated_c2 = ev_connection.contact_details.get_by_id(c2.id) # type: ignore + + assert updated_c1.street == "Test Street 1" + assert updated_c2.street == "Test Street 2" diff --git a/tests/test_filters.py b/tests/test_filters.py index 09c761e..25d8b94 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -24,7 +24,10 @@ def validate_response(response: tuple[list[Any], int], model: type, num_expected assert isinstance(instance, model) def test_filter_invoices(self, ev_connection: EasyvereinAPI): - search = InvoiceFilter(invNumber__in=["1", "2"], canceledInvoice__isnull=True, isDraft=False) + inv_numbers = [iv.invNumber for iv in ev_connection.invoice.get_all()[:2]] + assert [ivn is not None for ivn in inv_numbers] + + search = InvoiceFilter(invNumber__in=inv_numbers, canceledInvoice__isnull=True, isDraft=False) TestFilter.validate_response(ev_connection.invoice.get(search=search), Invoice, 2) diff --git a/tests/test_invoice.py b/tests/test_invoice.py index b4f4a36..0d60f52 100644 --- a/tests/test_invoice.py +++ b/tests/test_invoice.py @@ -1,4 +1,5 @@ import datetime +from typing import Generator import pytest from _pytest.fixtures import FixtureRequest @@ -11,6 +12,14 @@ from requests.structures import CaseInsensitiveDict +@pytest.fixture(scope="module", autouse=True) +def _remove_test_invoices(ev_connection: EasyvereinAPI) -> Generator[None, None, None]: + yield + for i in ev_connection.invoice.get_all(query="{id,invNumber}"): + if i.invNumber and i.invNumber.lower().startswith("test_"): + ev_connection.invoice.delete(i, delete_from_recycle_bin=True) + + class TestInvoices: def test_get_invoices(self, ev_connection: EasyvereinAPI): invoices, total_count = ev_connection.invoice.get() @@ -257,3 +266,67 @@ def test_create_invoice_with_attachment( # Purge invoice from wastebasket assert invoice.id is not None ev_connection.invoice.purge(invoice.id) + + +class TestInvoiceBulk: + def test_bulk_create(self, ev_connection: EasyvereinAPI, random_string: str): + name = random_string + invoice_data = [ + InvoiceCreate( + invNumber=f"test_{name}1", + receiver="Test Receiver 1", + totalPrice=100, + isDraft=True, + ), + InvoiceCreate( + invNumber=f"test_{name}2", + receiver="Test Receiver 2", + totalPrice=200, + isDraft=True, + ), + ] + + successes = ev_connection.invoice.bulk_create(invoice_data) + assert successes == [True, True] + + created_invoices = [ + i + for i in ev_connection.invoice.get_all(query="{id,invNumber}") + if i.invNumber and i.invNumber.startswith(f"test_{name}") + ] + + assert len(created_invoices) == 2 + + def test_bulk_update(self, ev_connection: EasyvereinAPI, random_string: str): + # Create two invoices + name = random_string + i1 = ev_connection.invoice.create( + InvoiceCreate( + invNumber=f"test_{name}3", + receiver="Test Receiver 3", + totalPrice=300, + isDraft=True, + ) + ) + i2 = ev_connection.invoice.create( + InvoiceCreate( + invNumber=f"test_{name}4", + receiver="Test Receiver 4", + totalPrice=400, + isDraft=True, + ) + ) + + update_data = [ + InvoiceUpdate(id=i1.id, description="Updated Description 1"), + InvoiceUpdate(id=i2.id, description="Updated Description 2"), + ] + + successes = ev_connection.invoice.bulk_update(update_data) + assert successes == [True, True] + + updated_i1 = ev_connection.invoice.get_by_id(i1.id) # type: ignore + updated_i2 = ev_connection.invoice.get_by_id(i2.id) # type: ignore + + assert updated_i1.description == "Updated Description 1" + assert updated_i2.description == "Updated Description 2" diff --git a/tests/test_member.py b/tests/test_member.py index 3f42be7..8515bf7 100644 --- a/tests/test_member.py +++ b/tests/test_member.py @@ -1,8 +1,21 @@ +from datetime import date, datetime, timedelta +from typing import Generator + import pytest from easyverein import EasyvereinAPI from easyverein.core.exceptions import EasyvereinAPINotFoundException -from easyverein.models.contact_details import ContactDetails -from easyverein.models.member import Member, MemberSetDosb, MemberSetLsb, MemberUpdate +from easyverein.models.contact_details import ContactDetails, ContactDetailsCreate +from easyverein.models.member import Member, MemberCreate, MemberSetDosb, MemberSetLsb, MemberUpdate + + +@pytest.fixture(scope="module", autouse=True) +def _remove_test_members_and_cas(ev_connection: EasyvereinAPI) -> Generator[None, None, None]: + yield + + for m in ev_connection.member.get_all(query="{id,contactDetails{firstName,id}}"): + if not isinstance(m.contactDetails.firstName, str) and m.contactDetails.firstName.lower().startswith("test_"): # type: ignore + ev_connection.member.delete(m, delete_from_recycle_bin=True) + ev_connection.contact_details.delete(m.contactDetails, delete_from_recycle_bin=True) # type: ignore class TestMember: @@ -115,3 +128,59 @@ def test_set_dosb(self, ev_connection: EasyvereinAPI, example_member: Member): # Verify member = ev_connection.member.get_by_id(example_member.id, query="{id,integrationDosbSport{id}}") assert member.integrationDosbSport == [] + + +class TestMemberBulk: + def test_bulk_create(self, ev_connection: EasyvereinAPI, example_member: Member, random_string: str): + name = random_string + ev_connection.contact_details.bulk_create( + [ + ContactDetailsCreate(firstName=f"test_{name}1", familyName="Member", isCompany=False), + ContactDetailsCreate(firstName=f"test_{name}2", familyName="Member", isCompany=False), + ] + ) + cds = [ + c + for c in ev_connection.contact_details.get_all(query="{id,firstName}") + if c.firstName and c.firstName.startswith(f"test_{name}") + ] + + member_data = [ + MemberCreate(emailOrUserName=f"test_{name}1@example.com", contactDetails=cds[0].id), + MemberCreate(emailOrUserName=f"test_{name}2@example.com", contactDetails=cds[1].id), + ] + + successes = ev_connection.member.bulk_create(member_data) + assert successes == [True, True] + + created_members = [ + m + for m in ev_connection.member.get_all(query="{id,contactDetails{firstName}}") + if m.contactDetails.firstName and m.contactDetails.firstName.startswith(f"test_{name}") # type: ignore + ] + + assert isinstance(created_members, list) + assert len(created_members) == 2 + + def test_bulk_update(self, ev_connection: EasyvereinAPI): + members = ev_connection.member.get_all(query="{id,joinDate}")[:2] + + dates_before: list[datetime | None] = [m.joinDate for m in members] + dates_after = [d + timedelta(days=1) if d else date(1970, 1, 1) for d in dates_before] + + update_data = [MemberUpdate(id=m.id, joinDate=d) for m, d in zip(members, dates_after)] + + ev_connection.member.bulk_update(update_data) + + updated_members = ev_connection.member.get_all(query="{id,joinDate}")[:2] + + assert isinstance(updated_members, list) + assert len(updated_members) == 2 + assert [m.joinDate for m in updated_members] == dates_after + + # reset data + ev_connection.member.bulk_update( + [MemberUpdate(id=m.id, joinDate=d) for m, d in zip(members, dates_before)], exclude_none=False + ) + reset_members = ev_connection.member.get_all(query="{id,joinDate}")[:2] + assert [m.joinDate for m in reset_members] == dates_before