Repository: carpentries/amy

Issue:
Membership: enable editing of rolled values
Need a way to update rolled over seats/workshops. For example, if we set up a new membership term by rolling over 4 seats, but then decided to rollover only 2, there should be a way to edit the rolled over values.

Note: the solution will have to update both the source membership (the one rolled from) and the dest. membership (rolled over to).


Discussion:

Resolving patch:
diff --git a/amy/fiscal/forms.py b/amy/fiscal/forms.py
--- a/amy/fiscal/forms.py
+++ b/amy/fiscal/forms.py
@@ -111,11 +111,30 @@ class Meta:
             "inhouse_instructor_training_seats",
             "additional_inhouse_instructor_training_seats",
             "emergency_contact",
+            "workshops_without_admin_fee_rolled_over",
+            "workshops_without_admin_fee_rolled_from_previous",
+            "public_instructor_training_seats_rolled_over",
+            "public_instructor_training_seats_rolled_from_previous",
+            "inhouse_instructor_training_seats_rolled_over",
+            "inhouse_instructor_training_seats_rolled_from_previous",
         ]
 
-    def __init__(self, *args, **kwargs):
+    def __init__(
+        self, *args, show_rolled_over=False, show_rolled_from_previous=False, **kwargs
+    ):
         super().__init__(*args, **kwargs)
 
+        # When editing membership, allow to edit rolled_over or rolled_from_previous
+        # values when membership was rolled over or rolled from other membership.
+        if not show_rolled_over:
+            del self.fields["workshops_without_admin_fee_rolled_over"]
+            del self.fields["public_instructor_training_seats_rolled_over"]
+            del self.fields["inhouse_instructor_training_seats_rolled_over"]
+        if not show_rolled_from_previous:
+            del self.fields["workshops_without_admin_fee_rolled_from_previous"]
+            del self.fields["public_instructor_training_seats_rolled_from_previous"]
+            del self.fields["inhouse_instructor_training_seats_rolled_from_previous"]
+
         # set up a layout object for the helper
         self.helper.layout = self.helper.build_default_layout(self)
 
@@ -258,7 +277,9 @@ class Meta(MembershipCreateForm.Meta):
 
     def __init__(self, *args, **kwargs):
         max_values = kwargs.pop("max_values", {})
