diff --git a/tapir/coop/migrations/0056_deleteshareownerlogentry_shareowner_deleted_at.py b/tapir/coop/migrations/0056_deleteshareownerlogentry_shareowner_deleted_at.py new file mode 100644 index 000000000..40abc3163 --- /dev/null +++ b/tapir/coop/migrations/0056_deleteshareownerlogentry_shareowner_deleted_at.py @@ -0,0 +1,45 @@ +# Generated by Django 5.1.12 on 2025-11-21 14:55 + +import django.contrib.postgres.fields.hstore +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "coop", + "0055_membershippauseupdatedlogentry_coop_member_old_val_6e6554_gin_and_more", + ), + ("log", "0008_logentry_log_logentr_user_id_c4ca60_idx_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DeleteShareOwnerLogEntry", + fields=[ + ( + "logentry_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="log.logentry", + ), + ), + ("values", django.contrib.postgres.fields.hstore.HStoreField()), + ], + options={ + "abstract": False, + }, + bases=("log.logentry",), + ), + migrations.AddField( + model_name="shareowner", + name="deleted_at", + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/tapir/coop/models.py b/tapir/coop/models.py index 1c3e4224e..f36726eb1 100644 --- a/tapir/coop/models.py +++ b/tapir/coop/models.py @@ -17,6 +17,7 @@ from tapir.coop.services.membership_pause_service import MembershipPauseService from tapir.coop.services.number_of_shares_service import NumberOfSharesService from tapir.core.config import help_text_displayed_name +from tapir.core.models import SoftDeleteMixin, NonDeleted from tapir.log.models import UpdateModelLogEntry, ModelLogEntry, LogEntry from tapir.utils.expection_utils import TapirException from tapir.utils.models import ( @@ -31,7 +32,89 @@ from tapir.utils.user_utils import UserUtils -class ShareOwner(models.Model): +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 ShareOwner(SoftDeleteMixin, models.Model): """ShareOwner represents a share_owner of a ShareOwnership. Usually, this is just a proxy for the associated user. However, it may also be used to @@ -96,86 +179,8 @@ class Meta: ) create_account_reminder_email_sent = models.BooleanField(default=False) - 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() + # overwrite from Mixin: + objects = NonDeleted.from_queryset(ShareOwnerQuerySet)() def blank_info_fields(self): """Used after a ShareOwner is linked to a user, which is used as the source for user info instead.""" @@ -866,3 +871,10 @@ def populate( return super().populate_base( actor=actor, share_owner=model.share_owner, model=model ) + + +class DeleteShareOwnerLogEntry(ModelLogEntry): + template_name = "coop/log/delete_share_owner_log_entry.html" + + def populate(self, share_owner: ShareOwner, actor, model): + return self.populate_base(share_owner=share_owner, actor=actor, model=model) diff --git a/tapir/coop/templates/coop/log/delete_share_owner_log_entry.html b/tapir/coop/templates/coop/log/delete_share_owner_log_entry.html new file mode 100644 index 000000000..4667c7a7c --- /dev/null +++ b/tapir/coop/templates/coop/log/delete_share_owner_log_entry.html @@ -0,0 +1,4 @@ +{% load i18n %} +{% blocktranslate %} +Member has been deleted. +{% endblocktranslate %} 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..0605a9d68 --- /dev/null +++ b/tapir/coop/templates/coop/shareowner_confirm_delete.html @@ -0,0 +1,32 @@ +{% extends "core/base.html" %} +{% load utils %} +{% load django_bootstrap5 %} +{% load i18n %} +{% load core %} +{% block title %} + {% translate "Confirm Delete" %}: {% get_display_name_for_viewer object request.user %} +{% endblock title %} +{% block content %} +
+
+
+ {% translate "Confirm Delete" %}: {% get_display_name_for_viewer object request.user %} +
+
+
{% translate "Are you sure you want to delete this member?" %}
+

+ {% translate "Please note that deleting this member will permanently remove their account and all associated data such as owned shares and payments. The deleted user will no longer be part of the cooperative. However, all shift records and logs will remain intact." %} +

