From 2eacd88c0b22afda2e669a19488a31186145c7f8 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 9 Aug 2021 20:43:20 +0000 Subject: [PATCH] Disable modification of past timeslots on official schedules. Fixes #3166. - Legacy-Id: 19295 --- ietf/meeting/tests_js.py | 330 +++++++++++- ietf/meeting/tests_views.py | 472 +++++++++++++++++- ietf/meeting/views.py | 64 ++- ietf/settings.py | 3 + ietf/static/ietf/css/ietf.css | 5 + ietf/static/ietf/js/edit-meeting-schedule.js | 213 ++++++-- .../meeting/edit_meeting_schedule.html | 41 +- .../edit_meeting_schedule_session.html | 1 + 8 files changed, 1074 insertions(+), 55 deletions(-) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 254db7704..ac82a71bf 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -11,10 +11,11 @@ from unittest import skipIf import django from django.utils.text import slugify +from django.utils.timezone import now from django.db.models import F import pytz -#from django.test.utils import override_settings +from django.test.utils import override_settings import debug # pyflakes:ignore @@ -44,6 +45,7 @@ if selenium_enabled(): @ifSeleniumEnabled +@override_settings(MEETING_SESSION_LOCK_TIME=datetime.timedelta(minutes=10)) class EditMeetingScheduleTests(IetfSeleniumTestCase): def test_edit_meeting_schedule(self): meeting = make_meeting_test_data() @@ -283,6 +285,332 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot1b.pk, s1.pk)), 'Session s1 should have moved to second timeslot on first meeting day') + def test_past_flags(self): + """Test that timeslots and sessions in the past are marked accordingly + + Would also like to test that past-hint flags are applied when a session is dragged, but that + requires simulating HTML5 drag-and-drop. Have not yet found a good way to do this. + """ + wait = WebDriverWait(self.driver, 2) + meeting = MeetingFactory(type_id='ietf') + room = RoomFactory(meeting=meeting) + + # get current time in meeting time zone + right_now = now().astimezone( + pytz.timezone(meeting.time_zone) + ) + if not settings.USE_TZ: + right_now = right_now.replace(tzinfo=None) + + past_timeslots = [ + TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(hours=n), + duration=datetime.timedelta(hours=1), location=room) + for n in range(1,4) + ] + future_timeslots = [ + TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(hours=n), + duration=datetime.timedelta(hours=1), location=room) + for n in range(1,4) + ] + now_timeslots = [ + # timeslot just barely in the past (to avoid race conditions) but overlapping now + TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(seconds=1), + duration=datetime.timedelta(hours=1), location=room), + # next slot is < MEETING_SESSION_LOCK_TIME in the future + TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(minutes=9), + duration=datetime.timedelta(hours=1), location=room) + ] + + past_sessions = [ + SchedTimeSessAssignment.objects.create( + schedule=meeting.schedule, + timeslot=ts, + session=SessionFactory(meeting=meeting, add_to_schedule=False), + ).session + for ts in past_timeslots + ] + future_sessions = [ + SchedTimeSessAssignment.objects.create( + schedule=meeting.schedule, + timeslot=ts, + session=SessionFactory(meeting=meeting, add_to_schedule=False), + ).session + for ts in future_timeslots + ] + now_sessions = [ + SchedTimeSessAssignment.objects.create( + schedule=meeting.schedule, + timeslot=ts, + session=SessionFactory(meeting=meeting, add_to_schedule=False), + ).session + for ts in now_timeslots + ] + + url = self.absreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number)) + self.login(username=meeting.schedule.owner.user.username) + self.driver.get(url) + + past_flags = self.driver.find_elements_by_css_selector( + ','.join('#timeslot{} .past-flag'.format(ts.pk) for ts in past_timeslots) + ) + self.assertGreaterEqual(len(past_flags), len(past_timeslots) + len(past_sessions), + 'Expected at least one flag for each past timeslot and session') + + now_flags = self.driver.find_elements_by_css_selector( + ','.join('#timeslot{} .past-flag'.format(ts.pk) for ts in now_timeslots) + ) + self.assertGreaterEqual(len(now_flags), len(now_timeslots) + len(now_sessions), + 'Expected at least one flag for each "now" timeslot and session') + + future_flags = self.driver.find_elements_by_css_selector( + ','.join('#timeslot{} .past-flag'.format(ts.pk) for ts in future_timeslots) + ) + self.assertGreaterEqual(len(future_flags), len(future_timeslots) + len(future_sessions), + 'Expected at least one flag for each future timeslot and session') + + wait.until(expected_conditions.presence_of_element_located( + (By.CSS_SELECTOR, '#timeslot{}.past'.format(past_timeslots[0].pk)) + )) + for flag in past_flags: + self.assertTrue(flag.is_displayed(), 'Past timeslot or session not flagged as past') + + for flag in now_flags: + self.assertTrue(flag.is_displayed(), '"Now" timeslot or session not flagged as past') + + for flag in future_flags: + self.assertFalse(flag.is_displayed(), 'Future timeslot or session is flagged as past') + + def test_past_swap_days_buttons(self): + """Swap days buttons should be hidden for past items""" + wait = WebDriverWait(self.driver, 2) + meeting = MeetingFactory(type_id='ietf', date=datetime.datetime.today() - datetime.timedelta(days=3), days=7) + room = RoomFactory(meeting=meeting) + + # get current time in meeting time zone + right_now = now().astimezone( + pytz.timezone(meeting.time_zone) + ) + if not settings.USE_TZ: + right_now = right_now.replace(tzinfo=None) + + past_timeslots = [ + TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(days=n), + duration=datetime.timedelta(hours=1), location=room) + for n in range(4) # includes 0 + ] + future_timeslots = [ + TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(days=n), + duration=datetime.timedelta(hours=1), location=room) + for n in range(1,4) + ] + now_timeslots = [ + # timeslot just barely in the past (to avoid race conditions) but overlapping now + TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(seconds=1), + duration=datetime.timedelta(hours=1), location=room), + # next slot is < MEETING_SESSION_LOCK_TIME in the future + TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(minutes=9), + duration=datetime.timedelta(hours=1), location=room) + ] + + url = self.absreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number)) + self.login(username=meeting.schedule.owner.user.username) + self.driver.get(url) + + past_swap_days_buttons = self.driver.find_elements_by_css_selector( + ','.join( + '.swap-days[data-start="{}"]'.format(ts.time.date().isoformat()) for ts in past_timeslots + ) + ) + self.assertEqual(len(past_swap_days_buttons), len(past_timeslots), 'Missing past swap days buttons') + + future_swap_days_buttons = self.driver.find_elements_by_css_selector( + ','.join( + '.swap-days[data-start="{}"]'.format(ts.time.date().isoformat()) for ts in future_timeslots + ) + ) + self.assertEqual(len(future_swap_days_buttons), len(future_timeslots), 'Missing future swap days buttons') + + now_swap_days_buttons = self.driver.find_elements_by_css_selector( + ','.join( + '.swap-days[data-start="{}"]'.format(ts.time.date().isoformat()) for ts in now_timeslots + ) + ) + # only one "now" button because both sessions are on the same day + self.assertEqual(len(now_swap_days_buttons), 1, 'Missing "now" swap days button') + + wait.until( + expected_conditions.presence_of_element_located( + (By.CSS_SELECTOR, '.timeslot.past') # wait until timeslots are updated by JS + ) + ) + + # check that swap buttons are disabled for past days + self.assertFalse( + any(button.is_displayed() for button in past_swap_days_buttons), + 'Past swap days buttons still visible for official schedule', + ) + self.assertTrue( + all(button.is_displayed() for button in future_swap_days_buttons), + 'Future swap days buttons not visible for official schedule', + ) + self.assertFalse( + any(button.is_displayed() for button in now_swap_days_buttons), + '"Now" swap days buttons still visible for official schedule', + ) + + # Open the swap days modal to verify that past day radios are disabled. + # Use a middle day because whichever day we click will be disabled as an + # option to swap. If we used the first or last day, a fencepost error in + # disabling options by date might be hidden. + clicked_index = 1 + future_swap_days_buttons[clicked_index].click() + try: + modal = wait.until( + expected_conditions.visibility_of_element_located( + (By.CSS_SELECTOR, '#swap-days-modal') + ) + ) + except TimeoutException: + self.fail('Modal never appeared') + self.assertFalse( + any(radio.is_enabled() + for radio in modal.find_elements_by_css_selector(','.join( + 'input[name="target_day"][value="{}"]'.format(ts.time.date().isoformat()) for ts in past_timeslots) + )), + 'Past day is enabled in swap-days modal for official schedule', + ) + # future_timeslots[:-1] in the next selector because swapping a day with itself is disabled + enabled_timeslots = (ts for ts in future_timeslots if ts != future_timeslots[clicked_index]) + self.assertTrue( + all(radio.is_enabled() + for radio in modal.find_elements_by_css_selector(','.join( + 'input[name="target_day"][value="{}"]'.format(ts.time.date().isoformat()) for ts in enabled_timeslots) + )), + 'Future day is not enabled in swap-days modal for official schedule', + ) + self.assertFalse( + any(radio.is_enabled() + for radio in modal.find_elements_by_css_selector(','.join( + 'input[name="target_day"][value="{}"]'.format(ts.time.date().isoformat()) for ts in now_timeslots) + )), + '"Now" day is enabled in swap-days modal for official schedule', + ) + + def test_past_swap_timeslot_col_buttons(self): + """Swap timeslot column buttons should be hidden for past items""" + wait = WebDriverWait(self.driver, 2) + meeting = MeetingFactory(type_id='ietf', date=datetime.datetime.today() - datetime.timedelta(days=3), days=7) + room = RoomFactory(meeting=meeting) + + # get current time in meeting time zone + right_now = now().astimezone( + pytz.timezone(meeting.time_zone) + ) + if not settings.USE_TZ: + right_now = right_now.replace(tzinfo=None) + + past_timeslots = [ + TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(hours=n), + duration=datetime.timedelta(hours=1), location=room) + for n in range(1,4) # does not include 0 to avoid race conditions + ] + future_timeslots = [ + TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(hours=n), + duration=datetime.timedelta(hours=1), location=room) + for n in range(1,4) + ] + now_timeslots = [ + # timeslot just barely in the past (to avoid race conditions) but overlapping now + TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(seconds=1), + duration=datetime.timedelta(hours=1), location=room), + # next slot is < MEETING_SESSION_LOCK_TIME in the future + TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(minutes=9), + duration=datetime.timedelta(hours=1), location=room) + ] + + url = self.absreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number)) + self.login(username=meeting.schedule.owner.user.username) + self.driver.get(url) + + past_swap_ts_buttons = self.driver.find_elements_by_css_selector( + ','.join( + '.swap-timeslot-col[data-start="{}"]'.format(ts.utc_start_time().isoformat()) for ts in past_timeslots + ) + ) + self.assertEqual(len(past_swap_ts_buttons), len(past_timeslots), 'Missing past swap timeslot col buttons') + + future_swap_ts_buttons = self.driver.find_elements_by_css_selector( + ','.join( + '.swap-timeslot-col[data-start="{}"]'.format(ts.utc_start_time().isoformat()) for ts in future_timeslots + ) + ) + self.assertEqual(len(future_swap_ts_buttons), len(future_timeslots), 'Missing future swap timeslot col buttons') + + now_swap_ts_buttons = self.driver.find_elements_by_css_selector( + ','.join( + '.swap-timeslot-col[data-start="{}"]'.format(ts.utc_start_time().isoformat()) for ts in now_timeslots + ) + ) + self.assertEqual(len(now_swap_ts_buttons), len(now_timeslots), 'Missing "now" swap timeslot col buttons') + + wait.until( + expected_conditions.presence_of_element_located( + (By.CSS_SELECTOR, '.timeslot.past') # wait until timeslots are updated by JS + ) + ) + + # check that swap buttons are disabled for past days + self.assertFalse( + any(button.is_displayed() for button in past_swap_ts_buttons), + 'Past swap timeslot col buttons still visible for official schedule', + ) + self.assertTrue( + all(button.is_displayed() for button in future_swap_ts_buttons), + 'Future swap timeslot col buttons not visible for official schedule', + ) + self.assertFalse( + any(button.is_displayed() for button in now_swap_ts_buttons), + '"Now" swap timeslot col buttons still visible for official schedule', + ) + + # Open the swap days modal to verify that past day radios are disabled. + # Use a middle day because whichever day we click will be disabled as an + # option to swap. If we used the first or last day, a fencepost error in + # disabling options by date might be hidden. + clicked_index = 1 + future_swap_ts_buttons[clicked_index].click() + try: + modal = wait.until( + expected_conditions.visibility_of_element_located( + (By.CSS_SELECTOR, '#swap-timeslot-col-modal') + ) + ) + except TimeoutException: + self.fail('Modal never appeared') + self.assertFalse( + any(radio.is_enabled() + for radio in modal.find_elements_by_css_selector(','.join( + 'input[name="target_timeslot"][value="{}"]'.format(ts.pk) for ts in past_timeslots) + )), + 'Past timeslot is enabled in swap-timeslot-col modal for official schedule', + ) + # future_timeslots[:-1] in the next selector because swapping a timeslot with itself is disabled + enabled_timeslots = (ts for ts in future_timeslots if ts != future_timeslots[clicked_index]) + self.assertTrue( + all(radio.is_enabled() + for radio in modal.find_elements_by_css_selector(','.join( + 'input[name="target_timeslot"][value="{}"]'.format(ts.pk) for ts in enabled_timeslots) + )), + 'Future timeslot is not enabled in swap-timeslot-col modal for official schedule', + ) + self.assertFalse( + any(radio.is_enabled() + for radio in modal.find_elements_by_css_selector(','.join( + 'input[name="target_timeslot"][value="{}"]'.format(ts.pk) for ts in now_timeslots) + )), + '"Now" timeslot is enabled in swap-timeslot-col modal for official schedule', + ) + def test_unassigned_sessions_sort(self): """Unassigned session sorting should behave correctly diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index fb72eb5d4..4170f0967 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -9,6 +9,7 @@ import os import random import re import shutil +import pytz from unittest import skipIf from mock import patch @@ -25,6 +26,7 @@ from django.test import Client, override_settings from django.db.models import F from django.http import QueryDict from django.template import Context, Template +from django.utils.timezone import now import debug # pyflakes:ignore @@ -950,6 +952,7 @@ class MeetingTests(TestCase): self.assertFalse(q('ul li a:contains("%s")' % slide.title)) +@override_settings(MEETING_SESSION_LOCK_TIME=datetime.timedelta(minutes=10)) class EditMeetingScheduleTests(TestCase): """Tests of the meeting editor view @@ -1026,7 +1029,7 @@ class EditMeetingScheduleTests(TestCase): time_header_labels = rg_div.find('div.time-header div.time-label').text() timeslot_rows = rg_div.find('div.timeslots') for row in timeslot_rows.items(): - time_labels = row.find('div.time-label').text() + time_labels = row.find('div.time-label div:not(.past-flag)').text() self.assertEqual(time_labels, time_header_labels) def test_bof_session_tag(self): @@ -1148,6 +1151,102 @@ class EditMeetingScheduleTests(TestCase): "Sessions in other room group's timeslots should be unchanged" ) + def test_swap_timeslots_denies_past(self): + """Swapping past timeslots is not allowed for an official schedule""" + meeting, room_groups = self._setup_for_swap_timeslots() + # clone official schedule as an unofficial schedule + Schedule.objects.create( + name='unofficial', + owner=meeting.schedule.owner, + meeting=meeting, + base=meeting.schedule.base, + origin=meeting.schedule, + ) + + + official_url = urlreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number)) + unofficial_url = urlreverse('ietf.meeting.views.edit_meeting_schedule', + kwargs=dict(num=meeting.number, + owner=str(meeting.schedule.owner.email()), + name='unofficial')) + username = meeting.schedule.owner.user.username + self.client.login(username=username, password=username + '+password') + + # Swap group 0's first and last sessions, first in the past + right_now = self._right_now_in(meeting.time_zone) + for room in room_groups[0]: + ts = room.timeslot_set.last() + ts.time = right_now - datetime.timedelta(minutes=5) + ts.save() + # timeslot_set is ordered by -time, so check that we know which is past/future + self.assertTrue(room_groups[0][0].timeslot_set.last().time < right_now) + self.assertTrue(room_groups[0][0].timeslot_set.first().time > right_now) + post_data = dict( + action='swaptimeslots', + origin_timeslot=str(room_groups[0][0].timeslot_set.first().pk), + target_timeslot=str(room_groups[0][0].timeslot_set.last().pk), + rooms=','.join([str(room.pk) for room in room_groups[0]]), + ) + r = self.client.post(official_url, post_data) + self.assertContains(r, "Can't swap these timeslots.", status_code=400) + + # same request should succeed for an unofficial schedule + r = self.client.post(unofficial_url, post_data) + self.assertEqual(r.status_code, 302) + + # now with origin/target reversed + post_data = dict( + action='swaptimeslots', + origin_timeslot=str(room_groups[0][0].timeslot_set.last().pk), + target_timeslot=str(room_groups[0][0].timeslot_set.first().pk), + rooms=','.join([str(room.pk) for room in room_groups[0]]), + ) + r = self.client.post(official_url, post_data) + self.assertContains(r, "Can't swap these timeslots.", status_code=400) + + # same request should succeed for an unofficial schedule + r = self.client.post(unofficial_url, post_data) + self.assertEqual(r.status_code, 302) + + # now with the "past" timeslot less than MEETING_SESSION_LOCK_TIME in the future + for room in room_groups[0]: + ts = room.timeslot_set.last() + ts.time = right_now + datetime.timedelta(minutes=9) # must be < MEETING_SESSION_LOCK_TIME + ts.save() + self.assertTrue(room_groups[0][0].timeslot_set.last().time < right_now + settings.MEETING_SESSION_LOCK_TIME) + self.assertTrue(room_groups[0][0].timeslot_set.first().time > right_now + settings.MEETING_SESSION_LOCK_TIME) + post_data = dict( + action='swaptimeslots', + origin_timeslot=str(room_groups[0][0].timeslot_set.first().pk), + target_timeslot=str(room_groups[0][0].timeslot_set.last().pk), + rooms=','.join([str(room.pk) for room in room_groups[0]]), + ) + r = self.client.post(official_url, post_data) + self.assertContains(r, "Can't swap these timeslots.", status_code=400) + + # now with both in the past + for room in room_groups[0]: + ts = room.timeslot_set.last() + ts.time = right_now - datetime.timedelta(minutes=5) + ts.save() + ts = room.timeslot_set.first() + ts.time = right_now - datetime.timedelta(hours=1) + ts.save() + past_slots = room_groups[0][0].timeslot_set.filter(time__lt=right_now) + self.assertEqual(len(past_slots), 2, 'Need two timeslots in the past!') + post_data = dict( + action='swaptimeslots', + origin_timeslot=str(past_slots[0].pk), + target_timeslot=str(past_slots[1].pk), + rooms=','.join([str(room.pk) for room in room_groups[0]]), + ) + r = self.client.post(official_url, post_data) + self.assertContains(r, "Can't swap these timeslots.", status_code=400) + + # same request should succeed for an unofficial schedule + r = self.client.post(unofficial_url, post_data) + self.assertEqual(r.status_code, 302) + def test_swap_timeslots_handles_unmatched(self): """Sessions in unmatched timeslots should be unassigned when swapped @@ -1234,6 +1333,377 @@ class EditMeetingScheduleTests(TestCase): "Sessions in other room group's timeslots should be unchanged" ) + def test_swap_days_denies_past(self): + """Swapping past days is not allowed for an official schedule""" + meeting, room_groups = self._setup_for_swap_timeslots() + # clone official schedule as an unofficial schedule + Schedule.objects.create( + name='unofficial', + owner=meeting.schedule.owner, + meeting=meeting, + base=meeting.schedule.base, + origin=meeting.schedule, + ) + + + official_url = urlreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number)) + unofficial_url = urlreverse('ietf.meeting.views.edit_meeting_schedule', + kwargs=dict(num=meeting.number, + owner=str(meeting.schedule.owner.email()), + name='unofficial')) + username = meeting.schedule.owner.user.username + self.client.login(username=username, password=username + '+password') + + # Swap group 0's first and last sessions, first in the past + right_now = self._right_now_in(meeting.time_zone) + yesterday = (right_now - datetime.timedelta(days=1)).date() + day_before = (right_now - datetime.timedelta(days=2)).date() + for room in room_groups[0]: + ts = room.timeslot_set.last() + ts.time = datetime.datetime.combine(yesterday, ts.time.time()) + ts.save() + # timeslot_set is ordered by -time, so check that we know which is past/future + self.assertTrue(room_groups[0][0].timeslot_set.last().time < right_now) + self.assertTrue(room_groups[0][0].timeslot_set.first().time > right_now) + post_data = dict( + action='swapdays', + source_day=yesterday.isoformat(), + target_day=room_groups[0][0].timeslot_set.first().time.date().isoformat(), + ) + r = self.client.post(official_url, post_data) + self.assertContains(r, "Can't swap these days.", status_code=400) + + # same request should succeed for an unofficial schedule + r = self.client.post(unofficial_url, post_data) + self.assertEqual(r.status_code, 302) + + # now with origin/target reversed + post_data = dict( + action='swapdays', + source_day=room_groups[0][0].timeslot_set.first().time.date().isoformat(), + target_day=yesterday.isoformat(), + rooms=','.join([str(room.pk) for room in room_groups[0]]), + ) + r = self.client.post(official_url, post_data) + self.assertContains(r, "Can't swap these days.", status_code=400) + + # same request should succeed for an unofficial schedule + r = self.client.post(unofficial_url, post_data) + self.assertEqual(r.status_code, 302) + + # now with both in the past + for room in room_groups[0]: + ts = room.timeslot_set.first() + ts.time = datetime.datetime.combine(day_before, ts.time.time()) + ts.save() + past_slots = room_groups[0][0].timeslot_set.filter(time__lt=right_now) + self.assertEqual(len(past_slots), 2, 'Need two timeslots in the past!') + post_data = dict( + action='swapdays', + source_day=yesterday.isoformat(), + target_day=day_before.isoformat(), + ) + r = self.client.post(official_url, post_data) + self.assertContains(r, "Can't swap these days.", status_code=400) + + # same request should succeed for an unofficial schedule + r = self.client.post(unofficial_url, post_data) + self.assertEqual(r.status_code, 302) + + def _decode_json_response(self, r): + try: + return json.loads(r.content.decode()) + except json.JSONDecodeError as err: + self.fail('Response was not valid JSON: {}'.format(err)) + + @staticmethod + def _right_now_in(tzname): + right_now = now().astimezone(pytz.timezone(tzname)) + if not settings.USE_TZ: + right_now = right_now.replace(tzinfo=None) + return right_now + + def test_assign_session(self): + """Allow assignment to future timeslots only for official schedule""" + meeting = MeetingFactory( + type_id='ietf', + date=(datetime.datetime.today() - datetime.timedelta(days=1)).date(), + days=3, + ) + right_now = self._right_now_in(meeting.time_zone) + + schedules = dict( + official=meeting.schedule, + unofficial=ScheduleFactory(meeting=meeting, owner=meeting.schedule.owner), + ) + + timeslots = dict( + past=TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(hours=1)), + future=TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(hours=1)), + ) + + url_for = lambda sched: urlreverse( + 'ietf.meeting.views.edit_meeting_schedule', + kwargs=dict( + num=meeting.number, + owner=str(sched.owner.email()), + name=sched.name, + ) + ) + + post_data = lambda ts: dict( + action='assign', + session=str(SessionFactory(meeting=meeting, add_to_schedule=False).pk), + timeslot=str(ts.pk), + ) + + username = meeting.schedule.owner.user.username + self.assertTrue(self.client.login(username=username, password=username + '+password')) + + # past timeslot, official schedule: reject + r = self.client.post(url_for(schedules['official']), post_data(timeslots['past'])) + self.assertEqual(r.status_code, 400) + self.assertEqual( + self._decode_json_response(r), + dict(success=False, error="Can't assign to this timeslot."), + ) + + # past timeslot, unofficial schedule: allow + r = self.client.post(url_for(schedules['unofficial']), post_data(timeslots['past'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + + # future timeslot, official schedule: allow + r = self.client.post(url_for(schedules['official']), post_data(timeslots['future'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + + # future timeslot, unofficial schedule: allow + r = self.client.post(url_for(schedules['unofficial']), post_data(timeslots['future'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + + def test_reassign_session(self): + """Do not allow assignment of past sessions for official schedule""" + meeting = MeetingFactory( + type_id='ietf', + date=(datetime.datetime.today() - datetime.timedelta(days=1)).date(), + days=3, + ) + right_now = self._right_now_in(meeting.time_zone) + + schedules = dict( + official=meeting.schedule, + unofficial=ScheduleFactory(meeting=meeting, owner=meeting.schedule.owner), + ) + + timeslots = dict( + past=TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(hours=1)), + other_past=TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(hours=2)), + barely_future=TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(minutes=9)), + future=TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(hours=1)), + other_future=TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(hours=2)), + ) + + self.assertLess( + timeslots['barely_future'].time - right_now, + settings.MEETING_SESSION_LOCK_TIME, + '"barely_future" timeslot is too far in the future. Check MEETING_SESSION_LOCK_TIME settings', + ) + + url_for = lambda sched: urlreverse( + 'ietf.meeting.views.edit_meeting_schedule', + kwargs=dict( + num=meeting.number, + owner=str(sched.owner.email()), + name=sched.name, + ) + ) + + def _new_session_in(timeslot, schedule): + return SchedTimeSessAssignment.objects.create( + schedule=schedule, + session=SessionFactory(meeting=meeting, add_to_schedule=False), + timeslot=timeslot, + ).session + + post_data = lambda session, new_ts: dict( + action='assign', + session=str(session.pk), + timeslot=str(new_ts.pk), + ) + + username = meeting.schedule.owner.user.username + self.assertTrue(self.client.login(username=username, password=username + '+password')) + + # past session to past timeslot, official: not allowed + session = _new_session_in(timeslots['past'], schedules['official']) + r = self.client.post(url_for(schedules['official']), post_data(session, timeslots['other_past'])) + self.assertEqual(r.status_code, 400) + self.assertEqual( + self._decode_json_response(r), + dict(success=False, error="Can't assign to this timeslot."), + ) + session.delete() # takes the SchedTimeSessAssignment with it + + # past session to future timeslot, official: not allowed + session = _new_session_in(timeslots['past'], schedules['official']) + r = self.client.post(url_for(schedules['official']), post_data(session, timeslots['future'])) + self.assertEqual(r.status_code, 400) + self.assertEqual( + self._decode_json_response(r), + dict(success=False, error="Can't reassign this session."), + ) + session.delete() # takes the SchedTimeSessAssignment with it + + # future session to past, timeslot, official: not allowed + session = _new_session_in(timeslots['future'], schedules['official']) + r = self.client.post(url_for(schedules['official']), post_data(session, timeslots['past'])) + self.assertEqual(r.status_code, 400) + self.assertEqual( + self._decode_json_response(r), + dict(success=False, error="Can't assign to this timeslot."), + ) + session.delete() # takes the SchedTimeSessAssignment with it + + # future session to future timeslot, unofficial: allowed + session = _new_session_in(timeslots['future'], schedules['unofficial']) + r = self.client.post(url_for(schedules['unofficial']), post_data(session, timeslots['other_future'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + session.delete() # takes the SchedTimeSessAssignment with it + + # future session to barely future timeslot, official: not allowed + session = _new_session_in(timeslots['future'], schedules['official']) + r = self.client.post(url_for(schedules['official']), post_data(session, timeslots['barely_future'])) + self.assertEqual(r.status_code, 400) + self.assertEqual( + self._decode_json_response(r), + dict(success=False, error="Can't assign to this timeslot."), + ) + session.delete() # takes the SchedTimeSessAssignment with it + + # future session to future timeslot, unofficial: allowed + session = _new_session_in(timeslots['future'], schedules['unofficial']) + r = self.client.post(url_for(schedules['unofficial']), post_data(session, timeslots['barely_future'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + session.delete() # takes the SchedTimeSessAssignment with it + + # past session to past timeslot, unofficial: allowed + session = _new_session_in(timeslots['past'], schedules['unofficial']) + r = self.client.post(url_for(schedules['unofficial']), post_data(session, timeslots['other_past'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + session.delete() # takes the SchedTimeSessAssignment with it + + # past session to future timeslot, unofficial: allowed + session = _new_session_in(timeslots['past'], schedules['unofficial']) + r = self.client.post(url_for(schedules['unofficial']), post_data(session, timeslots['future'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + session.delete() # takes the SchedTimeSessAssignment with it + + # future session to past timeslot, unofficial: allowed + session = _new_session_in(timeslots['future'], schedules['unofficial']) + r = self.client.post(url_for(schedules['unofficial']), post_data(session, timeslots['past'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + session.delete() # takes the SchedTimeSessAssignment with it + + # future session to future timeslot, unofficial: allowed + session = _new_session_in(timeslots['future'], schedules['unofficial']) + r = self.client.post(url_for(schedules['unofficial']), post_data(session, timeslots['other_future'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + session.delete() # takes the SchedTimeSessAssignment with it + + def test_unassign_session(self): + """Allow unassignment only of future timeslots for official schedule""" + meeting = MeetingFactory( + type_id='ietf', + date=(datetime.datetime.today() - datetime.timedelta(days=1)).date(), + days=3, + ) + right_now = self._right_now_in(meeting.time_zone) + + schedules = dict( + official=meeting.schedule, + unofficial=ScheduleFactory(meeting=meeting, owner=meeting.schedule.owner), + ) + + timeslots = dict( + past=TimeSlotFactory(meeting=meeting, time=right_now - datetime.timedelta(hours=1)), + future=TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(hours=1)), + barely_future=TimeSlotFactory(meeting=meeting, time=right_now + datetime.timedelta(minutes=9)), + ) + + self.assertLess( + timeslots['barely_future'].time - right_now, + settings.MEETING_SESSION_LOCK_TIME, + '"barely_future" timeslot is too far in the future. Check MEETING_SESSION_LOCK_TIME settings', + ) + + url_for = lambda sched: urlreverse( + 'ietf.meeting.views.edit_meeting_schedule', + kwargs=dict( + num=meeting.number, + owner=str(sched.owner.email()), + name=sched.name, + ) + ) + + post_data = lambda ts, sched: dict( + action='unassign', + session=str( + SchedTimeSessAssignment.objects.create( + schedule=sched, + timeslot=ts, + session=SessionFactory(meeting=meeting, add_to_schedule=False), + ).session.pk + ), + ) + + username = meeting.schedule.owner.user.username + self.assertTrue(self.client.login(username=username, password=username + '+password')) + + # past session, official schedule: reject + r = self.client.post(url_for(schedules['official']), post_data(timeslots['past'], schedules['official'])) + self.assertEqual(r.status_code, 400) + self.assertEqual( + self._decode_json_response(r), + dict(success=False, error="Can't unassign this session."), + ) + + # past timeslot, unofficial schedule: allow + r = self.client.post(url_for(schedules['unofficial']), post_data(timeslots['past'], schedules['unofficial'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + + # barely future session, official schedule: reject + r = self.client.post(url_for(schedules['official']), post_data(timeslots['barely_future'], schedules['official'])) + self.assertEqual(r.status_code, 400) + self.assertEqual( + self._decode_json_response(r), + dict(success=False, error="Can't unassign this session."), + ) + + # barely future timeslot, unofficial schedule: allow + r = self.client.post(url_for(schedules['unofficial']), post_data(timeslots['barely_future'], schedules['unofficial'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + + # future timeslot, official schedule: allow + r = self.client.post(url_for(schedules['official']), post_data(timeslots['future'], schedules['official'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + + # future timeslot, unofficial schedule: allow + r = self.client.post(url_for(schedules['unofficial']), post_data(timeslots['future'], schedules['unofficial'])) + self.assertEqual(r.status_code, 200) + self.assertTrue(self._decode_json_response(r)['success']) + + class ReorderSlidesTests(TestCase): diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 631e3eac9..d1d69d136 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -40,8 +40,9 @@ from django.template.loader import render_to_string from django.utils.encoding import force_str from django.utils.functional import curry from django.utils.text import slugify -from django.views.decorators.cache import cache_page from django.utils.html import format_html +from django.utils.timezone import now +from django.views.decorators.cache import cache_page from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt from django.views.generic import RedirectView @@ -468,6 +469,13 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): can_see, can_edit, secretariat = schedule_permissions(meeting, schedule, request.user) + lock_time = settings.MEETING_SESSION_LOCK_TIME + def timeslot_locked(ts): + meeting_now = now().astimezone(pytz.timezone(meeting.time_zone)) + if not settings.USE_TZ: + meeting_now = meeting_now.replace(tzinfo=None) + return schedule.is_official and (ts.time - meeting_now < lock_time) + if not can_see: if request.method == 'POST': permission_denied(request, "Can't view this schedule.") @@ -713,21 +721,39 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): return days + def _json_response(success, status=None, **extra_data): + if status is None: + status = 200 if success else 400 + data = dict(success=success, **extra_data) + return JsonResponse(data, status=status) + if request.method == 'POST': if not can_edit: permission_denied(request, "Can't edit this schedule.") action = request.POST.get('action') - # handle ajax requests + # Handle ajax requests. Most of these return JSON responses with at least a 'success' key. + # For the swapdays and swaptimeslots actions, the response is either a redirect to the + # updated page or a simple BadRequest error page. The latter should not normally be seen + # by the user, because the front end should be preventing most invalid requests. if action == 'assign' and request.POST.get('session', '').isdigit() and request.POST.get('timeslot', '').isdigit(): session = get_object_or_404(sessions, pk=request.POST['session']) timeslot = get_object_or_404(timeslots_qs, pk=request.POST['timeslot']) + if timeslot_locked(timeslot): + return _json_response(False, error="Can't assign to this timeslot.") tombstone_session = None existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule) + if existing_assignments: + assertion('len(existing_assignments) <= 1', + note='Multiple assignments for {} in schedule {}'.format(session, schedule)) + + if timeslot_locked(existing_assignments[0].timeslot): + return _json_response(False, error="Can't reassign this session.") + if schedule.pk == meeting.schedule_id and session.current_status == 'sched': old_timeslot = existing_assignments[0].timeslot # clone session and leave it as a tombstone @@ -760,17 +786,27 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): timeslot=timeslot, ) - r = {'success': True} if tombstone_session: prepare_sessions_for_display([tombstone_session]) - r['tombstone'] = render_to_string("meeting/edit_meeting_schedule_session.html", {'session': tombstone_session}) - return JsonResponse(r) + return _json_response( + True, + tombstone=render_to_string("meeting/edit_meeting_schedule_session.html", + {'session': tombstone_session}) + ) + else: + return _json_response(True) elif action == 'unassign' and request.POST.get('session', '').isdigit(): session = get_object_or_404(sessions, pk=request.POST['session']) - SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule).delete() + existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule) + assertion('len(existing_assignments) <= 1', + note='Multiple assignments for {} in schedule {}'.format(session, schedule)) + if not any(timeslot_locked(ea.timeslot) for ea in existing_assignments): + existing_assignments.delete() + else: + return _json_response(False, error="Can't unassign this session.") - return JsonResponse({'success': True}) + return _json_response(True) elif action == 'swapdays': # updating the client side is a bit complicated, so just @@ -778,13 +814,16 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): swap_days_form = SwapDaysForm(request.POST) if not swap_days_form.is_valid(): - return HttpResponse("Invalid swap: {}".format(swap_days_form.errors), status=400) + return HttpResponseBadRequest("Invalid swap: {}".format(swap_days_form.errors)) source_day = swap_days_form.cleaned_data['source_day'] target_day = swap_days_form.cleaned_data['target_day'] source_timeslots = [ts for ts in timeslots_qs if ts.time.date() == source_day] target_timeslots = [ts for ts in timeslots_qs if ts.time.date() == target_day] + if any(timeslot_locked(ts) for ts in source_timeslots + target_timeslots): + return HttpResponseBadRequest("Can't swap these days.") + swap_meeting_schedule_timeslot_assignments(schedule, source_timeslots, target_timeslots, target_day - source_day) return HttpResponseRedirect(request.get_full_path()) @@ -796,7 +835,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): # The origin/target timeslots do not need to be the same duration. swap_timeslots_form = SwapTimeslotsForm(meeting, request.POST) if not swap_timeslots_form.is_valid(): - return HttpResponse("Invalid swap: {}".format(swap_timeslots_form.errors), status=400) + return HttpResponseBadRequest("Invalid swap: {}".format(swap_timeslots_form.errors)) affected_rooms = swap_timeslots_form.cleaned_data['rooms'] origin_timeslot = swap_timeslots_form.cleaned_data['origin_timeslot'] @@ -812,6 +851,10 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): time=target_timeslot.time, duration=target_timeslot.duration, ) + if (any(timeslot_locked(ts) for ts in origin_timeslots) + or any(timeslot_locked(ts) for ts in target_timeslots)): + return HttpResponseBadRequest("Can't swap these timeslots.") + swap_meeting_schedule_timeslot_assignments( schedule, list(origin_timeslots), @@ -820,7 +863,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): ) return HttpResponseRedirect(request.get_full_path()) - return HttpResponse("Invalid parameters", status=400) + return _json_response(False, error="Invalid parameters") # Show only rooms that have regular sessions rooms = meeting.room_set.filter(session_types__slug='regular') @@ -904,6 +947,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): 'unassigned_sessions': unassigned_sessions, 'session_parents': session_parents, 'hide_menu': True, + 'lock_time': lock_time, }) diff --git a/ietf/settings.py b/ietf/settings.py index 0727768aa..1e058e61b 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -953,6 +953,9 @@ FLOORPLAN_DIR = os.path.join(MEDIA_ROOT, FLOORPLAN_MEDIA_DIR) MEETING_USES_CODIMD_DATE = datetime.date(2020,7,6) +# Session assignments on the official schedule lock this long before the timeslot starts +MEETING_SESSION_LOCK_TIME = datetime.timedelta(minutes=10) + # === OpenID Connect Provide Related Settings ================================== # Used by django-oidc-provider diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index ce571c539..1e611d2be 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1112,6 +1112,7 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { .edit-meeting-schedule .edit-grid .timeslot .time-label { display: flex; + flex-direction: column; position: absolute; height: 100%; width: 100%; @@ -1141,6 +1142,10 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { outline: #ffe0e0 solid 0.4em; } +.edit-meeting-schedule .edit-grid .timeslot.would-violate-hint.dropping { + background-color: #ccb3b3; +} + .edit-meeting-schedule .constraints .encircled, .edit-meeting-schedule .formatted-constraints .encircled { border: 1px solid #000; diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index a6f36ee6a..40d90a9cb 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -1,5 +1,14 @@ +/* globals alert, jQuery, moment */ jQuery(document).ready(function () { let content = jQuery(".edit-meeting-schedule"); + /* Drag data stored via the drag event dataTransfer interface is only accessible on + * dragstart and dragend events. Other drag events can see only the MIME types that have + * data. Use a non-registered type to identify our session drags. Unregistered MIME + * types are strongly discouraged by RFC6838, but we are not actually attempting to + * exchange data with anything outside this script so that really does not apply. */ + const dnd_mime_type = 'text/x.session-drag'; + const meetingTimeZone = content.data('timezone'); + const lockSeconds = Number(content.data('lock-seconds') || 0); function reportServerError(xhr, textStatus, error) { let errorText = error || textStatus; @@ -8,10 +17,21 @@ jQuery(document).ready(function () { alert("Error: " + errorText); } + /** + * Time to treat as current time for computing whether to lock timeslots + * @returns {*} Moment object equal to lockSeconds in the future + */ + function effectiveNow() { + return moment().add(lockSeconds, 'seconds'); + } + let sessions = content.find(".session").not(".readonly"); let timeslots = content.find(".timeslot"); let timeslotLabels = content.find(".time-label"); + let swapDaysButtons = content.find('.swap-days'); + let swapTimeslotButtons = content.find('.swap-timeslot-col'); let days = content.find(".day-flow .day"); + let officialSchedule = content.hasClass('official-schedule'); // hack to work around lack of position sticky support in old browsers, see https://caniuse.com/#feat=css-sticky if (content.find(".scheduling-panel").css("position") != "sticky") { @@ -19,13 +39,28 @@ jQuery(document).ready(function () { content.css("padding-bottom", "14em"); } + /** + * Parse a timestamp using meeting-local time zone if the timestamp does not specify one. + */ + function parseISOTimestamp(s) { + return moment.tz(s, moment.ISO_8601, meetingTimeZone); + } + + function startMoment(timeslot) { + return parseISOTimestamp(timeslot.data('start')); + } + + function endMoment(timeslot) { + return parseISOTimestamp(timeslot.data('end')); + } + function findTimeslotsOverlapping(intervals) { let res = []; timeslots.each(function () { var timeslot = jQuery(this); - let start = timeslot.data("start"); - let end = timeslot.data("end"); + let start = startMoment(timeslot); + let end = endMoment(timeslot); for (let i = 0; i < intervals.length; ++i) { if (end >= intervals[i][0] && intervals[i][1] >= start) { @@ -128,7 +163,7 @@ jQuery(document).ready(function () { if (sessionId) { let sessionIds = this.dataset.sessions; if (!sessionIds) { - applyChange = False; + applyChange = false; } else { wouldViolate = sessionIds.split(",").indexOf(sessionId) !== -1; } @@ -144,7 +179,9 @@ jQuery(document).ready(function () { if (selectedSession) { let intervals = []; timeslots.filter(":has(.session .constraints > span.would-violate-hint)").each(function () { - intervals.push([this.dataset.start, this.dataset.end]); + intervals.push( + [parseISOTimestamp(this.dataset.start), parseISOTimestamp(this.dataset.end)] + ); }); let overlappingTimeslots = findTimeslotsOverlapping(intervals); @@ -164,6 +201,61 @@ jQuery(document).ready(function () { } } + /** + * Should this timeslot be treated as a future timeslot? + * + * @param timeslot timeslot to test + * @param now (optional) threshold time (defaults to effectiveNow()) + * @returns Boolean true if the timeslot is in the future + */ + function isFutureTimeslot(timeslot, now) { + // resist the temptation to use native JS Date parsing, it is hopelessly broken + const timeslot_time = startMoment(timeslot); + return timeslot_time.isAfter(now || effectiveNow()); + } + + function hidePastTimeslotHints() { + timeslots.removeClass('past-hint'); + } + + function showPastTimeslotHints() { + timeslots.filter('.past').addClass('past-hint'); + } + + function updatePastTimeslots() { + const now = effectiveNow(); + + // mark timeslots + timeslots.filter( + ':not(.past)' + ).filter( + (_, ts) => !isFutureTimeslot(jQuery(ts), now) + ).addClass('past'); + + // hide swap day/timeslot column buttons + if (officialSchedule) { + swapDaysButtons.filter( + (_, elt) => parseISOTimestamp(elt.dataset.start).isSameOrBefore(now, 'day') + ).hide(); + swapTimeslotButtons.filter( + (_, elt) => parseISOTimestamp(elt.dataset.start).isSameOrBefore(now, 'minute') + ).hide(); + } + } + + function canEditSession(session) { + if (!officialSchedule) { + return true; + } + + const timeslot = jQuery(session).closest('div.timeslot'); + if (timeslot.length === 0) { + return true; + } + + return isFutureTimeslot(timeslot); + } + content.on("click", function (event) { if (jQuery(event.target).is(".session-info-container") || jQuery(event.target).closest(".session-info-container").length > 0) return; @@ -178,17 +270,41 @@ jQuery(document).ready(function () { } }); + // Was this drag started by dragging a session? + function isSessionDragEvent(event) { + return Boolean(event.originalEvent.dataTransfer.getData(dnd_mime_type)); + } + + /** + * Can a session be dropped in this element? + * + * Drop is allowed in drop-zones that are in unassigned-session or timeslot containers + * not marked as 'past'. + */ + function sessionDropAllowed(elt) { + if (!officialSchedule) { + return true; + } + + const relevant_parent = elt.closest('.timeslot, .unassigned-sessions'); + return relevant_parent && !(relevant_parent.classList.contains('past')); + } if (!content.find(".edit-grid").hasClass("read-only")) { // dragging sessions.on("dragstart", function (event) { - event.originalEvent.dataTransfer.setData("text/plain", this.id); - jQuery(this).addClass("dragging"); - - selectSessionElement(this); + if (canEditSession(this)) { + event.originalEvent.dataTransfer.setData(dnd_mime_type, this.id); + jQuery(this).addClass("dragging"); + selectSessionElement(this); + showPastTimeslotHints(); + } else { + event.preventDefault(); // do not start the drag + } }); sessions.on("dragend", function () { jQuery(this).removeClass("dragging"); + hidePastTimeslotHints(); }); sessions.prop('draggable', true); @@ -196,43 +312,53 @@ jQuery(document).ready(function () { // dropping let dropElements = content.find(".timeslot .drop-target,.unassigned-sessions .drop-target"); dropElements.on('dragenter', function (event) { - if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") - return; - - event.preventDefault(); // default action is signalling that this is not a valid target - jQuery(this).parent().addClass("dropping"); + if (sessionDropAllowed(this)) { + event.preventDefault(); // default action is signalling that this is not a valid target + jQuery(this).parent().addClass("dropping"); + } }); dropElements.on('dragover', function (event) { // we don't actually need this event, except we need to signal // that this is a valid drop target, by cancelling the default // action - event.preventDefault(); + if (sessionDropAllowed(this)) { + event.preventDefault(); + } }); dropElements.on('dragleave', function (event) { // skip dragleave events if they are to children - if (event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget)) - return; - - jQuery(this).parent().removeClass("dropping"); + const leaving_child = event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget); + if (!leaving_child && sessionDropAllowed(this)) { + jQuery(this).parent().removeClass('dropping'); + } }); dropElements.on('drop', function (event) { let dropElement = jQuery(this); - let sessionId = event.originalEvent.dataTransfer.getData("text/plain"); - if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") { + if (!isSessionDragEvent(event)) { + // event is result of something other than a session drag dropElement.parent().removeClass("dropping"); return; } + const sessionId = event.originalEvent.dataTransfer.getData(dnd_mime_type); let sessionElement = sessions.filter("#" + sessionId); - if (sessionElement.length == 0) { + if (sessionElement.length === 0) { + // drag event is not from a session we recognize dropElement.parent().removeClass("dropping"); return; } + // We now know this is a drop of a recognized session + + if (!sessionDropAllowed(this)) { + dropElement.parent().removeClass("dropping"); // just in case + return; // drop not allowed + } + event.preventDefault(); // prevent opening as link let dragParent = sessionElement.parent(); @@ -301,16 +427,26 @@ jQuery(document).ready(function () { }; // Disable a particular swap modal radio input - let updateSwapRadios = function (labels, radios, disableValue) { - labels.removeClass('text-muted'); - radios.prop('disabled', false); - radios.prop('checked', false); - let disableInput = radios.filter('[value="' + disableValue + '"]'); - if (disableInput) { - disableInput.parent().addClass('text-muted'); - disableInput.prop('disabled', true); - } - return disableInput; // return the input that was disabled, if any + let updateSwapRadios = function (labels, radios, disableValue, datePrecision) { + labels.removeClass('text-muted'); + radios.prop('disabled', false); + radios.prop('checked', false); + // disable the input requested by value + let disableInput = radios.filter('[value="' + disableValue + '"]'); + if (disableInput) { + disableInput.parent().addClass('text-muted'); + disableInput.prop('disabled', true); + } + if (officialSchedule) { + // disable any that have passed + const now=effectiveNow(); + const past_radios = radios.filter( + (_, radio) => parseISOTimestamp(radio.dataset.start).isSameOrBefore(now, datePrecision) + ); + past_radios.parent().addClass('text-muted'); + past_radios.prop('disabled', true); + } + return disableInput; // return the input that was specifically disabled, if any }; // swap days @@ -323,7 +459,7 @@ jQuery(document).ready(function () { // handler to prep and open the modal content.find(".swap-days").on("click", function () { let originDay = this.dataset.dayid; - let originRadio = updateSwapRadios(swapDaysLabels, swapDaysRadios, originDay); + let originRadio = updateSwapRadios(swapDaysLabels, swapDaysRadios, originDay, 'day'); // Fill in label in the modal title swapDaysModal.find(".modal-title .day").text(jQuery.trim(originRadio.parent().text())); @@ -346,7 +482,7 @@ jQuery(document).ready(function () { // handler to prep and open the modal content.find('.swap-timeslot-col').on('click', function() { let roomGroup = this.closest('.room-group').dataset; - updateSwapRadios(swapTimeslotsLabels, swapTimeslotsRadios, this.dataset.timeslotPk) + updateSwapRadios(swapTimeslotsLabels, swapTimeslotsRadios, this.dataset.timeslotPk, 'minute'); // show only options for this room group swapTimeslotsModal.find('.room-group').hide(); @@ -374,14 +510,15 @@ jQuery(document).ready(function () { sessions.each(function () { let timeslot = jQuery(this).closest(".timeslot"); - if (timeslot.length == 1) + if (timeslot.length === 1) { scheduledSessions.push({ - start: timeslot.data("start"), - end: timeslot.data("end"), - id: this.id.slice("session".length), + start: startMoment(timeslot), + end: endMoment(timeslot), + id: this.id.slice('session'.length), element: jQuery(this), timeslot: timeslot.get(0) }); + } }); scheduledSessions.sort(function (a, b) { @@ -570,6 +707,8 @@ jQuery(document).ready(function () { timeslotGroupInputs.on("click change", updateTimeslotGroupToggling); updateTimeslotGroupToggling(); + updatePastTimeslots(); + setInterval(updatePastTimeslots, 10 * 1000 /* ms */); // session info content.find(".session-info-container").on("mouseover", ".other-session", function (event) { diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 16ffea363..6718fd673 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -12,18 +12,26 @@ .parent-{{ parent.acronym }}.selected { background-color: {{ parent.light_scheduling_color }}; } .parent-{{ parent.acronym }}.other-session-selected { background-color: {{ parent.light_scheduling_color }}; } {% endfor %} + {# style past sessions to indicate they are not editable #} + .edit-meeting-schedule .edit-grid .timeslot.past-hint { filter: brightness(0.9); } + .edit-meeting-schedule .past-flag { visibility: hidden; font-size: smaller; } + .edit-meeting-schedule .edit-grid .timeslot.past .past-flag { visibility: visible; color: #aaaaaa; } {% endblock morecss %} {% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting agenda{% endblock %} {% block js %} + + {% endblock js %} {% block content %} {% origin %} -
+

{% if can_edit_properties %} @@ -90,7 +98,11 @@ {% for day, day_data in days.items %}

- {{ day|date:"l" }}
+ {{ day|date:"l" }} + +
{{ day|date:"N j, Y" }}
@@ -106,6 +118,7 @@ {{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }}
@@ -114,9 +127,17 @@ {% for room_data in rgroup %}{% with room_data.room as room %}
{% for t in room_data.timeslots %} -
+
- {{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }} +
 {# blank div keeps time centered vertically #}
+
{{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }}
+
Past
@@ -217,7 +238,11 @@ @@ -255,7 +280,11 @@
{% for t in rgroup.0.timeslots %} {% endfor %}
diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html index 3d819777d..064279d2f 100644 --- a/ietf/templates/meeting/edit_meeting_schedule_session.html +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -22,6 +22,7 @@ {% endfor %} {% endif %} +
Past
{# the JS uses this to display session information in the bottom panel #}