Repository: carpentries/amy

Issue:
Membership: document multiple extensions
Currently there's only one extension available and it gets overwritten if membership is extended multiple times.

1. Change the DB structure to hold multiple extensions (e.g. `[30, 15, 15]`)
2. Display multiple extensions and their cumulative value in membership
3. Add a comment “<User name> extended membership by 15 days on 2021-05-05” - similar to the story #1929.

No additional metadata needs to be stored with each extension.


Discussion:

Resolving patch:
diff --git a/amy/fiscal/views.py b/amy/fiscal/views.py
--- a/amy/fiscal/views.py
+++ b/amy/fiscal/views.py
@@ -9,6 +9,7 @@
 from django.urls import reverse, reverse_lazy
 from django.views.generic import FormView
 
+from extcomments.utils import add_comment_for_object
 from fiscal.base_views import (
     GetMembershipMixin,
     MembershipFormsetView,
@@ -174,6 +175,7 @@ class MembershipDetails(OnlyForAdminsMixin, AMYDetailView):
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context["title"] = "{0}".format(self.object)
+        context["membership_extensions_sum"] = sum(self.object.extensions)
         return context
 
 
@@ -302,6 +304,7 @@ class MembershipExtend(
     form_class = MembershipExtensionForm
     template_name = "generic_form.html"
     permission_required = "workshops.change_membership"
+    comment = "Extended membership by {days} days on {date}."
 
     def get_initial(self):
         return {
@@ -320,9 +323,16 @@ def form_valid(self, form):
         days = form.cleaned_data["extension"]
         extension = timedelta(days=days)
         self.membership.agreement_end += extension
-        self.membership.extended = days
+        self.membership.extensions.append(days)
         self.membership.save()
 
+        # Add a comment "Extended membership by X days on DATE" on user's behalf.
+        add_comment_for_object(
+            self.membership,
+            self.request.user,
+            self.comment.format(days=days, date=date.today()),
+        )
+
         return super().form_valid(form)
 
     def get_success_url(self) -> str:
diff --git a/amy/workshops/migrations/0245_membership_extensions.py b/amy/workshops/migrations/0245_membership_extensions.py
new file mode 100644
--- /dev/null
+++ b/amy/workshops/migrations/0245_membership_extensions.py
@@ -0,0 +1,43 @@
+# Generated by Django 2.2.18 on 2021-05-17 06:36
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+def move_extensions(apps, schema_editor):
+    """Move existing `extended` value into the new field `extensions`."""
+    Membership = apps.get_model('workshops', 'Membership')
+    for membership in Membership.objects.all():
+        membership.extensions = (
+            [] if membership.extended is None
+            else [membership.extended]
+        )
+        membership.save()
+
+
+def undo_extensions(apps, schema_editor):
+    """Move from `extensions` to single value `extended` field."""
+    Membership = apps.get_model('workshops', 'Membership')
+    for membership in Membership.objects.all():
+        membership.extended = sum(membership.extensions) or None
+        membership.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('workshops', '0244_auto_20210509_2051'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='membership',
+            name='extensions',
+            field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(), default=list, help_text='Number of days the agreement was extended. The field stores multiple extensions. The agreement end date has been moved by a cumulative number of days from this field.', size=None),
+        ),
+        migrations.RunPython(move_extensions, undo_extensions),
+        migrations.RemoveField(
+            model_name='membership',
+            name='extended',
+        ),
+    ]
diff --git a/amy/workshops/models.py b/amy/workshops/models.py
--- a/amy/workshops/models.py
+++ b/amy/workshops/models.py
@@ -7,6 +7,7 @@
     BaseUserManager,
     PermissionsMixin,
 )
+from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
 from django.db import models, transaction
@@ -148,11 +149,12 @@ class Membership(models.Model):
         help_text="If an extension is being granted, do not manually edit the end date."
         ' Use the "Extend" button on membership details page instead.'
     )