+
+ {% csrf_token %} + + {% translate 'Cancel' %} +
+
+
+
+{% endblock content %} diff --git a/tapir/coop/templates/coop/tags/user_coop_share_ownership_list_tag.html b/tapir/coop/templates/coop/tags/user_coop_share_ownership_list_tag.html index 31f7c7c98..4360cf68e 100644 --- a/tapir/coop/templates/coop/tags/user_coop_share_ownership_list_tag.html +++ b/tapir/coop/templates/coop/tags/user_coop_share_ownership_list_tag.html @@ -4,29 +4,38 @@ {% load core %}
- {% if share_owner %} + {% if share_owner and not share_owner.is_soft_deleted %} {% blocktranslate with coop_share_owner_id=share_owner.id %} Member #{{ coop_share_owner_id }} {% endblocktranslate %} - {% if perms.accounts.manage %} + {% if perms.accounts.manage or perms.group.manage %} - - file_present{% translate 'Membership confirmation' %} - - - edit{% translate 'Edit' %} - + {% if perms.accounts.manage %} + + file_present{% translate 'Membership confirmation' %} + + + edit{% translate 'Edit' %} + + {% endif %} + {% if perms.group.manage %} + + delete{% translate 'Delete' %} + + {% endif %} {% endif %} {% endif %}
- {% if share_owner %} + {% if share_owner and not share_owner.is_soft_deleted %}
{% translate "Status" %}:
diff --git a/tapir/coop/tests/test_shareowner_delete_view.py b/tapir/coop/tests/test_shareowner_delete_view.py new file mode 100644 index 000000000..a31c47ba8 --- /dev/null +++ b/tapir/coop/tests/test_shareowner_delete_view.py @@ -0,0 +1,68 @@ +from http import HTTPStatus + +from django.urls import reverse + +from tapir import settings +from tapir.accounts.tests.factories.factories import TapirUserFactory +from tapir.coop.models import DeleteShareOwnerLogEntry, ShareOwner +from tapir.utils.tests_utils import ( + PermissionTestMixin, + FeatureFlagTestMixin, + TapirFactoryTestBase, +) + + +class TestShareOwnerDeleteView( + PermissionTestMixin, FeatureFlagTestMixin, TapirFactoryTestBase +): + def setUp(self) -> None: + super().setUp() + + def get_allowed_groups(self): + return [ + settings.GROUP_VORSTAND, + settings.GROUP_EMPLOYEES, + ] + + def do_request(self): + tapir_user = TapirUserFactory() + return self.client.post( + reverse("coop:shareowner_delete", args=[tapir_user.share_owner.id]), + follow=True, + ) + + def test_ShareOwnerDeleteView_default_setsDeletedAtField(self): + self.login_as_vorstand() + tapir_user = TapirUserFactory() + self.assertIsNotNone(tapir_user.share_owner) + self.assertIsNone(tapir_user.share_owner.deleted_at) + response = self.client.post( + reverse("coop:shareowner_delete", args=[tapir_user.share_owner.id]), + follow=True, + ) + self.assertStatusCode(response, HTTPStatus.OK) + tapir_user.share_owner.refresh_from_db() + self.assertIsNotNone(tapir_user.share_owner.deleted_at) + self.assertEqual(DeleteShareOwnerLogEntry.objects.count(), 1) + + def test_ShareOwnerDeleteView_normalMemberTriesToDeleteItself_doesntDelete(self): + tapir_user = self.login_as_normal_user() + response = self.client.post( + reverse("coop:shareowner_delete", args=[tapir_user.share_owner.id]), + follow=True, + ) + self.assertStatusCode(response, HTTPStatus.FORBIDDEN) + tapir_user.share_owner.refresh_from_db() + self.assertIsNone(tapir_user.share_owner.deleted_at) + self.assertEqual(DeleteShareOwnerLogEntry.objects.count(), 0) + + def test_ShareOwnerDeleteView_adminTriesToDeleteItself_doesntDelete(self): + tapir_user = self.login_as_vorstand() + response = self.client.post( + reverse("coop:shareowner_delete", args=[tapir_user.share_owner.id]), + follow=True, + ) + self.assertStatusCode(response, HTTPStatus.FORBIDDEN) + tapir_user.share_owner.refresh_from_db() + self.assertIsNone(tapir_user.share_owner.deleted_at) + self.assertEqual(DeleteShareOwnerLogEntry.objects.count(), 0) diff --git a/tapir/coop/tests/test_shareowner_softdelete.py b/tapir/coop/tests/test_shareowner_softdelete.py new file mode 100644 index 000000000..ba4f36794 --- /dev/null +++ b/tapir/coop/tests/test_shareowner_softdelete.py @@ -0,0 +1,71 @@ +import pytest +from django.urls import reverse +from django.utils import timezone + +from tapir.accounts.tests.factories.factories import TapirUserFactory +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_default_setsDeletedAt(self): + share_owner = ShareOwnerFactory.create() + + self.assertIsNone(share_owner.deleted_at) + share_owner.soft_delete() + + share_owner.refresh_from_db() + + self.assertIsNotNone(share_owner.deleted_at) + self.assertLessEqual(share_owner.deleted_at, timezone.now()) + + def test_restore_default_deletedAtSetToNone(self): + share_owner = ShareOwnerFactory.create() + + share_owner.soft_delete() + share_owner.refresh_from_db() + self.assertIsNotNone(share_owner.deleted_at) + + share_owner.restore() + share_owner.refresh_from_db() + + self.assertIsNone(share_owner.deleted_at) + + def test_shareOwnerDefaultManager_default_containsOnlyNotDeletedShareOwners(self): + active_owner = ShareOwnerFactory.create() + + soft_deleted_owner = ShareOwnerFactory.create() + soft_deleted_owner.soft_delete() + + non_deleted_owners = ShareOwner.objects.all() + self.assertIn(active_owner, non_deleted_owners) + self.assertNotIn(soft_deleted_owner, non_deleted_owners) + + def test_shareOwnerEverythingManager_default_containsAlsoDeletedShareowners(self): + owner1 = ShareOwnerFactory.create() + + owner2 = ShareOwnerFactory.create() + owner2.soft_delete() + + all_owners = ShareOwner.everything.all() + + self.assertIn(owner1, all_owners) + self.assertIn(owner2, all_owners) + + def test_hardDelete_default_shareOwnerHardDeleted(self): + share_owner = ShareOwnerFactory.create() + all_users = ShareOwner.everything.all() + self.assertIn(share_owner, all_users) + share_owner.hard_delete() + + all_users = ShareOwner.everything.all() + self.assertNotIn(share_owner, all_users) + + def test_delete_cannotDeleteOwnAccount(self): + vorstand_user = self.login_as_vorstand() + response = self.client.post( + reverse("coop:shareowner_delete", args=[vorstand_user.share_owner.id]) + ) + self.assertEqual(response.status_code, 403) + self.assertIn(vorstand_user.share_owner, ShareOwner.everything.all()) diff --git a/tapir/coop/urls.py b/tapir/coop/urls.py index b1c8c0dad..668231a13 100644 --- a/tapir/coop/urls.py +++ b/tapir/coop/urls.py @@ -135,6 +135,11 @@ views.ShareOwnerUpdateView.as_view(), name="shareowner_update", ), + path( + "member//delete", + views.ShareOwnerDeleteView.as_view(), + name="shareowner_delete", + ), path( "statistics", views.StatisticsView.as_view(), diff --git a/tapir/coop/views/shareowner.py b/tapir/coop/views/shareowner.py index 2ea6abdbc..078e1c622 100644 --- a/tapir/coop/views/shareowner.py +++ b/tapir/coop/views/shareowner.py @@ -8,6 +8,7 @@ from django.contrib import messages from django.contrib.auth.decorators import permission_required, login_required from django.contrib.auth.mixins import PermissionRequiredMixin, LoginRequiredMixin +from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Q, F, OuterRef, Subquery from django.http import ( @@ -18,6 +19,7 @@ ) from django.shortcuts import get_object_or_404, redirect from django.template import Template, Context +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.views import generic, View @@ -62,6 +64,8 @@ CreateShareOwnershipsLogEntry, UpdateShareOwnershipLogEntry, ExtraSharesForAccountingRecap, + ShareOwnerQuerySet, + DeleteShareOwnerLogEntry, ) from tapir.coop.serializers import MemberRegistrationRequestSerializer from tapir.coop.services.investing_status_service import InvestingStatusService @@ -79,6 +83,7 @@ PERMISSION_COOP_ADMIN, PERMISSION_ACCOUNTS_MANAGE, PERMISSION_COOP_VIEW, + PERMISSION_GROUP_MANAGE, ) from tapir.shifts.models import ( SHIFT_USER_CAPABILITY_CHOICES, @@ -284,6 +289,35 @@ def get_context_data(self, **kwargs): return context +class ShareOwnerDeleteView( + LoginRequiredMixin, + PermissionRequiredMixin, + generic.DeleteView, +): + permission_required = PERMISSION_GROUP_MANAGE + model = ShareOwner + + def get_success_url(self): + return reverse("coop:shareowner_list") + + def dispatch(self, request, *args, **kwargs): + obj = self.get_object() + if request.user == obj.user: + return HttpResponseForbidden("You cannot delete your own account.") + return super().dispatch(request, *args, **kwargs) + + @transaction.atomic + def form_valid(self, form): + share_owner = self.get_object() + share_owner.soft_delete() + DeleteShareOwnerLogEntry().populate( + share_owner=share_owner, + actor=self.request.user, + model=self.object, + ).save() + return HttpResponseRedirect(self.get_success_url()) + + @require_POST @csrf_protect @login_required @@ -675,7 +709,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( @@ -684,7 +718,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(): @@ -692,13 +726,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 ) @@ -710,7 +742,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( @@ -720,44 +752,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 @@ -771,7 +795,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() @@ -781,21 +805,17 @@ 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 ) @staticmethod - def filter_by_join_date(queryset: ShareOwner.ShareOwnerQuerySet, name, value): + def filter_by_join_date(queryset: ShareOwnerQuerySet, name, value): """Filter ShareOwners based on their first share ownership start date.""" if value.start or value.stop: # Get the earliest start date for each share owner @@ -817,9 +837,7 @@ def filter_by_join_date(queryset: ShareOwner.ShareOwnerQuerySet, name, value): return queryset.distinct() @staticmethod - def filter_by_user_date_joined( - queryset: ShareOwner.ShareOwnerQuerySet, name, value - ): + def filter_by_user_date_joined(queryset: ShareOwnerQuerySet, name, value): """Filter ShareOwners based on when their associated TapirUser account was created.""" if value.start or value.stop: queryset = queryset.filter(user__isnull=False) diff --git a/tapir/core/models.py b/tapir/core/models.py index e6214643c..a08e30ab3 100644 --- a/tapir/core/models.py +++ b/tapir/core/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -27,3 +28,32 @@ def ensure_flag_exists(cls, flag_name): if cls.objects.filter(flag_name=flag_name).exists(): return cls.objects.create(flag_name=flag_name) + + +class NonDeleted(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(deleted_at__isnull=True) + + +class SoftDeleteMixin(models.Model): + deleted_at = models.DateTimeField(null=True, blank=True, default=None) + + class Meta: + abstract = True + + objects = NonDeleted() + everything = models.Manager() + + def soft_delete(self, using=None, keep_parents=False): + self.deleted_at = timezone.now() + self.save(update_fields=["deleted_at"], using=using) + + def hard_delete(self, using=None, keep_parents=False): + super().delete(using=using, keep_parents=keep_parents) + + def restore(self): + self.deleted_at = None + self.save(update_fields=["deleted_at"]) + + def is_soft_deleted(self): + return self.deleted_at is not None diff --git a/tapir/translations/locale/de/LC_MESSAGES/django.po b/tapir/translations/locale/de/LC_MESSAGES/django.po index b4497e421..2daa16ac8 100644 --- a/tapir/translations/locale/de/LC_MESSAGES/django.po +++ b/tapir/translations/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-07 14:00+0100\n" +"POT-Creation-Date: 2026-02-07 17:28+0100\n" "PO-Revision-Date: 2025-07-07 13:06+0000\n" "Last-Translator: Weblate Admin \n" "Language-Team: German \n" @@ -64,12 +64,12 @@ msgstr "Wichtige E-Mails" msgid "Displayed name" msgstr "Angezeigter Name" -#: accounts/models.py:70 coop/models.py:70 coop/models.py:445 +#: accounts/models.py:70 coop/models.py:153 coop/models.py:450 msgid "Pronouns" msgstr "Pronomen" #: accounts/models.py:71 accounts/templates/accounts/user_detail.html:71 -#: coop/models.py:72 coop/models.py:447 +#: coop/models.py:155 coop/models.py:452 #: coop/templates/coop/draftuser_detail.html:71 #: coop/templates/coop/draftuser_detail.html:142 #: coop/templates/coop/shareowner_detail.html:51 @@ -77,29 +77,29 @@ msgid "Phone number" msgstr "Telefonnummer" #: accounts/models.py:72 accounts/templates/accounts/user_detail.html:81 -#: coop/models.py:73 coop/models.py:448 +#: coop/models.py:156 coop/models.py:453 #: coop/templates/coop/draftuser_detail.html:146 #: coop/templates/coop/shareowner_detail.html:55 msgid "Birthdate" msgstr "Geburtsdatum" -#: accounts/models.py:73 coop/models.py:74 coop/models.py:449 +#: accounts/models.py:73 coop/models.py:157 coop/models.py:454 msgid "Street and house number" msgstr "Straße und Hausnummer" -#: accounts/models.py:74 coop/models.py:75 coop/models.py:450 +#: accounts/models.py:74 coop/models.py:158 coop/models.py:455 msgid "Extra address line" msgstr "Adresszusatz" -#: accounts/models.py:75 coop/models.py:76 coop/models.py:451 +#: accounts/models.py:75 coop/models.py:159 coop/models.py:456 msgid "Postcode" msgstr "Postleitzahl" -#: accounts/models.py:76 coop/models.py:77 coop/models.py:452 +#: accounts/models.py:76 coop/models.py:160 coop/models.py:457 msgid "City" msgstr "Ort" -#: accounts/models.py:77 coop/models.py:78 coop/models.py:453 +#: accounts/models.py:77 coop/models.py:161 coop/models.py:458 msgid "Country" msgstr "Land" @@ -126,7 +126,7 @@ msgid "Allow purchase tracking" msgstr "Erlaube Aufzeichnen deiner Einkäufe" #: accounts/models.py:90 accounts/templates/accounts/user_detail.html:101 -#: coop/models.py:81 coop/models.py:456 +#: coop/models.py:164 coop/models.py:461 #: coop/templates/coop/shareowner_detail.html:75 msgid "Preferred Language" msgstr "Bevorzugte Sprache" @@ -289,8 +289,8 @@ msgid "I agree that my membership card will be scanned at the checkout when I ma msgstr "Ich bin damit einverstanden, dass meine Mitgliedskarte beim Einkauf an der Kasse gescannt und somit mein Einkauf erfasst und gespeichert wird:" #: accounts/templates/accounts/purchase_tracking_card.html:30 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:168 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:172 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:177 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:181 msgid "Yes,No" msgstr "Ja, Nein" @@ -307,7 +307,7 @@ msgstr "Aktivieren" msgid "You can only look at your own barcode unless you have admin rights" msgstr "Du kannst nur deinen eigenen Strichcode sehen, es sei denn du hast Admin-Rechte" -#: accounts/templates/accounts/user_detail.html:20 coop/models.py:724 +#: accounts/templates/accounts/user_detail.html:20 coop/models.py:729 #: coop/templates/coop/draftuser_detail.html:86 #: coop/templates/coop/shareowner_detail.html:10 log/views.py:94 #: log/views.py:152 shifts/templates/shifts/shift_detail_printable.html:50 @@ -337,9 +337,9 @@ msgstr "Benutzername bearbeiten" #: coop/templates/coop/draftuser_detail.html:238 #: coop/templates/coop/membershipresignation_detail.html:107 #: coop/templates/coop/shareowner_detail.html:30 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:22 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:90 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:113 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:23 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:99 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:122 #: core/templates/core/featureflag_list.html:30 #: shifts/templates/shifts/shift_detail.html:61 #: shifts/templates/shifts/shift_template_detail.html:29 @@ -530,8 +530,8 @@ msgstr "Schickt mir eine Anleitung!" msgid "Enter a valid username. This value may contain only letters, numbers, and ./-/_ characters." msgstr "Der angegebene Name ist ungültig. Er darf nur Buchstaben, Zahlen und die Symbole ./-/_ enthalten." -#: accounts/views.py:129 accounts/views.py:134 coop/views/shareowner.py:276 -#: coop/views/shareowner.py:281 +#: accounts/views.py:129 accounts/views.py:134 coop/views/shareowner.py:281 +#: coop/views/shareowner.py:286 #, python-format msgid "Edit member: %(name)s" msgstr "Mitglied bearbeiten: %(name)s" @@ -648,7 +648,7 @@ msgstr "Anzahl zu erstellender Anteile" msgid "The end date must be later than the start date." msgstr "Das Enddatum muss später als das Start-Datum sein" -#: coop/forms.py:105 coop/models.py:462 +#: coop/forms.py:105 coop/models.py:467 msgid "Number of Shares" msgstr "Anzahl Anteile" @@ -729,168 +729,168 @@ msgstr "Absender und Empfänger der Übertragung der Anteile können nicht ident msgid "Cannot pay out, because shares have been gifted." msgstr "Die Anteile können nicht ausgezahlt werden, da sie verschenkt wurden" -#: coop/models.py:54 +#: coop/models.py:137 msgid "Is company" msgstr "Ist eine Firma" -#: coop/models.py:61 coop/models.py:436 +#: coop/models.py:144 coop/models.py:441 msgid "Administrative first name" msgstr "Amtlicher Vorname" -#: coop/models.py:63 coop/models.py:438 +#: coop/models.py:146 coop/models.py:443 msgid "Last name" msgstr "Nachname" -#: coop/models.py:65 coop/models.py:440 +#: coop/models.py:148 coop/models.py:445 msgid "Usage name" msgstr "Angezeigter Name" -#: coop/models.py:71 coop/models.py:446 +#: coop/models.py:154 coop/models.py:451 msgid "Email address" msgstr "E-Mail-Adresse" -#: coop/models.py:88 +#: coop/models.py:171 msgid "Is investing member" msgstr "Ist investierendes Mitglied" -#: coop/models.py:90 coop/models.py:476 +#: coop/models.py:173 coop/models.py:481 #: coop/templates/coop/draftuser_detail.html:176 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:48 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:167 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:57 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:176 msgid "Ratenzahlung" msgstr "Ratenzahlung" -#: coop/models.py:92 coop/models.py:469 +#: coop/models.py:175 coop/models.py:474 msgid "Attended Welcome Session" msgstr "An Willkommenstreffen teilgenommen" -#: coop/models.py:95 +#: coop/models.py:178 msgid "Is willing to gift a share" msgstr "Ist bereit Anteile zu verschenken" -#: coop/models.py:199 +#: coop/models.py:204 msgid "Cannot be a company and have a Tapir account" msgstr "Kann keine Firma sein und ein Tapir-Konto haben" -#: coop/models.py:215 +#: coop/models.py:220 msgid "User info should be stored in associated Tapir account" msgstr "Benutzer Infos sollen in dem Tapir Konto gespeichert warden" -#: coop/models.py:367 +#: coop/models.py:372 msgid "Not a member" msgstr "Kein Mitlied" -#: coop/models.py:368 coop/templates/coop/draftuser_detail.html:169 +#: coop/models.py:373 coop/templates/coop/draftuser_detail.html:169 msgid "Investing" msgstr "Investierend" -#: coop/models.py:369 coop/templates/coop/draftuser_detail.html:171 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:103 +#: coop/models.py:374 coop/templates/coop/draftuser_detail.html:171 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:112 #: coop/views/statistics.py:135 msgid "Active" msgstr "Aktiv" -#: coop/models.py:370 +#: coop/models.py:375 msgid "Paused" msgstr "Pausiert" -#: coop/models.py:465 +#: coop/models.py:470 msgid "Investing member" msgstr "Investierendes Mitglied" -#: coop/models.py:472 +#: coop/models.py:477 msgid "Signed Beteiligungserklärung" msgstr "Beteiligungserklärung unterschrieben" -#: coop/models.py:474 +#: coop/models.py:479 msgid "Paid Entrance Fee" msgstr "Eintrittsgeld bezahlt" -#: coop/models.py:531 +#: coop/models.py:536 msgid "Email address must be set." msgstr "Email-Adresse muss gesetzt sein." -#: coop/models.py:533 +#: coop/models.py:538 msgid "First name must be set." msgstr "Vorname muss gesetzt sein." -#: coop/models.py:535 +#: coop/models.py:540 msgid "Last name must be set." msgstr "Nachname muss gesetzt sein." -#: coop/models.py:539 +#: coop/models.py:544 msgid "Membership agreement must be signed." msgstr "Mitgliedsantrag muss unterschrieben sein." -#: coop/models.py:541 +#: coop/models.py:546 msgid "Amount of requested shares must be positive." msgstr "Die Anzahl der erwünschten Anteile muss positiv sein." -#: coop/models.py:543 +#: coop/models.py:548 msgid "Member already created." msgstr "Mitglied schon vorhanden." -#: coop/models.py:570 +#: coop/models.py:575 msgid "Paying member" msgstr "Zahlendes Mitglied" -#: coop/models.py:578 +#: coop/models.py:583 msgid "Credited member" msgstr "Empfangendes Mitglied" -#: coop/models.py:585 +#: coop/models.py:590 msgid "Amount" msgstr "Betrag" -#: coop/models.py:591 +#: coop/models.py:596 msgid "Payment date" msgstr "Zahldatum" -#: coop/models.py:594 +#: coop/models.py:599 msgid "Creation date" msgstr "Erstellungsdatum" -#: coop/models.py:599 +#: coop/models.py:604 msgid "Created by" msgstr "Erstellt durch" -#: coop/models.py:774 +#: coop/models.py:779 msgid "The cooperative buys the shares back from the member" msgstr "Die Kooperative kauft die Anteile des Mitglieds zurück." -#: coop/models.py:777 +#: coop/models.py:782 msgid "The member gifts the shares to the cooperative" msgstr "Das Mitglied schenkt die Anteile der Genossenschaft." -#: coop/models.py:779 +#: coop/models.py:784 msgid "The shares get transferred to another member" msgstr "Die Anteile werden an ein anderes Mitglied übertragen" -#: coop/models.py:782 +#: coop/models.py:787 msgid "Financial reasons" msgstr "Finanzielle Gründe" -#: coop/models.py:783 +#: coop/models.py:788 msgid "Health reasons" msgstr "Gesundheit" -#: coop/models.py:784 +#: coop/models.py:789 msgid "Distance" msgstr "Entfernung" -#: coop/models.py:785 +#: coop/models.py:790 msgid "Strategic orientation of SuperCoop" msgstr "Strategische Ausrichtung von Supercoop" -#: coop/models.py:786 +#: coop/models.py:791 msgid "Other" msgstr "Andere" -#: coop/models.py:791 coop/templates/coop/membershipresignation_detail.html:55 +#: coop/models.py:796 coop/templates/coop/membershipresignation_detail.html:55 msgid "Shareowner" msgstr "Genossenschaftsmitglied" -#: coop/models.py:810 +#: coop/models.py:815 msgid "Leave this empty if the resignation type is not a transfer to another member" msgstr "Lass das Feld leer, wenn es sich nicht um eine Übertragung auf ein anderes Mitglied handelt" @@ -929,7 +929,9 @@ msgstr "Löschen bestätigen" #: coop/templates/coop/confirm_delete_incoming_payment.html:31 #: coop/templates/coop/confirm_delete_share_ownership.html:24 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:127 +#: coop/templates/coop/shareowner_confirm_delete.html:24 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:30 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:136 #: financingcampaign/templates/financingcampaign/confirm_delete.html:24 #: shifts/templates/shifts/shift_confirm_delete.html:30 msgid "Delete" @@ -991,9 +993,9 @@ msgid "List of similar members" msgstr "Liste ähnlicher Mitglieder" #: coop/templates/coop/draftuser_detail.html:69 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:31 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:88 -#: coop/views/shareowner.py:602 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:40 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:97 +#: coop/views/shareowner.py:636 #: shifts/templates/shifts/user_shifts_overview_tag.html:26 msgid "Status" msgstr "Status" @@ -1016,7 +1018,7 @@ msgid "Shares requested" msgstr "Angeforderte Anteile" #: coop/templates/coop/draftuser_detail.html:190 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:53 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:62 #: shifts/models.py:46 msgid "Welcome Session" msgstr "Willkommenstreffen" @@ -1974,7 +1976,7 @@ msgid "General Tapir Accounts" msgstr "Allgemeine Tapir-Konten" #: coop/templates/coop/incoming_payment_list.html:9 -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:160 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:169 msgid "Payments" msgstr "Zahlungen" @@ -2011,6 +2013,14 @@ msgstr "" "\n" " Reaktiviertes ausgeschiedenes Mitglied\n" +#: coop/templates/coop/log/delete_share_owner_log_entry.html:2 +#, fuzzy +#| msgid "Member can be created" +msgid "" +"\n" +"Member has been deleted.\n" +msgstr "Mitglied kann erstellt werden: " + #: coop/templates/coop/log/update_incoming_payment_log_entry.html:2 msgid "Updated payment:" msgstr "Aktualisierte Zahlung:" @@ -2264,6 +2274,26 @@ msgstr "" msgid "Organisation logo" msgstr "" +#: coop/templates/coop/shareowner_confirm_delete.html:7 +#: coop/templates/coop/shareowner_confirm_delete.html:13 +#, fuzzy +#| msgid "Confirm delete" +msgid "Confirm Delete" +msgstr "Löschen bestätigen" + +#: coop/templates/coop/shareowner_confirm_delete.html:16 +msgid "Are you sure you want to delete this member?" +msgstr "" + +#: coop/templates/coop/shareowner_confirm_delete.html:18 +msgid "Please note that deleting this member will permanently remove their account and all associated data such as owned shares and payments. The deleted user will no longer be part of the cooperative. However, all shift records and logs will remain intact." +msgstr "" + +#: coop/templates/coop/shareowner_confirm_delete.html:27 +#: shifts/templates/shifts/user_shifts_overview_tag.html:177 +msgid "Cancel" +msgstr "Abmelden" + #: coop/templates/coop/shareowner_detail.html:22 msgid "Go to user page" msgstr "Zur Nutzer*innen-Seite" @@ -2360,11 +2390,11 @@ msgstr "" " Mitglied #%(coop_share_owner_id)s\n" " " -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:17 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:18 msgid "Membership confirmation" msgstr "Mitgliedsbestätigung" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:35 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:44 #, python-format msgid "" "\n" @@ -2372,7 +2402,7 @@ msgid "" " " msgstr "" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:40 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:49 #, python-format msgid "" "\n" @@ -2380,7 +2410,7 @@ msgid "" " " msgstr "" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:56 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:65 #: shifts/models.py:943 shifts/templates/shifts/shift_day_printable.html:207 #: shifts/templates/shifts/shift_day_printable.html:269 #: shifts/templates/shifts/shift_detail.html:298 @@ -2388,36 +2418,36 @@ msgstr "" msgid "Attended" msgstr "Teilgenommen" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:58 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:67 #: shifts/models.py:942 msgid "Pending" msgstr "Ausstehend" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:65 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:74 msgid "Mark Attended" msgstr "Als teilgenommen markieren" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:74 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:83 msgid "Owned shares" msgstr "Anteile" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:82 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:91 msgid "List of shares owned by this member" msgstr "Liste der Anteile die dieses Mitglied gehören" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:86 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:95 msgid "Starts at" msgstr "Anfangsdatum" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:87 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:96 msgid "Ends at" msgstr "Endet am" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:105 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:114 msgid "Sold or future" msgstr "" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:130 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:139 msgid "" "\n" " Only use this to correct mistakes, i.e. if the share was\n" @@ -2431,19 +2461,19 @@ msgstr "" "Bitte nur verwenden, wenn ein tatsächlicher Fehler vorliegt, z.B. ein Anteil eingetragen wurde, der nie gekauft wurde. Wenn die Person ihren Anteil einfach an den Coop zurückverkauft hat, markiere den Anteil bitte als 'verkauft'.\n" " " -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:153 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:162 msgid "Add Shares" msgstr "Anteile hinzufügen" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:171 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:180 msgid "Willing to gift a share" msgstr "Bereit Anteile zu schenken" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:181 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:190 msgid "Send membership confirmation email" msgstr "Mitgliedsbestätigung per Mail senden" -#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:187 +#: coop/templates/coop/tags/user_coop_share_ownership_list_tag.html:196 msgid "User is not a cooperative member." msgstr "Benutzer*in ist kein Genossenschaftsmitglied." @@ -2523,66 +2553,66 @@ msgstr "Auszahlungsende" msgid "Cancel membership of %(name)s" msgstr "Beende Mitgliedschaft von %(name)s" -#: coop/views/shareowner.py:137 coop/views/shareowner.py:142 +#: coop/views/shareowner.py:142 coop/views/shareowner.py:147 #, python-format msgid "Edit share: %(name)s" msgstr "Anteil bearbeiten: %(name)s" -#: coop/views/shareowner.py:162 coop/views/shareowner.py:167 +#: coop/views/shareowner.py:167 coop/views/shareowner.py:172 #, python-format msgid "Add shares to %(name)s" msgstr "Anteile zu %(name)s hinzufügen" -#: coop/views/shareowner.py:381 +#: coop/views/shareowner.py:415 msgid "Membership confirmation email sent." msgstr "Mitgliedsbestätigung per Mail gesendet." -#: coop/views/shareowner.py:603 shifts/templates/shifts/shift_filters.html:45 +#: coop/views/shareowner.py:637 shifts/templates/shifts/shift_filters.html:45 msgid "Any" msgstr "Alle" -#: coop/views/shareowner.py:608 +#: coop/views/shareowner.py:642 #: shifts/templates/shifts/user_shifts_overview_tag.html:79 msgid "Shift Status" msgstr "Schichtstatus" -#: coop/views/shareowner.py:616 +#: coop/views/shareowner.py:650 msgid "Is registered to an ABCD-slot that requires a qualification" msgstr "Ist für eine ABCD-Schicht eingetragen, die Qualifikation erfordert" -#: coop/views/shareowner.py:624 +#: coop/views/shareowner.py:658 msgid "Is registered to a slot that requires a qualification" msgstr "Ist für eine Schicht eingetragen, die Qualifikation erfordert" -#: coop/views/shareowner.py:632 +#: coop/views/shareowner.py:666 msgid "Has qualification" msgstr "Hat Qualifikation" -#: coop/views/shareowner.py:640 +#: coop/views/shareowner.py:674 msgid "Does not have qualification" msgstr "Hat die Qualifikation nicht" -#: coop/views/shareowner.py:647 shifts/forms.py:777 +#: coop/views/shareowner.py:681 shifts/forms.py:777 msgid "ABCD Week" msgstr "ABCD-Woche" -#: coop/views/shareowner.py:650 +#: coop/views/shareowner.py:684 msgid "Is fully paid" msgstr "Hat vollständig bezahlt" -#: coop/views/shareowner.py:653 +#: coop/views/shareowner.py:687 msgid "Name or member ID" msgstr "Name oder Mitgliedsnummer" -#: coop/views/shareowner.py:657 +#: coop/views/shareowner.py:691 msgid "Is currently exempted from shifts" msgstr "Ist derzeit von der Schichtarbeit befreit" -#: coop/views/shareowner.py:662 +#: coop/views/shareowner.py:696 msgid "Shift Name" msgstr "Schichtname" -#: coop/views/shareowner.py:978 +#: coop/views/shareowner.py:996 msgctxt "Willing to give a share" msgid "No" msgstr "Nein" @@ -2659,11 +2689,11 @@ msgstr "Mitgliederbüro kontaktieren" msgid "About tapir" msgstr "Über Tapir" -#: core/models.py:7 +#: core/models.py:8 msgid "Flag name" msgstr "" -#: core/models.py:10 +#: core/models.py:11 msgid "Flag value" msgstr "" @@ -4717,10 +4747,6 @@ msgstr "Solidaritäts-Schicht spenden" msgid "One of your banked shifts will be donated as a solidarity shift. Do you want to continue?" msgstr "Eine deiner gearbeiteten Schichten wird als Solidaritäts-Schicht gespendet. Möchtest du fortfahren?" -#: shifts/templates/shifts/user_shifts_overview_tag.html:177 -msgid "Cancel" -msgstr "Abmelden" - #: shifts/templates/shifts/user_shifts_overview_tag.html:180 msgid "Confirm" msgstr ""