Merged in [18712] from jennifer@painless-security.com:

Add timezone support to agenda weekview; display UTC on UTC agenda page. Fixes #3111.
 - Legacy-Id: 18796
Note: SVN reference [18712] has been migrated to Git commit d29553c0bc
This commit is contained in:
Robert Sparks 2021-01-15 19:59:56 +00:00
commit 159b8fe37c
9 changed files with 354 additions and 72 deletions

View file

@ -1,5 +1,7 @@
# -*- conf-mode -*-
/personal/kivinen/7.22.1.dev0@18689 # Hold for revision based on timezone-aware code
/personal/rcross/7.19.1.dev0@18663
/personal/rcross/7.19.1.dev0@18662

View file

@ -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,11 @@
"./fonts/*"
]
},
"moment": {
"main": [
"min/moment.min.js"
]
},
"tablesorter": {
"main": [
"dist/js/jquery.tablesorter.combined.min.js",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

@ -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)
@ -2328,16 +2321,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")

View file

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

View file

@ -373,10 +373,18 @@
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;

View file

@ -3,17 +3,12 @@
{% load static %}
<html> <head>
<script src="{% static 'ietf/js/agenda/agenda_filter.js' %}"></script>
<script src="{% static 'moment/min/moment.min.js' %}"></script>
<script src="{% static 'moment-timezone/builds/moment-timezone-with-data-10-year-range.min.js' %}"></script>
<script type="text/javascript">
var all_items = {{ items|safe }};
/* Also, process clock times to "minutes past midnight" */
all_items.forEach(function(item) {
item.start_time = parseInt(item.time.substr(0,2),10) * 60 +
parseInt(item.time.substr(2,2),10);
item.end_time = item.start_time + (item.duration / 60)
});
var color = {
'app': { fg: "#008", bg: "#eef"},
'art': { fg: "#808", bg: "#fef"},
@ -123,15 +118,11 @@
//===========================================================================
function draw_calendar() {
function draw_calendar(items, filter_params) {
var width = document.body.clientWidth;
var height = document.body.clientHeight;
var visible_items = all_items;
var filter_params = agenda_filter.get_filter_params(
agenda_filter.parse_query_params(window.location.search)
);
var visible_items = items;
if (agenda_filter.filtering_is_enabled(filter_params)) {
visible_items = visible_items.filter(is_visible(filter_params));
}
@ -143,8 +134,8 @@
day_start = visible_items[0].start_time;
} else {
// fallback in case all items were filtered
start_day = all_items[0].day;
day_start = all_items[0].start_time;
start_day = items[0].day;
day_start = items[0].start_time;
}
var end_day = start_day;
var day_end = 0;
@ -496,10 +487,124 @@
e.style.fontSize="8pt";
}
//===========================================================================
//
function get_first_item(items, type) {
var earliest;
for (var ii=0; ii < items.length; ii++) {
var this_item = items[ii];
if (type && (this_item.type !== type)) {
continue;
}
// Update earliest if we don't have an earliest item yet or this_item is earlier
if (!earliest || (items[ii].utc_time < earliest.utc_time)) {
earliest = items[ii];
}
}
return earliest;
}
//===========================================================================
//
function prepare_items(items, timezone_name) {
function make_display_item(item) {
return {
name: item.name,
group: item.group,
area: item.area,
room: item.room,
agenda: item.agenda,
key: item.key,
type: item.type,
filter_keywords: item.filter_keywords
}
};
/* Ported from Django view, which had the following comment:
* Only show assignments from the traditional meeting "week" (Sat-Fri).
* We'll determine this using the saturday before the first scheduled regular session. */
var first_session = get_first_item(items, 'Regular');
if (!first_session) {
first_session = get_first_item(items); // any type
}
var first_session_time = moment(first_session.utc_time).utc();
if (timezone_name) {
first_session_time.tz(timezone_name); // mutates the moment
}
// Moment.js day() uses 0 == Sunday, 6 == Saturday
days_since_saturday = first_session_time.day() - 6;
if (days_since_saturday < 0) {
days_since_saturday += 7;
}
saturday_before = first_session_time.clone().startOf('day').subtract(days_since_saturday, 'days');
var display_items = [];
for (var ii = 0; ii < items.length; ii++) {
var this_item = items[ii];
/* It's possible an event overlaps the moment of a daylight savings shift.
* Calculate the end_moment in utc() time, which has no DST. Once we switch
* to a time zone, end time minus start time may not equal duration. */
var start_moment = moment(this_item.utc_time).utc();
var end_moment = start_moment.clone().add(this_item.duration, 'seconds');
if (timezone_name) {
start_moment.tz(timezone_name);
end_moment.tz(timezone_name);
}
// Avoid off-by-one day number calculations if a session ends at midnight
var just_before_end_moment = end_moment.clone().subtract(1, 'millisecond');
var start_day = start_moment.diff(saturday_before, 'days') - 1; // shift so sunday = 0
var end_day = just_before_end_moment.diff(saturday_before, 'days') - 1; // shift so sunday = 0
// Generate display items - create multiple if item ends on different day than starts
for (var day = start_day; day <= end_day; day++) {
var display_item = make_display_item(this_item);
display_item.day = day;
if (day === start_day) {
// First day of session - compute start time
display_item.start_time = start_moment.diff(
start_moment.clone().startOf('day'),
'minutes'
);
} else {
// Not first day, start at midnight
display_item.start_time = 0;
display_item.name += " - continued";
}
if (day === end_day) {
// Last day of session - compute end time
display_item.end_time = end_moment.diff(
just_before_end_moment.clone().startOf('day'),
'minutes'
);
} else {
/* Not last day, use full day. Calculate this on the fly to account for
* daylight savings shifts, when a calendar day is not 24*60 minutes long. */
display_item.end_time = just_before_end_moment.clone().endOf('day').diff(
just_before_end_moment.clone().startOf('day'),
'minutes'
);
}
display_item.time = start_moment.format('HHmm') + '-' + end_moment.format('HHmm');
display_item.dayname = start_moment.format('dddd, ').toUpperCase() +
start_moment.format('MMMM D, Y');
display_items.push(display_item);
}
}
return display_items;
}
//===========================================================================
// Set up events for drawing the calendar
function redraw_weekview() {
draw_calendar();
var query_params = agenda_filter.parse_query_params(window.location.search);
var timezone_name = query_params.tz || '{{ timezone }}';
items = prepare_items(all_items, timezone_name);
draw_calendar(items, agenda_filter.get_filter_params(query_params));
}
window.addEventListener("resize", redraw_weekview, false);