Skip to content

Commit 923cb35

Browse files
committed
Add support for swapping days in the meeting schedule editor. Since
days may not be entirely the same, the algorithm will try to find the best matches between the timeslots and then unschedule any unmatched sessions for manual fixup. Clean up initial data fed to the schedule editor from the Python view. - Legacy-Id: 18343
1 parent 8496c79 commit 923cb35

7 files changed

Lines changed: 193 additions & 28 deletions

File tree

ietf/meeting/tests_js.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import sys
66
import time
77
import datetime
8-
from pyquery import PyQuery
98
from unittest import skipIf
109

1110
import django
@@ -102,6 +101,14 @@ def test_edit_meeting_schedule(self):
102101
time=max(slot1.end_time(), slot2.end_time()) + datetime.timedelta(minutes=10),
103102
)
104103

104+
slot4 = TimeSlot.objects.create(
105+
meeting=meeting,
106+
type_id='regular',
107+
location=room1,
108+
duration=datetime.timedelta(hours=2),
109+
time=slot1.time + datetime.timedelta(days=1),
110+
)
111+
105112
s1, s2 = Session.objects.filter(meeting=meeting, type='regular')
106113
s2.requested_duration = slot2.duration + datetime.timedelta(minutes=10)
107114
s2.save()
@@ -126,8 +133,7 @@ def test_edit_meeting_schedule(self):
126133
url = self.absreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number, name=schedule.name, owner=schedule.owner_email()))
127134
self.driver.get(url)
128135

129-
q = PyQuery(self.driver.page_source)
130-
self.assertEqual(len(q('.session')), 3)
136+
self.assertEqual(len(self.driver.find_elements_by_css_selector('.session')), 3)
131137

132138
# select - show session info
133139
s2_element = self.driver.find_element_by_css_selector('#session{}'.format(s2.pk))
@@ -240,6 +246,14 @@ def test_edit_meeting_schedule(self):
240246
self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal [data-dismiss=\"modal\"]").click()
241247
self.assertTrue(not self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal").is_displayed())
242248

249+
# swap days
250+
self.driver.find_element_by_css_selector(".day [data-target=\"#swap-days-modal\"][data-dayid=\"{}\"]".format(slot4.time.date().isoformat())).click()
251+
self.assertTrue(self.driver.find_element_by_css_selector("#swap-days-modal").is_displayed())
252+
self.driver.find_element_by_css_selector("#swap-days-modal input[name=\"target_day\"][value=\"{}\"]".format(slot1.time.date().isoformat())).click()
253+
self.driver.find_element_by_css_selector("#swap-days-modal button[type=\"submit\"]").click()
254+
255+
self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot4.pk, s1.pk)))
256+
243257

244258
@skipIf(skip_selenium, skip_message)
245259
@skipIf(django.VERSION[0]==2, "Skipping test with race conditions under Django 2")

ietf/meeting/tests_views.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -997,7 +997,7 @@ def test_edit_meeting_schedule(self):
997997
self.assertTrue(q(".room-name:contains(\"{}\")".format(room.name)))
998998
self.assertTrue(q(".room-name:contains(\"{}\")".format(room.capacity)))
999999

1000-
timeslots = TimeSlot.objects.filter(meeting=meeting, type='regular')
1000+
timeslots = list(TimeSlot.objects.filter(meeting=meeting, type='regular'))
10011001
self.assertTrue(q("#timeslot{}".format(timeslots[0].pk)))
10021002

10031003
for s in [s1, s2]:
@@ -1116,7 +1116,43 @@ def test_edit_meeting_schedule(self):
11161116
self.assertEqual(json.loads(r.content)['success'], True)
11171117
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s1)), [])
11181118

1119+
# try swapping days
1120+
timeslots.append(TimeSlot.objects.create(
1121+
meeting=meeting, type_id='regular', location=timeslots[0].location,
1122+
duration=timeslots[0].duration - datetime.timedelta(minutes=5),
1123+
time=timeslots[0].time + datetime.timedelta(days=1),
1124+
))
11191125

