Repository: carpentries/amy

Issue:
Fixes to workshop summary in membership view 
In the Membership summary view, it includes canceled workshops in the completed workshops count.  This should show only workshops that are in the past and have not been canceled/stalled.  "Completed" does not need to checked for it to be counted here.  We'd also like a column for "Planned" for future workshops.  

Also, because there are no limits to self organized workshops, there should only be two columns for completed and planned. 

Here's a sample of what this should look like.
![image](https://user-images.githubusercontent.com/829690/82246275-5b649180-9912-11ea-8047-a337d88798f7.png)
 


Discussion:
What should be the conditions for `planned` workshops?
`Planned` means that they have an event in AMY but it is in the future.  
`Completed` means they have an event in AMY but it is in the past (and not stalled/cancelled).



Does `planned` have to have both start and end dates? What about required tags?
For `planned` we should have future start and end dates, and tags should not be stalled or canceled.

Resolving patch:
diff --git a/amy/workshops/models.py b/amy/workshops/models.py
--- a/amy/workshops/models.py
+++ b/amy/workshops/models.py
@@ -170,13 +170,40 @@ def workshops_without_admin_fee_completed(self):
             administrator__domain="self-organized"
         )
         no_fee = Q(admin_fee=0) | Q(admin_fee=None)
-        date_started = Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
+        date_started = (
+            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
+            & Q(start__lt=datetime.date.today())
+        )
+        cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
+
+        return (
+            Event.objects.filter(host=self.organization)
+            .filter(date_started)
+            .filter(no_fee)
+            .exclude(self_organized)
+            .exclude(cancelled)
+            .count()
+        )
+
+    @cached_property
+    def workshops_without_admin_fee_planned(self):
+        """Count workshops without admin fee hosted in future during the agreement."""
+        self_organized = Q(administrator=None) | Q(
+            administrator__domain="self-organized"
+        )
+        no_fee = Q(admin_fee=0) | Q(admin_fee=None)
+        date_started = (
+            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
+            & Q(start__gte=datetime.date.today())
+        )
+        cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
 
         return (
             Event.objects.filter(host=self.organization)
             .filter(date_started)
             .filter(no_fee)
             .exclude(self_organized)
+            .exclude(cancelled)
             .count()
         )
 
@@ -187,20 +214,48 @@ def workshops_without_admin_fee_remaining(self):
             return None
         a = self.workshops_without_admin_fee_per_agreement
         b = self.workshops_without_admin_fee_completed
-        return a - b
+        c = self.workshops_without_admin_fee_planned
+        return a - b - c
 
     @cached_property
     def self_organized_workshops_completed(self):
-        """Count self-organized workshops hosted the year agreement started."""
+        """Count self-organized workshops hosted the year agreement started (completed,
+        ie. in past)."""
         self_organized = Q(administrator=None) | Q(
             administrator__domain="self-organized"
         )
-        date_started = Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
+        date_started = (
+            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
+            & Q(start__lt=datetime.date.today())
+        )
+        cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
+
+        return (
+            Event.objects.filter(host=self.organization)
+            .filter(date_started)
+            .filter(self_organized)
+            .exclude(cancelled)
+            .count()
+        )
+
+    @cached_property
+    def self_organized_workshops_planned(self):
+        """Count self-organized workshops hosted the year agreement started (planned,
+        ie. in future)."""
+        self_organized = Q(administrator=None) | Q(
+            administrator__domain="self-organized"
+        )
+        date_started = (
+            Q(start__gte=self.agreement_start, start__lt=self.agreement_end)
+            & Q(start__gte=datetime.date.today())
+        )
+        cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
 
         return (
             Event.objects.filter(host=self.organization)
             .filter(date_started)
             .filter(self_organized)
+            .exclude(cancelled)
             .count()
         )
 
@@ -212,7 +267,8 @@ def self_organized_workshops_remaining(self):
             return None
         a = self.self_organized_workshops_per_agreement
         b = self.self_organized_workshops_completed