-        super().__init__(*args, **kwargs)
+        super().__init__(
+            *args, show_rolled_over=True, show_rolled_from_previous=True, **kwargs
+        )
 
         # don't allow values over the limit imposed by the view using this form
         fields = [
diff --git a/amy/fiscal/views.py b/amy/fiscal/views.py
--- a/amy/fiscal/views.py
+++ b/amy/fiscal/views.py
@@ -235,6 +235,77 @@ class MembershipUpdate(
     pk_url_kwarg = "membership_id"
     template_name = "generic_form_with_comments.html"
 
+    def get_form_kwargs(self) -> Dict[str, Any]:
+        kwargs = super().get_form_kwargs()
+
+        show_rolled_over = False
+        show_rolled_from_previous = False
+        if self.object.rolled_to_membership:
+            show_rolled_over = True
+
+        try:
+            if self.object.rolled_from_membership:
+                show_rolled_from_previous = True
+        except Membership.DoesNotExist:
+            pass
+
+        kwargs["show_rolled_over"] = show_rolled_over
+        kwargs["show_rolled_from_previous"] = show_rolled_from_previous
+        return kwargs
+
+    def form_valid(self, form):
+        result = super().form_valid(form)
+        data = form.cleaned_data
+
+        # see if updated "rolled" values are available, and update related memberships
+        pairs = (
+            (
+                "workshops_without_admin_fee_rolled_over",
+                "workshops_without_admin_fee_rolled_from_previous",
+            ),
+            (
+                "public_instructor_training_seats_rolled_over",
+                "public_instructor_training_seats_rolled_from_previous",
+            ),
+            (
+                "inhouse_instructor_training_seats_rolled_over",
+                "inhouse_instructor_training_seats_rolled_from_previous",
+            ),
+        )
+        save_rolled_to = False
+        try:
+            for rolled_over, rolled_from in pairs:
+                if rolled_over in data:
+                    setattr(
+                        self.object.rolled_to_membership,
+                        rolled_from,
+                        data[rolled_over],
+                    )
+                    save_rolled_to = True
+
+            if save_rolled_to:
+                self.object.rolled_to_membership.save()
+        except Membership.DoesNotExist:
+            pass
+
+        save_rolled_from = False
+        try:
+            for rolled_over, rolled_from in pairs:
+                if rolled_from in data:
+                    setattr(
+                        self.object.rolled_from_membership,
+                        rolled_over,
+                        data[rolled_from],
+                    )
+                    save_rolled_from = True
+
+            if save_rolled_from:
+                self.object.rolled_from_membership.save()
+        except Membership.DoesNotExist:
+            pass
+
+        return result
+
 
 class MembershipDelete(OnlyForAdminsMixin, PermissionRequiredMixin, AMYDeleteView):
     model = Membership


Test patch:
diff --git a/amy/fiscal/tests/test_membership.py b/amy/fiscal/tests/test_membership.py
--- a/amy/fiscal/tests/test_membership.py
+++ b/amy/fiscal/tests/test_membership.py
@@ -694,6 +694,106 @@ def test_changing_consortium_to_nonconsortium(self):
         form = MembershipForm(data, instance=membership)
         self.assertTrue(form.is_valid())
 
+    def test_edit_membership_rolled_fields(self):
+        # Arrange
+        membership1 = Membership.objects.create(
+            name="Test Membership 1",
+            consortium=False,
+            variant="partner",
+            agreement_start=date(2021, 8, 1),
+            agreement_end=date(2022, 7, 31),
+            contribution_type="financial",
+            workshops_without_admin_fee_per_agreement=10,
+            public_instructor_training_seats=10,
+            inhouse_instructor_training_seats=10,
+        )
+        membership2 = Membership.objects.create(
+            name="Test Membership 2",
+            consortium=False,
+            variant="partner",
+            agreement_start=date(2022, 8, 1),
+            agreement_end=date(2023, 7, 31),
+            contribution_type="financial",
+            workshops_without_admin_fee_per_agreement=10,
+            public_instructor_training_seats=10,
+            inhouse_instructor_training_seats=10,
+        )
+        membership3 = Membership.objects.create(
+            name="Test Membership 3",
+            consortium=False,
+            variant="partner",
+            agreement_start=date(2023, 8, 1),
+            agreement_end=date(2024, 7, 31),
+            contribution_type="financial",
+            workshops_without_admin_fee_per_agreement=10,
+            public_instructor_training_seats=10,
+            inhouse_instructor_training_seats=10,
+        )
+        # 1st roll-over (only central workshops)
+        membership1.rolled_to_membership = membership2
+        membership1.workshops_without_admin_fee_rolled_over = 5
+        membership2.workshops_without_admin_fee_rolled_from_previous = 5
+
+        # 2nd roll-over (training seats of both kinds)
+        membership2.rolled_to_membership = membership3
+        membership2.public_instructor_training_seats_rolled_over = 6
+        membership2.inhouse_instructor_training_seats_rolled_over = 7
+        membership3.public_instructor_training_seats_rolled_from_previous = 6
+        membership3.inhouse_instructor_training_seats_rolled_from_previous = 7
+
+        membership1.save()
+        membership2.save()
+        membership3.save()
+
+        # Act
+        data = {
+            "name": membership2.name,
+            "public_status": membership2.public_status,
+            "variant": membership2.variant,
+            "agreement_start": membership2.agreement_start,
+            "agreement_end": membership2.agreement_end,
+            "contribution_type": membership2.contribution_type,
+            "public_instructor_training_seats": membership2.public_instructor_training_seats,  # noqa
+            "additional_public_instructor_training_seats": membership2.additional_public_instructor_training_seats,  # noqa
+            "inhouse_instructor_training_seats": membership2.inhouse_instructor_training_seats,  # noqa
+            "additional_inhouse_instructor_training_seats": membership2.additional_inhouse_instructor_training_seats,  # noqa
+            "workshops_without_admin_fee_rolled_from_previous": 6,  # was 5
+            "public_instructor_training_seats_rolled_over": 7,  # was 6
+            "inhouse_instructor_training_seats_rolled_over": 8,  # was 7
+        }
+        self.client.post(reverse("membership_edit", args=[membership2.id]), data=data)
+        membership1.refresh_from_db()
+        membership2.refresh_from_db()
+        membership3.refresh_from_db()
+
+        # Assert
+        self.assertEqual(
+            membership1.workshops_without_admin_fee_rolled_over,
+            6,
+        )
+        self.assertEqual(
+            membership2.workshops_without_admin_fee_rolled_from_previous,
+            6,
+        )
+
+        self.assertEqual(
+            membership2.public_instructor_training_seats_rolled_over,
+            7,
+        )
+        self.assertEqual(
+            membership3.public_instructor_training_seats_rolled_from_previous,
+            7,
+        )
+
+        self.assertEqual(
+            membership2.inhouse_instructor_training_seats_rolled_over,
+            8,
+        )
+        self.assertEqual(
+            membership3.inhouse_instructor_training_seats_rolled_from_previous,
+            8,
+        )
+
 
 class TestNewMembershipWorkflow(TestBase):
     def setUp(self):