From ea3882034a039bd55f4156efd97cd07d4223ee39 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 11 Aug 2020 13:45:41 +0000 Subject: [PATCH 1/5] Use querystring instead of URL hash for agenda filters - Legacy-Id: 18353 --- ietf/meeting/test_data.py | 11 + ietf/meeting/tests_api.py | 3 +- ietf/meeting/tests_js.py | 288 +++++++++++++++++++---- ietf/meeting/tests_views.py | 2 +- ietf/static/ietf/js/toggle-visibility.js | 74 ------ ietf/templates/meeting/agenda.html | 107 ++++++--- ietf/templates/meeting/past.html | 1 - ietf/utils/test_data.py | 1 - ietf/utils/test_runner.py | 2 + 9 files changed, 331 insertions(+), 158 deletions(-) delete mode 100644 ietf/static/ietf/js/toggle-visibility.js diff --git a/ietf/meeting/test_data.py b/ietf/meeting/test_data.py index 3545b9589..37ad1b557 100644 --- a/ietf/meeting/test_data.py +++ b/ietf/meeting/test_data.py @@ -115,6 +115,9 @@ def make_meeting_test_data(meeting=None, create_interims=False): break_slot = TimeSlot.objects.create(meeting=meeting, type_id="break", location=break_room, duration=datetime.timedelta(minutes=90), time=datetime.datetime.combine(session_date, datetime.time(7,0))) + plenary_slot = TimeSlot.objects.create(meeting=meeting, type_id="plenary", location=room, + duration=datetime.timedelta(minutes=60), + time=datetime.datetime.combine(session_date, datetime.time(11,0))) # mars WG mars = Group.objects.get(acronym='mars') mars_session = Session.objects.create(meeting=meeting, group=mars, @@ -158,6 +161,14 @@ def make_meeting_test_data(meeting=None, create_interims=False): SchedulingEvent.objects.create(session=break_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=break_slot, session=break_session, schedule=schedule) + # IETF Plenary + plenary_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="ietf"), + name="IETF Plenary", attendees=250, + requested_duration=datetime.timedelta(minutes=60), + type_id="plenary") + SchedulingEvent.objects.create(session=plenary_session, status_id='schedw', by=system_person) + SchedTimeSessAssignment.objects.create(timeslot=plenary_slot, session=plenary_session, schedule=schedule) + meeting.schedule = schedule meeting.save() diff --git a/ietf/meeting/tests_api.py b/ietf/meeting/tests_api.py index d176d3749..c7d010dc5 100644 --- a/ietf/meeting/tests_api.py +++ b/ietf/meeting/tests_api.py @@ -477,8 +477,7 @@ class TimeSlotEditingApiTests(TestCase): def test_manipulate_timeslot(self): meeting = make_meeting_test_data() - slot = meeting.timeslot_set.all()[0] - self.assertEqual(TimeSlot.objects.get(pk=slot.pk).type_id,'regular') + slot = meeting.timeslot_set.filter(type_id='regular')[0] url = urlreverse("ietf.meeting.ajax.timeslot_sloturl", kwargs=dict(num=meeting.number, slotid=slot.pk)) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 728854dd8..c66beace9 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -30,6 +30,7 @@ try: from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions + from selenium.common.exceptions import NoSuchElementException except ImportError as e: skip_selenium = True skip_message = "Skipping selenium tests: %s" % e @@ -50,29 +51,37 @@ def start_web_driver(): options.add_argument("no-sandbox") # docker needs this return webdriver.Chrome(options=options, service_log_path=settings.TEST_GHOSTDRIVER_LOG_PATH) -@skipIf(skip_selenium, skip_message) -class EditMeetingScheduleTests(IetfLiveServerTestCase): +class MeetingTestCase(IetfLiveServerTestCase): + def __init__(self, *args, **kwargs): + super(MeetingTestCase, self).__init__(*args, **kwargs) + self.login_view = 'ietf.ietfauth.views.login' + def setUp(self): + super(MeetingTestCase, self).setUp() self.driver = start_web_driver() self.driver.set_window_size(1024,768) def tearDown(self): self.driver.close() + def absreverse(self,*args,**kwargs): + return '%s%s'%(self.live_server_url,urlreverse(*args,**kwargs)) + + def login(self, username='plain'): + url = self.absreverse(self.login_view) + password = '%s+password' % username + self.driver.get(url) + self.driver.find_element_by_name('username').send_keys(username) + self.driver.find_element_by_name('password').send_keys(password) + self.driver.find_element_by_xpath('//button[@type="submit"]').click() + def debug_snapshot(self,filename='debug_this.png'): self.driver.execute_script("document.body.bgColor = 'white';") self.driver.save_screenshot(filename) - def absreverse(self,*args,**kwargs): - return '%s%s'%(self.live_server_url,urlreverse(*args,**kwargs)) - - def login(self): - url = self.absreverse('ietf.ietfauth.views.login') - self.driver.get(url) - self.driver.find_element_by_name('username').send_keys('plain') - self.driver.find_element_by_name('password').send_keys('plain+password') - self.driver.find_element_by_xpath('//button[@type="submit"]').click() +@skipIf(skip_selenium, skip_message) +class EditMeetingScheduleTests(MeetingTestCase): def test_edit_meeting_schedule(self): meeting = make_meeting_test_data() @@ -206,28 +215,7 @@ class EditMeetingScheduleTests(IetfLiveServerTestCase): @skipIf(skip_selenium, skip_message) @skipIf(django.VERSION[0]==2, "Skipping test with race conditions under Django 2") -class ScheduleEditTests(IetfLiveServerTestCase): - def setUp(self): - self.driver = start_web_driver() - self.driver.set_window_size(1024,768) - - def tearDown(self): - self.driver.close() - - def debug_snapshot(self,filename='debug_this.png'): - self.driver.execute_script("document.body.bgColor = 'white';") - self.driver.save_screenshot(filename) - - def absreverse(self,*args,**kwargs): - return '%s%s'%(self.live_server_url,urlreverse(*args,**kwargs)) - - def login(self): - url = self.absreverse('ietf.ietfauth.views.login') - self.driver.get(url) - self.driver.find_element_by_name('username').send_keys('plain') - self.driver.find_element_by_name('password').send_keys('plain+password') - self.driver.find_element_by_xpath('//button[@type="submit"]').click() - +class ScheduleEditTests(MeetingTestCase): def testUnschedule(self): meeting = make_meeting_test_data() @@ -265,27 +253,16 @@ class ScheduleEditTests(IetfLiveServerTestCase): self.assertEqual(SchedTimeSessAssignment.objects.filter(session__meeting__number=72,session__group__acronym='mars',schedule__name='test-schedule').count(),0) @skipIf(skip_selenium, skip_message) -class SlideReorderTests(IetfLiveServerTestCase): +class SlideReorderTests(MeetingTestCase): def setUp(self): - self.driver = start_web_driver() - self.driver.set_window_size(1024,768) + super(SlideReorderTests, self).setUp() self.session = SessionFactory(meeting__type_id='ietf', status_id='sched') self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='one'),order=1) self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='two'),order=2) self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='three'),order=3) - def tearDown(self): - self.driver.close() - - def absreverse(self,*args,**kwargs): - return '%s%s'%(self.live_server_url,urlreverse(*args,**kwargs)) - def secr_login(self): - url = '%s%s'%(self.live_server_url, urlreverse('ietf.ietfauth.views.login')) - self.driver.get(url) - self.driver.find_element_by_name('username').send_keys('secretary') - self.driver.find_element_by_name('password').send_keys('secretary+password') - self.driver.find_element_by_xpath('//button[@type="submit"]').click() + self.login('secretary') #@override_settings(DEBUG=True) def testReorderSlides(self): @@ -305,6 +282,223 @@ class SlideReorderTests(IetfLiveServerTestCase): names=self.session.sessionpresentation_set.values_list('document__name',flat=True) self.assertEqual(list(names),['one','three','two']) + +@skipIf(skip_selenium, skip_message) +class AgendaTests(MeetingTestCase): + # Groups whose display logic is inverted in agenda.html. These have + # toggles with class 'pickviewneg' in the template. + PICKVIEWNEG = ['iepg', 'tools', 'edu', 'ietf', 'iesg', 'iab'] + + def setUp(self): + super(AgendaTests, self).setUp() + self.meeting = make_meeting_test_data() + + def row_id_for_item(self, item): + return 'row-%s' % item.slug() + + def get_expected_items(self): + expected_items = self.meeting.schedule.assignments.exclude(timeslot__type__in=['lead','offagenda']) + self.assertGreater(len(expected_items), 0, 'Test setup generated an empty schedule') + return expected_items + + def test_agenda_view_displays_all_items(self): + """By default, all agenda items should be displayed""" + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.agenda')) + + for item in self.get_expected_items(): + row_id = 'row-%s' % item.slug() + try: + item_row = self.driver.find_element_by_id(row_id) + except NoSuchElementException: + item_row = None + self.assertIsNotNone(item_row, 'No row for schedule item "%s"' % row_id) + self.assertTrue(item_row.is_displayed(), 'Row for schedule item "%s" is not displayed' % row_id) + + def test_agenda_view_js_func_parse_query_params(self): + """Test parse_query_params() function""" + self.driver.get(self.absreverse('ietf.meeting.views.agenda')) + + # Only 'show' param + result = self.driver.execute_script( + 'return parse_query_params("?show=group1,group2,group3");' + ) + self.assertEqual(result, dict(show='group1,group2,group3')) + + # Only 'hide' param + result = self.driver.execute_script( + 'return parse_query_params("?hide=group4,group5,group6");' + ) + self.assertEqual(result, dict(hide='group4,group5,group6')) + + # Both 'show' and 'hide' + result = self.driver.execute_script( + 'return parse_query_params("?show=group1,group2,group3&hide=group4,group5,group6");' + ) + self.assertEqual(result, dict(show='group1,group2,group3', hide='group4,group5,group6')) + + def test_agenda_view_js_func_toggle_list_item(self): + """Test toggle_list_item() function""" + self.driver.get(self.absreverse('ietf.meeting.views.agenda')) + + result = self.driver.execute_script( + """ + // start empty, add item + var list0=[]; + toggle_list_item(list0, 'item'); + + // one item, remove it + var list1=['item']; + toggle_list_item(list1, 'item'); + + // one item, add another + var list2=['item1']; + toggle_list_item(list2, 'item2'); + + // multiple items, remove first + var list3=['item1', 'item2', 'item3']; + toggle_list_item(list3, 'item1'); + + // multiple items, remove middle + var list4=['item1', 'item2', 'item3']; + toggle_list_item(list4, 'item2'); + + // multiple items, remove last + var list5=['item1', 'item2', 'item3']; + toggle_list_item(list5, 'item3'); + + return [list0, list1, list2, list3, list4, list5]; + """ + ) + self.assertEqual(result[0], ['item'], 'Adding item to empty list failed') + self.assertEqual(result[1], [], 'Removing only item in a list failed') + self.assertEqual(result[2], ['item1', 'item2'], 'Adding second item to list failed') + self.assertEqual(result[3], ['item2', 'item3'], 'Removing first item from list failed') + self.assertEqual(result[4], ['item1', 'item3'], 'Removing middle item from list failed') + self.assertEqual(result[5], ['item1', 'item2'], 'Removing last item from list failed') + + def test_agenda_view_filter_show_one(self): + """Filtered agenda view should display only matching rows (one group selected)""" + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars') + self.assert_agenda_item_visibility(['mars'] + self.PICKVIEWNEG) # ames and secretariat not selected + + def test_agenda_view_filter_show_two(self): + """Filtered agenda view should display only matching rows (two groups selected)""" + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars,ames') + self.assert_agenda_item_visibility(['mars', 'ames'] + self.PICKVIEWNEG) # secretariat not selected + + def test_agenda_view_filter_all(self): + """Filtered agenda view should display only matching rows (all groups selected)""" + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.agenda')) + self.assert_agenda_item_visibility() + + def test_agenda_view_filter_hide(self): + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?hide=ietf') + self.assert_agenda_item_visibility([g for g in self.PICKVIEWNEG if g != 'ietf']) + + def test_agenda_view_filter_show_and_hide(self): + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars&hide=ietf') + self.assert_agenda_item_visibility( + ['mars'] + [g for g in self.PICKVIEWNEG if g != 'ietf'] + ) + + def assert_agenda_item_visibility(self, visible_groups=()): + """Assert that correct items are visible in current browser window + + If visible_groups is empty (the default), expects all items to be visible. + """ + for item in self.get_expected_items(): + row_id = self.row_id_for_item(item) + try: + item_row = self.driver.find_element_by_id(row_id) + except NoSuchElementException: + item_row = None + self.assertIsNotNone(item_row, 'No row for schedule item "%s"' % row_id) + if len(visible_groups) == 0 or item.session.group.acronym in visible_groups: + self.assertTrue(item_row.is_displayed(), 'Row for schedule item "%s" is not displayed but should be' % row_id) + else: + self.assertFalse(item_row.is_displayed(), 'Row for schedule item "%s" is displayed but should not be' % row_id) + + def test_agenda_view_group_filter_toggle(self): + """Clicking a group toggle enables/disables agenda filtering""" + group_acronym = 'mars' + + self.login() + url = self.absreverse('ietf.meeting.views.agenda') + self.driver.get(url) + + # Click the 'customize' anchor to reveal the group buttons + customize_anchor = WebDriverWait(self.driver, 2).until( + expected_conditions.element_to_be_clickable( + (By.CSS_SELECTOR, '#accordion a[data-toggle="collapse"]') + ) + ) + customize_anchor.click() + + # Click the group button + group_button = WebDriverWait(self.driver, 2).until( + expected_conditions.element_to_be_clickable( + (By.CSS_SELECTOR, 'button.pickview.%s' % group_acronym) + ) + ) + group_button.click() + + # Check visibility + self.assert_agenda_item_visibility([group_acronym] + self.PICKVIEWNEG) + + # Click the group button again + group_button = WebDriverWait(self.driver, 2).until( + expected_conditions.element_to_be_clickable( + (By.CSS_SELECTOR, 'button.pickview.%s' % group_acronym) + ) + ) + group_button.click() + + # Check visibility + self.assert_agenda_item_visibility() + + def test_agenda_view_group_filter_toggle_without_replace_state(self): + """Toggle should function for browsers without window.history.replaceState""" + group_acronym = 'mars' + + self.login() + url = self.absreverse('ietf.meeting.views.agenda') + self.driver.get(url) + + # Rather than digging up an ancient browser, simulate absence of history.replaceState + self.driver.execute_script('window.history.replaceState = undefined;') + + + # Click the 'customize' anchor to reveal the group buttons + customize_anchor = WebDriverWait(self.driver, 2).until( + expected_conditions.element_to_be_clickable( + (By.CSS_SELECTOR, '#accordion a[data-toggle="collapse"]') + ) + ) + customize_anchor.click() + + + # Get ready to click the group button + group_button = WebDriverWait(self.driver, 2).until( + expected_conditions.element_to_be_clickable( + (By.CSS_SELECTOR, 'button.pickview.%s' % group_acronym) + ) + ) + + # Be sure we're at the URL we think we're at before we click + self.assertEqual(self.driver.current_url, url) + group_button.click() # click! + + expected_url = '%s?show=%s' % (url, group_acronym) + WebDriverWait(self.driver, 2).until(expected_conditions.url_to_be(expected_url)) + # no assertion here - if WebDriverWait raises an exception, the test will fail. + # We separately test whether this URL will filter correctly. + # The following are useful debugging tools # If you add this to a LiveServerTestCase and run just this test, you can browse diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 84897c660..b887920c2 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -1596,7 +1596,7 @@ class InterimTests(TestCase): r = self.client.get(url) self.assertEqual(r.status_code, 200) self.assertEqual(r.get('Content-Type'), "text/calendar") - self.assertEqual(r.content.count(b'UID'), 7) + self.assertEqual(r.content.count(b'UID'), 8) # check filtered output url = url + '?filters=mars' r = self.client.get(url) diff --git a/ietf/static/ietf/js/toggle-visibility.js b/ietf/static/ietf/js/toggle-visibility.js deleted file mode 100644 index 75f6c59a9..000000000 --- a/ietf/static/ietf/js/toggle-visibility.js +++ /dev/null @@ -1,74 +0,0 @@ - -function toggle_visibility() { - var h = window.location.hash; - h = h.replace(/^#?,?/, ''); - - // reset UI elements to default state - $(".pickview").removeClass("active disabled"); - $(".pickviewneg").addClass("active"); - - if (h) { - // if there are items in the hash, hide all rows - $('[id^="row-"]').hide(); - - // show the customizer - $("#customize").collapse("show"); - - // loop through the has items and change the UI element and row visibilities accordingly - var query_array = []; - $.each(h.split(","), function (i, v) { - if (v.indexOf("-") == 0) { - // this is a "negative" item: when present, hide these rows - v = v.replace(/^-/, ''); - $('[id^="row-"]').filter('[id*="-' + v + '"]').hide(); - $(".view." + v).find("button").removeClass("active disabled"); - $("button.pickviewneg." + v).removeClass("active"); - } else { - // this is a regular item: when present, show these rows - $('[id^="row-"]').filter('[id*="-' + v + '"]').show(); - $(".view." + v).find("button").addClass("active disabled"); - $("button.pickview." + v).addClass("active"); - query_array.push("filters=" + v) - } - }); - - // adjust the custom .ics link - var link = $('a[href*="upcoming.ics"]'); - var new_href = link.attr("href").split("?")[0]+"?"+query_array.join("&"); - link.attr("href",new_href); - - } else { - // if the hash is empty, show all - $('[id^="row-"]').show(); - // adjust the custom .ics link - var link = $('a[href*="upcoming.ics"]'); - link.attr("href",link.attr("href").split("?")[0]); - } -} - -$(".pickview, .pickviewneg").click(function () { - var h = window.location.hash; - var item = $(this).text().trim().toLowerCase(); - if ($(this).hasClass("pickviewneg")) { - item = "-" + item; - } - - re = new RegExp('(^|#|,)' + item + "(,|$)"); - if (h.match(re) == null) { - if (h.replace("#", "").length == 0) { - h = item; - } else { - h += "," + item; - } - h = h.replace(/^#?,/, ''); - } else { - h = h.replace(re, "$2").replace(/^#?,/, ''); - } - window.location.hash = h.replace(/^#$/, ''); - toggle_visibility(); -}); - -$(document).ready(function () { - toggle_visibility(); -}); - diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 135ba35a9..68fa60f8c 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -359,16 +359,32 @@ {% block js %} - {% endblock %} diff --git a/ietf/utils/test_data.py b/ietf/utils/test_data.py index 9aa7bbdbd..231631b5d 100644 --- a/ietf/utils/test_data.py +++ b/ietf/utils/test_data.py @@ -428,7 +428,6 @@ def make_test_data(): return draft - return draft def make_review_data(doc): team1 = create_group(acronym="reviewteam", name="Review Team", type_id="review", list_email="reviewteam@ietf.org", parent=Group.objects.get(acronym="farfut")) diff --git a/ietf/utils/test_runner.py b/ietf/utils/test_runner.py index 5826a6f67..a2b2fbc82 100644 --- a/ietf/utils/test_runner.py +++ b/ietf/utils/test_runner.py @@ -789,6 +789,8 @@ class IetfLiveServerTestCase(StaticLiveServerTestCase): set_coverage_checking(False) super(IetfLiveServerTestCase, cls).setUpClass() + def setUp(self): + super(IetfLiveServerTestCase, self).setUp() # LiveServerTestCase uses TransactionTestCase which seems to # somehow interfere with the fixture loading process in # IetfTestRunner when running multiple tests (the first test From b1e3c1fe9227afafec360efbd78df02a20956b84 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 25 Aug 2020 14:35:44 +0000 Subject: [PATCH 2/5] Use new querystring format for agenda_ical and add tests. Ensure querystring precedes URL fragment in agenda JS. - Legacy-Id: 18413 --- ietf/meeting/tests_views.py | 247 +++++++++++++++++++++++++++-- ietf/meeting/views.py | 88 ++++++---- ietf/templates/meeting/agenda.html | 15 +- 3 files changed, 301 insertions(+), 49 deletions(-) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index b887920c2..31ed45ed3 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -197,7 +197,7 @@ class MeetingTests(TestCase): # iCal r = self.client.get(urlreverse("ietf.meeting.views.agenda_ical", kwargs=dict(num=meeting.number)) - + "?" + session.group.parent.acronym.upper()) + + "?show=" + session.group.parent.acronym.upper()) self.assertContains(r, session.group.acronym) self.assertContains(r, session.group.name) self.assertContains(r, slot.location.name) @@ -580,6 +580,28 @@ class MeetingTests(TestCase): post_date = meeting.importantdate_set.get(name=idn).date self.assertEqual(pre_date, post_date+datetime.timedelta(days=1)) + def assert_ical_response_is_valid(self, response, expected_event_summaries=None, expected_event_count=None): + """Validate an HTTP response containing iCal data + + Based on RFC2445, but not exhaustive by any means. Assumes a single iCalendar object. + """ + self.assertEqual(response.get('Content-Type'), "text/calendar") + + # Validate iCalendar object + self.assertContains(response, 'BEGIN:VCALENDAR', count=1) + self.assertContains(response, 'END:VCALENDAR', count=1) + self.assertContains(response, 'PRODID:', count=1) + self.assertContains(response, 'VERSION', count=1) + + # Validate event objects + if expected_event_count is None: + expected_event_count = len(expected_event_summaries) + self.assertContains(response, 'BEGIN:VEVENT', count=expected_event_count) + self.assertContains(response, 'END:VEVENT', count=expected_event_count) + self.assertContains(response, 'UID', count=expected_event_count) + for summary in expected_event_summaries: + self.assertContains(response, 'SUMMARY:' + summary) + def test_group_ical(self): meeting = make_meeting_test_data() s1 = Session.objects.filter(meeting=meeting, group__acronym="mars").first() @@ -592,23 +614,226 @@ class MeetingTests(TestCase): # url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'acronym':s1.group.acronym, }) r = self.client.get(url) - self.assertEqual(r.get('Content-Type'), "text/calendar") - self.assertContains(r, 'BEGIN:VEVENT') - self.assertEqual(r.content.count(b'UID'), 2) - self.assertContains(r, 'SUMMARY:mars - Martian Special Interest Group') + self.assert_ical_response_is_valid(r, + expected_event_summaries=['mars - Martian Special Interest Group'], + expected_event_count=2) self.assertContains(r, t1.time.strftime('%Y%m%dT%H%M%S')) self.assertContains(r, t2.time.strftime('%Y%m%dT%H%M%S')) - self.assertContains(r, 'END:VEVENT') # url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'session_id':s1.id, }) r = self.client.get(url) - self.assertEqual(r.get('Content-Type'), "text/calendar") - self.assertContains(r, 'BEGIN:VEVENT') - self.assertEqual(r.content.count(b'UID'), 1) - self.assertContains(r, 'SUMMARY:mars - Martian Special Interest Group') + self.assert_ical_response_is_valid(r, + expected_event_summaries=['mars - Martian Special Interest Group'], + expected_event_count=1) self.assertContains(r, t1.time.strftime('%Y%m%dT%H%M%S')) self.assertNotContains(r, t2.time.strftime('%Y%m%dT%H%M%S')) - self.assertContains(r, 'END:VEVENT') + + def test_meeting_agenda_has_static_ical_links(self): + """Links to the agenda_ical view must appear on the agenda page + + Confirms that these have the correct querystrings. Does not test the JS-based + 'Customized schedule' button. + """ + meeting = make_meeting_test_data() + + # get the agenda + url = urlreverse('ietf.meeting.views.agenda', kwargs=dict(num=meeting.number)) + r = self.client.get(url) + + # Check that it has the links we expect + ical_url = urlreverse('ietf.meeting.views.agenda_ical', kwargs=dict(num=meeting.number)) + q = PyQuery(r.content) + content = q('#content').html().lower() # don't care about case + # Should be a 'non-area events' link showing appropriate types + self.assertIn('%s?showtypes=plenary,other' % ical_url, content) + assignments = meeting.schedule.assignments.exclude(timeslot__type__in=['lead', 'offagenda']) + # Assume the test meeting is not using historic groups + groups = [a.session.group for a in assignments if a.session is not None] + for g in groups: + if g.parent_id is not None: + self.assertIn('%s?show=%s' % (ical_url, g.parent.acronym.lower()), content) + + def test_ical_filter_invalid_syntaxes(self): + meeting = make_meeting_test_data() + url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number}) + + r = self.client.get(url + '?unknownparam=mars') + self.assertEqual(r.status_code, 400, 'Unknown parameter should be rejected') + + r = self.client.get(url + '?mars') + self.assertEqual(r.status_code, 400, 'Missing parameter name should be rejected') + + def do_ical_filter_test(self, meeting, querystring, expected_session_summaries): + url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number}) + r = self.client.get(url + querystring) + self.assertEqual(r.status_code, 200) + self.assert_ical_response_is_valid(r, expected_event_summaries=expected_session_summaries) + + def test_ical_filter_default(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='', + expected_session_summaries=[ + 'Morning Break', + 'Registration', + 'IETF Plenary', + 'ames - Asteroid Mining Equipment Standardization Group', + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_show(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?show=mars', + expected_session_summaries=[ + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_hide(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?hide=ietf', + expected_session_summaries=[] + ) + + def test_ical_filter_show_and_hide(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?show=ames&hide=mars', + expected_session_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + ] + ) + + def test_ical_filter_show_and_hide_same_group(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?show=ames&hide=ames', + expected_session_summaries=[] + ) + + def test_ical_filter_showtypes(self): + meeting = make_meeting_test_data() + # Show break/plenary types + self.do_ical_filter_test( + meeting, + querystring='?showtypes=break,plenary', + expected_session_summaries=[ + 'IETF Plenary', + 'Morning Break', + ] + ) + + def test_ical_filter_hidetypes(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?hidetypes=plenary', + expected_session_summaries=[] + ) + + def test_ical_filter_showtypes_and_hidetypes(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?showtypes=break&hidetypes=plenary', + expected_session_summaries=[ + 'Morning Break', + ] + ) + + def test_ical_filter_showtypes_and_hidetypes_same_type(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?showtypes=plenary&hidetypes=plenary', + expected_session_summaries=[] + ) + + def test_ical_filter_show_and_showtypes(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?show=mars&showtypes=plenary', + expected_session_summaries=[ + 'IETF Plenary', + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_hide_and_showtypes(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?hide=ames&showtypes=regular', + expected_session_summaries=[ + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_show_and_hidetypes(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?show=ietf,mars&hidetypes=plenary', + expected_session_summaries=[ + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_hide_and_hidetypes(self): + meeting = make_meeting_test_data() + self.do_ical_filter_test( + meeting, + querystring='?hide=ietf,mars&hidetypes=plenary', + expected_session_summaries=[] + ) + + def test_ical_filter_show_hide_and_showtypes(self): + meeting = make_meeting_test_data() + # ames regular session should be suppressed + self.do_ical_filter_test( + meeting, + querystring='?show=mars&hide=ames&showtypes=plenary,regular', + expected_session_summaries=[ + 'IETF Plenary', + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_show_hide_and_hidetypes(self): + meeting = make_meeting_test_data() + # ietf plenary session should be suppressed + self.do_ical_filter_test( + meeting, + querystring='?show=mars,ietf&hide=ames&hidetypes=plenary', + expected_session_summaries=[ + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_all_params(self): + meeting = make_meeting_test_data() + # should include Morning Break / Registration due to secretariat in show list + # should include mars SIG because regular in showtypes list + # should not include IETF plenary because plenary in hidetypes list + # should not show ames SIG because ames in hide list + self.do_ical_filter_test( + meeting, + querystring='?show=secretariat,ietf&hide=ames&showtypes=regular&hidetypes=plenary', + expected_session_summaries=[ + 'Morning Break', + 'Registration', + 'mars - Martian Special Interest Group', + ] + ) def build_session_setup(self): # This setup is intentionally unusual - the session has one draft attached as a session presentation, diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 72d8e06bf..625044f25 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -27,7 +27,7 @@ import debug # pyflakes:ignore from django import forms from django.shortcuts import render, redirect, get_object_or_404 -from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponseBadRequest from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -1337,6 +1337,23 @@ def ical_session_status(session_with_current_status): return "CONFIRMED" def agenda_ical(request, num=None, name=None, acronym=None, session_id=None): + """Agenda ical view + + By default, all agenda items will be shown. A filter can be specified in + the querystring. It has the format + + ?show=...&hide=...&showtypes=...&hidetypes=... + + where any of the parameters can be omitted. The right-hand side of each + '=' is a comma separated list, which can be empty. If none of the filter + parameters are specified, no filtering will be applied, even if the query + string is not empty. + + The show and hide parameters each take a list of working group (wg) acronyms. + The showtypes and hidetypes parameters take a list of session types. + + Hiding (by wg or type) takes priority over showing. + """ meeting = get_meeting(num, type_in=None) schedule = get_schedule(meeting, name) updated = meeting.updated() @@ -1344,38 +1361,49 @@ def agenda_ical(request, num=None, name=None, acronym=None, session_id=None): if schedule is None and acronym is None and session_id is None: raise Http404 - q = request.META.get('QUERY_STRING','') or "" - filter = set(unquote(q).lower().split(',')) - include = [ i for i in filter if not (i.startswith('-') or i.startswith('~')) ] - include_types = set(["plenary","other"]) - exclude = [] - - # Process the special flags. - # "-wgname" will remove a working group from the output. - # "~Type" will add that type to the output. - # "-~Type" will remove that type from the output - # Current types are: - # Session, Other (default on), Break, Plenary (default on) - # Non-Working Group "wg names" include: - # edu, ietf, tools, iesg, iab - - for item in filter: - if len(item) > 2 and item[0] == '-' and item[1] == '~': - include_types -= set([item[2:]]) - elif len(item) > 1 and item[0] == '-': - exclude.append(item[1:]) - elif len(item) > 1 and item[0] == '~': - include_types |= set([item[1:]]) - assignments = schedule.assignments.exclude(timeslot__type__in=['lead','offagenda']) assignments = preprocess_assignments_for_agenda(assignments, meeting) - if q: - assignments = [a for a in assignments if - (a.timeslot.type_id in include_types - or (a.session.historic_group and a.session.historic_group.acronym in include) - or (a.session.historic_group and a.session.historic_group.historic_parent and a.session.historic_group.historic_parent.acronym in include)) - and (not a.session.historic_group or a.session.historic_group.acronym not in exclude)] + if len(request.GET) > 0: + # Parse group filters from GET parameters. The keys in this dict define the + # allowed querystring parameters. + filt_params = {'show': set(), 'hide': set(), 'showtypes': set(), 'hidetypes': set()} + + for key, value in request.GET.items(): + if key not in filt_params: + return HttpResponseBadRequest('Unrecognized parameter "%s"' % key) + if value is None: + return HttpResponseBadRequest( + 'Parameter "%s" is not assigned a value (use "key=" for an empty value)' % key + ) + filt_params[key] = set(unquote(value).lower().split(',')) + + def _should_include_assignment(assignment): + """Decide whether to include an assignment + + Relies on filt_params from parent scope. + """ + historic_group = assignment.session.historic_group + if historic_group: + group_acronym = historic_group.acronym + parent = historic_group.historic_parent + parent_acronym = parent.acronym if parent else None + else: + group_acronym = None + parent_acronym = None + session_type = assignment.timeslot.type_id + + # Hide if wg or type hide lists apply + if (group_acronym in filt_params['hide']) or (session_type in filt_params['hidetypes']): + return False + + # Show if any of the show lists apply, including showing by parent group + return ((group_acronym in filt_params['show']) or + (parent_acronym in filt_params['show']) or + (session_type in filt_params['showtypes'])) + + # Apply the filter + assignments = [a for a in assignments if _should_include_assignment(a)] if acronym: assignments = [ a for a in assignments if a.session.historic_group and a.session.historic_group.acronym == acronym ] diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 68fa60f8c..4f753ce5b 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -135,9 +135,9 @@

