From 1aa0d58bf7c6d49e38c3fa66fbbb39f4f7ce5a4b Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:33:20 +0200 Subject: [PATCH 01/56] init --- tapir/coop/models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tapir/coop/models.py b/tapir/coop/models.py index 1c3e4224e..79a12b091 100644 --- a/tapir/coop/models.py +++ b/tapir/coop/models.py @@ -95,6 +95,7 @@ class Meta: _("Is willing to gift a share"), null=True, blank=True ) create_account_reminder_email_sent = models.BooleanField(default=False) + deleted_at = models.DateTimeField(null=True, blank=True) # Soft-Delete class ShareOwnerQuerySet(models.QuerySet): def with_name(self, search_string: str): @@ -177,6 +178,14 @@ def with_status( objects = ShareOwnerQuerySet.as_manager() + def delete(self, using=None, keep_parents=False): + self.deleted_at = timezone.now() + self.save() + + def restore(self): + self.deleted_at = None + self.save() + def blank_info_fields(self): """Used after a ShareOwner is linked to a user, which is used as the source for user info instead.""" self.first_name = "" From a35819e1648477b786ec224af14a1ba9e4cb4312 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 5 Oct 2025 09:46:03 +0200 Subject: [PATCH 02/56] edit manager --- tapir/coop/models.py | 171 +++++++++++++++++++++++-------------------- 1 file changed, 91 insertions(+), 80 deletions(-) diff --git a/tapir/coop/models.py b/tapir/coop/models.py index 79a12b091..258e11a61 100644 --- a/tapir/coop/models.py +++ b/tapir/coop/models.py @@ -31,6 +31,95 @@ from tapir.utils.user_utils import UserUtils +class ShareOwnerQuerySet(models.QuerySet): + def with_name(self, search_string: str): + searches = [s for s in search_string.split(" ") if s != ""] + + combined_filters = Q(last_name__icontains="") + for search in searches: + word_filter = ( + Q(last_name__unaccent__icontains=search) + | Q(first_name__unaccent__icontains=search) + | Q(usage_name__unaccent__icontains=search) + | Q(user__first_name__unaccent__icontains=search) + | Q(user__usage_name__unaccent__icontains=search) + | Q(user__last_name__unaccent__icontains=search) + | Q(company_name__unaccent__icontains=search) + ) + combined_filters = combined_filters & word_filter + + return self.filter(combined_filters) + + def with_status( + self, status: str, at_datetime: datetime.datetime | datetime.date = None + ): + if at_datetime is None: + at_datetime = timezone.now() + + at_datetime = ensure_datetime(at_datetime) + + share_owners_with_nb_of_shares = NumberOfSharesService.annotate_share_owner_queryset_with_nb_of_active_shares( + ShareOwner.objects.all(), at_datetime.date() + ) + members_without_shares = share_owners_with_nb_of_shares.filter( + **{NumberOfSharesService.ANNOTATION_NUMBER_OF_ACTIVE_SHARES: 0} + ) + members_without_shares_ids = list( + members_without_shares.values_list("id", flat=True) + ) + + if status == MemberStatus.SOLD: + return self.filter(id__in=members_without_shares_ids).distinct() + + members_with_valid_shares = self.exclude(id__in=members_without_shares_ids) + + members_with_investing_annotation = InvestingStatusService.annotate_share_owner_queryset_with_investing_status_at_datetime( + ShareOwner.objects.all(), at_datetime + ) + investing_members = members_with_investing_annotation.filter( + **{InvestingStatusService.ANNOTATION_WAS_INVESTING: True} + ) + investing_members_ids = list(investing_members.values_list("id", flat=True)) + + if status == MemberStatus.INVESTING: + return members_with_valid_shares.filter( + id__in=investing_members_ids + ).distinct() + + member_with_shares_and_not_investing: Self = members_with_valid_shares.exclude( + id__in=investing_members_ids + ) + + members_with_paused_annotation = ( + MembershipPauseService.annotate_share_owner_queryset_with_has_active_pause( + ShareOwner.objects.all(), at_datetime.date() + ) + ) + paused_members = members_with_paused_annotation.filter( + **{MembershipPauseService.ANNOTATION_HAS_ACTIVE_PAUSE: True} + ) + paused_members_ids = list(paused_members.values_list("id", flat=True)) + + if status == MemberStatus.PAUSED: + return member_with_shares_and_not_investing.filter( + id__in=paused_members_ids + ).distinct() + + if status == MemberStatus.ACTIVE: + return member_with_shares_and_not_investing.exclude( + id__in=paused_members_ids + ).distinct() + + raise TapirException(f"Invalid status : {status}") + + +class NonDeleted(models.Manager): + def get_queryset(self): + return ShareOwnerQuerySet(self.model, using=self._db).filter( + deleted_at__isnull=True + ) + + class ShareOwner(models.Model): """ShareOwner represents a share_owner of a ShareOwnership. @@ -97,86 +186,8 @@ class Meta: create_account_reminder_email_sent = models.BooleanField(default=False) deleted_at = models.DateTimeField(null=True, blank=True) # Soft-Delete - class ShareOwnerQuerySet(models.QuerySet): - def with_name(self, search_string: str): - searches = [s for s in search_string.split(" ") if s != ""] - - combined_filters = Q(last_name__icontains="") - for search in searches: - word_filter = ( - Q(last_name__unaccent__icontains=search) - | Q(first_name__unaccent__icontains=search) - | Q(usage_name__unaccent__icontains=search) - | Q(user__first_name__unaccent__icontains=search) - | Q(user__usage_name__unaccent__icontains=search) - | Q(user__last_name__unaccent__icontains=search) - | Q(company_name__unaccent__icontains=search) - ) - combined_filters = combined_filters & word_filter - - return self.filter(combined_filters) - - def with_status( - self, status: str, at_datetime: datetime.datetime | datetime.date = None - ): - if at_datetime is None: - at_datetime = timezone.now() - - at_datetime = ensure_datetime(at_datetime) - - share_owners_with_nb_of_shares = NumberOfSharesService.annotate_share_owner_queryset_with_nb_of_active_shares( - ShareOwner.objects.all(), at_datetime.date() - ) - members_without_shares = share_owners_with_nb_of_shares.filter( - **{NumberOfSharesService.ANNOTATION_NUMBER_OF_ACTIVE_SHARES: 0} - ) - members_without_shares_ids = list( - members_without_shares.values_list("id", flat=True) - ) - - if status == MemberStatus.SOLD: - return self.filter(id__in=members_without_shares_ids).distinct() - - members_with_valid_shares = self.exclude(id__in=members_without_shares_ids) - - members_with_investing_annotation = InvestingStatusService.annotate_share_owner_queryset_with_investing_status_at_datetime( - ShareOwner.objects.all(), at_datetime - ) - investing_members = members_with_investing_annotation.filter( - **{InvestingStatusService.ANNOTATION_WAS_INVESTING: True} - ) - investing_members_ids = list(investing_members.values_list("id", flat=True)) - - if status == MemberStatus.INVESTING: - return members_with_valid_shares.filter( - id__in=investing_members_ids - ).distinct() - - member_with_shares_and_not_investing: Self = ( - members_with_valid_shares.exclude(id__in=investing_members_ids) - ) - - members_with_paused_annotation = MembershipPauseService.annotate_share_owner_queryset_with_has_active_pause( - ShareOwner.objects.all(), at_datetime.date() - ) - paused_members = members_with_paused_annotation.filter( - **{MembershipPauseService.ANNOTATION_HAS_ACTIVE_PAUSE: True} - ) - paused_members_ids = list(paused_members.values_list("id", flat=True)) - - if status == MemberStatus.PAUSED: - return member_with_shares_and_not_investing.filter( - id__in=paused_members_ids - ).distinct() - - if status == MemberStatus.ACTIVE: - return member_with_shares_and_not_investing.exclude( - id__in=paused_members_ids - ).distinct() - - raise TapirException(f"Invalid status : {status}") - - objects = ShareOwnerQuerySet.as_manager() + everything = models.Manager() + objects = NonDeleted() def delete(self, using=None, keep_parents=False): self.deleted_at = timezone.now() From 735ef3ef8e588b433be3c73040c782078c323265 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:31:44 +0200 Subject: [PATCH 03/56] create migration --- ...areowner_managers_shareowner_deleted_at.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tapir/coop/migrations/0056_alter_shareowner_managers_shareowner_deleted_at.py diff --git a/tapir/coop/migrations/0056_alter_shareowner_managers_shareowner_deleted_at.py b/tapir/coop/migrations/0056_alter_shareowner_managers_shareowner_deleted_at.py new file mode 100644 index 000000000..cc8239ffe --- /dev/null +++ b/tapir/coop/migrations/0056_alter_shareowner_managers_shareowner_deleted_at.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.12 on 2025-10-05 08:15 + +import django.db.models.manager +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "coop", + "0055_membershippauseupdatedlogentry_coop_member_old_val_6e6554_gin_and_more", + ), + ] + + operations = [ + migrations.AlterModelManagers( + name="shareowner", + managers=[ + ("everything", django.db.models.manager.Manager()), + ], + ), + migrations.AddField( + model_name="shareowner", + name="deleted_at", + field=models.DateTimeField(blank=True, null=True), + ), + ] From e71615af74468da7de189a2ff88924c4ae809db8 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:32:08 +0200 Subject: [PATCH 04/56] fix also ShareOwnerQuerySet outside of Shareowner class --- tapir/coop/views/shareowner.py | 43 ++++++++++++---------------------- 1 file changed, 15 insertions(+), 28 deletions(-) diff --git a/tapir/coop/views/shareowner.py b/tapir/coop/views/shareowner.py index 9423f2b2e..5d4b0914e 100644 --- a/tapir/coop/views/shareowner.py +++ b/tapir/coop/views/shareowner.py @@ -57,6 +57,7 @@ CreateShareOwnershipsLogEntry, UpdateShareOwnershipLogEntry, ExtraSharesForAccountingRecap, + ShareOwnerQuerySet, ) from tapir.coop.services.investing_status_service import InvestingStatusService from tapir.coop.services.membership_pause_service import MembershipPauseService @@ -662,7 +663,7 @@ def __init__(self, *args, **kwargs): ) @staticmethod - def shift_slot_filter(queryset: ShareOwner.ShareOwnerQuerySet, name, value: str): + def shift_slot_filter(queryset: ShareOwnerQuerySet, name, value: str): return queryset.filter( # Find all Tapir-Users currently enrolled in that shift-name "value" user__in=Shift.objects.filter( @@ -671,7 +672,7 @@ def shift_slot_filter(queryset: ShareOwner.ShareOwnerQuerySet, name, value: str) ).distinct() @staticmethod - def display_name_filter(queryset: ShareOwner.ShareOwnerQuerySet, name, value: str): + def display_name_filter(queryset: ShareOwnerQuerySet, name, value: str): # This is an ugly hack to enable searching by Mitgliedsnummer from the # one-stop search box in the top right if value.isdigit(): @@ -679,13 +680,11 @@ def display_name_filter(queryset: ShareOwner.ShareOwnerQuerySet, name, value: st return queryset.with_name(value).distinct() - def status_filter(self, queryset: ShareOwner.ShareOwnerQuerySet, name, value: str): + def status_filter(self, queryset: ShareOwnerQuerySet, name, value: str): return queryset.with_status(value, self.reference_time).distinct() @staticmethod - def shift_attendance_mode_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: str - ): + def shift_attendance_mode_filter(queryset: ShareOwnerQuerySet, name, value: str): queryset = ShiftAttendanceModeService.annotate_share_owner_queryset_with_attendance_mode_at_datetime( queryset ) @@ -697,7 +696,7 @@ def shift_attendance_mode_filter( @staticmethod def registered_to_abcd_slot_with_capability_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: str + queryset: ShareOwnerQuerySet, name, value: str ): return queryset.filter( user__in=TapirUser.objects.registered_to_abcd_shift_slot_with_capability( @@ -707,44 +706,36 @@ def registered_to_abcd_slot_with_capability_filter( @staticmethod def registered_to_slot_with_capability_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: str + queryset: ShareOwnerQuerySet, name, value: str ): return queryset.filter( user__in=TapirUser.objects.registered_to_shift_slot_with_capability(value) ).distinct() @staticmethod - def has_capability_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: str - ): + def has_capability_filter(queryset: ShareOwnerQuerySet, name, value: str): return queryset.filter( user__in=TapirUser.objects.has_capability(value) ).distinct() @staticmethod - def not_has_capability_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: str - ): + def not_has_capability_filter(queryset: ShareOwnerQuerySet, name, value: str): return queryset.exclude( user__in=TapirUser.objects.has_capability(value) ).distinct() @staticmethod - def has_tapir_account_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: bool - ): + def has_tapir_account_filter(queryset: ShareOwnerQuerySet, name, value: bool): return queryset.exclude(user__isnull=value).distinct() @staticmethod - def abcd_week_filter(queryset: ShareOwner.ShareOwnerQuerySet, name, value: str): + def abcd_week_filter(queryset: ShareOwnerQuerySet, name, value: str): return queryset.filter( user__shift_attendance_templates__slot_template__shift_template__group__name=value ).distinct() @staticmethod - def is_fully_paid_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: bool - ): + def is_fully_paid_filter(queryset: ShareOwnerQuerySet, name, value: bool): payment_filter = { f"{PaymentStatusService.ANNOTATION_CREDITED_PAYMENTS_SUM_AT_DATE}__gte": F( PaymentStatusService.ANNOTATION_EXPECTED_PAYMENTS_SUM_AT_DATE @@ -758,7 +749,7 @@ def is_fully_paid_filter( @staticmethod def is_currently_exempted_from_shifts_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: bool + queryset: ShareOwnerQuerySet, name, value: bool ): exemption_filter = Q( user__shift_user_data__shift_exemptions__in=ShiftExemption.objects.active_temporal() @@ -768,15 +759,11 @@ def is_currently_exempted_from_shifts_filter( return queryset.filter(exemption_filter).distinct() @staticmethod - def has_shift_partner_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: bool - ): + def has_shift_partner_filter(queryset: ShareOwnerQuerySet, name, value: bool): return queryset.filter(user__shift_user_data__shift_partner__isnull=not value) @staticmethod - def is_shift_partner_of_filter( - queryset: ShareOwner.ShareOwnerQuerySet, name, value: bool - ): + def is_shift_partner_of_filter(queryset: ShareOwnerQuerySet, name, value: bool): return queryset.filter( user__shift_user_data__shift_partner_of__isnull=not value ) From 8ee857f118476bbea6a149830e7cd3b159b19265 Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:32:14 +0200 Subject: [PATCH 05/56] tests --- .../coop/tests/test_shareowner_softdelete.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tapir/coop/tests/test_shareowner_softdelete.py diff --git a/tapir/coop/tests/test_shareowner_softdelete.py b/tapir/coop/tests/test_shareowner_softdelete.py new file mode 100644 index 000000000..8554c48cb --- /dev/null +++ b/tapir/coop/tests/test_shareowner_softdelete.py @@ -0,0 +1,54 @@ +import pytest +from django.utils import timezone + +from tapir.coop.models import ShareOwner +from tapir.coop.tests.factories import ShareOwnerFactory +from tapir.utils.tests_utils import TapirFactoryTestBase + + +class TestShareOwnershipSoftDelete(TapirFactoryTestBase): + def test_softDelete_DeletedAt_isNotNone(self): + share_owner = ShareOwnerFactory.create() + + assert share_owner.deleted_at is None + + share_owner.delete() + + share_owner.refresh_from_db() + + assert share_owner.deleted_at is not None + assert share_owner.deleted_at <= timezone.now() + + def test_softDelete_restoreDeleted_isNone(self): + share_owner = ShareOwnerFactory.create() + + share_owner.delete() + share_owner.refresh_from_db() + assert share_owner.deleted_at is not None + + share_owner.restore() + share_owner.refresh_from_db() + + assert share_owner.deleted_at is None + + def test_softDelete_nonDeletedManager_containsNotDeletedShareowners(self): + active_owner = ShareOwnerFactory.create() + + soft_deleted_owner = ShareOwnerFactory.create() + soft_deleted_owner.delete() + + non_deleted_owners = ShareOwner.objects.all() + + assert active_owner in non_deleted_owners + assert soft_deleted_owner not in non_deleted_owners + + def test_softDelete_everythingManager_containsAlsoDeletedShareowners(self): + owner1 = ShareOwnerFactory.create() + + owner2 = ShareOwnerFactory.create() + owner2.delete() + + all_owners = ShareOwner.everything.all() + + assert owner1 in all_owners + assert owner2 in all_owners From af3d11263b4eb6434deb22fd181cfca38128659b Mon Sep 17 00:00:00 2001 From: crosspolar <18083323+crosspolar@users.noreply.github.com> Date: Sun, 5 Oct 2025 17:42:09 +0200 Subject: [PATCH 06/56] buttons --- .../coop/shareowner_confirm_delete.html | 28 ++++++++++++ .../user_coop_share_ownership_list_tag.html | 5 +++ tapir/coop/urls.py | 5 +++ tapir/coop/views/shareowner.py | 43 +++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 tapir/coop/templates/coop/shareowner_confirm_delete.html diff --git a/tapir/coop/templates/coop/shareowner_confirm_delete.html b/tapir/coop/templates/coop/shareowner_confirm_delete.html new file mode 100644 index 000000000..e0bd2496d --- /dev/null +++ b/tapir/coop/templates/coop/shareowner_confirm_delete.html @@ -0,0 +1,28 @@ +{% extends "core/base.html" %} +{% load utils %} +{% load django_bootstrap5 %} +{% load i18n %} +{% load core %} +{% block title %} + {% translate "Confirm delete" %} {{ object }} +{% endblock title %} +{% block content %} +
Are you sure you want to delete this member?
++ Name: {{ card_title|safe }} +
+\n" +#| " If this is an error and does not correspond to your request, please send a short\n" +#| " email to the Member Office to let us know.\n" +#| "
\n" +#| "\n"
+#| " Cooperative greetings,
\n"
+#| " The Member Office\n"
+#| "
\n" @@ -1172,7 +1186,7 @@ msgid "" " email to the Member Office to let us know.\n" "
\n" "\n"
-" Cooperative greetings,
\n"
+" Cooperative greetings,
\n"
" The Member Office\n"
"
Are you sure you want to delete this member?
- Name: {{ card_title|safe }} + Name: {{ card_title }}