Skip to content

Commit 3489121

Browse files
Swap timeslot columns in addition to full days in schedule editor. Fixes ietf-tools#3216. Commit ready for merge.
- Legacy-Id: 19138
1 parent 7c09aec commit 3489121

7 files changed

Lines changed: 422 additions & 28 deletions

File tree

ietf/meeting/forms.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from ietf.doc.models import Document, DocAlias, State, NewRevisionDocEvent
1818
from ietf.group.models import Group, GroupFeatures
1919
from ietf.ietfauth.utils import has_role
20-
from ietf.meeting.models import Session, Meeting, Schedule, countries, timezones
20+
from ietf.meeting.models import Session, Meeting, Schedule, countries, timezones, TimeSlot, Room
2121
from ietf.meeting.helpers import get_next_interim_number, make_materials_directories
2222
from ietf.meeting.helpers import is_interim_meeting_approved, get_next_agenda_name
2323
from ietf.message.models import Message
@@ -362,3 +362,58 @@ class RequestMinutesForm(forms.Form):
362362
cc = MultiEmailField(required=False)
363363
subject = forms.CharField()
364364
body = forms.CharField(widget=forms.Textarea,strip=False)
365+
366+
367+
class SwapDaysForm(forms.Form):
368+
source_day = forms.DateField(required=True)
369+
target_day = forms.DateField(required=True)
370+
371+
372+
class CsvModelPkInput(forms.TextInput):
373+
"""Text input that expects a CSV list of PKs of a model instances"""
374+
def format_value(self, value):
375+
"""Convert value to contents of input text widget
376+
377+
Value is a list of pks, or None
378+
"""
379+
return '' if value is None else ','.join(str(v) for v in value)
380+
381+
def value_from_datadict(self, data, files, name):
382+
"""Convert data back to list of PKs"""
383+
value = super(CsvModelPkInput, self).value_from_datadict(data, files, name)
384+
return value.split(',')
385+
386+
387+
class SwapTimeslotsForm(forms.Form):
388+
"""Timeslot swap form
389+
390+
Interface uses timeslot instances rather than time/duration to simplify handling in
391+
the JavaScript. This might make more sense with a DateTimeField and DurationField for
392+
origin/target. Instead, grabs time and duration from a TimeSlot.
393+
394+
This is not likely to be practical as a rendered form. Current use is to validate
395+
data from an ad hoc form. In an ideal world, this would be refactored to use a complex
396+
custom widget, but unless it proves to be reused that would be a poor investment of time.
397+
"""
398+
origin_timeslot = forms.ModelChoiceField(
399+
required=True,
400+
queryset=TimeSlot.objects.none(), # default to none, fill in when we have a meeting
401+
widget=forms.TextInput,
402+
)
403+
target_timeslot = forms.ModelChoiceField(
404+
required=True,
405+
queryset=TimeSlot.objects.none(), # default to none, fill in when we have a meeting
406+
widget=forms.TextInput,
407+
)
408+
rooms = forms.ModelMultipleChoiceField(
409+
required=True,
410+
queryset=Room.objects.none(), # default to none, fill in when we have a meeting
411+
widget=CsvModelPkInput,
412+
)
413+
414+
def __init__(self, meeting, *args, **kwargs):
415+
super(SwapTimeslotsForm, self).__init__(*args, **kwargs)
416+
self.meeting = meeting
417+
self.fields['origin_timeslot'].queryset = meeting.timeslot_set.all()
418+
self.fields['target_timeslot'].queryset = meeting.timeslot_set.all()
419+
self.fields['rooms'].queryset = meeting.room_set.all()

ietf/meeting/tests_js.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ def test_edit_meeting_schedule(self):
5050
schedule = Schedule.objects.filter(meeting=meeting, owner__user__username="plain").first()
5151

