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
This commit is contained in:
Ole Laursen 2020-08-06 16:32:56 +00:00
parent 8496c7938a
commit 923cb35755
7 changed files with 193 additions and 28 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,9 +15,6 @@
{% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting agenda{% endblock %}
{% block js %}
<script type='text/javascript'>
var ietfData = {{ js_data|safe }};
</script>
<script type="text/javascript" src="{% static 'ietf/js/edit-meeting-schedule.js' %}"></script>
{% endblock js %}
@ -80,7 +77,7 @@
{% for day in days %}
<div class="day">
<div class="day-label">
<strong>{{ day.day|date:"l" }}</strong><br>
<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>
{{ day.day|date:"N j, Y" }}
</div>
@ -94,9 +91,9 @@
</div>
<div class="drop-target">
{% 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 %}
</div>
</div>
{% endfor %}
@ -173,5 +170,34 @@
</div>
</div>
<div id="swap-days-modal" class="modal" role="dialog" aria-labelledby="swap-days-modal-title">
<div class="modal-dialog modal-lg" role="document">
<form class="modal-content" method="post">{% csrf_token %}
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title" id="swap-days-modal-title">Swap <span class="day"></span> with</h4>
</div>
<input type="hidden" name="source_day" value="">
<div class="modal-body">
{% for day in days %}
<label>
<input type="radio" name="target_day" value="{{ day.day.isoformat }}"> {{ day.day|date:"l, N j, Y" }}
</label>
{% endfor %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="submit" name="action" value="swapdays" class="btn btn-primary">Swap days</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}