Repository: carpentries/amy

Issue:
Issue membership extensions by date, rather than number of days
Right now we can issue membership extensions by X number of days.  This is useful for rough guidelines but can cause confusion when it comes to exact dates.

For example, Membership X was supposed to end 2021-07-23.  We grant a three month extension, so the new end date should be 2020-10-23.   AMY requires extensions to be entered by number of days.  Entering a 90 day extension would make the end date by 2021-10-21.  This small difference can cause confusion when we communicate with member sites.

We would like to change this interface so the user enters in a new end date from a calendar, leaving AMY to calculate the datediff.

@Talishask  Please let me know if I've captured this issue accurately and completely.  


Discussion:
@maneesha @Talishask I thought of this case when implementing the extension form. That's why there's a recalculated field with new agreement end date:

![membership_extension_form](https://user-images.githubusercontent.com/72821/126901176-a3b7b383-4ccb-4321-ac5e-5dfbc657869f.gif)

Are you sure you want to rework this form?
What is in the screengrab above is OK as a workaround, but ideally we would want to be able to input the date and have number of days automatically calcuate.  Low prioirty.
@maneesha - Yes, the idea is captured accurately and appropriate. 
I also agree that the option to see the new end date before submitting is okay but a workaround to the ideal option of selecting the desired end date. 

Resolving patch:
diff --git a/amy/fiscal/forms.py b/amy/fiscal/forms.py
--- a/amy/fiscal/forms.py
+++ b/amy/fiscal/forms.py
@@ -445,12 +445,12 @@ def __init__(self, *args, **kwargs):
 class MembershipExtensionForm(forms.Form):
     agreement_start = forms.DateField(disabled=True, required=False)
     agreement_end = forms.DateField(disabled=True, required=False)
+    new_agreement_end = forms.DateField(required=True)
     extension = forms.IntegerField(
-        min_value=1,
-        required=True,
-        help_text="Number of days the agreement should be extended.",
+        disabled=True,
+        required=False,
+        help_text="Number of days the agreement will be extended.",
     )
-    new_agreement_end = forms.DateField(disabled=True, required=False)
     comment = MarkdownxFormField(
         label="Comment",
         help_text=(
@@ -467,6 +467,25 @@ class MembershipExtensionForm(forms.Form):
     class Media:
         js = ("membership_extend.js", "date_yyyymmdd.js")
 
+    def clean(self):
+        super().clean()
+        errors = dict()
+
+        # validate new agreement end date is later than original agreement end date
+        agreement_end = self.cleaned_data.get("agreement_end")
+        new_agreement_end = self.cleaned_data.get("new_agreement_end")
+        try:
+            if new_agreement_end <= agreement_end:
+                errors["new_agreement_end"] = ValidationError(
+                    "New agreement end date must be later than original agreement "
+                    "end date."
+                )
+        except TypeError:
+            pass
+
+        if errors:
+            raise ValidationError(errors)
+
 
 # ----------------------------------------------------------
 # Signals
diff --git a/amy/fiscal/views.py b/amy/fiscal/views.py
--- a/amy/fiscal/views.py
+++ b/amy/fiscal/views.py
@@ -1,4 +1,4 @@
-from datetime import date, timedelta
+from datetime import date
 from typing import Any, Dict
 
 from django.contrib import messages
@@ -379,7 +379,10 @@ class MembershipExtend(
     form_class = MembershipExtensionForm
     template_name = "generic_form.html"
     permission_required = "workshops.change_membership"
-    comment = "Extended membership by {days} days on {date}.\n\n----\n\n{comment}"
+    comment = (
+        "Extended membership by {days} days on {date} (new end date: {new_date})."
+        "\n\n----\n\n{comment}"
+    )
 
     def get_initial(self):
         return {
@@ -395,10 +398,11 @@ def get_context_data(self, **kwargs):
         return super().get_context_data(**kwargs)
 
     def form_valid(self, form):
-        days = form.cleaned_data["extension"]
+        agreement_end = form.cleaned_data["agreement_end"]
+        new_agreement_end = form.cleaned_data["new_agreement_end"]
+        days = (new_agreement_end - agreement_end).days
         comment = form.cleaned_data["comment"]
-        extension = timedelta(days=days)
-        self.membership.agreement_end += extension
+        self.membership.agreement_end = new_agreement_end
         self.membership.extensions.append(days)
         self.membership.save()
 
@@ -406,7 +410,12 @@ def form_valid(self, form):
         add_comment_for_object(
             self.membership,
             self.request.user,
-            self.comment.format(days=days, date=date.today(), comment=comment),
+            self.comment.format(
+                days=days,
+                date=date.today(),
+                new_date=new_agreement_end,
+                comment=comment,
+            ),
         )
 
         return super().form_valid(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
@@ -923,9 +923,9 @@ def test_form_ignoring_fields(self):
             # fields ignored
             "agreement_start": "invalid value",
             "agreement_end": "invalid value",
-            "new_agreement_end": "invalid value",
+            "extension": "invalid value",
             # field accepted
-            "extension": "1",
+            "new_agreement_end": "2021-08-10",
         }
 
         form = MembershipExtensionForm(data)
@@ -936,14 +936,16 @@ def test_form_validating_extension(self):
         data = [
             ("string", False),
             ("", False),
-            ("-1", False),
-            ("0", False),
-            ("1", True),
-            ("2", True),
+            ("2020-01-01", False),
+            ("2021-01-01", False),
+            ("2022-01-01", True),
         ]
-        for extension, valid in data:
-            with self.subTest(extension=extension, valid=valid):
-                form = MembershipExtensionForm(dict(extension=extension))
+        for end_date, valid in data:
+            with self.subTest(date=end_date, valid=valid):
+                form = MembershipExtensionForm(
+                    dict(new_agreement_end=end_date),
+                    initial=dict(agreement_end="2021-01-01"),
+                )
                 self.assertEqual(form.is_valid(), valid)
 
     def test_membership_extended(self):
@@ -963,7 +965,7 @@ def test_membership_extended(self):
             membership=membership,
             role=MemberRole.objects.first(),
         )
-        data = {"extension": 30}
+        data = {"new_agreement_end": date(2021, 3, 31)}
 
         response = self.client.post(
             reverse("membership_extend", args=[membership.pk]),
@@ -995,9 +997,9 @@ def test_membership_extended_multiple_times(self):
             membership=membership,
             role=MemberRole.objects.first(),
         )
-        data1 = {"extension": 30}
-        data2 = {"extension": 40}
-        data3 = {"extension": 50}
+        data1 = {"new_agreement_end": date(2021, 3, 31)}
+        data2 = {"new_agreement_end": date(2021, 5, 10)}
+        data3 = {"new_agreement_end": date(2021, 6, 29)}
 
         self.client.post(reverse("membership_extend", args=[membership.pk]), data=data1)
         self.client.post(reverse("membership_extend", args=[membership.pk]), data=data2)
@@ -1025,11 +1027,10 @@ def test_comment_added(self):
             membership=membership,
             role=MemberRole.objects.first(),
         )
-        extension = 30
         comment = "Everything is awesome."
-        data = {"extension": extension, "comment": comment}
+        data = {"new_agreement_end": date(2021, 3, 31), "comment": comment}
         today = date.today()
-        expected_comment = f"""Extended membership by {extension} days on {today}.
+        expected_comment = f"""Extended membership by 30 days on {today} (new end date: 2021-03-31).
 
 ----