5252
room1 = Room.objects.get(name="Test Room")
53-
slot1 = TimeSlot.objects.filter(meeting=meeting, location=room1).order_by('time').first()
53+
slot1 = TimeSlot.objects.filter(meeting=meeting, location=room1, type='regular').order_by('time').first()
54+
slot1b = TimeSlot.objects.filter(meeting=meeting, location=room1, type='regular').order_by('time').last()
55+
self.assertNotEqual(slot1.pk, slot1b.pk)
5456

5557
room2 = Room.objects.create(meeting=meeting, name="Test Room2", capacity=1)
5658
room2.session_types.add('regular')
@@ -251,7 +253,6 @@ def test_edit_meeting_schedule(self):
251253
self.assertIn('selected', s1_element.get_attribute('class'),
252254
'Session should be selectable when parent enabled')
253255

254-
255256
# hide timeslots
256257
self.driver.find_element_by_css_selector(".timeslot-group-toggles button").click()
257258
self.assertTrue(self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal").is_displayed())
@@ -260,12 +261,26 @@ def test_edit_meeting_schedule(self):
260261
self.assertTrue(not self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal").is_displayed())
261262

262263
# swap days
263-
self.driver.find_element_by_css_selector(".day [data-target=\"#swap-days-modal\"][data-dayid=\"{}\"]".format(slot4.time.date().isoformat())).click()
264+
self.driver.find_element_by_css_selector(".day .swap-days[data-dayid=\"{}\"]".format(slot4.time.date().isoformat())).click()
264265
self.assertTrue(self.driver.find_element_by_css_selector("#swap-days-modal").is_displayed())
265266
self.driver.find_element_by_css_selector("#swap-days-modal input[name=\"target_day\"][value=\"{}\"]".format(slot1.time.date().isoformat())).click()
266267
self.driver.find_element_by_css_selector("#swap-days-modal button[type=\"submit\"]").click()
267268

268-
self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot4.pk, s1.pk)))
269+
self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot4.pk, s1.pk)),
270+
'Session s1 should have moved to second meeting day')
271+
272+
# swap timeslot column - put session in a differently-timed timeslot
273+
self.driver.find_element_by_css_selector(
274+
'.day .swap-timeslot-col[data-timeslot-pk="{}"]'.format(slot1b.pk)
275+
).click() # open modal on the second timeslot for room1
276+
self.assertTrue(self.driver.find_element_by_css_selector("#swap-timeslot-col-modal").is_displayed())
277+
self.driver.find_element_by_css_selector(
278+
'#swap-timeslot-col-modal input[name="target_timeslot"][value="{}"]'.format(slot4.pk)
279+
).click() # select room1 timeslot that has a session in it
280+
self.driver.find_element_by_css_selector('#swap-timeslot-col-modal button[type="submit"]').click()
281+
282+
self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot1b.pk, s1.pk)),
283+
'Session s1 should have moved to second timeslot on first meeting day')
269284