1126+
SchedTimeSessAssignment.objects.create(schedule=schedule, session=s1, timeslot=timeslots[1])
1127+
1128+
self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s2, timeslot=timeslots[0])), 1)
1129+
self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s1, timeslot=timeslots[1])), 1)
1130+
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[2])), [])
1131+
1132+
r = self.client.post(url, {
1133+
'action': 'swapdays',
1134+
'source_day': timeslots[0].time.date().isoformat(),
1135+
'target_day': timeslots[2].time.date().isoformat(),
1136+
})
1137+
self.assertEqual(r.status_code, 302)
1138+
1139+
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[0])), [])
1140+
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[1])), [])
1141+
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s1)), [])
1142+
self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s2, timeslot=timeslots[2])), 1)
1143+
1144+
# swap back
1145+
r = self.client.post(url, {
1146+
'action': 'swapdays',
1147+
'source_day': timeslots[2].time.date().isoformat(),
1148+
'target_day': timeslots[0].time.date().isoformat(),
1149+
})
1150+
self.assertEqual(r.status_code, 302)
1151+
1152+
self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s2, timeslot=timeslots[0])), 1)
1153+
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[1])), [])
1154+
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[2])), [])
1155+
11201156
def test_copy_meeting_schedule(self):
11211157
meeting = make_meeting_test_data()
11221158

ietf/meeting/utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import debug # pyflakes:ignore
2020

2121
from ietf.dbtemplate.models import DBTemplate
22-
from ietf.meeting.models import Session, Meeting, SchedulingEvent, TimeSlot, Constraint
22+
from ietf.meeting.models import Session, Meeting, SchedulingEvent, TimeSlot, Constraint, SchedTimeSessAssignment
2323
from ietf.group.models import Group, Role
2424
from ietf.group.utils import can_manage_materials
2525
from ietf.name.models import SessionStatusName, ConstraintName
@@ -513,3 +513,44 @@ def prefetch_schedule_diff_objects(diffs):
513513
res.append(d_objs)
514514

515515
return res
516+
517+
def swap_meeting_schedule_timeslot_assignments(schedule, source_timeslots, target_timeslots, source_target_offset):
518+
"""Swap the assignments of the two meeting schedule timeslots in one
519+
go, automatically matching them up based on the specified offset,
520+
e.g. timedelta(days=1). For timeslots where no suitable swap match
521+
is found, the sessions are unassigned. Doesn't take tombstones into
522+
account."""
523+
524+
assignments_by_timeslot = defaultdict(list)
525+
526+
for a in SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot__in=source_timeslots + target_timeslots):
527+
assignments_by_timeslot[a.timeslot_id].append(a)
528+
529+
timeslots_to_match_up = [(source_timeslots, target_timeslots, source_target_offset), (target_timeslots, source_timeslots, -source_target_offset)]
530+
for lhs_timeslots, rhs_timeslots, lhs_offset in timeslots_to_match_up:
531+
timeslots_by_location = defaultdict(list)
532+
for rts in rhs_timeslots:
533+
timeslots_by_location[rts.location_id].append(rts)
534+
535+
for lts in lhs_timeslots:
536+
lts_assignments = assignments_by_timeslot.pop(lts.pk, [])
537+
if not lts_assignments:
538+
continue
539+
540+
swapped = False
541+
542+
most_overlapping_rts, max_overlap = max([
543+
(rts, max(datetime.timedelta(0), min(lts.end_time() + lhs_offset, rts.end_time()) - max(lts.time + lhs_offset, rts.time)))
544+
for rts in timeslots_by_location.get(lts.location_id, [])
545+
] + [(None, datetime.timedelta(0))], key=lambda t: t[1])
546+
547+
if max_overlap > datetime.timedelta(minutes=5):
548+
for a in lts_assignments:
549+
a.timeslot = most_overlapping_rts
550+
a.modified = datetime.datetime.now()
551+
a.save()
552+
swapped = True
553+
554+
if not swapped:
555+
for a in lts_assignments:
556+
a.delete()

ietf/meeting/views.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
from ietf.meeting.utils import data_for_meetings_overview
7979
from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_editor
8080
from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_objects
81+
from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments
8182
from ietf.message.utils import infer_message
8283
from ietf.secr.proceedings.utils import handle_upload_file
8384
from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files,
@@ -432,6 +433,10 @@ def copy_meeting_schedule(request, num, owner, name):
432433
})
433434

434435

436+
class SwapDaysForm(forms.Form):
437+
source_day = forms.DateField(required=True)
438+
target_day = forms.DateField(required=True)
439+
435440
@ensure_csrf_cookie
436441
def edit_meeting_schedule(request, num=None, owner=None, name=None):
437442
meeting = get_meeting(num)
@@ -547,12 +552,13 @@ def prepare_sessions_for_display(sessions):
547552
s.is_tombstone = s.current_status in tombstone_states
548553