Download as .ics

{% for p in group_parents %} - {{p.acronym|upper}} + {{p.acronym|upper}} {% endfor %} - Non-area events + Non-area events

@@ -364,7 +364,7 @@ qs = qs.replace(/^\?/, ''); $.each(qs.split('&'), function(i, v) { var toks = v.split('=', 2) - params[toks[0]] = toks[1]; + params[toks[0]] = toks[1].toLowerCase(); }); return params; } @@ -397,9 +397,7 @@ // loop through the has items and change the UI element and row visibilities accordingly $.each(hide_groups, function (i, v) { -// if (v.indexOf("-") == 0) { // this is a "negative" item: when present, hide these rows -// v = v.replace(/^-/, ''); $('[id^="row-"]').filter('[id*="-' + v + '"]').hide(); $(".view." + v).find("button").removeClass("active disabled"); $("button.pickviewneg." + v).removeClass("active"); @@ -412,14 +410,14 @@ }); // show the week view - $("#weekview").attr("src", "week-view.html" + window.location.hash).removeClass("hidden"); + $("#weekview").attr("src", "week-view.html" + window.location.search).removeClass("hidden"); // show the custom .ics link $("#ical-link").attr("href",$("#ical-link").attr("href").split("?")[0]+window.location.search); $("#ical-link").removeClass("hidden"); } else { - // if the hash is empty, show all and hide weekview + // if the hash is empty, show all and hide weekview / custom ical link $('[id^="row-"]').show(); $("#ical-link, #weekview").addClass("hidden"); } @@ -463,7 +461,8 @@ search = '?' + qparams.join('&'); } - var new_url = window.location.href.replace(/(\?.*)?$/, search); + // strip out the search / hash, then add back + var new_url = window.location.href.replace(/(\?.*)?(#.*)?$/, search + window.location.hash); if (window.history && window.history.replaceState) { // Keep current origin, replace search string, no page reload history.replaceState({}, document.title, new_url); From d9d52342175af310aa794a6adff7bdeb64cd33c6 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 1 Sep 2020 15:54:44 +0000 Subject: [PATCH 3/5] Use updated filter scheme for week-view and agenda - Legacy-Id: 18457 --- ietf/meeting/tests_js.py | 103 +++++++++++++++------ ietf/meeting/tests_views.py | 2 +- ietf/templates/meeting/agenda.html | 123 +++++++++++++++++--------- ietf/templates/meeting/week-view.html | 99 ++++++++++++++------- 4 files changed, 228 insertions(+), 99 deletions(-) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index c66beace9..60384b6aa 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -285,10 +285,6 @@ class SlideReorderTests(MeetingTestCase): @skipIf(skip_selenium, skip_message) class AgendaTests(MeetingTestCase): - # Groups whose display logic is inverted in agenda.html. These have - # toggles with class 'pickviewneg' in the template. - PICKVIEWNEG = ['iepg', 'tools', 'edu', 'ietf', 'iesg', 'iab'] - def setUp(self): super(AgendaTests, self).setUp() self.meeting = make_meeting_test_data() @@ -377,40 +373,75 @@ class AgendaTests(MeetingTestCase): self.assertEqual(result[4], ['item1', 'item3'], 'Removing middle item from list failed') self.assertEqual(result[5], ['item1', 'item2'], 'Removing last item from list failed') + def do_agenda_view_filter_test(self, querystring, visible_groups=()): + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.agenda') + querystring) + self.assert_agenda_item_visibility(visible_groups) + weekview_iframe = self.driver.find_element_by_id('weekview') + if len(querystring) == 0: + self.assertFalse(weekview_iframe.is_displayed(), 'Weekview should be hidden when filters off') + else: + self.assertTrue(weekview_iframe.is_displayed(), 'Weekview should be visible when filters on') + self.driver.switch_to.frame(weekview_iframe) + self.assert_weekview_item_visibility(visible_groups) + self.driver.switch_to.default_content() + def test_agenda_view_filter_show_one(self): """Filtered agenda view should display only matching rows (one group selected)""" - self.login() - self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars') - self.assert_agenda_item_visibility(['mars'] + self.PICKVIEWNEG) # ames and secretariat not selected + self.do_agenda_view_filter_test('?show=mars', ['mars']) def test_agenda_view_filter_show_two(self): """Filtered agenda view should display only matching rows (two groups selected)""" - self.login() - self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars,ames') - self.assert_agenda_item_visibility(['mars', 'ames'] + self.PICKVIEWNEG) # secretariat not selected + self.do_agenda_view_filter_test('?show=mars,ames', ['mars', 'ames']) def test_agenda_view_filter_all(self): """Filtered agenda view should display only matching rows (all groups selected)""" - self.login() - self.driver.get(self.absreverse('ietf.meeting.views.agenda')) - self.assert_agenda_item_visibility() + self.do_agenda_view_filter_test('', None) # None means all should be visible def test_agenda_view_filter_hide(self): - self.login() - self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?hide=ietf') - self.assert_agenda_item_visibility([g for g in self.PICKVIEWNEG if g != 'ietf']) + self.do_agenda_view_filter_test('?hide=ietf', []) def test_agenda_view_filter_show_and_hide(self): - self.login() - self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars&hide=ietf') - self.assert_agenda_item_visibility( - ['mars'] + [g for g in self.PICKVIEWNEG if g != 'ietf'] - ) + self.do_agenda_view_filter_test('?show=mars&hide=ietf', ['mars']) - def assert_agenda_item_visibility(self, visible_groups=()): + def test_agenda_view_filter_show_and_hide_same_group(self): + self.do_agenda_view_filter_test('?show=mars&hide=mars', []) + + def test_agenda_view_filter_showtypes(self): + self.do_agenda_view_filter_test('?showtypes=plenary', ['ietf']) # ietf has a plenary session + + def test_agenda_view_filter_hidetypes(self): + self.do_agenda_view_filter_test('?hidetypes=plenary', []) + + def test_agenda_view_filter_showtypes_and_hidetypes(self): + self.do_agenda_view_filter_test('?showtypes=plenary&hidetypes=regular', ['ietf']) # ietf has a plenary session + + def test_agenda_view_filter_showtypes_and_hidetypes_same_type(self): + self.do_agenda_view_filter_test('?showtypes=plenary&hidetypes=plenary', []) + + def test_agenda_view_filter_show_and_showtypes(self): + self.do_agenda_view_filter_test('?show=mars&showtypes=plenary', ['mars', 'ietf']) # ietf has a plenary session + + def test_agenda_view_filter_show_and_hidetypes(self): + self.do_agenda_view_filter_test('?show=ietf,mars&hidetypes=plenary', ['mars']) # ietf has a plenary session + + def test_agenda_view_filter_hide_and_hidetypes(self): + self.do_agenda_view_filter_test('?hide=ietf,mars&hidetypes=plenary', []) + + def test_agenda_view_filter_show_hide_and_showtypes(self): + self.do_agenda_view_filter_test('?show=mars&hide=ames&showtypes=plenary,regular', ['mars', 'ietf']) # ietf has plenary session + + def test_agenda_view_filter_show_hide_and_hidetypes(self): + self.do_agenda_view_filter_test('?show=mars,ietf&hide=ames&hidetypes=plenary', ['mars']) # ietf has plenary session + + def test_agenda_view_filter_all_params(self): + self.do_agenda_view_filter_test('?show=secretariat,ietf&hide=ames&showtypes=regular&hidetypes=plenary', + ['secretariat', 'mars']) + + def assert_agenda_item_visibility(self, visible_groups=None): """Assert that correct items are visible in current browser window - If visible_groups is empty (the default), expects all items to be visible. + If visible_groups is None (the default), expects all items to be visible. """ for item in self.get_expected_items(): row_id = self.row_id_for_item(item) @@ -419,11 +450,33 @@ class AgendaTests(MeetingTestCase): except NoSuchElementException: item_row = None self.assertIsNotNone(item_row, 'No row for schedule item "%s"' % row_id) - if len(visible_groups) == 0 or item.session.group.acronym in visible_groups: + if visible_groups is None or item.session.group.acronym in visible_groups: self.assertTrue(item_row.is_displayed(), 'Row for schedule item "%s" is not displayed but should be' % row_id) else: self.assertFalse(item_row.is_displayed(), 'Row for schedule item "%s" is displayed but should not be' % row_id) + def assert_weekview_item_visibility(self, visible_groups=None): + for item in self.get_expected_items(): + if item.session.name: + label = item.session.name + elif item.timeslot.type_id == 'break': + label = item.timeslot.name + elif item.session.group: + label = item.session.group.name + else: + label = 'Free Slot' + + try: + item_div = self.driver.find_element_by_xpath('//div/span[contains(text(),"%s")]/..' % label) + except NoSuchElementException: + item_div = None + + if visible_groups is None or item.session.group.acronym in visible_groups: + self.assertIsNotNone(item_div, 'No weekview entry for "%s" (%s)' % (label, item.slug())) + self.assertTrue(item_div.is_displayed(), 'Entry for "%s (%s)" is not displayed but should be' % (label, item.slug())) + else: + self.assertIsNone(item_div, 'Unexpected weekview entry for "%s" (%s)' % (label, item.slug())) + def test_agenda_view_group_filter_toggle(self): """Clicking a group toggle enables/disables agenda filtering""" group_acronym = 'mars' @@ -449,7 +502,7 @@ class AgendaTests(MeetingTestCase): group_button.click() # Check visibility - self.assert_agenda_item_visibility([group_acronym] + self.PICKVIEWNEG) + self.assert_agenda_item_visibility([group_acronym]) # Click the group button again group_button = WebDriverWait(self.driver, 2).until( diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 31ed45ed3..78a6e4436 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -801,7 +801,7 @@ class MeetingTests(TestCase): # ames regular session should be suppressed self.do_ical_filter_test( meeting, - querystring='?show=mars&hide=ames&showtypes=plenary,regular', + querystring='?show=ietf&hide=ames&showtypes=regular', expected_session_summaries=[ 'IETF Plenary', 'mars - Martian Special Interest Group', diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 4f753ce5b..91b077bec 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -120,12 +120,12 @@ {% endif %}

