From 0abebf2f10f0ed232c35838a188b18b9a09d10dd Mon Sep 17 00:00:00 2001
From: Jennifer Richards <jennifer@painless-security.com>
Date: Mon, 5 Dec 2022 17:19:36 -0400
Subject: [PATCH] feat: add 'IETF Meetings' filter to upcoming meetings page
 (#4826)

* feat: add 'IETF Meetings' filter to upcoming meetings page

* feat: apply 'ietf-meetings' filter to upcoming.ics

* fix: also filter upcoming IETF meetings in the graphical calendar

* test: update tests to match changes

Includes fixing an error in test_upcoming_view_filter_hide_type().
Due to a copy/paste error, it claimed to be testing with a type
both shown and hidden but was not actually hiding a type at all.
---
 ietf/meeting/helpers.py              |  8 ++-
 ietf/meeting/tests_js.py             | 97 ++++++++++++----------------
 ietf/meeting/tests_views.py          | 10 ++-
 ietf/meeting/views.py                | 15 ++++-
 ietf/templates/meeting/upcoming.html |  8 ++-
 5 files changed, 74 insertions(+), 64 deletions(-)

diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py
index fab10fad2..9a7d64867 100644
--- a/ietf/meeting/helpers.py
+++ b/ietf/meeting/helpers.py
@@ -256,6 +256,10 @@ class AgendaFilterOrganizer(AgendaKeywordTool):
         self.special_filters = None
         if self._use_legacy_keywords():
             self.extra_labels += ('Plenary',)  # need this when not using session purpose
+        self.manual_extra_labels = set()
+
+    def add_extra_filter(self, kw):
+        self.manual_extra_labels.add(kw)
 
     def get_non_area_keywords(self):
         """Get list of any 'non-area' (aka 'special') keywords
@@ -436,13 +440,13 @@ class AgendaFilterOrganizer(AgendaKeywordTool):
     def _extra_filters(self):
         """Get list of filters corresponding to self.extra_labels"""
         item_source = self.assignments or self.sessions or []
-        candidates = set(self.extra_labels)
+        candidates = set(self.extra_labels).union(self.manual_extra_labels)
         return self._filter_column(
             label=None,
             keyword=None,
             children=[
                 self._filter_entry(label=label, keyword=xslugify(label), toggled_by=[], is_bof=False)
-                for label in candidates if any(
+                for label in candidates if label in self.manual_extra_labels or any(
                     # Keep only those that will affect at least one session
                     [label.lower() in item.filter_keywords for item in item_source]
                 )]
diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py
index 607f28123..0e6a42343 100644
--- a/ietf/meeting/tests_js.py
+++ b/ietf/meeting/tests_js.py
@@ -1076,9 +1076,9 @@ class InterimTests(IetfSeleniumTestCase):
                     unexpected.add(entry.text)
             advance_month()
 
-        self.assertEqual(seen, visible_meetings, "Expected calendar entries not shown.")
-        self.assertEqual(not_visible, set(), "Hidden calendar entries for expected interim meetings.")
-        self.assertEqual(unexpected, set(), "Unexpected calendar entries visible")
+        self.assertCountEqual(seen, visible_meetings, "Expected calendar entries not shown.")
+        self.assertCountEqual(not_visible, set(), "Hidden calendar entries for expected interim meetings.")
+        self.assertCountEqual(unexpected, set(), "Unexpected calendar entries visible")
 
     def do_upcoming_view_filter_test(self, querystring, visible_meetings=()):
         self.login()
@@ -1152,102 +1152,85 @@ class InterimTests(IetfSeleniumTestCase):
         ietf_meetings = set(self.all_ietf_meetings())
         self.do_upcoming_view_filter_test('', ietf_meetings.union(self.displayed_interims()))
 
+    def test_upcoming_view_show_ietf_meetings(self):
+        self.do_upcoming_view_filter_test('?show=ietf-meetings', self.all_ietf_meetings())
+
     def test_upcoming_view_filter_show_group(self):
         # Show none
-        ietf_meetings = set(self.all_ietf_meetings())
-        self.do_upcoming_view_filter_test('?show=', ietf_meetings)
+        self.do_upcoming_view_filter_test('?show=')
 
         # Show one
-        self.do_upcoming_view_filter_test('?show=mars', 
-                                          ietf_meetings.union(
-                                              self.displayed_interims(groups=['mars'])
-                                          ))
+        self.do_upcoming_view_filter_test('?show=mars', self.displayed_interims(groups=['mars']))
 
         # Show two
-        self.do_upcoming_view_filter_test('?show=mars,ames', 
-                                          ietf_meetings.union(
-                                              self.displayed_interims(groups=['mars', 'ames'])
-                                          ))
+        self.do_upcoming_view_filter_test('?show=mars,ames',self.displayed_interims(groups=['mars', 'ames']))
+
+        # Show two plus ietf-meetings
+        self.do_upcoming_view_filter_test(
+            '?show=ietf-meetings,mars,ames',
+            set(self.all_ietf_meetings()).union(self.displayed_interims(groups=['mars', 'ames']))
+        )
 
     def test_upcoming_view_filter_show_area(self):
         mars = Group.objects.get(acronym='mars')
         area = mars.parent
-        ietf_meetings = set(self.all_ietf_meetings())
-        self.do_upcoming_view_filter_test('?show=%s' % area.acronym,
-                                          ietf_meetings.union(
-                                              self.displayed_interims(groups=['mars', 'ames'])
-                                          ))
+        self.do_upcoming_view_filter_test('?show=%s' % area.acronym, self.displayed_interims(groups=['mars', 'ames']))
 
     def test_upcoming_view_filter_show_type(self):
-        ietf_meetings = set(self.all_ietf_meetings())
-        self.do_upcoming_view_filter_test('?show=plenary',
-                                          ietf_meetings.union(
-                                              self.displayed_interims(groups=['sg'])
-                                          ))
+        self.do_upcoming_view_filter_test('?show=plenary', self.displayed_interims(groups=['sg']))
 
     def test_upcoming_view_filter_hide_group(self):
         mars = Group.objects.get(acronym='mars')
         area = mars.parent
 
         # Without anything shown, should see only ietf meetings
-        ietf_meetings = set(self.all_ietf_meetings())
-        self.do_upcoming_view_filter_test('?hide=mars', ietf_meetings)
+        self.do_upcoming_view_filter_test('?hide=mars')
 
         # With group shown
-        self.do_upcoming_view_filter_test('?show=ames,mars&hide=mars',
-                                          ietf_meetings.union(
-                                              self.displayed_interims(groups=['ames'])
-                                          ))
+        self.do_upcoming_view_filter_test('?show=ames,mars&hide=mars', self.displayed_interims(groups=['ames']))
         # With area shown
-        self.do_upcoming_view_filter_test('?show=%s&hide=mars' % area.acronym, 
-                                          ietf_meetings.union(
-                                              self.displayed_interims(groups=['ames'])
-                                          ))
-
+        self.do_upcoming_view_filter_test('?show=%s&hide=mars' % area.acronym, self.displayed_interims(groups=['ames']))
         # With type shown
-        self.do_upcoming_view_filter_test('?show=plenary&hide=sg',
-                                          ietf_meetings)
+        self.do_upcoming_view_filter_test('?show=plenary&hide=sg')
 
     def test_upcoming_view_filter_hide_area(self):
         mars = Group.objects.get(acronym='mars')
         area = mars.parent
 
-        # Without anything shown, should see only ietf meetings
-        ietf_meetings = set(self.all_ietf_meetings())
-        self.do_upcoming_view_filter_test('?hide=%s' % area.acronym, ietf_meetings)
+        # Without anything shown, should see nothing
+        self.do_upcoming_view_filter_test('?hide=%s' % area.acronym)
 
         # With area shown
-        self.do_upcoming_view_filter_test('?show=%s&hide=%s' % (area.acronym, area.acronym),
-                                          ietf_meetings)
+        self.do_upcoming_view_filter_test('?show=%s&hide=%s' % (area.acronym, area.acronym))
 
         # With group shown
-        self.do_upcoming_view_filter_test('?show=mars&hide=%s' % area.acronym, ietf_meetings)
+        self.do_upcoming_view_filter_test('?show=mars&hide=%s' % area.acronym)
 
         # With type shown
-        self.do_upcoming_view_filter_test('?show=regular&hide=%s' % area.acronym, ietf_meetings)
+        self.do_upcoming_view_filter_test('?show=regular&hide=%s' % area.acronym)
+
+        # with IETF meetings shown
+        self.do_upcoming_view_filter_test('?show=ietf-meetings,hide=%s' % area.acronym, self.all_ietf_meetings())
 
     def test_upcoming_view_filter_hide_type(self):
-        mars = Group.objects.get(acronym='mars')
-        area = mars.parent
-
-        # Without anything shown, should see only ietf meetings
-        ietf_meetings = set(self.all_ietf_meetings())
-        self.do_upcoming_view_filter_test('?hide=regular', ietf_meetings)
+        # Without anything shown, should see nothing
+        self.do_upcoming_view_filter_test('?hide=regular')
 
         # With group shown
-        self.do_upcoming_view_filter_test('?show=mars&hide=regular', ietf_meetings)
+        self.do_upcoming_view_filter_test('?show=mars&hide=regular')
 
         # With type shown
-        self.do_upcoming_view_filter_test('?show=plenary,regular&hide=%s' % area.acronym, 
-                                          ietf_meetings.union(
-                                              self.displayed_interims(groups=['sg'])
-                                          ))
+        self.do_upcoming_view_filter_test(
+            '?show=plenary,regular&hide=regular',
+            self.displayed_interims(groups=['sg'])
+        )
+
+        # With interim-meetings shown
+        self.do_upcoming_view_filter_test('?show=plenary,regular&hide=regular', self.displayed_interims(groups=['sg']))
 
     def test_upcoming_view_filter_whitespace(self):
         """Whitespace in filter lists should be ignored"""
-        meetings = set(self.all_ietf_meetings())
-        meetings.update(self.displayed_interims(groups=['mars']))
-        self.do_upcoming_view_filter_test('?show=mars , ames &hide=   ames', meetings)
+        self.do_upcoming_view_filter_test('?show=mars , ames &hide=   ames', self.displayed_interims(groups=['mars']))
 
     def test_upcoming_view_time_zone_selection(self):
         def _assert_interim_tz_correct(sessions, tz):
diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py
index 673faba58..482d3ccc6 100644
--- a/ietf/meeting/tests_views.py
+++ b/ietf/meeting/tests_views.py
@@ -4507,8 +4507,16 @@ class InterimTests(TestCase):
         # Just a quick check of functionality - details tested by test_js.InterimTests
         make_meeting_test_data(create_interims=True)
         url = urlreverse("ietf.meeting.views.upcoming_ical")
-        r = self.client.get(url + '?show=mars')
 
+        r = self.client.get(url + '?show=mars')
+        self.assertEqual(r.status_code, 200)
+        assert_ical_response_is_valid(self, r,
+                                      expected_event_summaries=[
+                                          'mars - Martian Special Interest Group',
+                                      ],
+                                      expected_event_count=1)
+
+        r = self.client.get(url + '?show=mars,ietf-meetings')
         self.assertEqual(r.status_code, 200)
         assert_ical_response_is_valid(self, r,
                                       expected_event_summaries=[
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index 6fe1e95e8..d269bbd70 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -3456,6 +3456,10 @@ def upcoming(request):
     # Set up for agenda filtering - only one filter_category here
     AgendaKeywordTagger(sessions=interim_sessions).apply()
     filter_organizer = AgendaFilterOrganizer(sessions=interim_sessions, single_category=True)
+    # Allow filtering to show only IETF Meetings. This adds a button labeled "IETF Meetings" to the
+    # "Other" column of the filter UI. When enabled, this adds the keyword "ietf-meetings" to the "show"
+    # filter list. The IETF meetings are explicitly labeled with this keyword in upcoming.html.
+    filter_organizer.add_extra_filter('IETF Meetings')
 
     entries = list(ietf_meetings)
     entries.extend(list(interim_sessions))
@@ -3544,9 +3548,14 @@ def upcoming_ical(request):
             a.session = sessions.get(a.session_id) or a.session
             a.session.ical_status = ical_session_status(a)
 
-    # handle IETFs separately
-    ietfs = [m for m in meetings if m.type_id == 'ietf']
-    preprocess_meeting_important_dates(ietfs)
+    # Handle IETFs separately. Manually apply the 'ietf-meetings' filter.
+    if filter_params is None or (
+            'ietf-meetings' in filter_params['show'] and 'ietf-meetings' not in filter_params['hide']
+    ):
+        ietfs = [m for m in meetings if m.type_id == 'ietf']
+        preprocess_meeting_important_dates(ietfs)
+    else:
+        ietfs = []
 
     meeting_vtz = {meeting.vtimezone() for meeting in meetings}
     meeting_vtz.discard(None)
diff --git a/ietf/templates/meeting/upcoming.html b/ietf/templates/meeting/upcoming.html
index c974d7338..eece8383f 100644
--- a/ietf/templates/meeting/upcoming.html
+++ b/ietf/templates/meeting/upcoming.html
@@ -46,7 +46,12 @@
             <tbody>
                 {% for entry in entries %}
                     <tr class="entry"
-                        {% if entry|classname == 'Session' %}data-filter-keywords="{{ entry.filter_keywords|join:',' }}"{% endif %}>
+                        {% if entry|classname == 'Session' %}
+                            data-filter-keywords="{{ entry.filter_keywords|join:',' }}"
+                        {% elif entry|classname == 'Meeting' %}
+                            data-filter-keywords="ietf-meetings"
+                        {% endif %}
+                    >
                         {% if entry|classname == 'Meeting' %}
                             {% with meeting=entry %}
                                 <td class="meeting-time"
@@ -122,6 +127,7 @@
                       {% with meeting=entry %}
                           {
                               ietf_meeting_number: '{{ meeting.number }}',
+                              filter_keywords: ['ietf-meetings'],
                               start_moment: moment.tz('{{meeting.date}}', '{{ meeting.time_zone }}').startOf('day'),
                               end_moment: moment.tz('{{meeting.end_date}}', '{{ meeting.time_zone }}').endOf('day'),
                               url: '{% url 'agenda' num=meeting.number %}'