549554

550-
if request.method == 'POST': # handle ajax requests
555+
if request.method == 'POST':
551556
if not can_edit:
552557
return HttpResponseForbidden("Can't edit this schedule")
553558

554559
action = request.POST.get('action')
555560

561+
# handle ajax requests
556562
if action == 'assign' and request.POST.get('session', '').isdigit() and request.POST.get('timeslot', '').isdigit():
557563
session = get_object_or_404(sessions, pk=request.POST['session'])
558564
timeslot = get_object_or_404(timeslots_qs, pk=request.POST['timeslot'])
@@ -605,7 +611,22 @@ def prepare_sessions_for_display(sessions):
605611

606612
return JsonResponse({'success': True})
607613

608-
return HttpResponse("Invalid parameters", status_code=400)
614+
elif action == 'swapdays':
615+
# updating the client side is a bit complicated, so just
616+
# do a full refresh
617+
618+
swap_days_form = SwapDaysForm(request.POST)
619+
if not swap_days_form.is_valid():
620+
return HttpResponse("Invalid swap: {}".format(swap_days_form.errors), status=400)
621+
622+
source_day = swap_days_form.cleaned_data['source_day']
623+
target_day = swap_days_form.cleaned_data['target_day']
624+
625+
swap_meeting_schedule_timeslot_assignments(schedule, [ts for ts in timeslots_qs if ts.time.date() == source_day], [ts for ts in timeslots_qs if ts.time.date() == target_day], target_day - source_day)
626+
627+
return HttpResponseRedirect(request.get_full_path())
628+
629+
return HttpResponse("Invalid parameters", status=400)
609630

610631
# prepare timeslot layout
611632

@@ -688,20 +709,12 @@ def cubehelix(i, total, hue=1.2, start_angle=0.5):
688709
p.scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round(x * 255)) for x in rgb_color))
689710
p.light_scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round((0.9 + 0.1 * x) * 255)) for x in rgb_color))
690711

