diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34caec6d9..43678fcb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,8 +100,9 @@ The class name is the convention for the word in texts, followed by how to write | DraftUser | `draft_user` | Also called Applicant. Represents a person that expressed interest in joining but that hasn't completed the subscription process yet. Gets deleted when ShareOwner is created. | | ShareOwner | `share_owner` | Represents a person or a company that is either currently owning at least a share, or has owned shares in the past. Therefore they are or have been a member of the cooperative. They may not be active, for example investing members or someone who sold their shares. | | TapirUser | `tapir_user` | Represents a person with a user account. Accounts are linked between Tapir and the Wiki for example. Gets created when the member becomes active (part of the shift system etc.), but can become inactive. | -| Shift | | Represents a shift with a specific date and time (for example, 18/06/21 10:00 to 13:00). Can be a one-time activity or an instance of a ShiftTemplate | -| ShiftTemplate | | Represents the recurring aspect of a shift in the ABCD system. For example helping at the shop on Tuesday, 10:00 to 13:00, on Week C. It has a weekday (Tuesday) and a time, but no date (18/06/21) | +| Shift | | Represents a shift with a specific date and time (for example, 18/06/21 10:00 to 13:00). Can be a one-time activity or an instance of a ShiftTemplate | +| ShiftTemplate | | Represents the recurring aspect of a shift in the ABCD system. For example helping at the shop on Tuesday, 10:00 to 13:00, on Week C. It has a weekday (Tuesday) and a time, but no date (18/06/21) | +| ShiftAttendance | 'attendances', 'shift_attendances' | Represents the registration of a member to a slot. An attendance is always in one of the states defined in the state variable and the ShiftAttendance.State enum class. This is used for tracking who is coming to which shift but also if members are attending enough shifts. See ShiftAttendance.update_shift_account_entry. | ### Django Shell diff --git a/tapir/accounts/templates/accounts/email/create_account_reminder.body.html b/tapir/accounts/templates/accounts/email/create_account_reminder.body.html index f1ad7329d..2a652cac7 100644 --- a/tapir/accounts/templates/accounts/email/create_account_reminder.body.html +++ b/tapir/accounts/templates/accounts/email/create_account_reminder.body.html @@ -18,8 +18,8 @@ or visit SuperCoop. Feel free to drop by and meet other SuperCoopies in person! Opening hours are::

