Repository: carpentries/amy

Issue:
Counting workshops with memberships
I have seen some discrepancies in how workshops are being counted on the membership page. For example: 

On a [member view page like this](https://amy.carpentries.org/fiscal/membership/253/) we see that  they have completed 4 of 6 allowed workshops plus 1 self organized workshop.

Yet when I do `SELECT * FROM workshops_event WHERE membership_id = 253`  I get 8 workshops total (1 self organized and 7 centrally organized).  

Where is this discrepancy coming from? 


Discussion:
The membership ended Aug 05, which makes some of ASU events fall out of calculated numbers.

Here are the events and why they are not counted in:

| name | reason |
| ------ | ------ |
|`2020-12-07--agave-shell-intro` | self-organized|
|`2020-12-14-asu` | tagged as `stalled`|
|`2021-01-18-tempe` | valid (even though tagged as `unresponsive`)|
|`2021-03-08-asu-online` | valid|
|`2021-06-14-asu-online` | valid|
|`2021-07-06-asu-online` | valid|
|`2021-08-16-asu-online` | outside membership agreement dates|
|`2021-09-10-asu-online` | outside membership agreement dates|

As you can see in the table above, only 4 entries are valid and these are the 4 counted on the membership page.
Ok.  Now that we have the membership field in `workshops_events`  this should be used for assigning a workshop to a membership, not the membership valid dates.

Resolving patch:
diff --git a/amy/workshops/models.py b/amy/workshops/models.py
--- a/amy/workshops/models.py
+++ b/amy/workshops/models.py
@@ -311,20 +311,18 @@ def __str__(self):
     def get_absolute_url(self):
         return reverse("membership_details", args=[self.id])
 
+    def _base_queryset(self):
+        """Provide universal queryset for looking up workshops for this membership."""
+        cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
+        return Event.objects.filter(membership=self).exclude(cancelled).distinct()
+
     def _workshops_without_admin_fee_queryset(self):
         """Provide universal queryset for looking up centrally-organised workshops for
         this membership."""
-        during_membership = Q(
-            start__gte=self.agreement_start, start__lt=self.agreement_end
-        )
-        cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
         return (
-            Event.objects.filter(during_membership)
-            .filter(membership=self)
+            self._base_queryset()
             .filter(administrator__in=Organization.objects.administrators())
             .exclude(administrator__domain="self-organized")
-            .exclude(cancelled)
-            .distinct()
         )
 
     def _workshops_without_admin_fee_completed_queryset(self):
@@ -365,7 +363,7 @@ def workshops_without_admin_fee_available(self) -> int:
 
     @cached_property
     def workshops_without_admin_fee_completed(self) -> int:
-        """Count centrally-organised workshops already hosted during the agreement.
+        """Count centrally-organised workshops already hosted by this membership.
 
         This value must not be higher than "contracted" (or available for counting)
         no-fee workshops.
@@ -378,7 +376,7 @@ def workshops_without_admin_fee_completed(self) -> int:
 
     @cached_property
     def workshops_without_admin_fee_planned(self) -> int:
-        """Count centrally-organised workshops hosted in future during the agreement.
+        """Count centrally-organised workshops hosted in future by this membership.
 
         This value must not be higher than "contracted" (or available for counting)
         no-fee workshops reduced by already completed no-fee workshops.
@@ -423,20 +421,10 @@ def workshops_discounted_planned(self) -> int:
     def _self_organized_workshops_queryset(self):
         """Provide universal queryset for looking up self-organised events for this
         membership."""
-        during_membership = Q(
-            start__gte=self.agreement_start, start__lt=self.agreement_end
-        )
-        cancelled = Q(tags__name="cancelled") | Q(tags__name="stalled")
         self_organized = Q(administrator=None) | Q(
             administrator__domain="self-organized"
         )
-        return (
-            Event.objects.filter(during_membership)
-            .filter(membership=self)
-            .filter(self_organized)
-            .exclude(cancelled)
-            .distinct()
-        )
+        return self._base_queryset().filter(self_organized)
 
     @cached_property
     def self_organized_workshops_completed(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
@@ -172,8 +172,8 @@ def test_workshops_without_admin_fee(self):
         self.setUpTasks()
         self.assertEqual(self.current.workshops_without_admin_fee_per_agreement, 10)
         self.assertEqual(self.current.workshops_without_admin_fee_total_allowed, 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_completed, 7)
+        self.assertEqual(self.current.workshops_without_admin_fee_planned, 3)
         self.assertEqual(self.current.workshops_without_admin_fee_remaining, 0)
 
     def test_delete_membership(self):