Disable modification of past timeslots on official schedules. Fixes #3166.
- Legacy-Id: 19295
This commit is contained in:
parent
24ec2ffc42
commit
2eacd88c0b
|
@ -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
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"> {# 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>
|
||||
|
|
|
@ -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 #}
|
||||
|
|
Loading…
Reference in a new issue