-    extended = models.PositiveIntegerField(
-        null=True,
-        blank=True,
-        help_text="Number of days the agreement was extended with. If this value was"
-        " provided, then the agreement end has been moved.",
+    extensions = ArrayField(
+        models.PositiveIntegerField(),
+        help_text="Number of days the agreement was extended. The field stores "
+        "multiple extensions. The agreement end date has been moved by a cumulative "
+        "number of days from this field.",
+        default=list,
     )
     CONTRIBUTION_CHOICES = (
         ("financial", "Financial"),


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
@@ -2,7 +2,7 @@
 from typing import List
 
 from django.urls import reverse
-from django_comments.models import Comment
+import django_comments
 
 from fiscal.forms import (
     MembershipCreateForm,
@@ -23,6 +23,8 @@
 )
 from workshops.tests.base import TestBase
 
+CommentModel = django_comments.get_model()
+
 
 class TestMembership(TestBase):
     def setUp(self):
@@ -514,7 +516,7 @@ def setUp(self):
     def test_creating_membership_with_no_comment(self):
         """Ensure that no comment is added when MembershipCreateForm without
         comment content is saved."""
-        self.assertEqual(Comment.objects.count(), 0)
+        self.assertEqual(CommentModel.objects.count(), 0)
         data = {
             "main_organization": self.org_alpha.pk,
             "name": "Test Membership",
@@ -532,12 +534,12 @@ def test_creating_membership_with_no_comment(self):
         }
         form = MembershipCreateForm(data)
         form.save()
-        self.assertEqual(Comment.objects.count(), 0)
+        self.assertEqual(CommentModel.objects.count(), 0)
 
     def test_creating_membership_with_comment(self):
         """Ensure that a comment is added when MembershipCreateForm with
         comment content is saved."""
-        self.assertEqual(Comment.objects.count(), 0)
+        self.assertEqual(CommentModel.objects.count(), 0)
         data = {
             "main_organization": self.org_alpha.pk,
             "name": "Test Membership",
@@ -555,10 +557,10 @@ def test_creating_membership_with_comment(self):
         }
         form = MembershipCreateForm(data)
         obj = form.save()
-        self.assertEqual(Comment.objects.count(), 1)
-        comment = Comment.objects.first()
+        self.assertEqual(CommentModel.objects.count(), 1)
+        comment = CommentModel.objects.first()
         self.assertEqual(comment.comment, "This is a test comment.")
-        self.assertIn(comment, Comment.objects.for_model(obj))
+        self.assertIn(comment, CommentModel.objects.for_model(obj))
 
     def test_membership_edit_form_no_comment(self):
         """Ensure membership edit form works and doesn't provide `comment` field.
@@ -590,7 +592,7 @@ def test_membership_edit_form_no_comment(self):
 
         self.assertNotIn("comment", MembershipForm.Meta.fields)
 
-        self.assertEqual(Comment.objects.count(), 0)
+        self.assertEqual(CommentModel.objects.count(), 0)
         data = {
             "main_organization": self.org_alpha.pk,
             "name": "Test Membership",
@@ -607,7 +609,7 @@ def test_membership_edit_form_no_comment(self):
         }
         form = MembershipForm(data, instance=membership)
         form.save()
-        self.assertEqual(Comment.objects.count(), 0)
+        self.assertEqual(CommentModel.objects.count(), 0)
 
     def test_membership_agreement_dates_validation(self):
         """Validate invalid agreement end date (can't be sooner than start date)."""
@@ -843,7 +845,6 @@ def test_membership_extended(self):
             variant="partner",
             agreement_start="2020-03-01",
             agreement_end="2021-03-01",
-            extended=None,
             contribution_type="financial",
             public_instructor_training_seats=0,
             additional_public_instructor_training_seats=0,
@@ -865,9 +866,70 @@ def test_membership_extended(self):
             response, reverse("membership_details", args=[membership.pk])
         )
         membership.refresh_from_db()
-        self.assertEqual(membership.extended, 30)
+        self.assertEqual(membership.extensions, [30])
         self.assertEqual(membership.agreement_end, date(2021, 3, 31))
 
+    def test_membership_extended_multiple_times(self):
+        membership = Membership.objects.create(
+            name="Test Membership",
+            consortium=False,
+            public_status="public",
+            variant="partner",
+            agreement_start="2020-03-01",
+            agreement_end="2021-03-01",
+            contribution_type="financial",
+            public_instructor_training_seats=0,
+            additional_public_instructor_training_seats=0,
+        )
+        Member.objects.create(
+            organization=self.org_alpha,
+            membership=membership,
+            role=MemberRole.objects.first(),
+        )
+        data1 = {"extension": 30}
+        data2 = {"extension": 40}
+        data3 = {"extension": 50}
+
+        self.client.post(reverse("membership_extend", args=[membership.pk]), data=data1)
+        self.client.post(reverse("membership_extend", args=[membership.pk]), data=data2)
+        self.client.post(reverse("membership_extend", args=[membership.pk]), data=data3)
+
+        membership.refresh_from_db()
+        self.assertEqual(membership.extensions, [30, 40, 50])
+        self.assertEqual(membership.agreement_end, date(2021, 6, 29))
+
+    def test_comment_added(self):
+        # Arrange
+        membership = Membership.objects.create(
+            name="Test Membership",
+            consortium=False,
+            public_status="public",
+            variant="partner",
+            agreement_start="2020-03-01",
+            agreement_end="2021-03-01",
+            contribution_type="financial",
+            public_instructor_training_seats=0,
+            additional_public_instructor_training_seats=0,
+        )
+        Member.objects.create(
+            organization=self.org_alpha,
+            membership=membership,
+            role=MemberRole.objects.first(),
+        )
+        extension = 30
+        data = {"extension": extension}
+        today = date.today()
+
+        # Act
+        self.client.post(reverse("membership_extend", args=[membership.pk]), data=data)
+
+        # Assert
+        self.assertEqual(CommentModel.objects.for_model(membership).count(), 1)
+        comment = CommentModel.objects.for_model(membership).first()
+        self.assertEqual(
+            comment.comment, f"Extended membership by {extension} days on {today}."
+        )
+
 
 class TestMembershipCreateRollOver(TestBase):
     def setUp(self):
@@ -896,7 +958,6 @@ def setUpMembership(self, consortium: bool = False):
             variant="partner",
             agreement_start="2020-03-01",
             agreement_end="2021-03-01",
-            extended=None,
             contribution_type="financial",
             workshops_without_admin_fee_per_agreement=10,
             public_instructor_training_seats=12,