Disable modification of past timeslots on official schedules. Fixes #3166.

- Legacy-Id: 19295
This commit is contained in:
Jennifer Richards 2021-08-09 20:43:20 +00:00
parent 24ec2ffc42
commit 2eacd88c0b
8 changed files with 1074 additions and 55 deletions

View file

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

View file

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

View file

@ -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,
})

View file

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

View file

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

View file

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

View file

@ -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 %}
<script type="text/javascript" src="{% static 'moment/min/moment.min.js' %}"></script>
<script type="text/javascript" src="{% static 'moment-timezone/builds/moment-timezone-with-data-10-year-range.min.js' %}"></script>
<script type="text/javascript" src="{% static 'ietf/js/edit-meeting-schedule.js' %}"></script>
{% endblock js %}
{% block content %}
{% origin %}
<div class="edit-meeting-schedule">
<div class="edit-meeting-schedule {% if schedule.is_official %}official-schedule{% endif %}"
data-timezone="{{ meeting.time_zone }}"
data-lock-seconds="{{ lock_time.total_seconds }}">
<p class="pull-right">
{% if can_edit_properties %}
@ -90,7 +98,11 @@
{% for day, day_data in days.items %}
<div class="day">
<div class="day-label">
<strong>{{ day|date:"l" }}</strong> <i class="fa fa-exchange swap-days" data-dayid="{{ day.isoformat }}"></i><br>
<strong>{{ day|date:"l" }}</strong>
<i class="fa fa-exchange swap-days"
data-dayid="{{ day.isoformat }}"
data-start="{{ day.isoformat }}"></i>
<br>
{{ day|date:"N j, Y" }}
</div>
@ -106,6 +118,7 @@
{{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }}
<i class="fa fa-exchange swap-timeslot-col"
data-origin-label="{{ day|date:"l, N j" }}, {{ t.time|date:"G:i" }}-{{ t.end_time|date:"G:i" }}"
data-start="{{ t.utc_start_time.isoformat }}"
data-timeslot-pk="{{ t.pk }}"></i>
</span>
</div>
@ -114,9 +127,17 @@
{% for room_data in rgroup %}{% with room_data.room as room %}
<div class="timeslots" data-roomcapacity="{{ room.capacity }}">
{% for t in room_data.timeslots %}
<div id="timeslot{{ t.pk }}" class="timeslot {{ t.start_end_group }}" data-start="{{ t.time.isoformat }}" data-end="{{ t.end_time.isoformat }}" data-duration="{{ t.duration.total_seconds }}" data-scheduledatlabel="{{ t.time|date:"l G:i" }}-{{ t.end_time|date:"G:i" }}" style="width: {{ t.layout_width }}rem;">
<div id="timeslot{{ t.pk }}"
class="timeslot {{ t.start_end_group }}"
data-start="{{ t.utc_start_time.isoformat }}"
data-end="{{ t.utc_end_time.isoformat }}"
data-duration="{{ t.duration.total_seconds }}"
data-scheduledatlabel="{{ t.time|date:"l G:i" }}-{{ t.end_time|date:"G:i" }}"
style="width: {{ t.layout_width }}rem;">
<div class="time-label">
{{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }}
<div class="past-flag">&nbsp;{# blank div keeps time centered vertically #}</div>
<div>{{ t.time|date:"G:i" }} - {{ t.end_time|date:"G:i" }}</div>
<div class="past-flag">Past</div>
</div>
<div class="drop-target">
@ -217,7 +238,11 @@
<div class="modal-body">
{% for day in days %}
<label>
<input type="radio" name="target_day" value="{{ day.isoformat }}"> {{ day|date:"l, N j, Y" }}
<input type="radio"
name="target_day"
data-start="{{ day.isoformat }}"
value="{{ day.isoformat }}">
{{ day|date:"l, N j, Y" }}
</label>
{% endfor %}
</div>
@ -255,7 +280,11 @@
<div class="timeslot-options">
{% for t in rgroup.0.timeslots %}
<label>
<input type="radio" name="target_timeslot" value="{{ t.pk }}">{{ t.time|date:"G:i" }}-{{ t.end_time|date:"G:i" }}
<input type="radio"
name="target_timeslot"
value="{{ t.pk }}"
data-start="{{ t.utc_start_time.isoformat }}">
{{ t.time|date:"G:i" }}-{{ t.end_time|date:"G:i" }}
</label>
{% endfor %}
</div>

View file

@ -22,6 +22,7 @@
{% endfor %}
</span>
{% endif %}
<div class="past-flag">Past</div>
</div>
{# the JS uses this to display session information in the bottom panel #}