-        return a - b
+        c = self.self_organized_workshops_planned
+        return a - b - c
 
     @cached_property
     def seats_instructor_training_total(self):


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
@@ -1,5 +1,4 @@
 from datetime import timedelta, date
-import itertools
 
 from django.urls import reverse
 from django_comments.models import Comment
@@ -16,22 +15,23 @@ def setUp(self):
         self._setUpRoles()
         self._setUpTags()
 
-        self.learner = Role.objects.get(name='learner')
-        self.instructor = Role.objects.get(name='instructor')
-        self.TTT = Tag.objects.get(name='TTT')
+        self.learner = Role.objects.get(name="learner")
+        self.instructor = Role.objects.get(name="instructor")
+        self.TTT = Tag.objects.get(name="TTT")
+        self.cancelled = Tag.objects.get(name="cancelled")
 
         # parametrize membership creation
-        self.agreement_start = date(2018, 8, 2)
+        self.agreement_start = date.today() - timedelta(days=180)
         self.agreement_start_next_day = self.agreement_start + timedelta(days=1)
-        self.agreement_end = date(2018, 12, 31)
-        self.workshop_interval = timedelta(days=13)
+        self.agreement_end = date.today() + timedelta(days=180)
+        self.workshop_interval = timedelta(days=30)
 
         # let's add a membership for one of the organizations
         self.current = Membership.objects.create(
-            variant='partner',
+            variant="partner",
             agreement_start=self.agreement_start,
             agreement_end=self.agreement_end,
-            contribution_type='financial',
+            contribution_type="financial",
             workshops_without_admin_fee_per_agreement=10,
             self_organized_workshops_per_agreement=20,
             seats_instructor_training=25,
@@ -39,7 +39,7 @@ def setUp(self):
             organization=self.org_beta,
         )
 
-        self_organized_admin = Organization.objects.get(domain='self-organized')
+        self_organized_admin = Organization.objects.get(domain="self-organized")
 
         # create a couple of workshops that span outside of agreement duration
         data = [
@@ -50,7 +50,7 @@ def setUp(self):
         ]
         for i, (start_date, admin, fee) in enumerate(data):
             Event.objects.create(
-                slug='event-outside-agreement-range-{}'.format(i),
+                slug="event-outside-agreement-range-{}".format(i),
                 host=self.org_beta,
                 # create each event starts roughly month later
                 start=start_date,
@@ -60,63 +60,70 @@ def setUp(self):
             )
 
         # let's add a few events for that organization
-        type_ = itertools.cycle(['self-organized', 'no-fee', 'self-organized'])
-        for i in range(20):
-            next_type = next(type_)
-            e = None
-
-            if next_type == 'self-organized':
-                e = Event.objects.create(
-                    slug='event-under-umbrella{}'.format(i),
-                    host=self.org_beta,
-                    # create each event starts roughly month later
-                    start=self.agreement_start + i * self.workshop_interval,
-                    end=self.agreement_start_next_day + i * self.workshop_interval,
-                    administrator=self_organized_admin,
-                )
-                e.tags.set([self.TTT])
-
-            elif next_type == 'no-fee':
-                e = Event.objects.create(
-                    slug='event-under-umbrella{}'.format(i),
-                    host=self.org_beta,
-                    # create each event starts roughly month later
-                    start=self.agreement_start + i * self.workshop_interval,
-                    end=self.agreement_start_next_day + i * self.workshop_interval,
-                    # just to satisfy the criteria
-                    administrator=self.org_beta,
-                    admin_fee=0,
-                )
-                e.tags.set([self.TTT])
+        for i in range(10):
+            # a self-organized event
+            e1 = Event.objects.create(
+                slug="event-self-org-{}".format(i),
+                host=self.org_beta,
+                # create each event starts roughly month later
+                start=self.agreement_start + i * self.workshop_interval,
+                end=self.agreement_start_next_day + i * self.workshop_interval,
+                administrator=self_organized_admin,
+            )
+            e1.tags.set([self.TTT])
+
+            # an event without fee
+            e2 = Event.objects.create(
+                slug="event-no-fee-{}".format(i),
+                host=self.org_beta,
+                # create each event starts roughly month later
+                start=self.agreement_start + i * self.workshop_interval,
+                end=self.agreement_start_next_day + i * self.workshop_interval,
+                # just to satisfy the criteria
+                administrator=self.org_beta,
+                admin_fee=0,
+            )
+            e2.tags.set([self.TTT])
+
+            # a cancelled event
+            e3 = Event.objects.create(
+                slug="event-cancelled-{}".format(i),
+                host=self.org_beta,
+                # create each event starts roughly month later
+                start=self.agreement_start + i * self.workshop_interval,
+                end=self.agreement_start_next_day + i * self.workshop_interval,
+                # just to satisfy the criteria
+                administrator=self.org_beta,
+                admin_fee=0,
+            )
+            e3.tags.set([self.cancelled])
 
             # add a number of tasks for counting instructor training seats
