Repository: carpentries/amy

Issue:
EventQuerySet.unpublished_conditional makes annotating events sometimes wrong
Any event with tag "cancelled" will *sort-of* appear twice in results for Django, therefore making annotation's result double, however finally there will be only *single* event in the query set.

More details:

* https://github.com/swcarpentry/amy/pull/1127#discussion_r95062085
* https://github.com/swcarpentry/amy/pull/1127/commits/0fe717ec78b6fb223065edf871dd60c6807fe8e0


Discussion:

Resolving patch:
diff --git a/workshops/models.py b/workshops/models.py
--- a/workshops/models.py
+++ b/workshops/models.py
@@ -747,9 +747,14 @@ def __str__(self):
 class EventQuerySet(models.query.QuerySet):
     '''Handles finding past, ongoing and upcoming events'''
 
+    def not_cancelled(self):
+        """Exclude cancelled events."""
+        return self.exclude(tags__name='cancelled')
+
     def active(self):
-        """Exclude inactive events (stalled or completed)."""
-        return self.exclude(tags__name='stalled').exclude(completed=True)
+        """Exclude inactive events (stalled, completed or cancelled)."""
+        return self.exclude(tags__name='stalled').exclude(completed=True) \
+                   .not_cancelled()
 
     def past_events(self):
         '''Return past events.
@@ -808,23 +813,24 @@ def unpublished_conditional(self):
         no_latitude = Q(latitude__isnull=True)
         no_longitude = Q(longitude__isnull=True)
         no_url = Q(url__isnull=True)
-        cancelled = Q(tags__name='cancelled')
         return (
             unknown_start | no_country | no_venue | no_address | no_latitude |
-            no_longitude | no_url | cancelled
+            no_longitude | no_url
         )
 
     def unpublished_events(self):
         """Return events considered as unpublished (see
         `unpublished_conditional` above)."""
         conditional = self.unpublished_conditional()
-        return self.filter(conditional).order_by('slug', 'id').distinct()
+        return self.not_cancelled().filter(conditional) \
+                   .order_by('slug', 'id').distinct()
 
     def published_events(self):
         """Return events considered as published (see `unpublished_conditional`
         above)."""
         conditional = self.unpublished_conditional()
-        return self.exclude(conditional).order_by('-start', 'id').distinct()
+        return self.not_cancelled().exclude(conditional) \
+                   .order_by('-start', 'id').distinct()
 
     def uninvoiced_events(self):
         '''Return a queryset for events that have not yet been invoiced.


Test patch:
diff --git a/workshops/test/test_event.py b/workshops/test/test_event.py
--- a/workshops/test/test_event.py
+++ b/workshops/test/test_event.py
@@ -91,9 +91,10 @@ def test_get_ongoing_events(self):
 
     def test_unpublished_events(self):
         """Ensure that events manager finds unpublished events correctly."""
-        expected = Event.objects.exclude(Q(slug__endswith='upcoming')) \
+        expected = Event.objects.exclude(slug__endswith='upcoming') \
                                 .exclude(slug__in=['ends-today-ongoing',
-                                                   'ends-tomorrow-ongoing'])
+                                                   'ends-tomorrow-ongoing']) \
+                                .exclude(slug__endswith='cancelled')
         self.assertEqual(set(Event.objects.unpublished_events()),
                          set(expected))
 
@@ -129,6 +130,26 @@ def test_unpublished_events_displayed_once(self):
             1, len(unpublished.filter(slug='2016-10-20-unpublished'))
         )
 
+    def test_cancelled_events(self):
+        """Regression test: make sure that cancelled events don't show up in
+        the unpublished, published or uninvoiced events."""
+        self._setUpTags()
+        cancelled_event = Event.objects.create(
+            slug='2017-01-07-cancelled',
+            start=date(2017, 1, 7),
+            end=date(2017, 1, 8),
+            host=Organization.objects.first(),
+            administrator=Organization.objects.first(),
+        )
+        cancelled_event.tags = Tag.objects.filter(name='cancelled')
+
+        published = Event.objects.published_events().select_related('host')
+        uninvoiced = Event.objects.uninvoiced_events().select_related('host')
+        unpublished = Event.objects.unpublished_events().select_related('host')
+        self.assertNotIn(cancelled_event, uninvoiced)
+        self.assertNotIn(cancelled_event, published)
+        self.assertNotIn(cancelled_event, unpublished)
+
     def test_delete_event(self):
         """Make sure deleted event and its tasks are no longer accessible."""
         event = Event.objects.get(slug="starts-today-ongoing")