From 923cb35755a52de2b09d89800836cf618e9cd6b5 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 6 Aug 2020 16:32:56 +0000 Subject: [PATCH] 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 --- ietf/meeting/tests_js.py | 20 +++++++-- ietf/meeting/tests_views.py | 38 +++++++++++++++- ietf/meeting/utils.py | 43 ++++++++++++++++++- ietf/meeting/views.py | 33 +++++++++----- ietf/static/ietf/css/ietf.css | 12 ++++++ ietf/static/ietf/js/edit-meeting-schedule.js | 35 ++++++++++++--- .../meeting/edit_meeting_schedule.html | 40 ++++++++++++++--- 7 files changed, 193 insertions(+), 28 deletions(-) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 7b5b5b924..879dd1d73 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -5,7 +5,6 @@ import sys import time import datetime -from pyquery import PyQuery from unittest import skipIf import django @@ -102,6 +101,14 @@ class EditMeetingScheduleTests(IetfLiveServerTestCase): time=max(slot1.end_time(), slot2.end_time()) + datetime.timedelta(minutes=10), ) + slot4 = TimeSlot.objects.create( + meeting=meeting, + type_id='regular', + location=room1, + duration=datetime.timedelta(hours=2), + time=slot1.time + datetime.timedelta(days=1), + ) + s1, s2 = Session.objects.filter(meeting=meeting, type='regular') s2.requested_duration = slot2.duration + datetime.timedelta(minutes=10) s2.save() @@ -126,8 +133,7 @@ class EditMeetingScheduleTests(IetfLiveServerTestCase): url = self.absreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num=meeting.number, name=schedule.name, owner=schedule.owner_email())) self.driver.get(url) - q = PyQuery(self.driver.page_source) - self.assertEqual(len(q('.session')), 3) + self.assertEqual(len(self.driver.find_elements_by_css_selector('.session')), 3) # select - show session info s2_element = self.driver.find_element_by_css_selector('#session{}'.format(s2.pk)) @@ -240,6 +246,14 @@ class EditMeetingScheduleTests(IetfLiveServerTestCase): self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal [data-dismiss=\"modal\"]").click() self.assertTrue(not self.driver.find_element_by_css_selector("#timeslot-group-toggles-modal").is_displayed()) + # swap days + self.driver.find_element_by_css_selector(".day [data-target=\"#swap-days-modal\"][data-dayid=\"{}\"]".format(slot4.time.date().isoformat())).click() + self.assertTrue(self.driver.find_element_by_css_selector("#swap-days-modal").is_displayed()) + self.driver.find_element_by_css_selector("#swap-days-modal input[name=\"target_day\"][value=\"{}\"]".format(slot1.time.date().isoformat())).click() + self.driver.find_element_by_css_selector("#swap-days-modal button[type=\"submit\"]").click() + + self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{} #session{}'.format(slot4.pk, s1.pk))) + @skipIf(skip_selenium, skip_message) @skipIf(django.VERSION[0]==2, "Skipping test with race conditions under Django 2") diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 8bc709ef2..51493ecc8 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -997,7 +997,7 @@ class EditTests(TestCase): self.assertTrue(q(".room-name:contains(\"{}\")".format(room.name))) self.assertTrue(q(".room-name:contains(\"{}\")".format(room.capacity))) - timeslots = TimeSlot.objects.filter(meeting=meeting, type='regular') + timeslots = list(TimeSlot.objects.filter(meeting=meeting, type='regular')) self.assertTrue(q("#timeslot{}".format(timeslots[0].pk))) for s in [s1, s2]: @@ -1116,7 +1116,43 @@ class EditTests(TestCase): self.assertEqual(json.loads(r.content)['success'], True) self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s1)), []) + # try swapping days + timeslots.append(TimeSlot.objects.create( + meeting=meeting, type_id='regular', location=timeslots[0].location, + duration=timeslots[0].duration - datetime.timedelta(minutes=5), + time=timeslots[0].time + datetime.timedelta(days=1), + )) + SchedTimeSessAssignment.objects.create(schedule=schedule, session=s1, timeslot=timeslots[1]) + + self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s2, timeslot=timeslots[0])), 1) + self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s1, timeslot=timeslots[1])), 1) + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[2])), []) + + r = self.client.post(url, { + 'action': 'swapdays', + 'source_day': timeslots[0].time.date().isoformat(), + 'target_day': timeslots[2].time.date().isoformat(), + }) + self.assertEqual(r.status_code, 302) + + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[0])), []) + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[1])), []) + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s1)), []) + self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s2, timeslot=timeslots[2])), 1) + + # swap back + r = self.client.post(url, { + 'action': 'swapdays', + 'source_day': timeslots[2].time.date().isoformat(), + 'target_day': timeslots[0].time.date().isoformat(), + }) + self.assertEqual(r.status_code, 302) + + self.assertEqual(len(SchedTimeSessAssignment.objects.filter(schedule=schedule, session=s2, timeslot=timeslots[0])), 1) + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[1])), []) + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot=timeslots[2])), []) + def test_copy_meeting_schedule(self): meeting = make_meeting_test_data() diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index b8d9452eb..a3888fd0e 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -19,7 +19,7 @@ from django.utils.safestring import mark_safe import debug # pyflakes:ignore from ietf.dbtemplate.models import DBTemplate -from ietf.meeting.models import Session, Meeting, SchedulingEvent, TimeSlot, Constraint +from ietf.meeting.models import Session, Meeting, SchedulingEvent, TimeSlot, Constraint, SchedTimeSessAssignment from ietf.group.models import Group, Role from ietf.group.utils import can_manage_materials from ietf.name.models import SessionStatusName, ConstraintName @@ -513,3 +513,44 @@ def prefetch_schedule_diff_objects(diffs): res.append(d_objs) return res + +def swap_meeting_schedule_timeslot_assignments(schedule, source_timeslots, target_timeslots, source_target_offset): + """Swap the assignments of the two meeting schedule timeslots in one + go, automatically matching them up based on the specified offset, + e.g. timedelta(days=1). For timeslots where no suitable swap match + is found, the sessions are unassigned. Doesn't take tombstones into + account.""" + + assignments_by_timeslot = defaultdict(list) + + for a in SchedTimeSessAssignment.objects.filter(schedule=schedule, timeslot__in=source_timeslots + target_timeslots): + assignments_by_timeslot[a.timeslot_id].append(a) + + timeslots_to_match_up = [(source_timeslots, target_timeslots, source_target_offset), (target_timeslots, source_timeslots, -source_target_offset)] + for lhs_timeslots, rhs_timeslots, lhs_offset in timeslots_to_match_up: + timeslots_by_location = defaultdict(list) + for rts in rhs_timeslots: + timeslots_by_location[rts.location_id].append(rts) + + for lts in lhs_timeslots: + lts_assignments = assignments_by_timeslot.pop(lts.pk, []) + if not lts_assignments: + continue + + swapped = False + + most_overlapping_rts, max_overlap = max([ + (rts, max(datetime.timedelta(0), min(lts.end_time() + lhs_offset, rts.end_time()) - max(lts.time + lhs_offset, rts.time))) + for rts in timeslots_by_location.get(lts.location_id, []) + ] + [(None, datetime.timedelta(0))], key=lambda t: t[1]) + + if max_overlap > datetime.timedelta(minutes=5): + for a in lts_assignments: + a.timeslot = most_overlapping_rts + a.modified = datetime.datetime.now() + a.save() + swapped = True + + if not swapped: + for a in lts_assignments: + a.delete() diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 243c73c85..dfb02c6e1 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -78,6 +78,7 @@ from ietf.meeting.utils import current_session_status from ietf.meeting.utils import data_for_meetings_overview from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_editor from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_objects +from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments from ietf.message.utils import infer_message from ietf.secr.proceedings.utils import handle_upload_file 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): }) +class SwapDaysForm(forms.Form): + source_day = forms.DateField(required=True) + target_day = forms.DateField(required=True) + @ensure_csrf_cookie def edit_meeting_schedule(request, num=None, owner=None, name=None): meeting = get_meeting(num) @@ -547,12 +552,13 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): s.is_tombstone = s.current_status in tombstone_states - if request.method == 'POST': # handle ajax requests + if request.method == 'POST': if not can_edit: return HttpResponseForbidden("Can't edit this schedule") action = request.POST.get('action') + # handle ajax 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']) @@ -605,7 +611,22 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): return JsonResponse({'success': True}) - return HttpResponse("Invalid parameters", status_code=400) + elif action == 'swapdays': + # updating the client side is a bit complicated, so just + # do a full refresh + + swap_days_form = SwapDaysForm(request.POST) + if not swap_days_form.is_valid(): + return HttpResponse("Invalid swap: {}".format(swap_days_form.errors), status=400) + + source_day = swap_days_form.cleaned_data['source_day'] + target_day = swap_days_form.cleaned_data['target_day'] + + 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) + + return HttpResponseRedirect(request.get_full_path()) + + return HttpResponse("Invalid parameters", status=400) # prepare timeslot layout @@ -688,20 +709,12 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): p.scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round(x * 255)) for x in rgb_color)) p.light_scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round((0.9 + 0.1 * x) * 255)) for x in rgb_color)) - js_data = { - 'can_edit': can_edit, - 'urls': { - 'assign': request.get_full_path() - } - } - return render(request, "meeting/edit_meeting_schedule.html", { 'meeting': meeting, 'schedule': schedule, 'can_edit': can_edit, 'can_edit_properties': can_edit or secretariat, 'secretariat': secretariat, - 'js_data': json.dumps(js_data, indent=2), 'days': days, 'room_labels': room_labels, 'timeslot_groups': sorted((d, list(sorted(t_groups))) for d, t_groups in timeslot_groups.items()), diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index a5f046459..8bde6d099 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1042,6 +1042,18 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { height: 3em; } +.edit-meeting-schedule .edit-grid .day-label .swap-days { + cursor: pointer; +} + +.edit-meeting-schedule .edit-grid .day-label .swap-days:hover { + color: #666; +} + +.edit-meeting-schedule #swap-days-modal .modal-body label { + display: block; +} + .edit-meeting-schedule .edit-grid .day-flow { margin-left: 8em; display: flex; diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index a7fe03ca6..82f531d5c 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -120,7 +120,7 @@ jQuery(document).ready(function () { }); - if (ietfData.can_edit) { + if (!content.find(".edit-grid").hasClass("read-only")) { // dragging sessions.on("dragstart", function (event) { event.originalEvent.dataTransfer.setData("text/plain", this.id); @@ -186,9 +186,6 @@ jQuery(document).ready(function () { function failHandler(xhr, textStatus, error) { dropElement.parent().removeClass("dropping"); - console.log("xhr", xhr) - console.log("textstatus", textStatus) - console.log("error", error) reportServerError(xhr, textStatus, error); } @@ -211,7 +208,7 @@ jQuery(document).ready(function () { if (dropParent.hasClass("unassigned-sessions")) { jQuery.ajax({ - url: ietfData.urls.assign, + url: window.location.href, method: "post", timeout: 5 * 1000, data: { @@ -222,7 +219,7 @@ jQuery(document).ready(function () { } else { jQuery.ajax({ - url: ietfData.urls.assign, + url: window.location.href, method: "post", data: { action: "assign", @@ -233,6 +230,32 @@ jQuery(document).ready(function () { }).fail(failHandler).done(done); } }); + + // swap days + content.find(".swap-days").on("click", function () { + let originDay = this.dataset.dayid; + let modal = content.find("#swap-days-modal"); + let radios = modal.find(".modal-body label"); + radios.removeClass("text-muted"); + radios.find("input[name=target_day]").prop("disabled", false).prop("checked", false); + + let originRadio = radios.find("input[name=target_day][value=" + originDay + "]"); + originRadio.parent().addClass("text-muted"); + originRadio.prop("disabled", true); + + modal.find(".modal-title .day").text(jQuery.trim(originRadio.parent().text())); + modal.find("input[name=source_day]").val(originDay); + + updateSwapDaysSubmitButton(); + }); + + function updateSwapDaysSubmitButton() { + content.find("#swap-days-modal button[type=submit]").prop("disabled", content.find("#swap-days-modal input[name=target_day]:checked").length == 0); + } + + content.find("#swap-days-modal input[name=target_day]").on("change", function () { + updateSwapDaysSubmitButton(); + }); } // hints for the current schedule diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index bf8f898e9..71321a193 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -15,9 +15,6 @@ {% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting agenda{% endblock %} {% block js %} - {% endblock js %} @@ -80,7 +77,7 @@ {% for day in days %}
- {{ day.day|date:"l" }}
+ {{ day.day|date:"l" }}
{{ day.day|date:"N j, Y" }}
@@ -94,9 +91,9 @@
- {% for assignment, session in t.session_assignments %} - {% include "meeting/edit_meeting_schedule_session.html" %} - {% endfor %} + {% for assignment, session in t.session_assignments %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %}
{% endfor %} @@ -173,5 +170,34 @@ + + {% endblock %}