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 @@