Repository: carpentries/amy

Issue:
Memberships: prevent changing consortium to non-consortium if there are >1 members assigned
Current implementation allows for membership to be changed from consortium (multiple members) to non-consortium (only one member), but a validation should be implemented to prevent that change when more than one member exists.

@maneesha may provide additional logic for the change, for example _the remaining member should receive a different membership role_.


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
@@ -1,4 +1,4 @@
-from crispy_forms.layout import Div, HTML, Field
+from crispy_forms.layout import Div, HTML
 from django import forms
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
@@ -140,6 +140,16 @@ def clean(self):
         except TypeError:
             pass
 
+        # check if multiple members are assigned - then disallow changing to
+        # non-consortium
+        new_consortium = self.cleaned_data.get("consortium")
+        members_count = self.instance.member_set.count()
+        if not new_consortium and members_count > 1:
+            errors["consortium"] = ValidationError(
+                "Cannot change to non-consortium when there are multiple members "
+                "assigned. Remove the members so that at most 1 is left."
+            )
+
         if errors:
             raise ValidationError(errors)
 
@@ -230,7 +240,20 @@ def __init__(self, *args, **kwargs):
         ].field.disabled = True
 
 
-class MemberForm(forms.ModelForm):
+class EditableFormsetFormMixin(forms.ModelForm):
+    EDITABLE = forms.BooleanField(
+        label="Change",
+        required=False,
+        widget=forms.CheckboxInput(attrs={"data-form-editable-check": ""}),
+    )
+
+    def clean(self):
+        if self.has_changed() and not self.cleaned_data["EDITABLE"]:
+            raise ValidationError("Form values weren't supposed to be changed.")
+        return super().clean()
+
+
+class MemberForm(EditableFormsetFormMixin, forms.ModelForm):
     """Form intended to use in formset for creating multiple membership members."""
 
     helper = BootstrapHelper(
@@ -257,16 +280,19 @@ def __init__(self, *args, **kwargs):
         # set up layout objects for the helpers - they're identical except for
         # visibility of the delete checkbox
         self.helper.layout = self.helper.build_default_layout(self)
+        self.helper.layout.append("id")
+        self.helper.layout.append("DELETE")  # visible; formset adds it
         self.helper_empty_form.layout = self.helper.build_default_layout(self)
-        self.helper.layout.append(Field("id"))
-        self.helper.layout.append(Field("DELETE"))  # visible; formset adds it
-        self.helper_empty_form.layout.append(Field("id"))
+        self.helper_empty_form.layout.append("id")
         self.helper_empty_form.layout.append(
-            Div(Field("DELETE"), css_class="d-none")  # hidden
+            Div("DELETE", css_class="d-none")  # hidden
         )
+        # remove EDITABLE checkbox from empty helper form
+        pos_index = self.helper_empty_form.layout.fields.index("EDITABLE")
+        self.helper_empty_form.layout.pop(pos_index)
 
 
-class MembershipTaskForm(forms.ModelForm):
+class MembershipTaskForm(EditableFormsetFormMixin, forms.ModelForm):
     """Form intended to use in formset for creating multiple membership members."""
 
     helper = BootstrapHelper(
@@ -293,13 +319,16 @@ def __init__(self, *args, **kwargs):
         # set up layout objects for the helpers - they're identical except for
         # visibility of the delete checkbox
         self.helper.layout = self.helper.build_default_layout(self)
+        self.helper.layout.append("id")
+        self.helper.layout.append("DELETE")  # visible; formset adds it
         self.helper_empty_form.layout = self.helper.build_default_layout(self)
-        self.helper.layout.append(Field("id"))
-        self.helper.layout.append(Field("DELETE"))  # visible; formset adds it
-        self.helper_empty_form.layout.append(Field("id"))
+        self.helper_empty_form.layout.append("id")
         self.helper_empty_form.layout.append(
-            Div(Field("DELETE"), css_class="d-none")  # hidden
+            Div("DELETE", css_class="d-none")  # hidden
         )
+        # remove EDITABLE checkbox from empty helper form
+        pos_index = self.helper_empty_form.layout.fields.index("EDITABLE")
+        self.helper_empty_form.layout.pop(pos_index)
 
 
 class MembershipExtensionForm(forms.Form):


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
@@ -631,26 +631,63 @@ def test_membership_agreement_dates_validation(self):
             ["Agreement end date can't be sooner than the start date."],
         )
 
+    def test_changing_consortium_to_nonconsortium(self):
+        membership = Membership.objects.create(
+            name="Test Membership",
+            consortium=True,
+            variant="partner",
+            agreement_start=date(2021, 3, 21),
+            agreement_end=date(2022, 3, 21),
+            contribution_type="financial",
+            workshops_without_admin_fee_per_agreement=10,
+            public_instructor_training_seats=25,
+            additional_public_instructor_training_seats=3,
+        )
+        m1 = Member.objects.create(
+            membership=membership,
+            organization=self.org_alpha,
+            role=MemberRole.objects.first(),
+        )
+        Member.objects.create(
+            membership=membership,
+            organization=self.org_beta,
+            role=MemberRole.objects.last(),
+        )
+
+        data = {
+            "name": membership.name,
+            "consortium": False,  # changing to non-consortium
+            "public_status": membership.public_status,
+            "agreement_start": membership.agreement_start,
+            "agreement_end": membership.agreement_end,
+            "variant": membership.variant,
+            "contribution_type": membership.contribution_type,
+            "public_instructor_training_seats": membership.public_instructor_training_seats,  # noqa
+            "additional_public_instructor_training_seats": membership.additional_public_instructor_training_seats,  # noqa
+            "inhouse_instructor_training_seats": membership.inhouse_instructor_training_seats,  # noqa
+            "additional_inhouse_instructor_training_seats": membership.additional_inhouse_instructor_training_seats,  # noqa
+        }
+        form = MembershipForm(data, instance=membership)
+        self.assertFalse(form.is_valid())
+        self.assertEqual(
+            form.errors["consortium"],
+            [
+                "Cannot change to non-consortium when there are multiple members "
+                "assigned. Remove the members so that at most 1 is left."
+            ],
+        )
+
+        # after deleting the member, form validates with no errors
+        m1.delete()
+        form = MembershipForm(data, instance=membership)
+        self.assertTrue(form.is_valid())
+
 
 class TestNewMembershipWorkflow(TestBase):
     def setUp(self):
         super().setUp()
         self._setUpUsersAndLogin()
 
-    def setUpMembership(self, consortium: bool):
-        self.membership = Membership.objects.create(
-            name="Test Membership",
-            consortium=consortium,
-            public_status="public",
-            variant="partner",
-            agreement_start="2021-02-14",
-            agreement_end="2022-02-14",
-            contribution_type="financial",
-            public_instructor_training_seats=0,
-            additional_public_instructor_training_seats=0,
-        )
-        self.member_role = MemberRole.objects.first()
-
     def test_new_nonconsortium_membership_redirects_to_details(self):
         """Ensure once created, new non-consortium membership redirects to it's details
         page."""
@@ -751,185 +788,6 @@ def test_new_consortium_membership_has_main_member(self):
         self.assertEqual(member.role.name, "contract_signatory")
         self.assertEqual(member.organization, self.org_alpha)
 
-    def test_adding_new_member_to_nonconsortium(self):
-        """Ensure only 1 member can be added to non-consortium membership."""
-        self.setUpMembership(consortium=False)
-        self.assertEqual(self.membership.member_set.count(), 0)
-
-        # only 1 member allowed
-        data = {
-            "form-TOTAL_FORMS": 1,
-            "form-INITIAL_FORMS": 0,
-            "form-MIN_NUM_FORMS": 0,
-            "form-MAX_NUM_FORMS": 1000,
-            "form-0-organization": self.org_alpha.pk,
-            "form-0-role": self.member_role.pk,
-            "form-0-id": "",
-        }
-        response = self.client.post(
-            reverse("membership_members", args=[self.membership.pk]),
-            data=data,
-            follow=True,
-        )
-
-        self.assertRedirects(
-            response, reverse("membership_details", args=[self.membership.pk])
-        )
-        self.assertEqual(self.membership.member_set.count(), 1)
-        self.assertEqual(list(self.membership.organizations.all()), [self.org_alpha])
-
-        # posting this will fail because only 1 form in the formset is allowed
-        data = {
-            "form-TOTAL_FORMS": 2,
-            "form-INITIAL_FORMS": 0,
-            "form-MIN_NUM_FORMS": 0,
-            "form-MAX_NUM_FORMS": 1000,
-            "form-0-organization": self.org_alpha.pk,
-            "form-0-role": self.member_role.pk,
-            "form-0-id": "",
-            "form-1-organization": self.org_beta.pk,
-            "form-1-role": self.member_role.pk,
-            "form-1-id": "",
-        }
-        response = self.client.post(
-            reverse("membership_members", args=[self.membership.pk]),
-            data=data,
-        )
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(self.membership.member_set.count(), 1)  # number didn't change
-
-    def test_adding_new_members_to_consortium(self):
-        """Ensure 1+ members can be added to consortium membership."""
-        self.setUpMembership(consortium=True)
-        self.assertEqual(self.membership.member_set.count(), 0)
-        data = {
-            "form-TOTAL_FORMS": 2,
-            "form-INITIAL_FORMS": 0,
-            "form-MIN_NUM_FORMS": 0,
-            "form-MAX_NUM_FORMS": 1000,
-            "form-0-organization": self.org_alpha.pk,
-            "form-0-role": self.member_role.pk,
-            "form-0-id": "",
-            "form-1-organization": self.org_beta.pk,
-            "form-1-role": self.member_role.pk,
-            "form-1-id": "",
-        }
-        response = self.client.post(
-            reverse("membership_members", args=[self.membership.pk]),
-            data=data,
-            follow=True,
-        )
-
-        self.assertRedirects(
-            response, reverse("membership_details", args=[self.membership.pk])
-        )
-        self.assertEqual(self.membership.member_set.count(), 2)
-        self.assertEqual(
-            list(self.membership.organizations.all()), [self.org_alpha, self.org_beta]
-        )
-
-    def test_removing_members_from_nonconsortium(self):
-        """Ensure removing the only member from non-consortium membership is not
-        allowed."""
-        self.setUpMembership(consortium=False)
-        m1 = Member.objects.create(
-            organization=self.org_alpha,
-            membership=self.membership,
-            role=self.member_role,
-        )
-
-        data = {
-            "form-TOTAL_FORMS": 1,
-            "form-INITIAL_FORMS": 1,
-            "form-MIN_NUM_FORMS": 0,
-            "form-MAX_NUM_FORMS": 1000,
-            "form-0-organization": m1.organization.pk,
-            "form-0-role": m1.role.pk,
-            "form-0-id": m1.pk,
-            "form-0-DELETE": "on",
-        }
-        response = self.client.post(
-            reverse("membership_members", args=[self.membership.pk]),
-            data=data,
-            follow=True,
-        )
-        self.assertEqual(response.status_code, 200)  # response failed
-        self.assertEqual(list(self.membership.organizations.all()), [self.org_alpha])
-
-    def test_removing_members_from_consortium(self):
-        """Ensure removing all members from consortium membership is allowed."""
-        self.setUpMembership(consortium=True)
-        m1 = Member.objects.create(
-            organization=self.org_alpha,
-            membership=self.membership,
-            role=self.member_role,
-        )
-        m2 = Member.objects.create(
-            organization=self.org_beta,
-            membership=self.membership,
-            role=self.member_role,
-        )
-
-        data = {
-            "form-TOTAL_FORMS": 2,
-            "form-INITIAL_FORMS": 2,
-            "form-MIN_NUM_FORMS": 0,
-            "form-MAX_NUM_FORMS": 1000,
-            "form-0-organization": m1.organization.pk,
-            "form-0-role": m1.role.pk,
-            "form-0-id": m1.pk,
-            "form-0-DELETE": "on",
-            "form-1-organization": m2.organization.pk,
-            "form-1-role": m2.role.pk,
-            "form-1-id": m2.pk,
-            "form-1-DELETE": "on",
-        }
-        response = self.client.post(
-            reverse("membership_members", args=[self.membership.pk]),
-            data=data,
-            follow=True,
-        )
-
-        self.assertRedirects(
-            response, reverse("membership_details", args=[self.membership.pk])
-        )
-        self.assertEqual(list(self.membership.organizations.all()), [])
-
-    def test_mix_adding_removing_members_from_consortium(self):
-        """Ensure a mixed-content formset for consortium membership members works
-        fine (e.g. a new member is added, and an old one is removed)."""
-        self.setUpMembership(consortium=True)
-        m1 = Member.objects.create(
-            organization=self.org_alpha,
-            membership=self.membership,
-            role=self.member_role,
-        )
-
-        data = {
-            "form-TOTAL_FORMS": 2,
-            "form-INITIAL_FORMS": 1,
-            "form-MIN_NUM_FORMS": 0,
-            "form-MAX_NUM_FORMS": 1000,
-            "form-0-organization": m1.organization.pk,
-            "form-0-role": m1.role.pk,
-            "form-0-id": m1.pk,
-            "form-0-DELETE": "on",
-            "form-1-organization": self.org_beta.pk,
-            "form-1-role": self.member_role.pk,
-            "form-1-id": "",
-        }
-        response = self.client.post(
-            reverse("membership_members", args=[self.membership.pk]),
-            data=data,
-            follow=True,
-        )
-
-        self.assertRedirects(
-            response, reverse("membership_details", args=[self.membership.pk])
-        )
-
-        self.assertEqual(list(self.membership.organizations.all()), [self.org_beta])
-
 
 class TestMembershipExtension(TestBase):
     def setUp(self):
diff --git a/amy/fiscal/tests/test_membership_members.py b/amy/fiscal/tests/test_membership_members.py
new file mode 100644
--- /dev/null
+++ b/amy/fiscal/tests/test_membership_members.py
@@ -0,0 +1,297 @@
+from django.urls import reverse
+
+from workshops.tests.base import TestBase
+from workshops.models import Membership, MemberRole, Member
+
+from fiscal.forms import MemberForm
+
+
+class TestMemberFormLayout(TestBase):
+    def test_main_helper_layout(self):
+        form = MemberForm()
+
+        self.assertEqual(
+            list(form.helper.layout),
+            ["organization", "role", "EDITABLE", "id", "DELETE"],
+        )
+
+    def test_empty_helper_layout(self):
+        form = MemberForm()
+
+        self.assertEqual(len(form.helper_empty_form.layout), 4)
+        self.assertEqual(
+            list(form.helper_empty_form.layout)[:3],
+            ["organization", "role", "id"],
+        )
+        self.assertEqual(form.helper_empty_form.layout[3].fields, ["DELETE"])
+
+
+class TestMembershipMembers(TestBase):
+    def setUp(self):
+        super().setUp()
+        self._setUpUsersAndLogin()
+
+    def setUpMembership(self, consortium: bool):
+        self.membership = Membership.objects.create(
+            name="Test Membership",
+            consortium=consortium,
+            public_status="public",
+            variant="partner",
+            agreement_start="2021-02-14",
+            agreement_end="2022-02-14",
+            contribution_type="financial",
+            public_instructor_training_seats=0,
+            additional_public_instructor_training_seats=0,
+        )
+        self.member_role = MemberRole.objects.first()
+
+    def test_adding_new_member_to_nonconsortium(self):
+        """Ensure only 1 member can be added to non-consortium membership."""
+        self.setUpMembership(consortium=False)
+        self.assertEqual(self.membership.member_set.count(), 0)
+
+        # only 1 member allowed
+        data = {
+            "form-TOTAL_FORMS": 1,
+            "form-INITIAL_FORMS": 0,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": self.org_alpha.pk,
+            "form-0-role": self.member_role.pk,
+            "form-0-id": "",
+            "form-0-EDITABLE": True,
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+
+        self.assertRedirects(
+            response, reverse("membership_details", args=[self.membership.pk])
+        )
+        self.assertEqual(self.membership.member_set.count(), 1)
+        self.assertEqual(list(self.membership.organizations.all()), [self.org_alpha])
+
+        # posting this will fail because only 1 form in the formset is allowed
+        data = {
+            "form-TOTAL_FORMS": 2,
+            "form-INITIAL_FORMS": 0,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": self.org_alpha.pk,
+            "form-0-role": self.member_role.pk,
+            "form-0-id": "",
+            "form-0-EDITABLE": True,
+            "form-1-organization": self.org_beta.pk,
+            "form-1-role": self.member_role.pk,
+            "form-1-id": "",
+            "form-1-EDITABLE": True,
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(self.membership.member_set.count(), 1)  # number didn't change
+
+    def test_adding_new_members_to_consortium(self):
+        """Ensure 1+ members can be added to consortium membership."""
+        self.setUpMembership(consortium=True)
+        self.assertEqual(self.membership.member_set.count(), 0)
+        data = {
+            "form-TOTAL_FORMS": 2,
+            "form-INITIAL_FORMS": 0,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": self.org_alpha.pk,
+            "form-0-role": self.member_role.pk,
+            "form-0-id": "",
+            "form-0-EDITABLE": True,
+            "form-1-organization": self.org_beta.pk,
+            "form-1-role": self.member_role.pk,
+            "form-1-id": "",
+            "form-1-EDITABLE": True,
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+
+        self.assertRedirects(
+            response, reverse("membership_details", args=[self.membership.pk])
+        )
+        self.assertEqual(self.membership.member_set.count(), 2)
+        self.assertEqual(
+            list(self.membership.organizations.all()), [self.org_alpha, self.org_beta]
+        )
+
+    def test_removing_members_from_nonconsortium(self):
+        """Ensure removing the only member from non-consortium membership is not
+        allowed."""
+        self.setUpMembership(consortium=False)
+        m1 = Member.objects.create(
+            organization=self.org_alpha,
+            membership=self.membership,
+            role=self.member_role,
+        )
+
+        data = {
+            "form-TOTAL_FORMS": 1,
+            "form-INITIAL_FORMS": 1,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": m1.organization.pk,
+            "form-0-role": m1.role.pk,
+            "form-0-id": m1.pk,
+            "form-0-EDITABLE": True,
+            "form-0-DELETE": "on",
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)  # response failed
+        self.assertEqual(list(self.membership.organizations.all()), [self.org_alpha])
+
+    def test_removing_members_from_consortium(self):
+        """Ensure removing all members from consortium membership is allowed."""
+        self.setUpMembership(consortium=True)
+        m1 = Member.objects.create(
+            organization=self.org_alpha,
+            membership=self.membership,
+            role=self.member_role,
+        )
+        m2 = Member.objects.create(
+            organization=self.org_beta,
+            membership=self.membership,
+            role=self.member_role,
+        )
+
+        data = {
+            "form-TOTAL_FORMS": 2,
+            "form-INITIAL_FORMS": 2,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": m1.organization.pk,
+            "form-0-role": m1.role.pk,
+            "form-0-id": m1.pk,
+            "form-0-EDITABLE": True,
+            "form-0-DELETE": "on",
+            "form-1-organization": m2.organization.pk,
+            "form-1-role": m2.role.pk,
+            "form-1-id": m2.pk,
+            "form-1-EDITABLE": True,
+            "form-1-DELETE": "on",
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+
+        self.assertRedirects(
+            response, reverse("membership_details", args=[self.membership.pk])
+        )
+        self.assertEqual(list(self.membership.organizations.all()), [])
+
+    def test_mix_adding_removing_members_from_consortium(self):
+        """Ensure a mixed-content formset for consortium membership members works
+        fine (e.g. a new member is added, and an old one is removed)."""
+        self.setUpMembership(consortium=True)
+        m1 = Member.objects.create(
+            organization=self.org_alpha,
+            membership=self.membership,
+            role=self.member_role,
+        )
+
+        data = {
+            "form-TOTAL_FORMS": 2,
+            "form-INITIAL_FORMS": 1,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": m1.organization.pk,
+            "form-0-role": m1.role.pk,
+            "form-0-id": m1.pk,
+            "form-0-EDITABLE": True,
+            "form-0-DELETE": "on",
+            "form-1-organization": self.org_beta.pk,
+            "form-1-role": self.member_role.pk,
+            "form-1-id": "",
+            "form-1-EDITABLE": True,
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+
+        self.assertRedirects(
+            response, reverse("membership_details", args=[self.membership.pk])
+        )
+
+        self.assertEqual(list(self.membership.organizations.all()), [self.org_beta])
+
+    def test_editing_noneditable_members_fails(self):
+        """Ensure an attempt to edit member without 'editable' checkbox ticked off
+        fails with validation error."""
+        self.setUpMembership(consortium=True)
+        m1 = Member.objects.create(
+            organization=self.org_alpha,
+            membership=self.membership,
+            role=self.member_role,
+        )
+
+        data = {
+            "form-TOTAL_FORMS": 1,
+            "form-INITIAL_FORMS": 1,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": self.org_beta.pk,
+            "form-0-role": m1.role.pk,
+            "form-0-id": m1.pk,
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+
+        self.assertEqual(response.status_code, 200)  # form failed
+        self.assertEqual(
+            response.context["formset"].errors[0],
+            {"__all__": ["Form values weren't supposed to be changed."]},
+        )
+        self.assertEqual(list(self.membership.organizations.all()), [self.org_alpha])
+
+    def test_not_editing_noneditable_members_succeeds(self):
+        """Ensure saving edit member without 'editable' checkbox ticked off works fine.
+        No changes are introduced to the member."""
+        self.setUpMembership(consortium=True)
+        m1 = Member.objects.create(
+            organization=self.org_alpha,
+            membership=self.membership,
+            role=self.member_role,
+        )
+
+        data = {
+            "form-TOTAL_FORMS": 1,
+            "form-INITIAL_FORMS": 1,
+            "form-MIN_NUM_FORMS": 0,
+            "form-MAX_NUM_FORMS": 1000,
+            "form-0-organization": m1.organization.pk,
+            "form-0-role": m1.role.pk,
+            "form-0-id": m1.pk,
+        }
+        response = self.client.post(
+            reverse("membership_members", args=[self.membership.pk]),
+            data=data,
+            follow=True,
+        )
+
+        self.assertRedirects(
+            response, reverse("membership_details", args=[self.membership.pk])
+        )
+        self.assertEqual(list(self.membership.organizations.all()), [self.org_alpha])
diff --git a/amy/fiscal/tests/test_membership_tasks.py b/amy/fiscal/tests/test_membership_tasks.py
--- a/amy/fiscal/tests/test_membership_tasks.py
+++ b/amy/fiscal/tests/test_membership_tasks.py
@@ -1,13 +1,32 @@
 from django.urls import reverse
 
 from workshops.tests.base import TestBase
-from workshops.models import (
-    Membership,
-)
+from workshops.models import Membership
 
+from fiscal.forms import MembershipTaskForm
 from fiscal.models import MembershipPersonRole, MembershipTask
 
 
+class TestMembershipTaskFormLayout(TestBase):
+    def test_main_helper_layout(self):
+        form = MembershipTaskForm()
+
+        self.assertEqual(
+            list(form.helper.layout),
+            ["person", "role", "EDITABLE", "id", "DELETE"],
+        )
+
+    def test_empty_helper_layout(self):
+        form = MembershipTaskForm()
+
+        self.assertEqual(len(form.helper_empty_form.layout), 4)
+        self.assertEqual(
+            list(form.helper_empty_form.layout)[:3],
+            ["person", "role", "id"],
+        )
+        self.assertEqual(form.helper_empty_form.layout[3].fields, ["DELETE"])
+
+
 class TestMembershipTasks(TestBase):
     def setUp(self):
         super().setUp()
@@ -34,9 +53,11 @@ def test_adding_new_tasks(self):
             "form-0-person": self.hermione.pk,
             "form-0-role": self.membership_person_role.pk,
             "form-0-id": "",
+            "form-0-EDITABLE": True,
             "form-1-person": self.harry.pk,
             "form-1-role": self.membership_person_role.pk,
             "form-1-id": "",
+            "form-1-EDITABLE": True,
         }
         response = self.client.post(
             reverse("membership_tasks", args=[self.membership.pk]),
@@ -72,10 +93,12 @@ def test_removing_tasks(self):
             "form-0-person": mt1.person.pk,
             "form-0-role": mt1.role.pk,
             "form-0-id": mt1.pk,
+            "form-0-EDITABLE": True,
             "form-0-DELETE": "on",
             "form-1-person": mt2.person.pk,
             "form-1-role": mt2.role.pk,
             "form-1-id": mt2.pk,
+            "form-1-EDITABLE": True,
             "form-1-DELETE": "on",
         }
         response = self.client.post(
@@ -110,14 +133,17 @@ def test_mix_adding_removing_tasks(self):
             "form-0-person": mt1.person.pk,
             "form-0-role": mt1.role.pk,
             "form-0-id": mt1.pk,
+            "form-0-EDITABLE": True,
             "form-0-DELETE": "on",
             "form-1-person": mt2.person.pk,
             "form-1-role": mt2.role.pk,
             "form-1-id": mt2.pk,
+            "form-1-EDITABLE": True,
             "form-1-DELETE": "on",
             "form-2-person": self.ron.pk,
             "form-2-role": self.membership_person_role.pk,
             "form-2-id": "",
+            "form-2-EDITABLE": True,
         }
         response = self.client.post(
             reverse("membership_tasks", args=[self.membership.pk]),