-            if e and i < 10:
+            if i < 5:
                 Task.objects.create(
-                    event=e, person=self.admin,
+                    event=e1,
+                    person=self.admin,
                     role=self.learner,
                     seat_membership=self.current,
                 )
             # add a number of tasks for counting instructor training seats, but
             # this time make these tasks instructor tasks - should not be
             # counted
-            if e and i > 10:
+            if i > 10:
                 Task.objects.create(
-                    event=e, person=self.admin,
+                    event=e2,
+                    person=self.admin,
                     role=self.instructor,
                     seat_membership=self.current,
                 )
-        # above code should create 11 events that start in 2018:
-        # self-organized, no-fee, self-organized, self-organized, no-fee,
-        # self-organized, self-organized, no-fee, self-organized,
-        # self-organized, no-fee, self-organized,
 
     def test_multiple_memberships(self):
         """Ensure we can have multiple memberships (even overlapping)."""
         overlapping = Membership.objects.create(
-            variant='partner',
+            variant="partner",
             agreement_start=date(2015, 7, 1),
             agreement_end=date(2016, 6, 30),
-            contribution_type='financial',
+            contribution_type="financial",
             workshops_without_admin_fee_per_agreement=10,
             self_organized_workshops_per_agreement=20,
             organization=self.org_beta,
@@ -127,105 +134,92 @@ def test_multiple_memberships(self):
 
     def test_workshops_without_admin_fee(self):
         """Ensure we calculate properly number of workshops per year."""
-        self.assertEqual(
-            self.current.workshops_without_admin_fee_per_agreement, 10)
-        self.assertEqual(
-            self.current.workshops_without_admin_fee_completed, 4)
-        self.assertEqual(
-            self.current.workshops_without_admin_fee_remaining, 6)
+        self.assertEqual(self.current.workshops_without_admin_fee_per_agreement, 10)
+        self.assertEqual(self.current.workshops_without_admin_fee_completed, 6)
+        self.assertEqual(self.current.workshops_without_admin_fee_planned, 4)
+        self.assertEqual(self.current.workshops_without_admin_fee_remaining, 0)
 
     def test_self_organized_workshops(self):
         """Ensure we calculate properly number of workshops per year."""
-        self.assertEqual(
-            self.current.self_organized_workshops_per_agreement, 20)
-        self.assertEqual(
-            self.current.self_organized_workshops_completed, 8)
-        self.assertEqual(
-            self.current.self_organized_workshops_remaining, 12)
+        self.assertEqual(self.current.self_organized_workshops_per_agreement, 20)
+        self.assertEqual(self.current.self_organized_workshops_completed, 6)
+        self.assertEqual(self.current.self_organized_workshops_planned, 4)
+        self.assertEqual(self.current.self_organized_workshops_remaining, 10)
 
     def test_delete_membership(self):
-        '''Test that we can delete membership instance'''
+        """Test that we can delete membership instance"""
         # first we need to remove all tasks refering to the membership
         Task.objects.all().delete()
         response = self.client.post(
-            reverse('membership_delete', args=[self.current.pk]),
-            follow=True
+            reverse("membership_delete", args=[self.current.pk]), follow=True
         )
         self.assertRedirects(
             response,
-            reverse('organization_details', args=[self.current.organization.domain]),
+            reverse("organization_details", args=[self.current.organization.domain]),
         )
-        self.assertFalse(response.context['organization'].membership_set.all())
-        self.assertEqual(response.context['organization'].membership_set.count(), 0)
+        self.assertFalse(response.context["organization"].membership_set.all())
+        self.assertEqual(response.context["organization"].membership_set.count(), 0)
         with self.assertRaises(Membership.DoesNotExist):
             self.current.refresh_from_db()
 
     def test_number_of_instructor_training_seats(self):
         """Ensure calculation of seats in the instructor training events is
         correct."""
-        self.assertEqual(
-            self.current.seats_instructor_training, 25
-        )
-        self.assertEqual(
-            self.current.additional_instructor_training_seats, 3
-        )
-        self.assertEqual(
-            self.current.seats_instructor_training_utilized, 10
-        )
-        self.assertEqual(
-            self.current.seats_instructor_training_remaining, 18
-        )
+        self.assertEqual(self.current.seats_instructor_training, 25)
+        self.assertEqual(self.current.additional_instructor_training_seats, 3)
+        self.assertEqual(self.current.seats_instructor_training_utilized, 5)
+        self.assertEqual(self.current.seats_instructor_training_remaining, 23)
 
-    def test_creating_event_with_no_comment(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)
         data = {
-            'organization': self.org_alpha.pk,
-            'variant': 'partner',
-            'contribution_type': 'financial',
-            'additional_instructor_training_seats': 0,
-            'seats_instructor_training': 0,
-            'comment': '',
+            "organization": self.org_alpha.pk,
+            "variant": "partner",
+            "contribution_type": "financial",
+            "additional_instructor_training_seats": 0,
+            "seats_instructor_training": 0,
+            "comment": "",
         }
         form = MembershipCreateForm(data)
         form.save()
         self.assertEqual(Comment.objects.count(), 0)
 
-    def test_creating_event_with_comment(self):
+    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)
         data = {
-            'organization': self.org_alpha.pk,
-            'variant': 'partner',
-            'contribution_type': 'financial',
-            'additional_instructor_training_seats': 0,
-            'seats_instructor_training': 0,
-            'comment': 'This is a test comment.',
+            "organization": self.org_alpha.pk,
+            "variant": "partner",
+            "contribution_type": "financial",
+            "additional_instructor_training_seats": 0,
+            "seats_instructor_training": 0,
+            "comment": "This is a test comment.",
         }
         form = MembershipCreateForm(data)
         obj = form.save()
         self.assertEqual(Comment.objects.count(), 1)
         comment = Comment.objects.first()
-        self.assertEqual(comment.comment, 'This is a test comment.')
+        self.assertEqual(comment.comment, "This is a test comment.")
         self.assertIn(comment, Comment.objects.for_model(obj))
 
-    def test_workshop_edit_form_no_comment(self):
-        """Ensure workshop edit form works and doesn't provide `comment` field.
+    def test_membership_edit_form_no_comment(self):
+        """Ensure membership edit form works and doesn't provide `comment` field.
 
         This is a regression test against #1437:
         https://github.com/swcarpentry/amy/issues/1437
         """
-        self.assertNotIn('comment', MembershipForm.Meta.fields)
+        self.assertNotIn("comment", MembershipForm.Meta.fields)
 
         self.assertEqual(Comment.objects.count(), 0)
         data = {
-            'organization': self.org_alpha.pk,
-            'variant': 'partner',
-            'contribution_type': 'financial',
-            'additional_instructor_training_seats': 0,
-            'seats_instructor_training': 0,
+            "organization": self.org_alpha.pk,
+            "variant": "partner",
+            "contribution_type": "financial",
+            "additional_instructor_training_seats": 0,
+            "seats_instructor_training": 0,
         }
         form = MembershipForm(data, instance=self.current)
         form.save()