diff --git a/ietf/meeting/test_data.py b/ietf/meeting/test_data.py index 181620cdc..d7d21bd27 100644 --- a/ietf/meeting/test_data.py +++ b/ietf/meeting/test_data.py @@ -116,6 +116,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, @@ -159,6 +162,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=base_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() @@ -209,7 +220,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_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 1a696130d..c840153a1 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -4,10 +4,14 @@ import time import datetime +import shutil +import os from unittest import skipIf import django from django.urls import reverse as urlreverse +from django.utils.text import slugify +from django.db.models import F #from django.test.utils import override_settings import debug # pyflakes:ignore @@ -15,10 +19,13 @@ import debug # pyflakes:ignore from ietf.doc.factories import DocumentFactory from ietf.group import colors from ietf.person.models import Person +from ietf.group.models import Group from ietf.meeting.factories import SessionFactory from ietf.meeting.test_data import make_meeting_test_data -from ietf.meeting.models import Schedule, SchedTimeSessAssignment, Session, Room, TimeSlot, Constraint, ConstraintName -from ietf.meeting.models import SchedulingEvent, SessionStatusName +from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session, + Room, TimeSlot, Constraint, ConstraintName, + Meeting, SchedulingEvent, SessionStatusName) +from ietf.meeting.utils import add_event_info_to_session_qs from ietf.utils.test_runner import IetfLiveServerTestCase from ietf.utils.pipe import pipe from ietf import settings @@ -31,6 +38,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 @@ -51,29 +59,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() @@ -258,28 +274,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() @@ -317,27 +312,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): @@ -357,6 +341,477 @@ 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): + 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')) + + parse_query_params = 'return agenda_filter_for_testing.parse_query_params' + + # Only 'show' param + result = self.driver.execute_script( + parse_query_params + '("?show=group1,group2,group3");' + ) + self.assertEqual(result, dict(show='group1,group2,group3')) + + # Only 'hide' param + result = self.driver.execute_script( + parse_query_params + '("?hide=group4,group5,group6");' + ) + self.assertEqual(result, dict(hide='group4,group5,group6')) + + # Both 'show' and 'hide' + result = self.driver.execute_script( + parse_query_params + '("?show=group1,group2,group3&hide=group4,group5,group6");' + ) + self.assertEqual(result, dict(show='group1,group2,group3', hide='group4,group5,group6')) + + # Encoded + result = self.driver.execute_script( + parse_query_params + '("?show=%20group1,%20group2,%20group3&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)s(list0, 'item'); + + // one item, remove it + var list1=['item']; + %(toggle_list_item)s(list1, 'item'); + + // one item, add another + var list2=['item1']; + %(toggle_list_item)s(list2, 'item2'); + + // multiple items, remove first + var list3=['item1', 'item2', 'item3']; + %(toggle_list_item)s(list3, 'item1'); + + // multiple items, remove middle + var list4=['item1', 'item2', 'item3']; + %(toggle_list_item)s(list4, 'item2'); + + // multiple items, remove last + var list5=['item1', 'item2', 'item3']; + %(toggle_list_item)s(list5, 'item3'); + + return [list0, list1, list2, list3, list4, list5]; + """ % {'toggle_list_item': 'agenda_filter_for_testing.toggle_list_item'} + ) + 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 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_none(self): + """Filtered agenda view should display only matching rows (no group selected)""" + self.do_agenda_view_filter_test('?show=', []) + + def test_agenda_view_filter_show_one(self): + """Filtered agenda view should display only matching rows (one group selected)""" + self.do_agenda_view_filter_test('?show=mars', ['mars']) + + def test_agenda_view_filter_show_area(self): + mars = Group.objects.get(acronym='mars') + area = mars.parent + self.do_agenda_view_filter_test('?show=%s' % area.acronym, ['ames', 'mars']) + + def test_agenda_view_filter_show_two(self): + """Filtered agenda view should display only matching rows (two groups 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.do_agenda_view_filter_test('', None) # None means all should be visible + + def test_agenda_view_filter_hide(self): + self.do_agenda_view_filter_test('?hide=ietf', []) + + def test_agenda_view_filter_hide_area(self): + mars = Group.objects.get(acronym='mars') + area = mars.parent + self.do_agenda_view_filter_test('?show=mars&hide=%s' % area.acronym, []) + + def test_agenda_view_filter_show_and_hide(self): + self.do_agenda_view_filter_test('?show=mars&hide=ietf', ['mars']) + + 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 None (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 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' + + 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]) + + # 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. + + +@skipIf(skip_selenium, skip_message) +class InterimTests(MeetingTestCase): + def setUp(self): + super(InterimTests, self).setUp() + self.materials_dir = self.tempdir('materials') + self.saved_agenda_path = settings.AGENDA_PATH + settings.AGENDA_PATH = self.materials_dir + self.meeting = make_meeting_test_data(create_interims=True) + + def tearDown(self): + settings.AGENDA_PATH = self.saved_agenda_path + shutil.rmtree(self.materials_dir) + super(InterimTests, self).tearDown() + + def tempdir(self, label): + # Borrowed from test_utils.TestCase + slug = slugify(self.__class__.__name__.replace('.','-')) + dirname = "tmp-{label}-{slug}-dir".format(**locals()) + if 'VIRTUAL_ENV' in os.environ: + dirname = os.path.join(os.environ['VIRTUAL_ENV'], dirname) + path = os.path.abspath(dirname) + if not os.path.exists(path): + os.mkdir(path) + return path + + def displayed_interims(self, groups=None): + sessions = add_event_info_to_session_qs( + Session.objects.filter( + meeting__type_id='interim', + timeslotassignments__schedule=F('meeting__schedule'), + timeslotassignments__timeslot__time__gte=datetime.datetime.today() + ) + ).filter(current_status__in=('sched','canceled')) + meetings = [] + for s in sessions: + if groups is None or s.group.acronym in groups: + s.meeting.calendar_label = s.group.acronym # annotate with group + meetings.append(s.meeting) + return meetings + + def all_ietf_meetings(self): + meetings = Meeting.objects.filter( + type_id='ietf', + date__gte=datetime.datetime.today()-datetime.timedelta(days=7) + ) + for m in meetings: + m.calendar_label = 'IETF %s' % m.number + return meetings + + def assert_upcoming_meeting_visibility(self, visible_meetings=None): + """Assert that correct items are visible in current browser window + + If visible_meetings is None (the default), expects all items to be visible. + """ + expected = {mtg.number for mtg in visible_meetings} + not_visible = set() + unexpected = set() + entries = self.driver.find_elements_by_css_selector( + 'table#upcoming-meeting-table > tbody > tr.entry' + ) + for entry in entries: + nums = [n for n in expected if n in entry.text] + self.assertLessEqual(len(nums), 1, 'Multiple matching meeting numbers') + if len(nums) > 0: # asserted that it's at most 1, so if it's not 0, it's 1. + expected.remove(nums[0]) + if not entry.is_displayed(): + not_visible.add(nums[0]) + continue + # Found an unexpected row - this is ok as long as it's hidden + if entry.is_displayed(): + unexpected.add(entry.text) + + self.assertEqual(expected, set(), "Missing entries for expected iterim meetings.") + self.assertEqual(not_visible, set(), "Hidden rows for expected interim meetings.") + self.assertEqual(unexpected, set(), "Unexpected row visible") + + def assert_upcoming_meeting_calendar(self, visible_meetings=None): + """Assert that correct items are sent to the calendar""" + def advance_month(): + button = WebDriverWait(self.driver, 2).until( + expected_conditions.element_to_be_clickable( + (By.CSS_SELECTOR, 'div#calendar button.fc-next-button'))) + button.click() + + seen = set() + not_visible = set() + unexpected = set() + + # Test that we see all the expected meetings when we scroll through the + # entire year. We only check the group names / IETF numbers. This should + # be good enough to catch filtering errors but does not validate the + # details of what's shown on the calendar. Need 13 iterations instead of + # 12 in order to check the starting month of the following year, which + # will usually contain the day 1 year from the start date. + for _ in range(13): + entries = self.driver.find_elements_by_css_selector( + 'div#calendar div.fc-content' + ) + for entry in entries: + meetings = [m for m in visible_meetings if m.calendar_label in entry.text] + if len(meetings) > 0: + seen.add(meetings[0]) + if not entry.is_displayed(): + not_visible.add(meetings[0]) + continue + # Found an unexpected row - this is ok as long as it's hidden + if entry.is_displayed(): + 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") + + def do_upcoming_view_filter_test(self, querystring, visible_meetings=()): + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.upcoming') + querystring) + self.assert_upcoming_meeting_visibility(visible_meetings) + self.assert_upcoming_meeting_calendar(visible_meetings) + + # Check the ical links + simplified_querystring = querystring.replace(' ', '%20') # encode spaces' + ics_link = self.driver.find_element_by_link_text('Download as .ics') + self.assertIn(simplified_querystring, ics_link.get_attribute('href')) + webcal_link = self.driver.find_element_by_link_text('Subscribe with webcal') + self.assertIn(simplified_querystring, webcal_link.get_attribute('href')) + + def test_upcoming_view_displays_all_interims(self): + """By default, all upcoming interims and IETF meetings should be displayed""" + meetings = set(self.all_ietf_meetings()) + meetings.update(self.displayed_interims()) + self.do_upcoming_view_filter_test('', meetings) + + def test_upcoming_view_filter_show_none(self): + meetings = set(self.all_ietf_meetings()) + self.do_upcoming_view_filter_test('?show=', meetings) + + def test_upcoming_view_filter_show_one(self): + meetings = set(self.all_ietf_meetings()) + meetings.update(self.displayed_interims(groups=['mars'])) + self.do_upcoming_view_filter_test('?show=mars', meetings) + + def test_upcoming_view_filter_show_area(self): + mars = Group.objects.get(acronym='mars') + area = mars.parent + meetings = set(self.all_ietf_meetings()) + meetings.update(self.displayed_interims(groups=['mars', 'ames'])) + self.do_upcoming_view_filter_test('?show=%s' % area.acronym, meetings) + + def test_upcoming_view_filter_show_two(self): + meetings = set(self.all_ietf_meetings()) + meetings.update(self.displayed_interims(groups=['mars', 'ames'])) + self.do_upcoming_view_filter_test('?show=mars,ames', meetings) + + 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) + + def test_upcoming_view_filter_hide(self): + # Not a useful application, but test for completeness... + meetings = set(self.all_ietf_meetings()) + self.do_upcoming_view_filter_test('?hide=mars', meetings) + + def test_upcoming_view_filter_hide_area(self): + mars = Group.objects.get(acronym='mars') + area = mars.parent + meetings = set(self.all_ietf_meetings()) + self.do_upcoming_view_filter_test('?show=mars&hide=%s' % area.acronym, meetings) + + def test_upcoming_view_filter_show_and_hide_same_group(self): + 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) + + # The upcoming meetings view does not handle showtypes / hidetypes + # 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 f9c7230d3..a3b6060bc 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,33 @@ 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. Checks that + expected_event_summaries are found, but other events are allowed to be present. Specify the + expected_event_count if you want to reject additional events. + """ + 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') @@ -201,7 +229,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) @@ -607,23 +635,282 @@ 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') + 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')) - 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') + 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')) - 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_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}) + + 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) + 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() + 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_area(self): + meeting = make_meeting_test_data() + mars = Group.objects.get(acronym='mars') + area = mars.parent + self.do_ical_filter_test( + meeting, + querystring='?show=%s' % area.acronym, + expected_session_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + 'mars - Martian Special Interest Group', + ] + ) + + def test_ical_filter_hide_area(self): + meeting = make_meeting_test_data() + mars = Group.objects.get(acronym='mars') + area = mars.parent + self.do_ical_filter_test( + meeting, + querystring='?show=mars&hide=%s' % area.acronym, + 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=ietf&hide=ames&showtypes=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, @@ -1927,45 +2214,210 @@ 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() - 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_filters_ignored(self): + """The upcoming view should ignore filter querystrings""" + r, interims = self.do_upcoming_test() + self.assertContains(r, interims['mars'].number) + self.assertContains(r, interims['ames'].number) + self.assertContains(r, 'IETF 72') + + r, interims = self.do_upcoming_test('show=ames', create_meeting=False) + self.assertContains(r, interims['mars'].number) + self.assertContains(r, interims['ames'].number) + self.assertContains(r, 'IETF 72') + + r, interims = self.do_upcoming_test('show=ames&hide=ames,mars', create_meeting=False) + self.assertContains(r, interims['mars'].number) + self.assertContains(r, interims['ames'].number) + self.assertContains(r, 'IETF 72') + + def do_upcoming_ical_test(self, querystring=None, create_meeting=True): + if create_meeting: + 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): meeting = make_meeting_test_data(create_interims=True) populate_important_dates(meeting) + r = self.do_upcoming_ical_test(create_meeting=False) - url = urlreverse("ietf.meeting.views.upcoming_ical") - r = self.client.get(url) - self.assertEqual(r.status_code, 200) + # Expect events for important dates plus 4 - one for each WG and one for the IETF meeting + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + 'mars - Martian Special Interest Group', + 'sg - Some Group', + 'IETF 72', + ], + expected_event_count=4 + meeting.importantdate_set.count()) - today = datetime.date.today() - 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 - self.assertContains(r, mars_interim.number) - self.assertContains(r, ames_interim.number) - self.assertContains(r, 'IETF 72') - self.assertEqual(r.get('Content-Type'), "text/calendar") - self.assertEqual(r.content.count(b'UID'), 3 + meeting.importantdate_set.count()) + 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', + 'IETF 72', + ], + expected_event_count=3) - # 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") - self.assertEqual(r.content.count(b'UID'), 2 + meeting.importantdate_set.count()) + 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=['IETF 72'], expected_event_count=1) + 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', + 'IETF 72', + ], + expected_event_count=2) + + 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=[ + 'ames - Asteroid Mining Equipment Standardization Group', + 'mars - Martian Special Interest Group', + 'IETF 72', + ], + expected_event_count=3) + + 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=['IETF 72']) + + 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', + 'IETF 72', + ], + expected_event_count=2) + + 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', + 'IETF 72', + ], + expected_event_count=3) + + def test_upcoming_ical_filter_show_and_hidetypes(self): + r = self.do_upcoming_ical_test('show=mars,sg&hidetypes=regular') + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'sg - Some Group', + 'IETF 72', + ], + expected_event_count=2) + + 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', + 'IETF 72', + ], + expected_event_count=2) + + 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=['IETF 72'], expected_event_count=1) + + 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', + 'IETF 72', + ], + expected_event_count=3) + + 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', + 'IETF 72' + ], + expected_event_count=2) + + 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', + 'IETF 72', + ], + expected_event_count=2) + + def test_upcoming_ical_filter_show_area(self): + make_meeting_test_data(create_interims=True) + mars = Group.objects.get(acronym='mars') + area = mars.parent + r = self.do_upcoming_ical_test('show=%s' % area.acronym, + create_meeting=False) + assert_ical_response_is_valid(self, r, + expected_event_summaries=[ + 'ames - Asteroid Mining Equipment Standardization Group', + 'mars - Martian Special Interest Group', + 'IETF 72', + ], + expected_event_count=3) + + def test_upcoming_ical_filter_hide_area(self): + make_meeting_test_data(create_interims=True) + mars = Group.objects.get(acronym='mars') + area = mars.parent + r = self.do_upcoming_ical_test('show=mars&hide=%s' % area.acronym, + create_meeting=False) + assert_ical_response_is_valid(self, r, expected_event_summaries=['IETF 72'], expected_event_count=1) 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 edaebc7e3..2d79029a3 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -25,8 +25,9 @@ from wsgiref.handlers import format_date_time from django import forms from django.shortcuts import render, redirect, get_object_or_404 -from django.http import ( HttpResponse, HttpResponseRedirect, HttpResponseForbidden, HttpResponseNotFound, - Http404, JsonResponse) +from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseForbidden, + HttpResponseNotFound, Http404, HttpResponseBadRequest, + JsonResponse) from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -1773,7 +1774,78 @@ def ical_session_status(assignment): 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 + (parent_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 + + 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() @@ -1781,41 +1853,20 @@ 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 = SchedTimeSessAssignment.objects.filter( schedule__in=[schedule, schedule.base], timeslot__type__private=False, ) 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)] + try: + filt_params = parse_agenda_filter_params(request.GET) + except ValueError as e: + return HttpResponseBadRequest(str(e)) + + if filt_params is not None: + # Apply the filter + 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 ] @@ -3189,6 +3240,7 @@ def upcoming(request): 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) + interim_sessions = add_event_info_to_session_qs( Session.objects.filter( meeting__type_id='interim', @@ -3196,6 +3248,22 @@ def upcoming(request): timeslotassignments__timeslot__time__gte=today ) ).filter(current_status__in=('sched','canceled')) + + # get groups for group UI display - same algorithm as in agenda(), but + # using group / parent instead of historic_group / historic_parent + groups = [s.group for s in interim_sessions + if s.group + and s.group.type_id in ('wg', 'rg', 'ag', 'rag', 'iab', 'program') + and s.group.parent] + group_parents = {g.parent for g in groups if g.parent} + seen = set() + for p in group_parents: + p.group_list = [] + for g in groups: + if g.acronym not in seen and g.parent.acronym == p.acronym: + p.group_list.append(g) + seen.add(g.acronym) + for session in interim_sessions: session.historic_group = session.group @@ -3210,15 +3278,25 @@ def upcoming(request): # add menu actions actions = [] if can_request_interim_meeting(request.user): - actions.append(('Request new interim meeting', - reverse('ietf.meeting.views.interim_request'))) - actions.append(('Download as .ics', - reverse('ietf.meeting.views.upcoming_ical'))) - actions.append(('Subscribe with webcal', - 'webcal://'+request.get_host()+reverse('ietf.meeting.views.upcoming_ical'))) + actions.append(dict( + label='Request new interim meeting', + url=reverse('ietf.meeting.views.interim_request'), + append_filter=False) + ) + actions.append(dict( + label='Download as .ics', + url=reverse('ietf.meeting.views.upcoming_ical'), + append_filter=True) + ) + actions.append(dict( + label='Subscribe with webcal', + url='webcal://'+request.get_host()+reverse('ietf.meeting.views.upcoming_ical'), + append_filter=True) + ) return render(request, 'meeting/upcoming.html', { 'entries': entries, + 'group_parents': group_parents, 'menu_actions': actions, 'menu_entries': menu_entries, 'selected_menu_entry': selected_menu_entry, @@ -3228,8 +3306,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 @@ -3246,13 +3327,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/static/ietf/doc/agenda-filtering-description.txt b/ietf/static/ietf/doc/agenda-filtering-description.txt new file mode 100644 index 000000000..b5cc22c31 --- /dev/null +++ b/ietf/static/ietf/doc/agenda-filtering-description.txt @@ -0,0 +1,70 @@ +* New filtering behavior + +Agenda filtering is defined by four parameters + + * show (group/area) + * hide (group/area) + * showtypes (session type) + * hidetypes (session type) + +These are specified as querystring parameters to the requested URL. The value +for each parameter is a comma-separated list of groups or types. For example, +the following URL specifies that sessions the babel group or art area (except +for the asdf and dispatch groups) should be shown: + +https://datatracker.ietf.org/meeting/agenda/?show=art,babel&hide=asdf,dispatch + +Note that, in contrast to the URL fragment previously used, the querystring is +sent in the HTTP request. As a result, manual changes to the filter string will +cause page reloads. On modern browsers, the customization UI avoids these +unnecessary reloads. + +If none of the filtering parameters are specified, then filtering is disabled +and all sessions will be shown. If one or more is specified, even if empty, +filtering is enabled. The filtered list of sessions begins empty. Sessions are +added and removed by the following steps, in order. + + 1. Add any session whose group or area is in the "show" list. + 2. Add any session whose type is in the "showtypes" list. + 3. Remove any session whose group or area is in the "hide list. + 4. Remove any session whose type is in thie "hidetypes" list. + +As a result, the "hide" and "hidetypes" list take priority over the "show" and +"showtypes" lists. + +Filtering is implemented for the meeting agenda page and its iCal download +links and for the upcoming meetings page and its iCal links. Filtering is +performed server-side for the iCal links and client-side for the other pages. +The upcoming meetings page only filters using group/area filters and ignores +the type filters. + +Unrecognized or badly formatted parameter names are treated as errors. Empty +parameter values are allowed. Group, area, and type names are not validated, +so care must be taken to avoid typos. + + +* New UI: "Non-area" customization buttons + +The "special sessions" buttons across the bottom of the customization UI have +been moved to a "Non-area" column on the right side of the UI. This has a +heading button and two sets of buttons. The top set contains session type +buttons ("Plenary" and "Other"). The bottom set contains group and area +buttons. + +When no non-area session type is active, only "regular" sessions will appear in +the agenda. In this situation, the non-area group buttons are disabled. A click +on the header button will enable or disable both session types. + +If one or both session types is activated, sessions of that type will be shown +and the non-area group buttons will be enabled. These are active by default. +Clicking an active group button will deactivate the button and add the +corresponding group to the "hide" list. If the group was in the "show" list, it +will be removed when added to the "hide" list (and vice versa). + + +* New UI: Upcoming meeting customization + +The customization UI from the agenda page now appears on the upcoming meetings +page. It does not include the "Non-area" buttons. + + diff --git a/ietf/static/ietf/js/agenda/agenda_filter.js b/ietf/static/ietf/js/agenda/agenda_filter.js new file mode 100644 index 000000000..264c77690 --- /dev/null +++ b/ietf/static/ietf/js/agenda/agenda_filter.js @@ -0,0 +1,317 @@ +var agenda_filter_for_testing = {}; // methods to be accessed for automated testing +var agenda_filter = function () { + 'use strict' + + var update_callback // function(filter_params) + var enable_non_area = false // if true, show the non-area filters + + /* Add to list without duplicates */ + function add_list_item (list, item) { + if (list.indexOf(item) === -1) { + list.push(item); + } + } + + /* Remove from list, if present */ + function remove_list_item (list, item) { + var item_index = list.indexOf(item); + if (item_index !== -1) { + list.splice(item_index, 1) + } + } + + /* Add to list if not present, remove if present */ + function toggle_list_item (list, item) { + var item_index = list.indexOf(item); + if (item_index === -1) { + list.push(item) + } else { + list.splice(item_index, 1) + } + } + + function parse_query_params (qs) { + var params = {} + qs = decodeURI(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 + } + + /* filt = 'show' or 'hide' */ + function get_filter_from_qparams (qparams, filt) { + if (!qparams[filt] || (qparams[filt] === true)) { + return []; + } + return $.map(qparams[filt].split(','), function(s){return s.trim();}); + } + + function get_filter_params (qparams) { + var enabled = !!(qparams.show || qparams.hide || qparams.showtypes || qparams.hidetypes); + return { + enabled: enabled, + 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 filtering_is_enabled (filter_params) { + return filter_params['enabled']; + } + + function get_area_items (area) { + var types = []; + var groups = []; + var neg_groups = []; + + $('.view.' + area).find('button').each(function (index, elt) { + elt = $(elt) // jquerify + var item = elt.text().trim().toLowerCase() + if (elt.hasClass('picktype')) { + types.push(item) + } else if (elt.hasClass('pickview')) { + groups.push(item); + } else if (elt.hasClass('pickviewneg')) { + neg_groups.push(item) + } + }); + return { 'groups': groups, 'neg_groups': neg_groups, 'types': types }; + } + + // Update the filter / customization UI to match the current filter parameters + function update_filter_ui (filter_params) { + var area_group_buttons = $('.view .pickview, .pick-area'); + var non_area_header_button = $('button.pick-non-area'); + var non_area_type_buttons = $('.view.non-area .picktype'); + var non_area_group_buttons = $('.view.non-area button.pickviewneg'); + + if (!filtering_is_enabled(filter_params)) { + // Not filtering - set everything to defaults and exit + area_group_buttons.removeClass('active'); + non_area_header_button.removeClass('active'); + non_area_type_buttons.removeClass('active'); + non_area_group_buttons.removeClass('active'); + non_area_group_buttons.addClass('disabled'); + return; + } + + // show the customizer - it will stay visible even if filtering is disabled + $('#customize').collapse('show') + + // Group and area buttons - these are all positive selections + area_group_buttons.each(function (index, elt) { + elt = $(elt); + var item = elt.text().trim().toLowerCase(); + var area = elt.attr('data-group-area'); + if ((filter_params['hide_groups'].indexOf(item) === -1) // not hidden... + && ((filter_params['show_groups'].indexOf(item) !== -1) // AND shown... + || (area && (filter_params['show_groups'].indexOf(area.trim().toLowerCase()) !== -1))) // OR area shown + ) { + elt.addClass('active'); + } else { + elt.removeClass('active'); + } + }); + + // Non-area buttons need special handling. Only have positive type and negative group buttons. + // Assume non-area heading is disabled, then enable if one of the types is active + non_area_header_button.removeClass('active'); + non_area_group_buttons.addClass('disabled'); + non_area_type_buttons.each(function (index, elt) { + // Positive type selection buttons + elt = $(elt); + var item = elt.text().trim().toLowerCase(); + if ((filter_params['show_types'].indexOf(item) !== -1) + && (filter_params['hide_types'].indexOf(item) === -1)){ + elt.addClass('active'); + non_area_header_button.addClass('active'); + non_area_group_buttons.removeClass('disabled'); + } else { + elt.removeClass('active'); + } + }); + + non_area_group_buttons.each(function (index, elt) { + // Negative group selection buttons + elt = $(elt); + var item = elt.text().trim().toLowerCase(); + if (filter_params['hide_groups'].indexOf(item) === -1) { + elt.addClass('active'); + } else { + elt.removeClass('active'); + } + }); + } + + /* Update state of the view to match the filters + * + * Calling the individual update_* functions outside of this method will likely cause + * various parts of the page to get out of sync. + */ + function update_view () { + var filter_params = get_filter_params(parse_query_params(window.location.search)) + update_filter_ui(filter_params) + if (update_callback) { + update_callback(filter_params) + } + } + + + /* Trigger an update so the user will see the page appropriate for given filter_params + * + * Updates the URL to match filter_params, then updates the history / display to match + * (if supported) or loads the new URL. + */ + function update_filters (filter_params) { + var qparams = [] + var search = '' + if (filter_params['show_groups'].length > 0) { + qparams.push('show=' + filter_params['show_groups'].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('&') + } + + // 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) + update_view() + } else { + // No window.history.replaceState support, page reload required + window.location = new_url + } + } + + /* Helper for pick group/type button handlers - toggles the appropriate parameter entry + * elt - the jquery element that was clicked + * param_type - key of the filter param to update (show_groups, show_types, etc) + */ + function handle_pick_button (elt, param_type) { + var area = elt.attr('data-group-area'); + var item = elt.text().trim().toLowerCase(); + var fp = get_filter_params(parse_query_params(window.location.search)); + var neg_param_type = { + show_groups: 'hide_groups', + hide_groups: 'show_groups', + show_types: 'hide_types', + hide_types: 'show_types' + }[param_type]; + + if (area && (fp[param_type].indexOf(area.trim().toLowerCase()) !== -1)) { + // Area is shown - toggle hide list + toggle_list_item(fp[neg_param_type], item); + remove_list_item(fp[param_type], item); + } else { + toggle_list_item(fp[param_type], item); + remove_list_item(fp[neg_param_type], item); + } + return fp; + } + + function is_disabled(elt) { + return elt.hasClass('disabled'); + } + + // Various "pick" button handlers + $('.pickview').click(function () { + if (is_disabled($(this))) { return; } + update_filters(handle_pick_button($(this), 'show_groups')) + }); + + $('.pickviewneg').click(function () { + if (is_disabled($(this))) { return; } + update_filters(handle_pick_button($(this), 'hide_groups')) + }); + + $('.picktype').click(function () { + if (is_disabled($(this))) { return; } + var fp = handle_pick_button($(this), 'show_types') + // If we just disabled the last non-area type, clear out the hide groups list. + var items = get_area_items('non-area') + var any_left = false + $.each(items.types, function (index, session_type) { + if (fp['show_types'].indexOf(session_type) !== -1) { + any_left = true + } + }) + if (!any_left) { + fp['hide_groups'] = [] + } + update_filters(fp); + }); + + // Click handler for an area header button + $('.pick-area').click(function() { + if (is_disabled($(this))) { return; } + var fp = handle_pick_button($(this), 'show_groups'); + var items = get_area_items($(this).text().trim().toLowerCase()); + + // Clear all the individual group show/hide options + $.each(items.groups, function(index, group) { + remove_list_item(fp['show_groups'], group); + remove_list_item(fp['hide_groups'], group); + }); + update_filters(fp); + }); + + // Click handler for the "Non-Area" header button + $('.pick-non-area').click(function () { + var items = get_area_items('non-area'); + + var fp = get_filter_params(parse_query_params(window.location.search)) + if ($(this).hasClass('active')) { + // Were active - disable or hide everything + $.each(items.types, function (index, session_type) { + remove_list_item(fp['show_types'], session_type) + }) + // When no types are shown, no need to hide groups. Empty hide_groups list. + fp['hide_groups'] = [] + } else { + // Were not active - enable or stop hiding everything + $.each(items.types, function (index, session_type) { + add_list_item(fp['show_types'], session_type) + }) + $.each(items.neg_groups, function (index, group) { + remove_list_item(fp['hide_groups'], group) + }) + } + update_filters(fp); + }); + + // Entry point to filtering code when page loads + function enable () { + $(document).ready(function () { + update_view() + }) + } + + // Make private functions available for unit testing + agenda_filter_for_testing.toggle_list_item = toggle_list_item; + agenda_filter_for_testing.parse_query_params = parse_query_params; + + // Public interface methods + return { + enable: enable, + filtering_is_enabled: filtering_is_enabled, + include_non_area_selectors: function () {enable_non_area = true}, + set_update_callback: function (cb) {update_callback = cb} + } +}(); \ No newline at end of file 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/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 @@
- You can customize the agenda view to show only selected sessions, - by clicking on groups and areas in the table below. - To be able to return to the customized view later, bookmark the resulting URL. -
- - {% if group_parents|length %} -Groups displayed in italics are BOFs.
- -- - | - {% endfor %} -
---|
-
- {% for group in p.group_list %}
-
-
-
-
- {% endfor %}
- |
- {% endfor %}
-
No WG / RG data available -- no WG / RG sessions have been scheduled yet.- {% endif %} -
Also show special sessions of these groups:
-For more on regular IETF meetings see here
- {% if menu_entries %} + {% include 'meeting/agenda_filter.html' with group_parents=group_parents customize_button_text="Customize the meeting list..." only%} + + {% if menu_entries %}