diff --git a/tapir/coop/forms.py b/tapir/coop/forms.py index c3327dae0..a660edec2 100644 --- a/tapir/coop/forms.py +++ b/tapir/coop/forms.py @@ -1,4 +1,3 @@ -from dateutil.relativedelta import relativedelta from django import forms from django.core.exceptions import ValidationError from django.forms import DateField, IntegerField @@ -289,7 +288,6 @@ def clean(self): cleaned_data = super().clean() share_owner: ShareOwner = cleaned_data.get("share_owner") resignation_type = cleaned_data.get("resignation_type") - cancellation_date = cleaned_data.get("cancellation_date") transferring_shares_to = cleaned_data.get("transferring_shares_to") paid_out = cleaned_data.get("paid_out") @@ -298,20 +296,15 @@ def clean(self): self.validate_duplicates(share_owner, transferring_shares_to) self.validate_if_gifted(resignation_type, paid_out) - if resignation_type == MembershipResignation.ResignationType.BUY_BACK: - self.cleaned_data["pay_out_day"] = cancellation_date + relativedelta( - day=31, month=12, years=3 - ) - else: - self.cleaned_data["pay_out_day"] = cancellation_date - self.cleaned_data["paid_out"] = True - return cleaned_data def validate_share_owner(self, share_owner): - if MembershipResignation.objects.filter( - share_owner__id=share_owner.id - ).exists(): + if ( + self.instance.pk is None + and MembershipResignation.objects.filter( + share_owner__id=share_owner.id + ).exists() + ): self.add_error( "share_owner", ValidationError(_("This member is already resigned.")), diff --git a/tapir/coop/migrations/0047_membershipresignationdeletelogentry_and_more.py b/tapir/coop/migrations/0047_membershipresignationdeletelogentry_and_more.py new file mode 100644 index 000000000..60c88f00c --- /dev/null +++ b/tapir/coop/migrations/0047_membershipresignationdeletelogentry_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 5.1.1 on 2024-11-09 16:53 + +import django.contrib.postgres.fields.hstore +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("coop", "0046_deleteincomingpaymentlogentry"), + ("log", "0007_auto_20240702_1748"), + ] + + operations = [ + migrations.CreateModel( + name="MembershipResignationDeleteLogEntry", + 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.AlterField( + model_name="membershipresignation", + name="transferring_shares_to", + field=models.ForeignKey( + help_text="Leave this empty if the resignation type is not a transfer to another member", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="owner_to_transfer", + to="coop.shareowner", + verbose_name="OwnerToTransfer", + ), + ), + ] diff --git a/tapir/coop/models.py b/tapir/coop/models.py index 25ae0419d..464471a1b 100644 --- a/tapir/coop/models.py +++ b/tapir/coop/models.py @@ -807,7 +807,7 @@ class ResignationType(models.TextChoices): pay_out_day = models.DateField(null=True) cancellation_reason = models.CharField(max_length=1000) resignation_type = models.CharField(choices=ResignationType.choices, max_length=50) - transferring_shares_to = models.OneToOneField( + transferring_shares_to = models.ForeignKey( ShareOwner, on_delete=models.deletion.PROTECT, verbose_name="OwnerToTransfer", @@ -860,3 +860,16 @@ def populate( old_frozen=old_frozen, new_frozen=new_frozen, ) + + +class MembershipResignationDeleteLogEntry(ModelLogEntry): + template_name = "coop/log/delete_resignmember_log_entry.html" + + def populate( + self, + actor: User, + model: MembershipResignation, + ): + return super().populate_base( + actor=actor, share_owner=model.share_owner, model=model + ) diff --git a/tapir/coop/services/MembershipResignationService.py b/tapir/coop/services/MembershipResignationService.py index e609b5857..dc8d5a184 100644 --- a/tapir/coop/services/MembershipResignationService.py +++ b/tapir/coop/services/MembershipResignationService.py @@ -1,4 +1,5 @@ import datetime +from dateutil.relativedelta import relativedelta from django.db import transaction @@ -14,22 +15,27 @@ class MembershipResignationService: @staticmethod @transaction.atomic - def update_shifts_and_shares(resignation: MembershipResignation): + def update_shifts_and_shares_and_pay_out_day(resignation: MembershipResignation): tapir_user: TapirUser = getattr(resignation.share_owner, "user", None) shares = ShareOwnership.objects.filter(share_owner=resignation.share_owner) match resignation.resignation_type: case MembershipResignation.ResignationType.BUY_BACK: - new_end_date = resignation.cancellation_date.replace(day=31, month=12) - new_end_date = new_end_date.replace(year=new_end_date.year + 3) + new_end_date = resignation.cancellation_date + relativedelta( + years=+3, day=31, month=12 + ) resignation.pay_out_day = new_end_date resignation.save() shares.update(end_date=new_end_date) return case MembershipResignation.ResignationType.GIFT_TO_COOP: + resignation.pay_out_day = resignation.cancellation_date + resignation.save() shares.update(end_date=resignation.cancellation_date) case MembershipResignation.ResignationType.TRANSFER: + resignation.pay_out_day = resignation.cancellation_date + resignation.save() shares_to_create = [ ShareOwnership( share_owner=resignation.transferring_shares_to, @@ -38,7 +44,6 @@ def update_shifts_and_shares(resignation: MembershipResignation): for _ in shares ] ShareOwnership.objects.bulk_create(shares_to_create) - shares.update(end_date=resignation.cancellation_date) case _: raise ValueError( f"Unknown resignation type: {resignation.resignation_type}" @@ -58,9 +63,9 @@ def update_shifts(tapir_user: TapirUser, resignation: MembershipResignation): ) for attendance_template in ShiftAttendanceTemplate.objects.filter( - user=tapir_user + user=tapir_user, ): - attendance_template.cancel_attendances(start_date) + attendance_template.cancel_attendances(starting_from=start_date) attendance_template.delete() attendances = ShiftAttendance.objects.filter( @@ -72,8 +77,8 @@ def update_shifts(tapir_user: TapirUser, resignation: MembershipResignation): @staticmethod @transaction.atomic - def delete_end_dates(member: MembershipResignation): - ShareOwnership.objects.filter(share_owner=member.share_owner).update( + def delete_end_dates(resignation: MembershipResignation): + ShareOwnership.objects.filter(share_owner=resignation.share_owner).update( end_date=None ) @@ -83,15 +88,12 @@ def delete_shareowner_membershippauses(resignation: MembershipResignation): share_owner=resignation.share_owner ): if pause.end_date is not None: - if resignation.pay_out_day is not None: - if resignation.pay_out_day <= pause.end_date: - pause.update(end_date=resignation.pay_out_day) - elif pause.start_date > resignation.pay_out_day: - pause.delete() - else: - if resignation.cancellation_date <= pause.end_date: - pause.update(end_date=resignation.cancellation_date) - elif pause.start_date > resignation.cancellation_date: - pause.delete() + if resignation.pay_out_day <= pause.end_date: + pause.end_date = resignation.pay_out_day + pause.save() else: - pause.update(end_date=resignation.cancellation_date) + if pause.start_date > resignation.pay_out_day: + pause.delete() + else: + pause.end_date = resignation.cancellation_date + pause.save() diff --git a/tapir/coop/templates/coop/draftuser_list.html b/tapir/coop/templates/coop/draftuser_list.html index 05bfce701..d4ffa763e 100644 --- a/tapir/coop/templates/coop/draftuser_list.html +++ b/tapir/coop/templates/coop/draftuser_list.html @@ -52,7 +52,7 @@

{% translate "Applicants" %}
{% bootstrap_form filter.form %}
-
{% bootstrap_form filter.form %}
- {% translate "Membership pauses" %}
{% bootstrap_form filter.form %}
-
-
-
- {% translate "Resigned member" %}: {% get_display_name_for_viewer object.share_owner request.user %} -
-
-
-
{% translate "Share owner" %}:
-
- {% get_display_name_for_viewer object.share_owner request.user %} -
-
-
-
{% translate "Cancellation reason" %}:
-
{{ object.cancellation_reason }}
-
-
-
{% translate "Cancellation date" %}:
-
{{ object.cancellation_date|date:"d.m.Y" }}
+
+
+ {% endblock content %} diff --git a/tapir/coop/templates/coop/shareowner_list.html b/tapir/coop/templates/coop/shareowner_list.html index 64de22a28..bb17092ac 100644 --- a/tapir/coop/templates/coop/shareowner_list.html +++ b/tapir/coop/templates/coop/shareowner_list.html @@ -48,7 +48,7 @@
{% translate "Cooperative Members" %}
{% bootstrap_form filter.form %}
- None: self.given_feature_flag_value(feature_flag_membership_resignation, True) mock_timezone_now(self, self.NOW) - def test_empty_form(self): - form = MembershipResignationForm() - self.assertIn("share_owner", form.fields) - self.assertIn("resignation_type", form.fields) - self.assertIn("cancellation_date", form.fields) - self.assertIn("transferring_shares_to", form.fields) + def test_isValid_sendingValidData_returnsTrue(self): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create() + data = { + "share_owner": share_owner, + "cancellation_reason": "A test reason.", + "cancellation_date": resignation.cancellation_date, + "resignation_type": resignation.resignation_type, + "transferring_shares_to": resignation.transferring_shares_to, + "paid_out": resignation.paid_out, + } + form = MembershipResignationForm(data=data) + self.assertTrue(form.is_valid()) + + def test_validateShareOwner_memberIsAlreadyResigned_errorIsAddedToForm(self): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create(share_owner=share_owner) + form = MembershipResignationForm(data={"share_owner": resignation.share_owner}) + self.assertIn("share_owner", form.errors.keys()) + self.assertIn( + "This member is already resigned.", + form.errors["share_owner"], + ) + + def test_validateShareOwner_noResignation_noErrors(self): + share_owner = ShareOwnerFactory.create() + form = MembershipResignationForm(data={"share_owner": share_owner}) + form.validate_share_owner(share_owner) + self.assertNotIn("share_owner", form.errors.keys()) - def test_validations(self): - self.assertRaises( - ValidationError, MembershipResignationForm.validate_duplicates + def test_validateTransferChoice_tranferringShareOwnerNotChosen_errorIsAddedToForm( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.build( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.TRANSFER, ) - self.assertRaises( - ValidationError, MembershipResignationForm.validate_share_owner + form = MembershipResignationForm( + data={ + "share_owner": resignation.share_owner, + "resignation_type": resignation.resignation_type, + } ) - self.assertRaises( - ValidationError, MembershipResignationForm.validate_transfer_choice + self.assertIn("transferring_shares_to", form.errors.keys()) + self.assertIn( + "Please select the member that the shares should be transferred to.", + form.errors["transferring_shares_to"], ) - self.assertRaises(ValidationError, MembershipResignationForm.validate_if_gifted) - def test_membershipResignationForm_loggedInAsMemberOffice_accessGranted(self): - self.login_as_member_office_user() - resignation_form: MembershipResignationForm = MembershipResignationForm.create() + def test_validateTransferChoice_tranferringShareOwnerNotChosen_errorIsAddedToForm( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.build( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.BUY_BACK, + transferring_shares_to=ShareOwnerFactory.create(), + ) + form = MembershipResignationForm( + data={ + "share_owner": resignation.share_owner, + "resignation_type": resignation.resignation_type, + "transferring_shares_to": resignation.transferring_shares_to, + } + ) + self.assertIn("transferring_shares_to", form.errors.keys()) + self.assertIn( + "If the shares don't get transferred to another member, this field should be empty.", + form.errors["transferring_shares_to"], + ) - response = self.client.get( - reverse("coop:resign_member_edit", args=[resignation_form.id]) + def test_validateDuplicates_SenderAndReceiverOfTransferringSharesDuplicate_errorIsAddedToForm( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, transferring_shares_to=share_owner + ) + form = MembershipResignationForm( + data={ + "share_owner": resignation.share_owner, + "transferring_shares_to": resignation.share_owner, + } + ) + self.assertIn("transferring_shares_to", form.errors.keys()) + self.assertIn( + "Sender and receiver of transferring the share(s) cannot be the same.", + form.errors["transferring_shares_to"], + ) + + def test_validateDuplicates_SenderAndReceiverOfTransferringSharesAreNotTheSame_noErrorIsAddedToForm( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.TRANSFER, + ) + form = MembershipResignationForm( + data={ + "share_owner": resignation.share_owner, + "resignation_type": resignation.resignation_type, + "transferring_shares_to": ShareOwnerFactory.create(), + } + ) + self.assertNotIn("transferring_shares_to", form.errors.keys()) + + def test_validateIfGifted_paidOutIsTrue_errorIsAddedToForm(self): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.GIFT_TO_COOP, + paid_out=True, + ) + form = MembershipResignationForm( + data={ + "share_owner": ShareOwnerFactory.create(), + "cancellation_date": resignation.cancellation_date, + "cancellation_reason": resignation.cancellation_reason, + "resignation_type": resignation.resignation_type, + "paid_out": resignation.paid_out, + } + ) + self.assertIn("paid_out", form.errors.keys()) + self.assertIn( + "Cannot pay out, because shares have been gifted.", + form.errors["paid_out"], + ) + + def test_validateIfGifted_paidOutIsTrueAndResignationtypeIsNotGiftToCoop_noErrorIsAddedToForm( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.BUY_BACK, + paid_out=True, + ) + form = MembershipResignationForm( + data={ + "share_owner": ShareOwnerFactory.create(), + "cancellation_date": resignation.cancellation_date, + "cancellation_reason": resignation.cancellation_reason, + "resignation_type": resignation.resignation_type, + "paid_out": resignation.paid_out, + } ) - self.assertStatusCode(response, HTTPStatus.OK) + self.assertNotIn("paid_out", form.errors.keys()) diff --git a/tapir/coop/tests/membership_resignation/test_service.py b/tapir/coop/tests/membership_resignation/test_service.py index 2e157d30e..d015da493 100644 --- a/tapir/coop/tests/membership_resignation/test_service.py +++ b/tapir/coop/tests/membership_resignation/test_service.py @@ -7,6 +7,19 @@ mock_timezone_now, ) +from tapir.coop.models import MembershipResignation, ShareOwnership, MembershipPause +from tapir.shifts.models import ShiftSlot, ShiftAttendance, ShiftAttendanceTemplate +from tapir.coop.tests.factories import ( + MembershipResignationFactory, + ShareOwnerFactory, + MembershipPauseFactory, +) +from tapir.shifts.tests.factories import ShiftFactory, ShiftTemplateFactory +from tapir.accounts.tests.factories.factories import TapirUserFactory +from tapir.coop.services.MembershipResignationService import ( + MembershipResignationService, +) + class TestMembershipResignationService(FeatureFlagTestMixin, TapirFactoryTestBase): NOW = datetime.datetime(year=2024, month=9, day=15) @@ -17,5 +30,204 @@ def setUp(self) -> None: self.given_feature_flag_value(feature_flag_membership_resignation, True) mock_timezone_now(self, self.NOW) - def test_todo(self): - self.fail("TODO, test service functions.") + def test_updateShiftsAndSharesAndPayOutDay_SharesAndPayOutDayForResignationTypeBuyBack_transferredAndSet( + self, + ): + self.login_as_member_office_user() + share_owner = ShareOwnerFactory.create(nb_shares=2) + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.BUY_BACK, + ) + MembershipResignationService.update_shifts_and_shares_and_pay_out_day( + resignation=resignation, + ) + shares_after_update = resignation.share_owner.share_ownerships + for share in shares_after_update.all(): + self.assertEqual( + resignation.cancellation_date.replace( + day=31, month=12, year=resignation.cancellation_date.year + 3 + ), + share.end_date, + ) + + def test_updateShiftsAndSharesAndPayOutDay_SharesAndPayOutDayForResignationTypeGiftToCoop_transferredAndSet( + self, + ): + self.login_as_member_office_user() + share_owner = ShareOwnerFactory.create(nb_shares=2) + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.GIFT_TO_COOP, + ) + MembershipResignationService.update_shifts_and_shares_and_pay_out_day( + resignation=resignation, + ) + shares_after_update = resignation.share_owner.share_ownerships + for share in shares_after_update.all(): + self.assertEqual(share.end_date, resignation.cancellation_date) + + def test_updateShiftsAndSharesAndPayOutDay_SharesAndPayOutDayForResignationTypeTransfer_transferredAndSet( + self, + ): + self.login_as_member_office_user() + share_owner = ShareOwnerFactory.create(nb_shares=2) + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.TRANSFER, + ) + MembershipResignationService.update_shifts_and_shares_and_pay_out_day( + resignation=resignation, + ) + shares_after_update = resignation.share_owner.share_ownerships + self.assertEqual( + ShareOwnership.objects.filter( + share_owner=resignation.transferring_shares_to, + start_date=resignation.cancellation_date, + ).count(), + shares_after_update.count(), + ) + for share in shares_after_update.all(): + self.assertEqual(share.end_date, None) + + def test_updateShifts_shiftsAndShiftAttendance_cancelled(self): + tapir_user = TapirUserFactory.create() + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create(share_owner=share_owner) + shift_template = ShiftTemplateFactory.create() + shift = ShiftFactory.create(start_time=self.NOW.replace(day=16)) + ShiftAttendance.objects.create(user=tapir_user, slot=shift.slots.first()) + ShiftAttendanceTemplate.objects.create( + user=tapir_user, slot_template=shift_template.slot_templates.first() + ) + MembershipResignationService.update_shifts( + tapir_user=tapir_user, resignation=resignation + ) + self.assertEqual(ShiftAttendanceTemplate.objects.count(), 0) + self.assertEqual( + ShiftAttendance.objects.get(user=tapir_user).state, + ShiftAttendance.State.CANCELLED, + ) + + def test_updateShifts_shiftsBeforeResignation_notCancelled(self): + tapir_user = TapirUserFactory.create() + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, cancellation_date=self.TODAY + ) + shift_template = ShiftTemplateFactory.create() + shift = ShiftFactory.create(start_time=self.NOW.replace(day=14)) + ShiftAttendance.objects.create(user=tapir_user, slot=shift.slots.first()) + ShiftAttendanceTemplate.objects.create( + user=tapir_user, + slot_template=shift_template.slot_templates.first(), + ) + MembershipResignationService.update_shifts( + tapir_user=tapir_user, resignation=resignation + ) + self.assertEqual( + ShiftAttendance.objects.get(user=tapir_user).state, + ShiftAttendance.State.PENDING, + ) + self.assertEqual(ShiftAttendanceTemplate.objects.count(), 0) + + def test_deleteEndDates_endDatesOfShares_removedFromShares(self): + share_owner = ShareOwnerFactory.create( + nb_shares=1, + ) + ShareOwnership.objects.filter(share_owner=share_owner).update( + end_date=datetime.datetime(year=2024, month=3, day=21), + ) + resignation = MembershipResignationFactory.create(share_owner=share_owner) + MembershipResignationService.delete_end_dates(resignation=resignation) + shares = ShareOwnership.objects.filter(share_owner=resignation.share_owner) + for share in shares: + self.assertEqual(share.end_date, None) + + def test_deleteShareownerMembershippauses_pauseThatsEndsAfterPayOutDay_updatedToNewDate( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.BUY_BACK, + cancellation_date=datetime.date(year=1995, month=6, day=20), + ) + pause_that_ends_after_pay_out_day = MembershipPauseFactory.create( + share_owner=resignation.share_owner, + start_date=datetime.date(year=1990, month=1, day=12), + end_date=datetime.date(year=2000, month=6, day=20), + ) + MembershipResignationService.delete_shareowner_membershippauses( + resignation=resignation + ) + pause_that_ends_after_pay_out_day.refresh_from_db() + self.assertEqual( + pause_that_ends_after_pay_out_day.end_date, resignation.pay_out_day + ) + + def test_deleteShareownerMembershippauses_pauseWithNoEndDate_getsDeleted( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.BUY_BACK, + cancellation_date=datetime.date(year=1995, month=6, day=20), + ) + pause_with_no_end_date = MembershipPauseFactory.create( + share_owner=resignation.share_owner, + start_date=datetime.date(year=2001, month=6, day=20), + end_date=None, + ) + MembershipResignationService.delete_shareowner_membershippauses( + resignation=resignation + ) + self.assertFalse( + MembershipPause.objects.filter(id=pause_with_no_end_date.id).exists() + ) + + def test_deleteShareownerMembershippauses_pauseWithNoEndDateAndStartDateAndSmallerThanPayOutDay_endDateSetToCancellationDate( + self, + ): + share_owner = ShareOwnerFactory.create() + resignation = MembershipResignationFactory.create( + share_owner=share_owner, + resignation_type=MembershipResignation.ResignationType.BUY_BACK, + cancellation_date=datetime.date(year=1995, month=6, day=20), + ) + pause_with_smaller_start_date = MembershipPauseFactory.create( + share_owner=resignation.share_owner, + start_date=datetime.date(year=1990, month=1, day=12), + end_date=None, + ) + MembershipResignationService.delete_shareowner_membershippauses( + resignation=resignation + ) + pause_with_smaller_start_date.refresh_from_db() + self.assertEqual( + pause_with_smaller_start_date.end_date, resignation.cancellation_date + ) + + def test_updateShiftsAndSharesAndPayOutDay_resignationTransferringShares_toSameMember( + self, + ): + share_owner = ShareOwnerFactory.create(nb_shares=2) + resignation_one = MembershipResignationFactory.create( + share_owner=ShareOwnerFactory.create(nb_shares=1), + resignation_type=MembershipResignation.ResignationType.TRANSFER, + transferring_shares_to=share_owner, + ) + resignation_two = MembershipResignationFactory.create( + share_owner=ShareOwnerFactory.create(nb_shares=1), + resignation_type=MembershipResignation.ResignationType.TRANSFER, + transferring_shares_to=share_owner, + ) + MembershipResignationService.update_shifts_and_shares_and_pay_out_day( + resignation_one + ) + MembershipResignationService.update_shifts_and_shares_and_pay_out_day( + resignation_two + ) + share_owner.refresh_from_db() + self.assertTrue(share_owner.num_shares() == 4) diff --git a/tapir/coop/tests/test_create_membershipresignation.py b/tapir/coop/tests/test_create_membershipresignation.py index a02188597..768cf9426 100644 --- a/tapir/coop/tests/test_create_membershipresignation.py +++ b/tapir/coop/tests/test_create_membershipresignation.py @@ -27,7 +27,7 @@ def test_membershipResignation_created_isTrue(self): "share_owner": ShareOwnerFactory.create().id, "cancellation_date": datetime.now(), "pay_out_day": datetime.now() - + relativedelta(day=31, month=12, years=3), + + relativedelta(years=+3, day=31, month=12), "cancellation_reason": resignation.cancellation_reason, "resignation_type": resignation.resignation_type, "transferring_shares_to": ShareOwnerFactory.create().id, diff --git a/tapir/coop/views/membership_resignation.py b/tapir/coop/views/membership_resignation.py index 89d066a12..3c268e327 100644 --- a/tapir/coop/views/membership_resignation.py +++ b/tapir/coop/views/membership_resignation.py @@ -23,6 +23,7 @@ MembershipResignation, MembershipResignationCreateLogEntry, MembershipResignationUpdateLogEntry, + MembershipResignationDeleteLogEntry, ) from tapir.coop.services.MembershipResignationService import ( MembershipResignationService, @@ -160,6 +161,7 @@ def display_name_filter( class MembershipResignationList( LoginRequiredMixin, + PermissionRequiredMixin, FilterView, ExportMixin, SingleTableView, @@ -169,6 +171,7 @@ class MembershipResignationList( template_name = "coop/membership_resignation_list.html" export_formats = ["csv", "json"] filterset_class = MembershipResignationFilter + permission_required = PERMISSION_COOP_MANAGE def get_context_data(self, **kwargs): if not FeatureFlag.get_flag_value(feature_flag_membership_resignation): @@ -213,6 +216,10 @@ def get_context_data(self, **kwargs): def form_valid(self, form): with transaction.atomic(): result = super().form_valid(form) + membership_resignation: MembershipResignation = form.instance + MembershipResignationService.update_shifts_and_shares_and_pay_out_day( + resignation=membership_resignation + ) new_frozen = freeze_for_log(form.instance) if self.old_object_frozen != new_frozen: MembershipResignationUpdateLogEntry().populate( @@ -221,7 +228,6 @@ def form_valid(self, form): model=form.instance, actor=self.request.user, ).save() - return result @@ -246,7 +252,7 @@ def get_context_data(self, **kwargs): def form_valid(self, form): result = super().form_valid(form) membership_resignation: MembershipResignation = form.instance - MembershipResignationService.update_shifts_and_shares( + MembershipResignationService.update_shifts_and_shares_and_pay_out_day( resignation=membership_resignation ) MembershipResignationService.delete_shareowner_membershippauses( @@ -290,15 +296,25 @@ def get(self, request, *args, **kwargs): class MembershipResignationRemoveFromListView( - LoginRequiredMixin, PermissionRequiredMixin, DeleteView + LoginRequiredMixin, + PermissionRequiredMixin, + DeleteView, + UpdateViewLogMixin, ): model = MembershipResignation permission_required = PERMISSION_COOP_MANAGE success_url = reverse_lazy("coop:resigned_members_list") def form_valid(self, form): - if not FeatureFlag.get_flag_value(feature_flag_membership_resignation): - raise PermissionDenied("The membership resignation feature is disabled.") + with transaction.atomic(): + if not FeatureFlag.get_flag_value(feature_flag_membership_resignation): + raise PermissionDenied( + "The membership resignation feature is disabled." + ) - MembershipResignationService.delete_end_dates(self.get_object()) + MembershipResignationService.delete_end_dates(self.get_object()) + MembershipResignationDeleteLogEntry().populate( + model=self.get_object(), + actor=self.request.user, + ).save() return super().form_valid(form) diff --git a/tapir/log/templates/log/log_overview.html b/tapir/log/templates/log/log_overview.html index 49ede0323..7d6d89c46 100644 --- a/tapir/log/templates/log/log_overview.html +++ b/tapir/log/templates/log/log_overview.html @@ -8,19 +8,41 @@ {% block head %} {{ block.super }} + {{ filter.form.media }} {% endblock head %} {% block content %}
-
- {% translate "Logs" %} -
+
+
+
+
+ {% translate "Logs" %} +
+
+
+ +
+
+