diff --git a/amy/extforms/forms.py b/amy/extforms/forms.py index ae21df061..d580e9940 100644 --- a/amy/extforms/forms.py +++ b/amy/extforms/forms.py @@ -37,6 +37,7 @@ class Meta: fields = ( "review_process", "member_code", + "member_code_override", "personal", "family", "email", @@ -109,6 +110,7 @@ def __init__(self, *args, **kwargs): self.set_other_fields(self.helper.layout) self.set_fake_required_fields() self.set_accordion(self.helper.layout) + self.set_display_member_code_override(visible=False) self.set_hr(self.helper.layout) def set_other_field(self, field_name: str, layout: Layout) -> None: @@ -143,6 +145,7 @@ def set_accordion(self, layout: Layout) -> None: self["review_process"].field.widget.subfields = { # type: ignore "preapproved": [ self["member_code"], + self["member_code_override"], ], "open": [], # this option doesn't require any additional fields } @@ -155,6 +158,7 @@ def set_accordion(self, layout: Layout) -> None: layout.fields.remove("review_process") layout.fields.remove("member_code") + layout.fields.remove("member_code_override") # insert div+field at previously saved position layout.insert( @@ -167,6 +171,10 @@ def set_accordion(self, layout: Layout) -> None: ), ) + def set_display_member_code_override(self, *, visible: bool) -> None: + widget = forms.CheckboxInput() if visible else forms.HiddenInput() + self.fields["member_code_override"].widget = widget + def set_hr(self, layout: Layout) -> None: # add
around "underrepresented*" fields index = layout.fields.index("underrepresented") @@ -202,30 +210,50 @@ def validate_member_code( ) -> None | dict[str, ValidationError]: errors = dict() member_code = self.cleaned_data.get("member_code", "") + member_code_override = self.cleaned_data.get("member_code_override", False) error_msg = ( "This code is invalid. " "This may be due to a typo, an expired code, " "or a code that has not yet been activated. " "Please confirm that you have copied the code correctly, " - "or confirm the code with the Membership Contact for your group." + "or confirm the code with the Membership Contact for your group. " + "If the code seems to be correct, tick the checkbox below to ignore " + "this message." ) if not member_code: return None + member_code_valid = self.member_code_valid(member_code) + if member_code_valid and member_code_override: + # case where a user corrects their code but ticks the box anyway + # checkbox doesn't need to be ticked, so correct it quietly and continue + self.cleaned_data["member_code_override"] = False + self.set_display_member_code_override(visible=False) + elif not member_code_valid: + self.set_display_member_code_override(visible=True) + if not member_code_override: + # user must either correct the code or tick the override + errors["member_code"] = ValidationError(error_msg) + + return errors + + def member_code_valid(self, code: str) -> bool: + """Returns True if the code matches an active Membership, + including a grace period of 90 days before and after the Membership dates. + """ try: # find relevant membership - may raise Membership.DoesNotExist - membership = Membership.objects.get(registration_code=member_code) - # confirm that membership is currently active - # grace period: 90 days before and after - if not membership.active_on_date( - date.today(), grace_before=90, grace_after=90 - ): - errors["member_code"] = ValidationError(error_msg) + membership = Membership.objects.get(registration_code=code) except Membership.DoesNotExist: - errors["member_code"] = ValidationError(error_msg) + return False - return errors + # confirm that membership is currently active + # grace period: 90 days before and after + if not membership.active_on_date(date.today(), grace_before=90, grace_after=90): + return False + + return True def clean(self): super().clean() diff --git a/amy/extforms/tests/test_training_request_form.py b/amy/extforms/tests/test_training_request_form.py index 55fee4997..a945f46c0 100644 --- a/amy/extforms/tests/test_training_request_form.py +++ b/amy/extforms/tests/test_training_request_form.py @@ -1,6 +1,7 @@ from datetime import date, timedelta from django.core import mail +from django.forms import CheckboxInput, HiddenInput from django.test import override_settings from django.urls import reverse @@ -134,6 +135,11 @@ def test_review_process_validation__preapproved_code_empty(self): rv, "Registration code is required for pre-approved training review process.", ) + # test that override field is not visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + HiddenInput, + ) def test_review_process_validation__open_code_nonempty(self): """Shouldn't pass when review_process requires *NO* member_code.""" @@ -151,6 +157,27 @@ def test_review_process_validation__open_code_nonempty(self): self.assertContains( rv, "Registration code must be empty for open training review process." ) + # test that override field is not visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + HiddenInput, + ) + + @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", False)]}) + def test_member_code_validation__not_enforced(self): + """Invalid code should pass if enforcement is not enabled.""" + # Arrange + data = { + "review_process": "preapproved", + "member_code": "invalid", + } + + # Act + rv = self.client.post(reverse("training_request"), data=data) + + # Assert + self.assertEqual(rv.status_code, 200) + self.assertNotContains(rv, self.INVALID_MEMBER_CODE_ERROR) @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", True)]}) def test_member_code_validation__code_valid(self): @@ -168,6 +195,11 @@ def test_member_code_validation__code_valid(self): # Assert self.assertEqual(rv.status_code, 200) self.assertNotContains(rv, self.INVALID_MEMBER_CODE_ERROR) + # test that override field is not visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + HiddenInput, + ) @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", True)]}) def test_member_code_validation__code_invalid(self): @@ -184,6 +216,11 @@ def test_member_code_validation__code_invalid(self): # Assert self.assertEqual(rv.status_code, 200) self.assertContains(rv, self.INVALID_MEMBER_CODE_ERROR) + # test that override field is visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + CheckboxInput, + ) @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", True)]}) def test_member_code_validation__code_inactive_early(self): @@ -203,6 +240,11 @@ def test_member_code_validation__code_inactive_early(self): # Assert self.assertEqual(rv.status_code, 200) self.assertContains(rv, self.INVALID_MEMBER_CODE_ERROR) + # test that override field is visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + CheckboxInput, + ) @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", True)]}) def test_member_code_validation__code_inactive_late(self): @@ -222,3 +264,73 @@ def test_member_code_validation__code_inactive_late(self): # Assert self.assertEqual(rv.status_code, 200) self.assertContains(rv, self.INVALID_MEMBER_CODE_ERROR) + # test that override field is visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + CheckboxInput, + ) + + @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", True)]}) + def test_member_code_validation__code_invalid_override(self): + """Invalid member code should be accepted when the override is ticked.""" + # Arrange + data = { + "review_process": "preapproved", + "member_code": "invalid", + "member_code_override": True, + } + + # Act + rv = self.client.post(reverse("training_request"), data=data) + + # Assert + self.assertEqual(rv.status_code, 200) + self.assertNotContains(rv, self.INVALID_MEMBER_CODE_ERROR) + # test that override field is visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + CheckboxInput, + ) + + @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", True)]}) + def test_member_code_validation__code_valid_override(self): + """Override should be quietly hidden if a valid code is used.""" + # Arrange + self.setUpMembership() + data = { + "review_process": "preapproved", + "member_code": "valid123", + "member_code_override": True, + } + + # Act + rv = self.client.post(reverse("training_request"), data=data) + + # Assert + self.assertEqual(rv.status_code, 200) + self.assertNotContains(rv, self.INVALID_MEMBER_CODE_ERROR) + # test that override field is not visible + self.assertEqual( + rv.context["form"].fields["member_code_override"].widget.__class__, + HiddenInput, + ) + + @override_settings(FLAGS={"ENFORCE_MEMBER_CODES": [("boolean", True)]}) + def test_member_code_validation__code_valid_override_full_request(self): + """Override should be quietly changed to False if a valid code is used + in a successful submission.""" + # Arrange + self.setUpMembership() + self.data["member_code"] = "valid123" + self.data["member_code_override"] = True + self.passCaptcha(self.data) + + # Act + rv = self.client.post(reverse("training_request"), data=self.data, follow=True) + + # Assert + self.assertEqual(rv.status_code, 200) + self.assertEqual(rv.resolver_match.view_name, "training_request_confirm") + self.assertFalse( + TrainingRequest.objects.get(member_code="valid123").member_code_override + ) diff --git a/amy/fiscal/tests/test_membership.py b/amy/fiscal/tests/test_membership.py index da74a85dd..4e2c9bb18 100644 --- a/amy/fiscal/tests/test_membership.py +++ b/amy/fiscal/tests/test_membership.py @@ -1129,11 +1129,11 @@ def test_comment_added(self): comment = "Everything is awesome." data = {"new_agreement_end": date(2021, 3, 31), "comment": comment} today = date.today() - expected_comment = f"""Extended membership by 30 days on {today} (new end date: 2021-03-31). - ----- - -{comment}""" + expected_comment = ( + f"Extended membership by 30 days on {today} (new end date: 2021-03-31)." + "\n\n----\n\n" + f"{comment}" + ) # Act self.client.post(reverse("membership_extend", args=[membership.pk]), data=data) diff --git a/amy/templates/includes/trainingrequest_details.html b/amy/templates/includes/trainingrequest_details.html index 5bfb508f0..c874c1dfc 100644 --- a/amy/templates/includes/trainingrequest_details.html +++ b/amy/templates/includes/trainingrequest_details.html @@ -26,6 +26,8 @@ {{ object.get_review_process_display|default:"—" }} Registration Code: {{ object.member_code|default:"—" }} + Continue with registration code marked as invalid: + {{ object.member_code_override|yesno }} Personal name: {{ object.personal }} Middle name: diff --git a/amy/workshops/migrations/0267_trainingrequest_accept_invalid_member_code.py b/amy/workshops/migrations/0267_trainingrequest_accept_invalid_member_code.py new file mode 100644 index 000000000..870243c14 --- /dev/null +++ b/amy/workshops/migrations/0267_trainingrequest_accept_invalid_member_code.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.20 on 2023-10-19 07:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("workshops", "0266_rename_group_name_trainingrequest_member_code"), + ] + + operations = [ + migrations.AddField( + model_name="trainingrequest", + name="member_code_override", + field=models.BooleanField( + blank=True, + default=False, + help_text="A member of our team will check the code and follow up with you if there are any problems that require your attention.", + verbose_name="Continue with registration code marked as invalid", + ), + ), + ] diff --git a/amy/workshops/models.py b/amy/workshops/models.py index 686dba82f..a4becefb0 100644 --- a/amy/workshops/models.py +++ b/amy/workshops/models.py @@ -2123,6 +2123,14 @@ class TrainingRequest( "a Carpentries member site or for a specific scheduled " "event, please enter it here:", ) + member_code_override = models.BooleanField( + null=False, + default=False, + blank=True, + verbose_name="Continue with registration code marked as invalid", + help_text="A member of our team will check the code and follow up with you if " + "there are any problems that require your attention.", + ) personal = models.CharField( max_length=STR_LONG,