270285
def test_unassigned_sessions_sort(self):
271286
"""Unassigned session sorting should behave correctly

ietf/meeting/tests_views.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,187 @@ def test_bof_session_tag(self):
994994
self.assertIn('BoF', bof_tags.eq(0).text(),
995995
'BoF tag should contain text "BoF"')
996996

997+
def _setup_for_swap_timeslots(self):
998+
"""Create a meeting, rooms, and schedule for swap_timeslots testing
999+
1000+
Creates two groups of rooms with disjoint timeslot sets, modeling the room grouping in
1001+
the edit_meeting_schedule view.
1002+
"""
1003+
# Meeting must be in the future so it can be edited
1004+
meeting = MeetingFactory(
1005+
type_id='ietf',
1006+
date=datetime.date.today() + datetime.timedelta(days=7),
1007+
populate_schedule=False,
1008+
)
1009+
meeting.schedule = ScheduleFactory(meeting=meeting)
1010+
meeting.save()
1011+
1012+
# Create room groups
1013+
room_groups = [
1014+
RoomFactory.create_batch(2, meeting=meeting),
1015+
RoomFactory.create_batch(2, meeting=meeting),
1016+
]
1017+
1018+
# Set up different sets of timeslots
1019+
t0 = datetime.datetime.combine(meeting.date, datetime.time(11, 0))
1020+
dur = datetime.timedelta(hours=2)
1021+
for room in room_groups[0]:
1022+
TimeSlotFactory(meeting=meeting, location=room, duration=dur, time=t0)
1023+
TimeSlotFactory(meeting=meeting, location=room, duration=dur, time=t0 + datetime.timedelta(days=1, hours=2))
1024+
TimeSlotFactory(meeting=meeting, location=room, duration=dur, time=t0 + datetime.timedelta(days=2, hours=4))
1025+
1026+
for room in room_groups[1]:
1027+
TimeSlotFactory(meeting=meeting, location=room, duration=dur, time=t0 + datetime.timedelta(hours=1))
1028+
TimeSlotFactory(meeting=meeting, location=room, duration=dur, time=t0 + datetime.timedelta(days=1, hours=3))
1029+
TimeSlotFactory(meeting=meeting, location=room, duration=dur, time=t0 + datetime.timedelta(days=2, hours=5))
1030+
1031+
# And now put sessions in the timeslots
1032+
for ts in meeting.timeslot_set.all():
1033+
SessionFactory(
1034+
meeting=meeting,
1035+
name=str(ts.pk), # label to identify where it started
1036+
add_to_schedule=False,
1037+
).timeslotassignments.create(
1038+
timeslot=ts,
1039+
schedule=meeting.schedule,
1040+
)
1041+
return meeting, room_groups
1042+
1043+
def test_swap_timeslots(self):
1044+
"""Schedule timeslot groups should swap properly
1045+
1046+
This tests the case currently exercised by the UI - where the rooms are grouped according to
1047+
entirely equivalent sets of timeslots. Thus, there is always a matching timeslot for every (or no)
1048+
room as long as the rooms parameter to the ajax call includes only one group.
1049+
"""
1050+
meeting, room_groups = self._setup_for_swap_timeslots()
1051+
1052+
url = urlreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number))
1053+
username = meeting.schedule.owner.user.username
1054+
self.client.login(username=username, password=username + '+password')
1055+
1056+
# Swap group 0's first and last sessions
1057+
r = self.client.post(
1058+
url,
1059+
dict(
1060+
action='swaptimeslots',
1061+
origin_timeslot=str(room_groups[0][0].timeslot_set.first().pk),
1062+
target_timeslot=str(room_groups[0][0].timeslot_set.last().pk),
1063+
rooms=','.join([str(room.pk) for room in room_groups[0]]),
1064+
)
1065+
)
1066+
self.assertEqual(r.status_code, 302)
1067+
1068+
# Validate results
1069+
for index, room in enumerate(room_groups[0]):
1070+
timeslots = list(room.timeslot_set.all())
1071+
self.assertEqual(timeslots[0].session.name, str(timeslots[-1].pk),
1072+
'Session from last timeslot in room (0, {}) should now be in first'.format(index))
1073+
self.assertEqual(timeslots[-1].session.name, str(timeslots[0].pk),
1074+
'Session from first timeslot in room (0, {}) should now be in last'.format(index))
1075+
self.assertEqual(
1076+
[ts.session.name for ts in timeslots[1:-1]],
1077+
[str(ts.pk) for ts in timeslots[1:-1]],
1078+
'Sessions in middle timeslots should be unchanged'
1079+
)
1080+
for index, room in enumerate(room_groups[1]):
1081+
timeslots = list(room.timeslot_set.all())
1082+
self.assertFalse(
1083+
any(ts.session is None for ts in timeslots),
1084+
"Sessions in other room group's timeslots should still be assigned"
1085+
)
1086+
self.assertEqual(
1087+
[ts.session.name for ts in timeslots],
1088+
[str(ts.pk) for ts in timeslots],
1089+
"Sessions in other room group's timeslots should be unchanged"
1090+
)
1091+
1092+
def test_swap_timeslots_handles_unmatched(self):
1093+
"""Sessions in unmatched timeslots should be unassigned when swapped
1094+
1095+
This more generally tests the back end by exercising the situation where a timeslot in the
1096+
affected rooms does not have an equivalent timeslot target. This is not used by the UI as of
1097+
now (2021-06-22), but should function correctly.
1098+
"""
1099+
meeting, room_groups = self._setup_for_swap_timeslots()
1100+
1101+
# Remove a timeslot and session from only one room in group 0
1102+
ts_to_remove = room_groups[0][1].timeslot_set.last()
1103+
ts_to_remove.session.delete()
1104+
ts_to_remove.delete() # our object still exists but has no db object
1105+
1106+
# Add a matching timeslot to group 1 so we can be sure it's being ignored.
1107+
# If not, this session will be unassigned when we swap timeslots on group 0.
1108+
new_ts = TimeSlotFactory(
1109+
meeting=meeting,
1110+
location=room_groups[1][0],
1111+
duration=ts_to_remove.duration,
1112+
time=ts_to_remove.time,
1113+
)
1114+
SessionFactory(
1115+
meeting=meeting,
1116+
name=str(new_ts.pk),
1117+
add_to_schedule=False,
1118+
).timeslotassignments.create(
1119+
timeslot=new_ts,
1120+
schedule=meeting.schedule,
1121+
)
1122+
1123+
url = urlreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number))
1124+
username = meeting.schedule.owner.user.username
1125+
self.client.login(username=username, password=username + '+password')
1126+
1127+
# Now swap between first and last timeslots in group 0
1128+
r = self.client.post(
1129+
url,
1130+
dict(
1131+
action='swaptimeslots',
1132+
origin_timeslot=str(room_groups[0][0].timeslot_set.first().pk),
1133+
target_timeslot=str(room_groups[0][0].timeslot_set.last().pk),
1134+
rooms=','.join([str(room.pk) for room in room_groups[0]]),
1135+
)
1136+
)
1137+
self.assertEqual(r.status_code, 302)
1138+
1139+
# Validate results
1140+
for index, room in enumerate(room_groups[0]):
1141+
timeslots = list(room.timeslot_set.all())
1142+
if index == 1:
1143+
# special case - this has no matching timeslot because we deleted it above
1144+
self.assertIsNone(timeslots[0].session, 'Unmatched timeslot should be empty after swap')
1145+
session_that_should_be_unassigned = Session.objects.get(name=str(timeslots[0].pk))
1146+
self.assertEqual(session_that_should_be_unassigned.timeslotassignments.count(), 0,
1147+
'Session that was in an unmatched timeslot should now be unassigned')
1148+
# check from 2nd timeslot to the last since we deleted the original last timeslot
1149+
self.assertEqual(
1150+
[ts.session.name for ts in timeslots[1:]],
1151+
[str(ts.pk) for ts in timeslots[1:]],
1152+
'Sessions in middle timeslots should be unchanged'
1153+
)
1154+
else:
1155+
self.assertEqual(timeslots[0].session.name, str(timeslots[-1].pk),
1156+
'Session from last timeslot in room (0, {}) should now be in first'.format(index))
1157+
self.assertEqual(timeslots[-1].session.name, str(timeslots[0].pk),
1158+
'Session from first timeslot in room (0, {}) should now be in last'.format(index))
1159+
self.assertEqual(
1160+
[ts.session.name for ts in timeslots[1:-1]],
1161+
[str(ts.pk) for ts in timeslots[1:-1]],
1162+
'Sessions in middle timeslots should be unchanged'
1163+
)
1164+
1165+
# Still should have no effect on other rooms, even if they matched a timeslot
1166+
for index, room in enumerate(room_groups[1]):
1167+
timeslots = list(room.timeslot_set.all())
1168+
self.assertFalse(
1169+
any(ts.session is None for ts in timeslots),
1170+
"Sessions in other room group's timeslots should still be assigned"
1171+
)
1172+
self.assertEqual(
1173+
[ts.session.name for ts in timeslots],
1174+
[str(ts.pk) for ts in timeslots],
1175+
"Sessions in other room group's timeslots should be unchanged"
1176+
)
1177+
9971178

9981179
class ReorderSlidesTests(TestCase):
9991180

ietf/meeting/views.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
from ietf.mailtrigger.utils import gather_address_lists
5757
from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission
5858
from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName
59-
from ietf.meeting.forms import CustomDurationField
59+
from ietf.meeting.forms import CustomDurationField, SwapDaysForm, SwapTimeslotsForm
6060
from ietf.meeting.helpers import get_areas, get_person_by_email, get_schedule_by_name
6161
from ietf.meeting.helpers import build_all_agenda_slices, get_wg_name_list
6262
from ietf.meeting.helpers import get_all_assignments_from_schedule
@@ -455,11 +455,6 @@ def new_meeting_schedule(request, num, owner=None, name=None):
455455
'form': form,
456456
})
457457

458-
459-
class SwapDaysForm(forms.Form):
460-
source_day = forms.DateField(required=True)
461-
target_day = forms.DateField(required=True)
462-
463458
@ensure_csrf_cookie
464459
def edit_meeting_schedule(request, num=None, owner=None, name=None):
465460
meeting = get_meeting(num)
@@ -794,6 +789,37 @@ def prepare_timeslots_for_display(timeslots, rooms):
794789

795790
return HttpResponseRedirect(request.get_full_path())
796791

792+
elif action == 'swaptimeslots':
793+
# Swap sets of timeslots with equal start/end time for a given set of rooms.
794+
# Gets start and end times from TimeSlot instances for the origin and target,
795+
# then swaps all timeslots for the requested rooms whose start/end match those.
796+
# The origin/target timeslots do not need to be the same duration.
797+
swap_timeslots_form = SwapTimeslotsForm(meeting, request.POST)
798+
if not swap_timeslots_form.is_valid():
799+
return HttpResponse("Invalid swap: {}".format(swap_timeslots_form.errors), status=400)
800+
801+
affected_rooms = swap_timeslots_form.cleaned_data['rooms']
802+
origin_timeslot = swap_timeslots_form.cleaned_data['origin_timeslot']
803+
target_timeslot = swap_timeslots_form.cleaned_data['target_timeslot']
804+
805+
origin_timeslots = meeting.timeslot_set.filter(
806+
location__in=affected_rooms,
807+
time=origin_timeslot.time,
808+
duration=origin_timeslot.duration,
809+
)
810+
target_timeslots = meeting.timeslot_set.filter(
811+
location__in=affected_rooms,
812+
time=target_timeslot.time,
813+
duration=target_timeslot.duration,
814+
)
815+
swap_meeting_schedule_timeslot_assignments(
816+
schedule,
817+
list(origin_timeslots),
818+
list(target_timeslots),
819+
target_timeslot.time - origin_timeslot.time,
820+
)
821+
return HttpResponseRedirect(request.get_full_path())
822+
797823
return HttpResponse("Invalid parameters", status=400)
798824

799825
# Show only rooms that have regular sessions

ietf/static/ietf/css/ietf.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,21 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
13561356
cursor: pointer;
13571357
}
13581358

1359+
.edit-meeting-schedule .modal .day-options {
1360+
display: flex;
1361+
flex-flow: row wrap;
1362+
}
1363+
1364+
.edit-meeting-schedule .modal .timeslot-options {
1365+
display: flex;
1366+
flex-flow: column nowrap;
1367+
justify-content: flex-start;
1368+
}
1369+
1370+
.edit-meeting-schedule .modal .room-group {
1371+
margin: 2em;
1372+
}
1373+
13591374
.edit-meeting-schedule .scheduling-panel .session-info-container {
13601375
padding-left: 0.5em;
13611376
flex: 0 0 25em;

0 commit comments

Comments
 (0)