From d29553c0bcd26ef1ac37d90a65801e452d5375c0 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 17 Nov 2020 19:17:53 +0000 Subject: [PATCH] Add timezone support to agenda weekview; display UTC on UTC agenda page. Fixes #3111. Commit ready for merge. - Legacy-Id: 18712 --- ietf/bower.json | 8 ++ ietf/meeting/tests_js.py | 197 +++++++++++++++++++++++++- ietf/meeting/tests_views.py | 31 +--- ietf/meeting/views.py | 35 +---- ietf/templates/meeting/agenda.html | 14 +- ietf/templates/meeting/week-view.html | 137 +++++++++++++++--- 6 files changed, 350 insertions(+), 72 deletions(-) diff --git a/ietf/bower.json b/ietf/bower.json index 2ac6daa33..973a61f3c 100644 --- a/ietf/bower.json +++ b/ietf/bower.json @@ -13,6 +13,8 @@ "js-cookie": "~2", "jquery": "~1", "jquery.tablesorter": "~2", + "moment": "~2", + "moment-timezone": "~0", "respond": "~1", "select2": "~3", "select2-bootstrap-css": "~1", @@ -33,6 +35,12 @@ "./fonts/*" ] }, + "moment": { + "main": [ + "dist/moment.js", + "min/moment.min.js" + ] + }, "tablesorter": { "main": [ "dist/js/jquery.tablesorter.combined.min.js", diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 1fbeddd55..1c1447a79 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -13,6 +13,7 @@ import django from django.urls import reverse as urlreverse from django.utils.text import slugify from django.db.models import F +from pytz import timezone #from django.test.utils import override_settings import debug # pyflakes:ignore @@ -23,7 +24,7 @@ from ietf.group import colors from ietf.person.models import Person from ietf.group.models import Group from ietf.group.factories import GroupFactory -from ietf.meeting.factories import SessionFactory +from ietf.meeting.factories import SessionFactory, TimeSlotFactory from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session, Room, TimeSlot, Constraint, ConstraintName, @@ -904,6 +905,200 @@ class AgendaTests(MeetingTestCase): self.driver.find_element_by_xpath('//a[text()="%s"]' % slide.title) +@skipIf(skip_selenium, skip_message) +class WeekviewTests(MeetingTestCase): + def setUp(self): + super(WeekviewTests, self).setUp() + self.meeting = make_meeting_test_data() + + 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_timezone_default(self): + """Week view should show local times by default""" + self.assertNotEqual(self.meeting.time_zone.lower(), 'utc', + 'Cannot test local time weekview because meeting is using UTC time.') + self.login() + self.driver.get(self.absreverse('ietf.meeting.views.week_view')) + for item in self.get_expected_items(): + if item.session.name: + expected_name = item.session.name + elif item.timeslot.type_id == 'break': + expected_name = item.timeslot.name + else: + expected_name = item.session.group.name + expected_time = '-'.join([item.timeslot.local_start_time().strftime('%H%M'), + item.timeslot.local_end_time().strftime('%H%M')]) + WebDriverWait(self.driver, 2).until( + expected_conditions.presence_of_element_located( + (By.XPATH, + '//div/div[contains(text(), "%s")]/span[contains(text(), "%s")]' % ( + expected_time, expected_name)) + ) + ) + + def test_timezone_selection(self): + """Week view should show time zones when requested""" + # Must test utc; others are picked arbitrarily + zones_to_test = ['utc', 'America/Halifax', 'Asia/Bangkok', 'Africa/Dakar', 'Europe/Dublin'] + self.login() + for zone_name in zones_to_test: + zone = timezone(zone_name) + self.driver.get(self.absreverse('ietf.meeting.views.week_view') + '?tz=' + zone_name) + for item in self.get_expected_items(): + if item.session.name: + expected_name = item.session.name + elif item.timeslot.type_id == 'break': + expected_name = item.timeslot.name + else: + expected_name = item.session.group.name + + start_time = item.timeslot.utc_start_time().astimezone(zone) + end_time = item.timeslot.utc_end_time().astimezone(zone) + expected_time = '-'.join([start_time.strftime('%H%M'), + end_time.strftime('%H%M')]) + + WebDriverWait(self.driver, 2).until( + expected_conditions.presence_of_element_located( + (By.XPATH, + '//div/div[contains(text(), "%s")]/span[contains(text(), "%s")]' % ( + expected_time, expected_name)) + ), + 'Could not find event "%s" at %s for time zone %s' % (expected_name, + expected_time, + zone_name), + ) + + def test_event_wrapping(self): + """Events that overlap midnight should be shown on both days + + This assumes that the meeting is in America/New_York timezone. + """ + def _assert_wrapped(displayed, expected_time_string): + self.assertEqual(len(displayed), 2) + first = displayed[0] + first_parent = first.find_element_by_xpath('..') + second = displayed[1] + second_parent = second.find_element_by_xpath('..') + self.assertNotIn('continued', first.text) + self.assertIn(expected_time_string, first_parent.text) + self.assertIn('continued', second.text) + self.assertIn(expected_time_string, second_parent.text) + + def _assert_not_wrapped(displayed, expected_time_string): + self.assertEqual(len(displayed), 1) + first = displayed[0] + first_parent = first.find_element_by_xpath('..') + self.assertNotIn('continued', first.text) + self.assertIn(expected_time_string, first_parent.text) + + duration = datetime.timedelta(minutes=120) # minutes + + # Session during a single day in meeting local time but multi-day UTC + # Compute a time that overlaps midnight, UTC, but won't when shifted to a local time zone + start_time_utc = timezone('UTC').localize( + datetime.datetime.combine(self.meeting.date, datetime.time(23,0)) + ) + start_time_local = start_time_utc.astimezone(timezone(self.meeting.time_zone)) + + daytime_session = SessionFactory( + meeting=self.meeting, + name='Single Day Session for Wrapping Test', + add_to_schedule=False, + ) + daytime_timeslot = TimeSlotFactory( + meeting=self.meeting, + time=start_time_local.replace(tzinfo=None), # drop timezone for Django + duration=duration, + ) + daytime_session.timeslotassignments.create(timeslot=daytime_timeslot, schedule=self.meeting.schedule) + + # Session that overlaps midnight in meeting local time + overnight_session = SessionFactory( + meeting=self.meeting, + name='Overnight Session for Wrapping Test', + add_to_schedule=False, + ) + overnight_timeslot = TimeSlotFactory( + meeting=self.meeting, + time=datetime.datetime.combine(self.meeting.date, datetime.time(23,0)), + duration=duration, + ) + overnight_session.timeslotassignments.create(timeslot=overnight_timeslot, schedule=self.meeting.schedule) + + # Check assumptions about events overlapping midnight + self.assertEqual(daytime_timeslot.local_start_time().day, + daytime_timeslot.local_end_time().day, + 'Daytime event should not overlap midnight in local time') + self.assertNotEqual(daytime_timeslot.utc_start_time().day, + daytime_timeslot.utc_end_time().day, + 'Daytime event should overlap midnight in UTC') + + self.assertNotEqual(overnight_timeslot.local_start_time().day, + overnight_timeslot.local_end_time().day, + 'Overnight event should overlap midnight in local time') + self.assertEqual(overnight_timeslot.utc_start_time().day, + overnight_timeslot.utc_end_time().day, + 'Overnight event should not overlap midnight in UTC') + + self.login() + + # Test in meeting local time + self.driver.get(self.absreverse('ietf.meeting.views.week_view')) + + time_string = '-'.join([daytime_timeslot.local_start_time().strftime('%H%M'), + daytime_timeslot.local_end_time().strftime('%H%M')]) + displayed = WebDriverWait(self.driver, 2).until( + expected_conditions.presence_of_all_elements_located( + (By.XPATH, + '//div/div[contains(text(), "%s")]/span[contains(text(), "%s")]' % ( + time_string, + daytime_session.name)) + ) + ) + _assert_not_wrapped(displayed, time_string) + + time_string = '-'.join([overnight_timeslot.local_start_time().strftime('%H%M'), + overnight_timeslot.local_end_time().strftime('%H%M')]) + displayed = WebDriverWait(self.driver, 2).until( + expected_conditions.presence_of_all_elements_located( + (By.XPATH, + '//div/div[contains(text(), "%s")]/span[contains(text(), "%s")]' % ( + time_string, + overnight_session.name)) + ) + ) + _assert_wrapped(displayed, time_string) + + # Test in utc time + self.driver.get(self.absreverse('ietf.meeting.views.week_view') + '?tz=utc') + + time_string = '-'.join([daytime_timeslot.utc_start_time().strftime('%H%M'), + daytime_timeslot.utc_end_time().strftime('%H%M')]) + displayed = WebDriverWait(self.driver, 2).until( + expected_conditions.presence_of_all_elements_located( + (By.XPATH, + '//div/div[contains(text(), "%s")]/span[contains(text(), "%s")]' % ( + time_string, + daytime_session.name)) + ) + ) + _assert_wrapped(displayed, time_string) + + time_string = '-'.join([overnight_timeslot.utc_start_time().strftime('%H%M'), + overnight_timeslot.utc_end_time().strftime('%H%M')]) + displayed = WebDriverWait(self.driver, 2).until( + expected_conditions.presence_of_all_elements_located( + (By.XPATH, + '//div/div[contains(text(), "%s")]/span[contains(text(), "%s")]' % ( + time_string, + overnight_session.name)) + ) + ) + _assert_not_wrapped(displayed, time_string) + @skipIf(skip_selenium, skip_message) class InterimTests(MeetingTestCase): def setUp(self): diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 9eae14f2e..ac5097ff3 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -360,11 +360,17 @@ class MeetingTests(TestCase): def test_agenda_week_view(self): meeting = make_meeting_test_data() - url = urlreverse("ietf.meeting.views.week_view",kwargs=dict(num=meeting.number)) + "#farfut" + url = urlreverse("ietf.meeting.views.week_view",kwargs=dict(num=meeting.number)) + "?show=farfut" r = self.client.get(url) self.assertEqual(r.status_code,200) self.assertTrue(all([x in unicontent(r) for x in ['var all_items', 'maximize', 'draw_calendar', ]])) + # Specifying a time zone should not change the output (time zones are handled by the JS) + url = urlreverse("ietf.meeting.views.week_view",kwargs=dict(num=meeting.number)) + "?show=farfut&tz=Asia/Bangkok" + r_with_tz = self.client.get(url) + self.assertEqual(r_with_tz.status_code,200) + self.assertEqual(r.content, r_with_tz.content) + @override_settings(MEETING_MATERIALS_SERVE_LOCALLY=False, MEETING_DOC_HREFS = settings.MEETING_DOC_CDN_HREFS) def test_materials_through_cdn(self): meeting = make_meeting_test_data(create_interims=True) @@ -686,9 +692,6 @@ class MeetingTests(TestCase): 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()), @@ -708,16 +711,6 @@ class MeetingTests(TestCase): '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) @@ -2107,16 +2100,6 @@ class InterimTests(TestCase): expected_event_count=2) - def test_upcoming_ical_filter_invalid_syntaxes(self): - make_meeting_test_data() - url = urlreverse('ietf.meeting.views.upcoming_ical') - - 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 test_upcoming_json(self): make_meeting_test_data(create_interims=True) url = urlreverse("ietf.meeting.views.upcoming_json") diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 9b67eb2f3..7f16ab788 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -100,7 +100,6 @@ from ietf.utils.pipe import pipe from ietf.utils.pdf import pdf_pages from ietf.utils.response import permission_denied from ietf.utils.text import xslugify -from ietf.utils.timezone import date2datetime from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm, InterimCancelForm, InterimSessionInlineFormSet, FileUploadForm, RequestMinutesForm,) @@ -1719,13 +1718,6 @@ def week_view(request, num=None, name=None, owner=None): schedule__in=[schedule, schedule.base], timeslot__type__private=False, ) - # Only show assignments from the traditional meeting "week" (Sat-Fri). - # We'll determine this using the saturday before the first scheduled regular session. - first_regular_session = meeting.schedule.qs_assignments_with_sessions.filter(session__type_id='regular').order_by('timeslot__time').first() - first_regular_session_time = first_regular_session.timeslot.time if first_regular_session else date2datetime(meeting.date) - saturday_before = first_regular_session_time.replace(hour=0, minute=0, second=0, microsecond=0) - datetime.timedelta(days=(first_regular_session_time.weekday() - 5)%7) -# saturday_after = saturday_before + datetime.timedelta(days=7) -# filtered_assignments = filtered_assignments.filter(timeslot__time__gte=saturday_before,timeslot__time__lt=saturday_after) filtered_assignments = preprocess_assignments_for_agenda(filtered_assignments, meeting) tag_assignments_with_filter_keywords(filtered_assignments) @@ -1734,16 +1726,8 @@ def week_view(request, num=None, name=None, owner=None): # we don't HTML escape any of these as the week-view code is using createTextNode item = { "key": str(a.timeslot.pk), - "day": (a.timeslot.time - saturday_before).days - 1, - "time": a.timeslot.time.strftime("%H%M") + "-" + a.timeslot.end_time().strftime("%H%M"), + "utc_time": a.timeslot.utc_start_time().strftime("%Y%m%dT%H%MZ"), # ISO8601 compliant "duration": a.timeslot.duration.seconds, - "time_id": a.timeslot.time.strftime("%m%d%H%M"), - "dayname": "{weekday}, {month} {day_of_month}, {year}".format( - weekday=a.timeslot.time.strftime("%A").upper(), - month=a.timeslot.time.strftime("%B"), - day_of_month=a.timeslot.time.strftime("%d").lstrip("0"), - year=a.timeslot.time.strftime("%Y"), - ), "type": a.timeslot.type.name, "filter_keywords": ",".join(a.filter_keywords), } @@ -1780,6 +1764,7 @@ def week_view(request, num=None, name=None, owner=None): return render(request, "meeting/week-view.html", { "items": json.dumps(items), + "timezone": meeting.time_zone, }) @role_required('Area Director','Secretariat','IAB') @@ -1866,20 +1851,14 @@ def parse_agenda_filter_params(querydict): if len(querydict) == 0: return None - # Parse group filters from GET parameters. The keys in this dict define the - # allowed querystring parameters. + # Parse group filters from GET parameters. Other params are ignored. 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(',') - vals = [v.strip() for v in vals] - filt_params[key] = set([v for v in vals if len(v) > 0]) # remove empty strings + if key in filt_params: + vals = unquote(value).lower().split(',') + vals = [v.strip() for v in vals] + filt_params[key] = set([v for v in vals if len(v) > 0]) # remove empty strings return filt_params diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 8b3b8a6f2..5d0097195 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -370,13 +370,21 @@ // Filtering is enabled weekview.removeClass("hidden"); - + var wv_iframe = document.getElementById('weekview'); var wv_window = wv_iframe.contentWindow; - var new_url = 'week-view.html' + window.location.search; + var queryparams = window.location.search; + {% if "-utc" in request.path %} + if (queryparams) { + queryparams += '&tz=utc'; + } else { + queryparams = '?tz=utc'; + } + {% endif %} + var new_url = 'week-view.html' + queryparams; if (wv_iframe.src && wv_window.history && wv_window.history.replaceState) { wv_window.history.replaceState({}, '', new_url); - wv_window.draw_calendar() + wv_window.redraw_weekview(); } else { // either have not yet loaded the iframe or we do not support history replacement wv_iframe.src = new_url; diff --git a/ietf/templates/meeting/week-view.html b/ietf/templates/meeting/week-view.html index 99c4d65d3..c91fa8eda 100644 --- a/ietf/templates/meeting/week-view.html +++ b/ietf/templates/meeting/week-view.html @@ -3,17 +3,12 @@ {% load static %} + +