691-
js_data = {
692-
'can_edit': can_edit,
693-
'urls': {
694-
'assign': request.get_full_path()
695-
}
696-
}
697-
698712
return render(request, "meeting/edit_meeting_schedule.html", {
699713
'meeting': meeting,
700714
'schedule': schedule,
701715
'can_edit': can_edit,
702716
'can_edit_properties': can_edit or secretariat,
703717
'secretariat': secretariat,
704-
'js_data': json.dumps(js_data, indent=2),
705718
'days': days,
706719
'room_labels': room_labels,
707720
'timeslot_groups': sorted((d, list(sorted(t_groups))) for d, t_groups in timeslot_groups.items()),

ietf/static/ietf/css/ietf.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,18 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
10421042
height: 3em;
10431043
}
10441044

1045+
.edit-meeting-schedule .edit-grid .day-label .swap-days {
1046+
cursor: pointer;
1047+
}
1048+
1049+
.edit-meeting-schedule .edit-grid .day-label .swap-days:hover {
1050+
color: #666;
1051+
}
1052+
1053+
.edit-meeting-schedule #swap-days-modal .modal-body label {
1054+
display: block;
1055+
}
1056+
10451057
.edit-meeting-schedule .edit-grid .day-flow {
10461058
margin-left: 8em;
10471059
display: flex;

ietf/static/ietf/js/edit-meeting-schedule.js

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ jQuery(document).ready(function () {
120120
});
121121

122122

123-
if (ietfData.can_edit) {
123+
if (!content.find(".edit-grid").hasClass("read-only")) {
124124
// dragging
125125
sessions.on("dragstart", function (event) {
126126
event.originalEvent.dataTransfer.setData("text/plain", this.id);
@@ -186,9 +186,6 @@ jQuery(document).ready(function () {
186186

187187
function failHandler(xhr, textStatus, error) {
188188
dropElement.parent().removeClass("dropping");
189-
console.log("xhr", xhr)
190-
console.log("textstatus", textStatus)
191-
console.log("error", error)
192189
reportServerError(xhr, textStatus, error);
193190
}
194191

@@ -211,7 +208,7 @@ jQuery(document).ready(function () {
211208

212209
if (dropParent.hasClass("unassigned-sessions")) {
213210
jQuery.ajax({
214-
url: ietfData.urls.assign,
211+
url: window.location.href,
215212
method: "post",
216213
timeout: 5 * 1000,
217214
data: {
@@ -222,7 +219,7 @@ jQuery(document).ready(function () {
222219
}
223220
else {
224221
jQuery.ajax({
225-
url: ietfData.urls.assign,
222+
url: window.location.href,
226223
method: "post",
227224
data: {
228225
action: "assign",
@@ -233,6 +230,32 @@ jQuery(document).ready(function () {
233230
}).fail(failHandler).done(done);
234231
}
235232
});
233+
234+
// swap days
235+
content.find(".swap-days").on("click", function () {
236+
let originDay = this.dataset.dayid;
237+
let modal = content.find("#swap-days-modal");
238+
let radios = modal.find(".modal-body label");
239+
radios.removeClass("text-muted");
240+
radios.find("input[name=target_day]").prop("disabled", false).prop("checked", false);
241+
242+
let originRadio = radios.find("input[name=target_day][value=" + originDay + "]");
243+
originRadio.parent().addClass("text-muted");
244+
originRadio.prop("disabled", true);
245+
246+
modal.find(".modal-title .day").text(jQuery.trim(originRadio.parent().text()));
247+
modal.find("input[name=source_day]").val(originDay);
248+
249+
updateSwapDaysSubmitButton();
250+
});
251+
252+
function updateSwapDaysSubmitButton() {
253+
content.find("#swap-days-modal button[type=submit]").prop("disabled", content.find("#swap-days-modal input[name=target_day]:checked").length == 0);
254+
}
255+
256+
content.find("#swap-days-modal input[name=target_day]").on("change", function () {
257+
updateSwapDaysSubmitButton();
258+
});
236259
}
237260

238261
// hints for the current schedule

ietf/templates/meeting/edit_meeting_schedule.html

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@
1515
{% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting agenda{% endblock %}
1616

1717
{% block js %}
18-
<script type='text/javascript'>
19-
var ietfData = {{ js_data|safe }};
20-
</script>
2118
<script type="text/javascript" src="{% static 'ietf/js/edit-meeting-schedule.js' %}"></script>
2219
{% endblock js %}
2320

@@ -80,7 +77,7 @@
8077
{% for day in days %}
8178
<div class="day">
8279
<div class="day-label">
83-
<strong>{{ day.day|date:"l" }}</strong><br>
80+
<strong>{{ day.day|date:"l" }}</strong> <i class="fa fa-exchange swap-days" data-dayid="{{ day.day.isoformat }}" data-toggle="modal" data-target="#swap-days-modal"></i><br>
8481
{{ day.day|date:"N j, Y" }}
8582
</div>
8683

@@ -94,9 +91,9 @@
9491
</div>
9592

9693
<div class="drop-target">
97-
{% for assignment, session in t.session_assignments %}
98-
{% include "meeting/edit_meeting_schedule_session.html" %}
99-
{% endfor %}
94+
{% for assignment, session in t.session_assignments %}
95+
{% include "meeting/edit_meeting_schedule_session.html" %}
96+
{% endfor %}
10097
</div>
10198
</div>
10299
{% endfor %}
@@ -173,5 +170,34 @@ <h4 class="modal-title" id="timeslot-group-toggles-modal-title">Displayed timesl
173170
</div>
174171
</div>
175172

173+
<div id="swap-days-modal" class="modal" role="dialog" aria-labelledby="swap-days-modal-title">
174+
<div class="modal-dialog modal-lg" role="document">
175+
<form class="modal-content" method="post">{% csrf_token %}
176+
<div class="modal-header">
177+
<button type="button" class="close" data-dismiss="modal">
178+
<span aria-hidden="true">&times;</span>
179+
<span class="sr-only">Close</span>
180+
</button>
181+
<h4 class="modal-title" id="swap-days-modal-title">Swap <span class="day"></span> with</h4>
182+
</div>
183+
184+
<input type="hidden" name="source_day" value="">
185+
186+
<div class="modal-body">
187+
{% for day in days %}
188+
<label>
189+
<input type="radio" name="target_day" value="{{ day.day.isoformat }}"> {{ day.day|date:"l, N j, Y" }}
190+
</label>
191+
{% endfor %}
192+
</div>
193+
194+
<div class="modal-footer">
195+
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
196+
<button type="submit" name="action" value="swapdays" class="btn btn-primary">Swap days</button>
197+
</div>
198+
</form>
199+
</div>
200+
</div>
201+
176202
</div>
177203
{% endblock %}

0 commit comments

Comments
 (0)