Also show special sessions of these groups:

-
-
-
-
-
-
+
+
+
+
+
+
@@ -362,10 +362,13 @@ function parse_query_params(qs) { var params = {}; qs = qs.replace(/^\?/, ''); - $.each(qs.split('&'), function(i, v) { - var toks = v.split('=', 2) - params[toks[0]] = toks[1].toLowerCase(); - }); + if (qs) { + var param_strs = qs.split('&'); + for (var ii = 0; ii < param_strs.length; ii++) { + var toks = param_strs[ii].split('=', 2) + params[toks[0]] = toks[1] || true; + } + } return params; } @@ -374,43 +377,56 @@ return qparams[filt] ? qparams[filt].split(',') : []; } - function toggle_visibility() { - var qparams = parse_query_params(window.location.search); - var show_groups = get_filter_from_qparams(qparams, 'show'); - var hide_groups = get_filter_from_qparams(qparams, 'hide'); + function get_filter_params(qparams) { + return { + show_groups: get_filter_from_qparams(qparams, 'show'), + hide_groups: get_filter_from_qparams(qparams, 'hide'), + show_types: get_filter_from_qparams(qparams, 'showtypes'), + hide_types: get_filter_from_qparams(qparams, 'hidetypes'), + }; + } + function toggle_visibility(filter_params) { // reset UI elements to default state $(".pickview").removeClass("active disabled"); $(".pickviewneg").addClass("active"); - if (show_groups.length || hide_groups.length) { - // if groups were selected for filtering, hide all rows that are - // hidden by default, show all rows that are shown by default + if (filter_params['show_groups'].length || + filter_params['hide_groups'].length || + filter_params['show_types'].length || + filter_params['hide_types'].length + ) { + // if groups were selected for filtering, hide all rows by default $('[id^="row-"]').hide(); - $.each($(".pickviewneg").text().trim().split(/ +/), function (i, v) { - v = v.trim().toLowerCase(); - $('[id^="row-"]').filter('[id*="-' + v + '"]').show(); - }); // show the customizer $("#customize").collapse("show"); // loop through the has items and change the UI element and row visibilities accordingly - $.each(hide_groups, function (i, v) { - // this is a "negative" item: when present, hide these rows - $('[id^="row-"]').filter('[id*="-' + v + '"]').hide(); - $(".view." + v).find("button").removeClass("active disabled"); - $("button.pickviewneg." + v).removeClass("active"); - }); - $.each(show_groups, function (i, v) { - // this is a regular item: when present, show these rows + $.each(filter_params['show_groups'], function (i, v) { + // this is a regular item by wg: when present, show these rows $('[id^="row-"]').filter('[id*="-' + v + '"]').show(); $(".view." + v).find("button").addClass("active disabled"); $("button.pickview." + v).addClass("active"); }); + $.each(filter_params['show_types'], function (i, v) { + // this is a regular item by type: when present, show these rows + $('[id^="row-"]').filter('[timeslot-type*="' + v + '"]').show(); + }); + $.each(filter_params['hide_groups'], function (i, v) { + // this is a "negative" item by wg: when present, hide these rows + $('[id^="row-"]').filter('[id*="-' + v + '"]').hide(); + $(".view." + v).find("button").removeClass("active disabled"); + $("button.pickviewneg." + v).removeClass("active"); + }); + $.each(filter_params['hide_types'], function (i, v) { + // this is a "negative" item by type: when present, hide these rows + $('[id^="row-"]').filter('[timeslot-type*="' + v + '"]').hide(); + }); // show the week view - $("#weekview").attr("src", "week-view.html" + window.location.search).removeClass("hidden"); + update_weekview(); + $("#weekview").removeClass("hidden"); // show the custom .ics link $("#ical-link").attr("href",$("#ical-link").attr("href").split("?")[0]+window.location.search); @@ -426,16 +442,14 @@ $(".pickview, .pickviewneg").click(function () { // Get clicked item label var item = $(this).text().trim().toLowerCase(); - var qparams = parse_query_params(window.location.search); - var show_groups = get_filter_from_qparams(qparams, 'show'); - var hide_groups = get_filter_from_qparams(qparams, 'hide'); + var fp = get_filter_params(parse_query_params(window.location.search)); if ($(this).hasClass("pickviewneg")) { - toggle_list_item(hide_groups, item); + toggle_list_item(fp['hide_groups'], item); } else { - toggle_list_item(show_groups, item); + toggle_list_item(fp['show_groups'], item); } - update_filters(show_groups, hide_groups); + update_filters(fp); }); /* Add to list if not present, remove if present */ @@ -448,14 +462,20 @@ } } - function update_filters(show, hide) { + function update_filters(filter_params) { var qparams = []; var search = ''; - if (show.length > 0) { - qparams.push('show=' + show.join()); + if (filter_params['show_groups'].length > 0) { + qparams.push('show=' + filter_params['show_groups'].join()); } - if (hide.length > 0) { - qparams.push('hide=' + hide.join()); + if (filter_params['hide_groups'].length > 0) { + qparams.push('hide=' + filter_params['hide_groups'].join()); + } + if (filter_params['show_types'].length > 0) { + qparams.push('showtypes=' + filter_params['show_types'].join()); + } + if (filter_params['hide_types'].length > 0) { + qparams.push('hidetypes=' + filter_params['hide_types'].join()); } if (qparams.length > 0) { search = '?' + qparams.join('&'); @@ -466,15 +486,32 @@ if (window.history && window.history.replaceState) { // Keep current origin, replace search string, no page reload history.replaceState({}, document.title, new_url); - toggle_visibility(); + toggle_visibility(filter_params); } else { // No window.history.replaceState support, page reload required window.location = new_url; } } + function update_weekview() { + var wv_iframe = document.getElementById('weekview'); + var wv_window = wv_iframe.contentWindow; + var new_url = 'week-view.html' + window.location.search; + if (wv_iframe.src && wv_window.history && wv_window.history.replaceState) { + wv_window.history.replaceState({}, '', new_url); + wv_window.draw_calendar() + } else { + // ho history.replaceState, page reload required + wv_iframe.src = new_url; + } + } + $(document).ready(function () { - toggle_visibility(); + toggle_visibility( + get_filter_params( + parse_query_params(window.location.search) + ) + ); }); $(".modal").on("show.bs.modal", function () { diff --git a/ietf/templates/meeting/week-view.html b/ietf/templates/meeting/week-view.html index e73d18f5c..e97da16f2 100644 --- a/ietf/templates/meeting/week-view.html +++ b/ietf/templates/meeting/week-view.html @@ -107,33 +107,60 @@ } } + //=========================================================================== - function is_visible(include) { + function parse_query_params(qs) { + var params = {}; + qs = qs.replace(/^\?/, '').toLowerCase(); + if (qs) { + var param_strs = qs.split('&'); + for (var ii = 0; ii < param_strs.length; ii++) { + var toks = param_strs[ii].split('=', 2) + params[toks[0]] = toks[1] || true; + } + } + return params; + } + + //=========================================================================== + + function get_filter_from_qparams(qparams, filt) { + return qparams[filt] ? qparams[filt].split(',') : []; + } + + function get_filter_params(qparams) { + return { + show_groups: get_filter_from_qparams(qparams, 'show'), + hide_groups: get_filter_from_qparams(qparams, 'hide'), + show_types: get_filter_from_qparams(qparams, 'showtypes'), + hide_types: get_filter_from_qparams(qparams, 'hidetypes'), + }; + } + //=========================================================================== + + function is_visible(query_params) { + // Returns a method to filter objects for visibility + // Accepts show, hide, showtypes, and hidetypes filters. Also accepts + // '@' to show sessions in a particular state (e.g., @bof). + // Current types are: + // Session, Other, Break, Plenary + var fp = get_filter_params(query_params); + return function (item) { - // "-wgname" will remove a working group from the output. - // "~Type" will add that type to the output. - // "-~Type" will remove that type from the output - // "@bof" will include all BOFs - // Current types are: - // Session, Other, Break, Plenary + var item_group = (item.group || '').toLowerCase(); + var item_type = (item.type || '').toLowerCase(); + var item_area = (item.area || '').toLowerCase(); + var item_state = (item.state || '').toLowerCase(); - if ("group" in item) { - if (include[(item.group).toLowerCase()]) { return true; } - if (include["-"+(item.group).toLowerCase()]) { return false; } + if ((fp['hide_groups'].indexOf(item_group) >= 0) || + (fp['hide_types'].indexOf(item_type) >= 0)) { + return false; } - if ("state" in item) { - if (include["@"+(item.state).toLowerCase()]) { return true; } - } - if (include["~"+(item.type).toLowerCase()]) { return true; } - if (include["-~"+(item.type).toLowerCase()]) { return false; } - if ("area" in item) { - if (include[(item.area).toLowerCase()]) { return true; } - } - if (item.type === "Plenary") { return true; } - if (item.type === "Other") { return true; } - - return false; + return ((fp['show_groups'].indexOf(item_group) >= 0) || + (fp['show_groups'].indexOf(item_area) >= 0) || + (fp['show_types'].indexOf(item_type) >= 0) || + query_params['@'+item_state]); } } @@ -143,16 +170,23 @@ var width = document.body.clientWidth; var height = document.body.clientHeight; - var include = {}; - window.location.hash.replace("#",'').split(',').forEach(function(key){ - include[(key + "").toLowerCase()] = true; - }); + var visible_items = all_items; + var qs = window.location.search; + if (qs.length > 1) { + visible_items = visible_items.filter(is_visible(parse_query_params(qs))); + } - var visible_items = all_items.filter(is_visible(include)); - - var start_day = visible_items[0].day; + var start_day; + var day_start; + if (visible_items.length > 0) { + start_day = visible_items[0].day; + day_start = visible_items[0].start_time; + } else { + // fallback in case all items were filtered + start_day = all_items[0].day; + day_start = all_items[0].start_time; + } var end_day = start_day; - var day_start = visible_items[0].start_time; var day_end = 0; compute_swimlanes(visible_items); @@ -324,6 +358,11 @@ document.body.appendChild(e); }); + + // Div to indicate rendering has occurred, for testing purposes. + var elt = document.createElement('div'); + elt.id = 'wv-end'; + document.body.appendChild(elt); } //=========================================================================== From 4c709bbfa5706121d25d5aea10adf532bf772ffa Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Wed, 2 Sep 2020 02:02:33 +0000 Subject: [PATCH 4/5] Add filtering support (but no UI) to upcoming meeting views; add a group attribute to agenda view rows to avoid ambiguity when selecting rows to show/hide. Branch ready for merge. - Legacy-Id: 18458 --- ietf/meeting/test_data.py | 2 +- ietf/meeting/tests_views.py | 283 +++++++++++++++++++++++------ ietf/meeting/views.py | 143 +++++++++------ ietf/templates/group/meetings.html | 2 +- ietf/templates/meeting/agenda.html | 15 +- 5 files changed, 333 insertions(+), 112 deletions(-) diff --git a/ietf/meeting/test_data.py b/ietf/meeting/test_data.py index 37ad1b557..bef972ce8 100644 --- a/ietf/meeting/test_data.py +++ b/ietf/meeting/test_data.py @@ -219,7 +219,7 @@ def make_interim_test_data(): ad = Person.objects.get(user__username='ad') RoleFactory(group=area,person=ad,name_id='ad') mars = GroupFactory(acronym='mars',parent=area,name='Martian Special Interest Group') - ames = GroupFactory(acronym='ames',parent=area) + ames = GroupFactory(acronym='ames',parent=area,name='Asteroid Mining Equipment Standardization Group') RoleFactory(group=mars,person__user__username='marschairman',name_id='chair') RoleFactory(group=ames,person__user__username='ameschairman',name_id='chair') diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 78a6e4436..8376c8292 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -22,6 +22,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.test import Client, override_settings from django.db.models import F +from django.http import QueryDict import debug # pyflakes:ignore @@ -37,7 +38,7 @@ from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignm from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data from ietf.meeting.utils import finalize, condition_slide_order from ietf.meeting.utils import add_event_info_to_session_qs -from ietf.meeting.views import session_draft_list +from ietf.meeting.views import session_draft_list, parse_agenda_filter_params from ietf.name.models import SessionStatusName, ImportantDateName, RoleName from ietf.utils.decorators import skip_coverage from ietf.utils.mail import outbox, empty_outbox, get_payload_text @@ -61,6 +62,31 @@ else: "location indicated in settings.py.") print(" "+skip_message) + +def assert_ical_response_is_valid(test_inst, response, expected_event_summaries=None, expected_event_count=None): + """Validate an HTTP response containing iCal data + + Based on RFC2445, but not exhaustive by any means. Assumes a single iCalendar object. + """ + test_inst.assertEqual(response.get('Content-Type'), "text/calendar") + + # Validate iCalendar object + test_inst.assertContains(response, 'BEGIN:VCALENDAR', count=1) + test_inst.assertContains(response, 'END:VCALENDAR', count=1) + test_inst.assertContains(response, 'PRODID:', count=1) + test_inst.assertContains(response, 'VERSION', count=1) + + # Validate event objects + if expected_event_summaries is not None: + for summary in expected_event_summaries: + test_inst.assertContains(response, 'SUMMARY:' + summary) + + if expected_event_count is not None: + test_inst.assertContains(response, 'BEGIN:VEVENT', count=expected_event_count) + test_inst.assertContains(response, 'END:VEVENT', count=expected_event_count) + test_inst.assertContains(response, 'UID', count=expected_event_count) + + class MeetingTests(TestCase): def setUp(self): self.materials_dir = self.tempdir('materials') @@ -580,28 +606,6 @@ class MeetingTests(TestCase): post_date = meeting.importantdate_set.get(name=idn).date self.assertEqual(pre_date, post_date+datetime.timedelta(days=1)) - def assert_ical_response_is_valid(self, response, expected_event_summaries=None, expected_event_count=None): - """Validate an HTTP response containing iCal data - - Based on RFC2445, but not exhaustive by any means. Assumes a single iCalendar object. - """ - self.assertEqual(response.get('Content-Type'), "text/calendar") - - # Validate iCalendar object - self.assertContains(response, 'BEGIN:VCALENDAR', count=1) - self.assertContains(response, 'END:VCALENDAR', count=1) - self.assertContains(response, 'PRODID:', count=1) - self.assertContains(response, 'VERSION', count=1) - - # Validate event objects - if expected_event_count is None: - expected_event_count = len(expected_event_summaries) - self.assertContains(response, 'BEGIN:VEVENT', count=expected_event_count) - self.assertContains(response, 'END:VEVENT', count=expected_event_count) - self.assertContains(response, 'UID', count=expected_event_count) - for summary in expected_event_summaries: - self.assertContains(response, 'SUMMARY:' + summary) - def test_group_ical(self): meeting = make_meeting_test_data() s1 = Session.objects.filter(meeting=meeting, group__acronym="mars").first() @@ -614,17 +618,18 @@ class MeetingTests(TestCase): # url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'acronym':s1.group.acronym, }) r = self.client.get(url) - self.assert_ical_response_is_valid(r, - expected_event_summaries=['mars - Martian Special Interest Group'], - expected_event_count=2) + assert_ical_response_is_valid(self, + r, + expected_event_summaries=['mars - Martian Special Interest Group'], + expected_event_count=2) self.assertContains(r, t1.time.strftime('%Y%m%dT%H%M%S')) self.assertContains(r, t2.time.strftime('%Y%m%dT%H%M%S')) # url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number, 'session_id':s1.id, }) r = self.client.get(url) - self.assert_ical_response_is_valid(r, - expected_event_summaries=['mars - Martian Special Interest Group'], - expected_event_count=1) + assert_ical_response_is_valid(self, r, + expected_event_summaries=['mars - Martian Special Interest Group'], + expected_event_count=1) self.assertContains(r, t1.time.strftime('%Y%m%dT%H%M%S')) self.assertNotContains(r, t2.time.strftime('%Y%m%dT%H%M%S')) @@ -652,7 +657,36 @@ class MeetingTests(TestCase): for g in groups: if g.parent_id is not None: self.assertIn('%s?show=%s' % (ical_url, g.parent.acronym.lower()), content) - + + def test_parse_agenda_filter_params(self): + def _r(show=(), hide=(), showtypes=(), hidetypes=()): + """Helper to create expected result dict""" + return dict(show=set(show), hide=set(hide), showtypes=set(showtypes), hidetypes=set(hidetypes)) + + self.assertIsNone(parse_agenda_filter_params(QueryDict(''))) + + self.assertRaises(ValueError, parse_agenda_filter_params, QueryDict('unknown')) # unknown param + self.assertRaises(ValueError, parse_agenda_filter_params, QueryDict('unknown=x')) # unknown param + + # test valid combos (not exhaustive) + for qstr, expected in ( + ('show=', _r()), ('hide=', _r()), ('showtypes=', _r()), ('hidetypes=', _r()), + ('show=x', _r(show=['x'])), ('hide=x', _r(hide=['x'])), + ('showtypes=x', _r(showtypes=['x'])), ('hidetypes=x', _r(hidetypes=['x'])), + ('show=x,y,z', _r(show=['x','y','z'])), + ('hide=x,y,z', _r(hide=['x','y','z'])), + ('showtypes=x,y,z', _r(showtypes=['x','y','z'])), + ('hidetypes=x,y,z', _r(hidetypes=['x','y','z'])), + ('show=a&hide=a', _r(show=['a'], hide=['a'])), + ('show=a&hide=b', _r(show=['a'], hide=['b'])), + ('show=a&hide=b&showtypes=c&hidetypes=d', _r(show=['a'], hide=['b'], showtypes=['c'], hidetypes=['d'])), + ): + self.assertEqual( + parse_agenda_filter_params(QueryDict(qstr)), + expected, + 'Parsed "%s" incorrectly' % qstr, + ) + def test_ical_filter_invalid_syntaxes(self): meeting = make_meeting_test_data() url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number}) @@ -667,7 +701,10 @@ class MeetingTests(TestCase): url = urlreverse('ietf.meeting.views.agenda_ical', kwargs={'num':meeting.number}) r = self.client.get(url + querystring) self.assertEqual(r.status_code, 200) - self.assert_ical_response_is_valid(r, expected_event_summaries=expected_session_summaries) + assert_ical_response_is_valid(self, + r, + expected_event_summaries=expected_session_summaries, + expected_event_count=len(expected_session_summaries)) def test_ical_filter_default(self): meeting = make_meeting_test_data() @@ -1799,37 +1836,179 @@ class InterimTests(TestCase): #self.assertIn('CANCELLED', q('[id*="'+id+'"]').text()) self.assertIn('CANCELLED', q('tr>td>a>span').text()) - def test_upcoming(self): - make_meeting_test_data(create_interims=True) + def do_upcoming_test(self, querystring=None, create_meeting=True): + if create_meeting: + make_meeting_test_data(create_interims=True) url = urlreverse("ietf.meeting.views.upcoming") + if querystring is not None: + url += '?' + querystring + today = datetime.date.today() add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', group__acronym='mars')).filter(current_status='apprw').first() - mars_interim = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', meeting__date__gt=today, group__acronym='mars')).filter(current_status='sched').first().meeting - ames_interim = add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', meeting__date__gt=today, group__acronym='ames')).filter(current_status='canceled').first().meeting - r = self.client.get(url) - self.assertContains(r, mars_interim.number) - self.assertContains(r, ames_interim.number) + interims = dict( + mars=add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', meeting__date__gt=today, group__acronym='mars')).filter(current_status='sched').first().meeting, + ames=add_event_info_to_session_qs(Session.objects.filter(meeting__type='interim', meeting__date__gt=today, group__acronym='ames')).filter(current_status='canceled').first().meeting, + ) + self.check_interim_tabs(url) + return self.client.get(url), interims + + def test_upcoming(self): + r, interims = self.do_upcoming_test() + self.assertContains(r, interims['mars'].number) + self.assertContains(r, interims['ames'].number) self.assertContains(r, 'IETF 72') # cancelled session q = PyQuery(r.content) self.assertIn('CANCELLED', q('tr>td.text-right>span').text()) - self.check_interim_tabs(url) + + def test_upcoming_filter_show(self): + r, interims = self.do_upcoming_test('show=ames') + self.assertNotContains(r, interims['mars'].number) + self.assertContains(r, interims['ames'].number) + self.assertContains(r, 'IETF 72') + # cancelled session + q = PyQuery(r.content) + self.assertIn('CANCELLED', q('tr>td.text-right>span').text()) + + def test_upcoming_filter_show_area(self): + make_meeting_test_data(create_interims=True) + area = Group.objects.get(acronym='mars').parent + self.assertEqual(area, + Group.objects.get(acronym='ames').parent, + 'The mars and ames groups have different areas; this breaks this test') + r, interims = self.do_upcoming_test('show=%s' % area.acronym, create_meeting=False) + self.assertContains(r, interims['mars'].number) + self.assertContains(r, interims['ames'].number) + self.assertContains(r, 'IETF 72') + + def test_upcoming_filter_hide(self): + r, interims = self.do_upcoming_test('hide=mars') + self.assertNotContains(r, interims['mars'].number) + self.assertNotContains(r, interims['ames'].number) + self.assertContains(r, 'IETF 72') + + def test_upcoming_filter_show_and_hide(self): + r, interims = self.do_upcoming_test('show=mars,ames&hide=ames') + self.assertContains(r, interims['mars'].number) + self.assertNotContains(r, interims['ames'].number) + self.assertContains(r, 'IETF 72') + + def do_upcoming_ical_test(self, querystring=None): + make_meeting_test_data(create_interims=True) + + # Create a group with a plenary interim session for testing type filters + somegroup = GroupFactory(acronym='sg', name='Some Group') + sg_interim = make_interim_meeting(somegroup, datetime.date.today() + datetime.timedelta(days=20)) + sg_sess = sg_interim.session_set.first() + sg_slot = sg_sess.timeslotassignments.first().timeslot + sg_sess.type_id = 'plenary' + sg_slot.type_id = 'plenary' + sg_sess.save() + sg_slot.save() + + url = urlreverse("ietf.meeting.views.upcoming_ical") + if querystring is not None: + url += '?' + querystring + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + return r def test_upcoming_ical(self): - make_meeting_test_data(create_interims=True) - url = urlreverse("ietf.meeting.views.upcoming_ical") - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertEqual(r.get('Content-Type'), "text/calendar") - self.assertEqual(r.content.count(b'UID'), 8) - # check filtered output - url = url + '?filters=mars' - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - self.assertEqual(r.get('Content-Type'), "text/calendar") - # print r.content - self.assertEqual(r.content.count(b'UID'), 2) + r = self.do_upcoming_ical_test() + print(r.content.decode()) + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + 'mars - Martian Special Interest Group', + 'sg - Some Group', + ], + expected_event_count=9) + def test_upcoming_ical_filter_show(self): + r = self.do_upcoming_ical_test('show=mars,ames') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'mars - Martian Special Interest Group', + 'ames - Asteroid Mining Equipment Standardization Group', + ]) + + def test_upcoming_ical_filter_hide(self): + r = self.do_upcoming_ical_test('hide=mars') + assert_ical_response_is_valid(self, r, expected_event_summaries=[]) + + def test_upcoming_ical_filter_show_and_hide(self): + r = self.do_upcoming_ical_test('show=mars,ames&hide=mars') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + ]) + + def test_upcoming_ical_filter_showtypes(self): + r = self.do_upcoming_ical_test('showtypes=regular') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'mars - Martian Special Interest Group', + 'ames - Asteroid Mining Equipment Standardization Group', + ]) + + def test_upcoming_ical_filter_hidetypes(self): + r = self.do_upcoming_ical_test('hidetypes=regular') + assert_ical_response_is_valid(self, r, expected_event_summaries=[]) + + def test_upcoming_ical_filter_showtypes_and_hidetypes(self): + r = self.do_upcoming_ical_test('showtypes=plenary,regular&hidetypes=regular') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'sg - Some Group', + ]) + + def test_upcoming_ical_filter_show_and_showtypes(self): + r = self.do_upcoming_ical_test('show=mars&showtypes=plenary') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'mars - Martian Special Interest Group', + 'sg - Some Group', + ]) + + def test_upcoming_ical_filter_show_and_hidetypes(self): + r = self.do_upcoming_ical_test('show=mars,sg&showtypes=regular') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'sg - Some Group', + ]) + + def test_upcoming_ical_filter_hide_and_showtypes(self): + r = self.do_upcoming_ical_test('hide=mars&showtypes=regular') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + ]) + + def test_upcoming_ical_filter_hide_and_hidetypes(self): + r = self.do_upcoming_ical_test('hide=mars&hidetypes=regular') + assert_ical_response_is_valid(self, r, expected_event_summaries=[]) + + def test_upcoming_ical_filter_show_hide_and_showtypes(self): + r = self.do_upcoming_ical_test('show=ames&hide=mars&showtypes=regular,plenary') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + 'sg - Some Group', + ]) + + def test_upcoming_ical_filter_show_hide_and_hidetypes(self): + r = self.do_upcoming_ical_test('show=ames,sg&hide=mars&hidetypes=regular') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'sg - Some Group', + ]) + + def test_upcoming_ical_filter_all_params(self): + r = self.do_upcoming_ical_test('show=sg&hide=ames&showtypes=regular&hidetypes=plenary') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'mars - Martian Special Interest Group', + ]) def test_upcoming_json(self): make_meeting_test_data(create_interims=True) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 625044f25..c81250cbc 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -1336,6 +1336,58 @@ def ical_session_status(session_with_current_status): else: return "CONFIRMED" +def parse_agenda_filter_params(querydict): + """Parse agenda filter parameters from a request""" + if len(querydict) == 0: + return None + + # Parse group filters from GET parameters. The keys in this dict define the + # allowed querystring parameters. + filt_params = {'show': set(), 'hide': set(), 'showtypes': set(), 'hidetypes': set()} + + for key, value in querydict.items(): + if key not in filt_params: + raise ValueError('Unrecognized parameter "%s"' % key) + if value is None: + return ValueError( + 'Parameter "%s" is not assigned a value (use "key=" for an empty value)' % key + ) + vals = unquote(value).lower().split(',') + filt_params[key] = set([v for v in vals if len(v) > 0]) # remove empty strings + + return filt_params + + +def should_include_assignment(filter_params, assignment): + """Decide whether to include an assignment + + When filtering by wg, uses historic_group if available as an attribute + on the session, otherwise falls back to using group. + """ + historic_group = getattr(assignment.session, 'historic_group', None) + if historic_group: + group_acronym = historic_group.acronym + parent = historic_group.historic_parent + parent_acronym = parent.acronym if parent else None + else: + group = assignment.session.group + group_acronym = group.acronym + if group.parent: + parent_acronym = group.parent.acronym + else: + parent_acronym = None + session_type = assignment.timeslot.type_id + + # Hide if wg or type hide lists apply + if (group_acronym in filter_params['hide']) or (session_type in filter_params['hidetypes']): + return False + + # Show if any of the show lists apply, including showing by parent group + return ((group_acronym in filter_params['show']) or + (parent_acronym in filter_params['show']) or + (session_type in filter_params['showtypes'])) + + def agenda_ical(request, num=None, name=None, acronym=None, session_id=None): """Agenda ical view @@ -1364,46 +1416,14 @@ def agenda_ical(request, num=None, name=None, acronym=None, session_id=None): assignments = schedule.assignments.exclude(timeslot__type__in=['lead','offagenda']) assignments = preprocess_assignments_for_agenda(assignments, meeting) - if len(request.GET) > 0: - # Parse group filters from GET parameters. The keys in this dict define the - # allowed querystring parameters. - filt_params = {'show': set(), 'hide': set(), 'showtypes': set(), 'hidetypes': set()} - - for key, value in request.GET.items(): - if key not in filt_params: - return HttpResponseBadRequest('Unrecognized parameter "%s"' % key) - if value is None: - return HttpResponseBadRequest( - 'Parameter "%s" is not assigned a value (use "key=" for an empty value)' % key - ) - filt_params[key] = set(unquote(value).lower().split(',')) + try: + filt_params = parse_agenda_filter_params(request.GET) + except ValueError as e: + return HttpResponseBadRequest(str(e)) - def _should_include_assignment(assignment): - """Decide whether to include an assignment - - Relies on filt_params from parent scope. - """ - historic_group = assignment.session.historic_group - if historic_group: - group_acronym = historic_group.acronym - parent = historic_group.historic_parent - parent_acronym = parent.acronym if parent else None - else: - group_acronym = None - parent_acronym = None - session_type = assignment.timeslot.type_id - - # Hide if wg or type hide lists apply - if (group_acronym in filt_params['hide']) or (session_type in filt_params['hidetypes']): - return False - - # Show if any of the show lists apply, including showing by parent group - return ((group_acronym in filt_params['show']) or - (parent_acronym in filt_params['show']) or - (session_type in filt_params['showtypes'])) - + if filt_params is not None: # Apply the filter - assignments = [a for a in assignments if _should_include_assignment(a)] + assignments = [a for a in assignments if should_include_assignment(filt_params, a)] if acronym: assignments = [ a for a in assignments if a.session.historic_group and a.session.historic_group.acronym == acronym ] @@ -2754,24 +2774,47 @@ def past(request): }) def upcoming(request): - '''List of upcoming meetings''' + """List of upcoming meetings + + Only querystring filters by wg name are supported. Always includes IETF meetings; + filters 'interim' type meetings by wg name as requested. The showtypes/hidetypes + filters are ignored.. + """ today = datetime.date.today() + filter_params = parse_agenda_filter_params(request.GET) # Get ietf meetings starting 7 days ago, and interim meetings starting today ietf_meetings = Meeting.objects.filter(type_id='ietf', date__gte=today-datetime.timedelta(days=7)) for m in ietf_meetings: m.end = m.date+datetime.timedelta(days=m.days) + entries = list(ietf_meetings) + interim_sessions = add_event_info_to_session_qs( Session.objects.filter( - meeting__type_id='interim', + meeting__type_id='interim', timeslotassignments__schedule=F('meeting__schedule'), timeslotassignments__timeslot__time__gte=today ) ).filter(current_status__in=('sched','canceled')) + if filter_params is not None: + group_shown = interim_sessions.filter( + group__acronym__in=filter_params['show'] + ) + parent_group_shown = interim_sessions.filter( + group__parent__acronym__in=filter_params['show'] + ) + # The '|' combines querysets with OR - qs.filter(x=1) | qs.filter(y=2) + # translates to a 'WHERE x=1 OR y=2' in the SQL. + interim_sessions = ( + group_shown | parent_group_shown + ).exclude( + # N.B., we only consider parent group (area) for show, not for hide. + # This is consistent with previous behavior but is worth revisiting. + group__acronym__in=filter_params['hide'] + ) + for session in interim_sessions: session.historic_group = session.group - - entries = list(ietf_meetings) entries.extend(list(interim_sessions)) entries.sort(key = lambda o: pytz.utc.localize(datetime.datetime.combine(o.date, datetime.datetime.min.time())) if isinstance(o,Meeting) else o.official_timeslotassignment().timeslot.utc_start_time()) @@ -2800,8 +2843,11 @@ def upcoming(request): def upcoming_ical(request): - '''Return Upcoming meetings in iCalendar file''' - filters = request.GET.getlist('filters') + """Return Upcoming meetings in iCalendar file + + Filters by wg name and session type. + """ + filter_params = parse_agenda_filter_params(request.GET) today = datetime.date.today() # get meetings starting 7 days ago -- we'll filter out sessions in the past further down @@ -2818,13 +2864,8 @@ def upcoming_ical(request): ).distinct()) # apply filters - if filters: - assignments = [a for a in assignments if - a.session.group and ( - a.session.group.acronym in filters or ( - a.session.group.parent and a.session.group.parent.acronym in filters - ) - ) ] + if filter_params is not None: + assignments = [a for a in assignments if should_include_assignment(filter_params, a)] # we already collected sessions with current_status, so reuse those sessions = {s.pk: s for m in meetings for s in m.sessions} diff --git a/ietf/templates/group/meetings.html b/ietf/templates/group/meetings.html index 8672c8d36..24a3603d6 100644 --- a/ietf/templates/group/meetings.html +++ b/ietf/templates/group/meetings.html @@ -34,7 +34,7 @@
Future Meetings - +
{% with sessions=future show_request=True show_ical=True can_edit_materials=can_edit %} diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 91b077bec..328fc003b 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -126,6 +126,7 @@
+
@@ -192,7 +193,7 @@ {% endif %} {% if item.timeslot.type.slug == 'break' or item.timeslot.type.slug == 'reg' or item.timeslot.type.slug == 'other' %} - + {% if "-utc" in request.path %} {{item.timeslot.utc_start_time|date:"G:i"}}-{{item.timeslot.utc_end_time|date:"G:i"}} @@ -250,7 +251,7 @@ {% if item.timeslot.type_id == 'regular' or item.timeslot.type.slug == 'plenary' %} {% if item.session.historic_group %} - + {% if item.timeslot.type.slug == 'plenary' %} {% if "-utc" in request.path %} @@ -361,7 +362,7 @@ +