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:
parent
8496c7938a
commit
923cb35755
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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">×</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 %}
|
||||
|
|
Loading…
Reference in a new issue