From de99911e385b2734bd611229b3020f7bfe65019a Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 5 Mar 2020 19:02:44 +0000 Subject: [PATCH 1/9] Add regexp validation of Schedule.name - Legacy-Id: 17388 --- ietf/meeting/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 60c4142fc..a3fb5fbb1 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2019, All Rights Reserved +# Copyright The IETF Trust 2007-2020, All Rights Reserved # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function, unicode_literals @@ -14,7 +14,7 @@ import string import debug # pyflakes:ignore -from django.core.validators import MinValueValidator +from django.core.validators import MinValueValidator, RegexValidator from django.db import models from django.db.models import Max from django.conf import settings @@ -611,7 +611,7 @@ class Schedule(models.Model): schedule, others may copy it """ meeting = ForeignKey(Meeting, null=True, related_name='schedule_set') - name = models.CharField(max_length=16, blank=False) + name = models.CharField(max_length=16, blank=False, help_text="Letters, numbers and -:_ allowed.", validators=[RegexValidator(r'^[A-Za-z0-9-:_]*$')]) owner = ForeignKey(Person) visible = models.BooleanField(default=True, help_text="Make this agenda available to those who know about it.") public = models.BooleanField(default=True, help_text="Make this agenda publically available.") From 393ee64bec32f9b11f8d0df6c955d37d7fea3415 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 5 Mar 2020 19:15:44 +0000 Subject: [PATCH 2/9] Add new view for editing meeting schedules. This is a preliminary basic version with timeslots in a fixed grid and drag and drop for assigning and unassigning. Compared to the existing JS based view, it is missing session details (attendences etc.), conflicts and other warnings, toggling of sessions in areas, area coloring, extending to next timeslot and probably more. Add new auxiliary view to copy a schedule for the new schedule editor. - Legacy-Id: 17389 --- ietf/meeting/tests_views.py | 98 ++++++- ietf/meeting/urls.py | 5 +- ietf/meeting/views.py | 265 +++++++++++++++++- ietf/static/ietf/css/ietf.css | 77 +++++ ietf/static/ietf/js/edit-meeting-schedule.js | 94 +++++++ .../meeting/copy_meeting_schedule.html | 20 ++ .../meeting/edit_meeting_schedule.html | 110 ++++++++ .../edit_meeting_schedule_session.html | 3 + 8 files changed, 668 insertions(+), 4 deletions(-) create mode 100644 ietf/static/ietf/js/edit-meeting-schedule.js create mode 100644 ietf/templates/meeting/copy_meeting_schedule.html create mode 100644 ietf/templates/meeting/edit_meeting_schedule.html create mode 100644 ietf/templates/meeting/edit_meeting_schedule_session.html diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index c84b07742..7581b8758 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -34,7 +34,7 @@ from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_r from ietf.meeting.helpers import send_interim_approval_request from ietf.meeting.helpers import send_interim_cancellation_notice from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates -from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent +from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting from ietf.meeting.utils import finalize, condition_slide_order from ietf.meeting.utils import add_event_info_to_session_qs @@ -911,10 +911,104 @@ class EditTests(TestCase): def test_edit_schedule(self): meeting = make_meeting_test_data() - + self.client.login(username="secretary", password="secretary+password") r = self.client.get(urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number))) self.assertContains(r, "load_assignments") + + def test_edit_meeting_schedule(self): + meeting = make_meeting_test_data() + + self.client.login(username="secretary", password="secretary+password") + + # check we have the grid and everything set up + url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number)) + r = self.client.get(url) + q = PyQuery(r.content) + + room = Room.objects.get(meeting=meeting, session_types='regular') + self.assertTrue(q("th:contains(\"{}\")".format(room.name))) + self.assertTrue(q("th:contains(\"{}\")".format(room.capacity))) + + timeslots = TimeSlot.objects.filter(meeting=meeting, type='regular') + self.assertTrue(q("td:contains(\"{}\")".format(timeslots[0].time.strftime("%H:%M")))) + self.assertTrue(q("td.timeslot[data-timeslot=\"{}\"]".format(timeslots[0].pk))) + + sessions = Session.objects.filter(meeting=meeting, type='regular') + for s in sessions: + self.assertIn(s.group.acronym, q("#session{}".format(s.pk)).text()) + + self.assertIn("You can't edit this schedule", r.content) + + # can't change anything + r = self.client.post(url, { + 'action': 'assign', + 'timeslot': timeslots[0].pk, + 'session': sessions[0].pk, + }) + self.assertEqual(r.status_code, 403) + + # turn us into owner + meeting.schedule.owner = Person.objects.get(user__username="secretary") + meeting.schedule.save() + + url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name)) + r = self.client.get(url) + self.assertNotIn("You can't edit this schedule", r.content) + + SchedTimeSessAssignment.objects.filter(session=sessions[0]).delete() + + # assign + r = self.client.post(url, { + 'action': 'assign', + 'timeslot': timeslots[0].pk, + 'session': sessions[0].pk, + }) + self.assertEqual(r.content, "OK") + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=sessions[0]).timeslot, timeslots[0]) + + # move assignment + r = self.client.post(url, { + 'action': 'assign', + 'timeslot': timeslots[1].pk, + 'session': sessions[0].pk, + }) + self.assertEqual(r.content, "OK") + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=sessions[0]).timeslot, timeslots[1]) + + # unassign + r = self.client.post(url, { + 'action': 'unassign', + 'session': sessions[0].pk, + }) + self.assertEqual(r.content, "OK") + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule, session=sessions[0])), []) + + + def test_copy_meeting_schedule(self): + meeting = make_meeting_test_data() + + self.client.login(username="secretary", password="secretary+password") + + url = urlreverse("ietf.meeting.views.copy_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name)) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + # copy + r = self.client.post(url, { + 'name': "newtest", + 'public': "on", + }) + self.assertNoFormPostErrors(r) + + new_schedule = Schedule.objects.get(meeting=meeting, owner__user__username='secretary', name='newtest') + self.assertEqual(new_schedule.public, True) + self.assertEqual(new_schedule.visible, False) + + old_assignments = {(a.session_id, a.timeslot_id) for a in SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule)} + for a in SchedTimeSessAssignment.objects.filter(schedule=new_schedule): + self.assertIn((a.session_id, a.timeslot_id), old_assignments) + # FIXME: test extendedfrom is copied correctly def test_save_agenda_as_and_read_permissions(self): meeting = make_meeting_test_data() diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 0691eaa26..dab2a4fa6 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2007-2019, All Rights Reserved +# Copyright The IETF Trust 2007-2020, All Rights Reserved from django.conf.urls import include from django.views.generic import RedirectView @@ -27,6 +27,7 @@ safe_for_all_meeting_types = [ type_ietf_only_patterns = [ url(r'^agenda/%(owner)s/%(schedule_name)s/edit$' % settings.URL_REGEXPS, views.edit_schedule), + url(r'^agenda/%(owner)s/%(schedule_name)s/edit/$' % settings.URL_REGEXPS, views.edit_meeting_schedule), url(r'^agenda/%(owner)s/%(schedule_name)s/details$' % settings.URL_REGEXPS, views.edit_schedule_properties), url(r'^agenda/%(owner)s/%(schedule_name)s/delete$' % settings.URL_REGEXPS, views.delete_schedule), url(r'^agenda/%(owner)s/%(schedule_name)s/make_official$' % settings.URL_REGEXPS, views.make_schedule_official), @@ -40,6 +41,7 @@ type_ietf_only_patterns = [ url(r'^agenda/%(owner)s/%(schedule_name)s/session/(?P\d+).json$' % settings.URL_REGEXPS, ajax.assignment_json), url(r'^agenda/%(owner)s/%(schedule_name)s/sessions.json$' % settings.URL_REGEXPS, ajax.assignments_json), url(r'^agenda/%(owner)s/%(schedule_name)s.json$' % settings.URL_REGEXPS, ajax.schedule_infourl), + url(r'^agenda/%(owner)s/%(schedule_name)s/copy/$' % settings.URL_REGEXPS, views.copy_meeting_schedule), url(r'^agenda/by-room$', views.agenda_by_room), url(r'^agenda/by-type$', views.agenda_by_type), url(r'^agenda/by-type/(?P[a-z]+)$', views.agenda_by_type), @@ -76,6 +78,7 @@ type_ietf_only_patterns_id_optional = [ url(r'^agenda(?P.txt)$', views.agenda), url(r'^agenda(?P.csv)$', views.agenda), url(r'^agenda/edit$', views.edit_schedule), + url(r'^agenda/edit/$', views.edit_meeting_schedule), url(r'^requests$', views.meeting_requests), url(r'^agenda/agenda\.ics$', views.ical_agenda), url(r'^agenda\.ics$', views.ical_agenda), diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 75999e6fa..8d99ed62e 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -4,6 +4,7 @@ from __future__ import absolute_import, print_function, unicode_literals +from collections import defaultdict import csv import datetime import glob @@ -42,6 +43,7 @@ from django.template.loader import render_to_string from django.utils.functional import curry from django.views.decorators.cache import cache_page from django.utils.text import slugify +from django.utils.html import escape from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt from django.views.generic import RedirectView @@ -349,6 +351,266 @@ def edit_timeslots(request, num=None): "ts_list":ts_list, }) +class CopyScheduleForm(forms.ModelForm): + class Meta: + model = Schedule + fields = ['name', 'visible', 'public'] + + def __init__(self, schedule, new_owner, *args, **kwargs): + super(CopyScheduleForm, self).__init__(*args, **kwargs) + + self.schedule = schedule + self.new_owner = new_owner + + username = new_owner.user.username + + name_suggestion = username + counter = 2 + + existing_names = set(Schedule.objects.filter(meeting=schedule.meeting_id, owner=new_owner).values_list('name', flat=True)) + while name_suggestion in existing_names: + name_suggestion = username + str(counter) + counter += 1 + + self.fields['name'].initial = name_suggestion + self.fields['name'].label = "Name of new schedule" + + def clean_name(self): + name = self.cleaned_data.get('name') + if name and Schedule.objects.filter(meeting=self.schedule.meeting_id, owner=self.new_owner, name=name): + raise forms.ValidationError("Schedule with this name already exists.") + return name + +@role_required('Area Director','Secretariat') +def copy_meeting_schedule(request, num, owner, name): + meeting = get_meeting(num) + schedule = get_object_or_404(meeting.schedule_set, owner__email__address=owner, name=name) + + if request.method == 'POST': + form = CopyScheduleForm(schedule, request.user.person, request.POST) + + if form.is_valid(): + new_schedule = form.save(commit=False) + new_schedule.meeting = schedule.meeting + new_schedule.owner = request.user.person + new_schedule.save() + + # keep a mapping so that extendedfrom references can be chased + old_pk_to_new_pk = {} + extendedfroms = {} + for assignment in schedule.assignments.all(): + extendedfrom_id = assignment.extendedfrom_id + + # clone by resetting primary key + old_pk = assignment.pk + assignment.pk = None + assignment.schedule = new_schedule + assignment.extendedfrom = None + assignment.save() + + old_pk_to_new_pk[old_pk] = assignment.pk + if extendedfrom_id is not None: + extendedfroms[assignment.pk] = extendedfrom_id + + for pk, extendedfrom_id in extendedfroms.values(): + if extendedfrom_id in old_pk_to_new_pk: + SchedTimeSessAssignment.objects.filter(pk=pk).update(extendedfrom=old_pk_to_new_pk[extendedfrom_id]) + + # now redirect to this new schedule + return redirect(edit_meeting_schedule, meeting.number, new_schedule.owner_email(), new_schedule.name) + + else: + form = CopyScheduleForm(schedule, request.user.person) + + return render(request, "meeting/copy_meeting_schedule.html", { + 'meeting': meeting, + 'schedule': schedule, + 'form': form, + }) + + +@ensure_csrf_cookie +def edit_meeting_schedule(request, num=None, owner=None, name=None): + meeting = get_meeting(num) + if name is None: + schedule = meeting.schedule + else: + schedule = get_schedule_by_name(meeting, get_person_by_email(owner), name) + + if schedule is None: + raise Http404("No meeting information for meeting %s owner %s schedule %s available" % (num, owner, name)) + + can_see, can_edit, secretariat = schedule_permissions(meeting, schedule, request.user) + + if not can_see: + if request.method == 'POST': + return HttpResponseForbidden("Can't view this schedule") + + # FIXME: check this + return render(request, "meeting/private_schedule.html", + {"schedule":schedule, + "meeting": meeting, + "meeting_base_url": request.build_absolute_uri(meeting.base_url()), + "hide_menu": True + }, status=403, content_type="text/html") + + assignments = get_all_assignments_from_schedule(schedule) + + # FIXME + #areas = get_areas() + #ads = find_ads_for_meeting(meeting) + + css_ems_per_hour = 1.5 + + rooms = meeting.room_set.filter(session_types__slug='regular').distinct().order_by("capacity") + timeslots_qs = meeting.timeslot_set.filter(type='regular').prefetch_related('type', 'sessions').order_by('time', 'location', 'name') + + sessions = add_event_info_to_session_qs( + Session.objects.filter( + meeting=meeting, + # Restrict graphical scheduling to regular meeting requests (Sessions) for now + type='regular', + ), + requested_time=True, + requested_by=True, + ).exclude(current_status__in=['notmeet', 'disappr', 'deleted', 'apprw']).prefetch_related( + 'resources', 'group', 'group__parent', + ) + + if request.method == 'POST': + if not can_edit: + return HttpResponseForbidden("Can't edit this schedule") + + action = request.POST.get('action') + + 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']) + + existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule) + if existing_assignments: + existing_assignments.update(timeslot=timeslot, modified=datetime.datetime.now()) + else: + SchedTimeSessAssignment.objects.create( + session=session, + schedule=schedule, + timeslot=timeslot, + ) + + return HttpResponse("OK") + + elif action == 'unassign' and request.POST.get('session', '').isdigit(): + session = get_object_or_404(sessions, pk=request.POST['session']) + SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule).delete() + + return HttpResponse("OK") + + return HttpResponse("Invalid parameters", status_code=400) + + assignments_by_session = defaultdict(list) + for a in assignments: + assignments_by_session[a.session_id].append(a) + + # prepare timeslot matrix + times = {} # start time -> end time + timeslots = {} + timeslots_by_pk = {} + for ts in timeslots_qs: + ts_end_time = ts.end_time() + if ts_end_time < times.get(ts.time, datetime.datetime.max): + times[ts.time] = ts_end_time + + timeslots[(ts.location_id, ts.time)] = ts + timeslots_by_pk[ts.pk] = ts + ts.session_assignments = [] + + timeslot_matrix = [ + (start_time, end_time, (end_time - start_time).seconds / 60.0 / 60.0 * css_ems_per_hour, [(r, timeslots.get((r.pk, start_time))) for r in rooms]) + for start_time, end_time in sorted(times.items()) + ] + + # prepare sessions + unassigned_sessions = [] + session_data = [] + for s in sessions: + d = { + 'id': s.pk, + } + + if s.requested_by: + d['requested_by'] = s.requested_by + if s.requested_time: + d['requested_time'] = s.requested_time.isoformat() + + room_resources = s.resources.all() + if room_resources: + d['room_resources'] = [ + { + 'name': escape(r.name.name), + } + for r in room_resources + ] + + if s.group: + label = escape(s.group.acronym) + elif s.name: + label = escape(s.name) + else: + label = "???" + + s.scheduling_label = label + + if s.attendees is not None: + d['attendees'] = s.attendees + + if s.group and s.group.is_bof(): + d['bof'] = True + + if s.group and s.group.parent: + d['group_parent'] = escape(s.group.parent.acronym.upper()) + + if s.comments: + d['comments'] = s.comments + + s.requested_duration_in_hours = s.requested_duration.seconds / 60.0 / 60.0 + s.scheduling_height = s.requested_duration_in_hours * css_ems_per_hour + + scheduled = False + + for a in assignments_by_session.get(s.pk, []): + timeslot = timeslots_by_pk.get(a.timeslot_id) + if timeslot: + timeslot.session_assignments.append((a, s)) + scheduled = True + + session_data.append(d) + + if not scheduled: + unassigned_sessions.append(s) + + schedule_data = { + 'schedule': schedule.id, + 'sessions': session_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, + 'schedule_data': json.dumps(schedule_data, indent=2), + 'rooms': rooms, + 'timeslot_matrix': timeslot_matrix, + 'unassigned_sessions': unassigned_sessions, + 'timeslot_width': (100.0 - 10) / len(rooms), + #'areas': areas, + 'hide_menu': True, + }) + + ############################################################################## #@role_required('Area Director','Secretariat') # disable the above security for now, check it below. @@ -421,6 +683,7 @@ def edit_schedule(request, num=None, owner=None, name=None): "hide_menu": True, }) + ############################################################################## # show the properties associated with a schedule (visible, public) # @@ -446,7 +709,7 @@ def edit_schedule_properties(request, num=None, owner=None, name=None): if form.is_valid(): form.save() return HttpResponseRedirect(reverse('ietf.meeting.views.list_schedules',kwargs={'num': num})) - else: + else: form = SchedulePropertiesForm(instance=schedule) return render(request, "meeting/properties_edit.html", {"schedule":schedule, diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 28dc5082c..3f8ef79f6 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -983,3 +983,80 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { padding: 8px; border-top: 1px solid #ddd; } + +/* === Edit Meeting Schedule ====================================== */ + +.edit-meeting-schedule { + padding-bottom: 10em; /* ensure there's room for the scheduling panel */ +} + +.edit-meeting-schedule .session { + background-color: #fff; + padding: 0 0.4em; + margin: 0.2em 0.4em; + border-radius: 0.3em; + text-align: center; +} + +.edit-meeting-schedule .session[draggable] { + cursor: grabbing; +} + +.edit-meeting-schedule .session.dragging { + opacity: 0.3; + transition: opacity 0.4s; +} + +.edit-meeting-schedule .session i.fa-comment-o { + width: 0; /* prevent icon from participating in text centering */ +} + +.edit-meeting-schedule .edit-meeting-grid th { + text-align: center; +} + +.edit-meeting-schedule .edit-meeting-grid td.day { + padding-top: 1em; +} + +.edit-meeting-schedule .edit-meeting-grid td.timeslot { + border: 2px solid #fff; + background-color: #f6f6f6; + padding: 1px; +} + +.edit-meeting-schedule .edit-meeting-grid td.timeslot.disabled { + background-color: #fff; +} + +.edit-meeting-schedule .edit-meeting-grid td.timeslot.dropping { + background-color: #f0f0f0; + transition: background-color 0.2s; +} + +.edit-meeting-schedule .scheduling-panel { + position: fixed; + bottom: 0; + left: 0; + margin: 0; + padding: 0 1em; + width: 100%; + background-color: #fff; + opacity: 0.95; + z-index: 1; +} + +.edit-meeting-schedule .unassigned-sessions { + min-height: 4em; + background-color: #f6f6f6; +} + +.edit-meeting-schedule .unassigned-sessions.dropping { + background-color: #f0f0f0; + transition: background-color 0.2s; +} + +.edit-meeting-schedule .unassigned-sessions .session { + display: inline-block; + min-width: 6em; +} diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js new file mode 100644 index 000000000..27fc5ef42 --- /dev/null +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -0,0 +1,94 @@ +jQuery(document).ready(function () { + if (!ietfScheduleData.can_edit) + return; + + var content = jQuery(".edit-meeting-schedule"); + + function failHandler(xhr, textStatus, error) { + alert("Error: " + error); + } + + var sessions = content.find(".session"); + + // dragging + sessions.on("dragstart", function (event) { + event.originalEvent.dataTransfer.setData("text/plain", this.id); + jQuery(this).addClass("dragging"); + }); + sessions.on("dragend", function () { + jQuery(this).removeClass("dragging"); + + }); + + sessions.prop('draggable', true); + + // dropping + var dropElements = content.find(".timeslot,.unassigned-sessions"); + dropElements.on('dragenter', function (event) { + if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") + return; + + if (jQuery(this).hasClass("disabled")) + return; + + event.preventDefault(); // default action is signalling that this is not a valid target + jQuery(this).addClass("dropping"); + }); + + dropElements.on('dragover', function (event) { + // we don't actually need this event, except we need to signal + // that this is a valid drop target, by cancelling the default + // action + event.preventDefault(); + }); + + dropElements.on('dragleave', function (event) { + jQuery(this).removeClass("dropping"); + }); + + dropElements.on('drop', function (event) { + jQuery(this).removeClass("dropping"); + + var sessionId = event.originalEvent.dataTransfer.getData("text/plain"); + if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") + return; + + var sessionElement = sessions.filter("#" + sessionId); + if (sessionElement.length == 0) + return; + + event.preventDefault(); // prevent opening as link + + if (sessionElement.parent().is(this)) + return; + + var dropElement = jQuery(this); + + function done() { + dropElement.append(sessionElement); // move element + } + + if (dropElement.hasClass("unassigned-sessions")) { + jQuery.ajax({ + url: ietfScheduleData.urls.assign, + method: "post", + data: { + action: "unassign", + session: sessionId.slice("session".length) + } + }).fail(failHandler).done(done); + } + else { + jQuery.ajax({ + url: ietfScheduleData.urls.assign, + method: "post", + data: { + action: "assign", + session: sessionId.slice("session".length), + timeslot: dropElement.data("timeslot") + } + }).fail(failHandler).done(done); + } + }); +}); + diff --git a/ietf/templates/meeting/copy_meeting_schedule.html b/ietf/templates/meeting/copy_meeting_schedule.html new file mode 100644 index 000000000..ad40ede01 --- /dev/null +++ b/ietf/templates/meeting/copy_meeting_schedule.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015-2020, All Rights Reserved #} +{% load origin %} +{% load staticfiles %} +{% load ietf_filters %} +{% load bootstrap3 %} + +{% block content %} + {% origin %} +

{% block title %}Copy schedule {{ schedule.name }}{% endblock %}

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + + {% endbuttons %} +
+{% endblock %} diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html new file mode 100644 index 000000000..3fac4309b --- /dev/null +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{# Copyright The IETF Trust 2015-2020, All Rights Reserved #} +{% load origin %} +{% load staticfiles %} +{% load ietf_filters %} + +{% block morecss %} + {# FIXME #} + {% for area in area_list %} + .group-colored-{{ area.upcase_acronym}} { + border-color: {{ area.fg_color}}; + color:{{ area.fg_color }}; + background-color: {{ area.bg_color }} + } + {% endfor %} +{% endblock morecss %} + +{% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting schedule{% endblock %} + +{% block js %} + + + +{% endblock js %} + + +{% block content %} + {% origin %} +
+ +

+ Copy schedule + · + + All schedules for meeting +

+ +

+ Schedule name: {{ schedule.name }} + + · + + Owner: {{ schedule.owner }} + + {% if not can_edit %} + · + + You can't edit this schedule. Take a copy first. + {% endif %} +

+ + + + + + {% for r in rooms %} + + {% endfor %} + + + + + {% for start_time, end_time, hours, room_timeslots in timeslot_matrix %} + {% ifchanged %} + + + + {% endifchanged %} + + + + + {% for r, timeslot in room_timeslots %} + + {% endfor %} + + {% endfor %} + +
{{ r.name }}{% if r.capacity %} - {{ r.capacity }} {% endif %}
+ {{ start_time|date:"l, F j, Y" }} +
+
{{ start_time|date:"G:i" }}-{{ end_time|date:"G:i" }}
+
+ {% for assignment, session in timeslot.session_assignments %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} +
+ +
+

Unscheduled

+
+ {% for session in unassigned_sessions %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} +
+
+ +
+{% endblock %} diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html new file mode 100644 index 000000000..7d9828a49 --- /dev/null +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -0,0 +1,3 @@ +
+ {{ session.scheduling_label }} {% if session.comments %}{% endif %} +
From 5faccf5379e55329f32e2999ca2fdaf82a511012 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 9 Mar 2020 19:40:31 +0000 Subject: [PATCH 3/9] Put the time slots in the new meeting schedule editor on a time scale per day. This opens the possibility of having the slots not match across the different rooms. Add colors to session in areas/IRTF. Add support for toggling display of areas/IRTF. Show graphical hint when a time slot is overfull. - Legacy-Id: 17415 --- ietf/meeting/tests_views.py | 7 +- ietf/meeting/views.py | 129 ++++++++++++++---- ietf/static/ietf/css/ietf.css | 129 ++++++++++++++---- ietf/static/ietf/js/edit-meeting-schedule.js | 34 +++++ .../meeting/edit_meeting_schedule.html | 90 ++++++------ .../edit_meeting_schedule_session.html | 2 +- 6 files changed, 293 insertions(+), 98 deletions(-) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 7581b8758..369a6dfec 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -927,12 +927,11 @@ class EditTests(TestCase): q = PyQuery(r.content) room = Room.objects.get(meeting=meeting, session_types='regular') - self.assertTrue(q("th:contains(\"{}\")".format(room.name))) - self.assertTrue(q("th:contains(\"{}\")".format(room.capacity))) + self.assertTrue(q("h5:contains(\"{}\")".format(room.name))) + self.assertTrue(q("h5:contains(\"{}\")".format(room.capacity))) timeslots = TimeSlot.objects.filter(meeting=meeting, type='regular') - self.assertTrue(q("td:contains(\"{}\")".format(timeslots[0].time.strftime("%H:%M")))) - self.assertTrue(q("td.timeslot[data-timeslot=\"{}\"]".format(timeslots[0].pk))) + self.assertTrue(q("div.timeslot[data-timeslot=\"{}\"]".format(timeslots[0].pk))) sessions = Session.objects.filter(meeting=meeting, type='regular') for s in sessions: diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 8d99ed62e..3876ccbca 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -11,6 +11,7 @@ import glob import io import itertools import json +import math import os import pytz import re @@ -456,14 +457,8 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): assignments = get_all_assignments_from_schedule(schedule) - # FIXME - #areas = get_areas() - #ads = find_ads_for_meeting(meeting) - - css_ems_per_hour = 1.5 - rooms = meeting.room_set.filter(session_types__slug='regular').distinct().order_by("capacity") - timeslots_qs = meeting.timeslot_set.filter(type='regular').prefetch_related('type', 'sessions').order_by('time', 'location', 'name') + timeslots_qs = meeting.timeslot_set.filter(type='regular').prefetch_related('type', 'sessions').order_by('location', 'time', 'name') sessions = add_event_info_to_session_qs( Session.objects.filter( @@ -511,25 +506,106 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): for a in assignments: assignments_by_session[a.session_id].append(a) - # prepare timeslot matrix - times = {} # start time -> end time - timeslots = {} - timeslots_by_pk = {} - for ts in timeslots_qs: - ts_end_time = ts.end_time() - if ts_end_time < times.get(ts.time, datetime.datetime.max): - times[ts.time] = ts_end_time + # Prepare timeslot layout. We arrange time slots in columns per + # room where everything inside is grouped by day. Things inside + # the days are then layouted proportionally to the actual time of + # day, to ensure that everything lines up, even if the time slots + # are not the same in the different rooms. - timeslots[(ts.location_id, ts.time)] = ts - timeslots_by_pk[ts.pk] = ts - ts.session_assignments = [] + def timedelta_to_css_ems(timedelta): + css_ems_per_hour = 1.8 + return timedelta.seconds / 60.0 / 60.0 * css_ems_per_hour - timeslot_matrix = [ - (start_time, end_time, (end_time - start_time).seconds / 60.0 / 60.0 * css_ems_per_hour, [(r, timeslots.get((r.pk, start_time))) for r in rooms]) - for start_time, end_time in sorted(times.items()) - ] + # time labels column + timeslots_by_day = defaultdict(list) + for t in timeslots_qs: + timeslots_by_day[t.time.date()].append(t) + + day_min_max = [] + for day, timeslots in sorted(timeslots_by_day.iteritems()): + day_min_max.append((day, min(t.time for t in timeslots), max(t.end_time() for t in timeslots))) + + time_labels = [] + for day, day_min_time, day_max_time in day_min_max: + day_labels = [] + + hourly_delta = 2 + + first_hour = int(math.ceil((day_min_time.hour + day_min_time.minute / 60.0) / hourly_delta) * hourly_delta) + t = day_min_time.replace(hour=first_hour, minute=0, second=0, microsecond=0) + + last_hour = int(math.floor((day_max_time.hour + day_max_time.minute / 60.0) / hourly_delta) * hourly_delta) + end = day_max_time.replace(hour=last_hour, minute=0, second=0, microsecond=0) + + while t <= end: + day_labels.append((t, 'top', timedelta_to_css_ems(t - day_min_time), 'left')) + t += datetime.timedelta(seconds=hourly_delta * 60 * 60) + + if not day_labels: + day_labels.append((day_min_time, 'top', 0, 'left')) + + time_labels.append({ + 'day': day, + 'height': timedelta_to_css_ems(day_max_time - day_min_time), + 'labels': day_labels, + }) + + # room columns + timeslots_by_room_and_day = defaultdict(list) + for t in timeslots_qs: + timeslots_by_room_and_day[(t.location_id, t.time.date())].append(t) + + room_columns = [] + for r in rooms: + room_days = [] + + for day, day_min_time, day_max_time in day_min_max: + day_timeslots = [] + for t in timeslots_by_room_and_day.get((r.pk, day), []): + day_timeslots.append({ + 'timeslot': t, + 'offset': timedelta_to_css_ems(t.time - day_min_time), + 'height': timedelta_to_css_ems(t.end_time() - t.time), + }) + + room_days.append({ + 'day': day, + 'timeslots': day_timeslots, + 'height': timedelta_to_css_ems(day_max_time - day_min_time), + }) + + if any(d['timeslots'] for d in room_days): + room_columns.append({ + 'room': r, + 'days': room_days, + }) # prepare sessions + for ts in timeslots_qs: + ts.session_assignments = [] + timeslots_by_pk = {ts.pk: ts for ts in timeslots_qs} + + def cubehelix(i, total, hue=1.2, start_angle=0.5): + # https://arxiv.org/pdf/1108.5083.pdf + rotations = total // 4 + x = float(i + 1) / (total + 1) + phi = 2 * math.pi * (start_angle / 3 + rotations * x) + a = hue * x * (1 - x) / 2.0 + + return ( + max(0, min(x + a * (-0.14861 * math.cos(phi) + 1.78277 * math.sin(phi)), 1)), + max(0, min(x + a * (-0.29227 * math.cos(phi) + -0.90649 * math.sin(phi)), 1)), + max(0, min(x + a * (1.97294 * math.cos(phi)), 1)), + ) + + session_parents = sorted(set( + s.group.parent for s in sessions + if s.group and s.group.parent and s.group.parent.type_id == 'area' or s.group.parent.acronym == 'irtf' + ), key=lambda p: p.acronym) + for i, p in enumerate(session_parents): + rgb_color = cubehelix(i, len(session_parents)) + p.scheduling_color = "#" + "".join(chr(int(round(x * 255))).encode('hex') for x in rgb_color) + unassigned_sessions = [] session_data = [] for s in sessions: @@ -573,7 +649,8 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): d['comments'] = s.comments s.requested_duration_in_hours = s.requested_duration.seconds / 60.0 / 60.0 - s.scheduling_height = s.requested_duration_in_hours * css_ems_per_hour + s.layout_height = timedelta_to_css_ems(s.requested_duration) + s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else "" scheduled = False @@ -602,11 +679,11 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): 'schedule': schedule, 'can_edit': can_edit, 'schedule_data': json.dumps(schedule_data, indent=2), + 'time_labels': time_labels, 'rooms': rooms, - 'timeslot_matrix': timeslot_matrix, + 'room_columns': room_columns, 'unassigned_sessions': unassigned_sessions, - 'timeslot_width': (100.0 - 10) / len(rooms), - #'areas': areas, + 'session_parents': session_parents, 'hide_menu': True, }) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 3f8ef79f6..9177d288a 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -990,57 +990,83 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { padding-bottom: 10em; /* ensure there's room for the scheduling panel */ } -.edit-meeting-schedule .session { +.edit-meeting-schedule .edit-grid { + display: flex; +} + +.edit-meeting-schedule .schedule-column h5 { + text-align: center; + margin: 0; + white-space: nowrap; +} + +.edit-meeting-schedule .schedule-column .day { + position: relative; + margin-bottom: 3em; +} + +.edit-meeting-schedule .schedule-column .day > div { + position: absolute; +} + +.edit-meeting-schedule .time-labels-column > div { + min-width: 5em; + padding-right: 0.5em; +} + +.edit-meeting-schedule .time-labels-column .time-label { + width: 100%; +} + +.edit-meeting-schedule .time-labels-column .time-label.top-aligned { + border-top: 1px solid #ccc; +} + +.edit-meeting-schedule .time-labels-column .time-label.text-left span { background-color: #fff; - padding: 0 0.4em; - margin: 0.2em 0.4em; - border-radius: 0.3em; - text-align: center; + padding-right: 0.2em; } -.edit-meeting-schedule .session[draggable] { - cursor: grabbing; +.edit-meeting-schedule .time-labels-column .time-label.bottom-aligned { + border-bottom: 1px solid #ccc; } -.edit-meeting-schedule .session.dragging { - opacity: 0.3; - transition: opacity 0.4s; +.edit-meeting-schedule .room-column { + flex-grow: 1; } -.edit-meeting-schedule .session i.fa-comment-o { - width: 0; /* prevent icon from participating in text centering */ +.edit-meeting-schedule .room-column .day-label { + visibility: hidden; /* it's there to take up the space, but not shown */ } -.edit-meeting-schedule .edit-meeting-grid th { - text-align: center; -} - -.edit-meeting-schedule .edit-meeting-grid td.day { - padding-top: 1em; -} - -.edit-meeting-schedule .edit-meeting-grid td.timeslot { - border: 2px solid #fff; +.edit-meeting-schedule .timeslot { + display: flex; + flex-direction: column; background-color: #f6f6f6; - padding: 1px; + width: 100%; + border-right: 0.2em solid #fff; + border-left: 0.2em solid #fff; + overflow: hidden; } -.edit-meeting-schedule .edit-meeting-grid td.timeslot.disabled { - background-color: #fff; -} - -.edit-meeting-schedule .edit-meeting-grid td.timeslot.dropping { +.edit-meeting-schedule .timeslot.dropping { background-color: #f0f0f0; transition: background-color 0.2s; } +.edit-meeting-schedule .timeslot.overfull { + border-bottom: 2px dashed #ddd; +} + .edit-meeting-schedule .scheduling-panel { - position: fixed; + position: fixed; /* backwards compatibility */ + position: sticky; bottom: 0; left: 0; margin: 0; padding: 0 1em; width: 100%; + border-top: 0.2em solid #eee; background-color: #fff; opacity: 0.95; z-index: 1; @@ -1056,7 +1082,52 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { transition: background-color 0.2s; } +.edit-meeting-schedule .session-parent-toggles { + margin-top: 1em; +} + +.edit-meeting-schedule .session-parent-toggles label { + font-weight: normal; + margin-right: 1em; + padding: 0 1em; + border: 0.1em solid #eee; + cursor: pointer; +} + +/* sessions */ +.edit-meeting-schedule .session { + background-color: #fff; + padding: 0 0.2em; + padding-left: 0.5em; + border: 0.2em solid #f6f6f6; /* this compensates for sessions being relatively smaller than they should */ + border-radius: 0.4em; + text-align: center; + overflow: hidden; +} + +.edit-meeting-schedule .session[draggable] { + cursor: grabbing; +} + +.edit-meeting-schedule .session.dragging { + opacity: 0.3; + transition: opacity 0.4s; +} + +.edit-meeting-schedule .session .color { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: middle; +} + +.edit-meeting-schedule .session i.fa-comment-o { + width: 0; /* prevent icon from participating in text centering */ +} + .edit-meeting-schedule .unassigned-sessions .session { + vertical-align: top; display: inline-block; min-width: 6em; + margin-right: 0.4em; } diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index 27fc5ef42..730752dbb 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -9,6 +9,7 @@ jQuery(document).ready(function () { } var sessions = content.find(".session"); + var timeslots = content.find(".timeslot"); // dragging sessions.on("dragstart", function (event) { @@ -66,6 +67,7 @@ jQuery(document).ready(function () { function done() { dropElement.append(sessionElement); // move element + maintainTimeSlotHints(); } if (dropElement.hasClass("unassigned-sessions")) { @@ -90,5 +92,37 @@ jQuery(document).ready(function () { }).fail(failHandler).done(done); } }); + + + // hints + function maintainTimeSlotHints() { + timeslots.each(function () { + var total = 0; + jQuery(this).find(".session").each(function () { + total += +jQuery(this).data("duration"); + }); + + jQuery(this).toggleClass("overfull", total > +jQuery(this).data("duration")); + }); + } + + maintainTimeSlotHints(); + + // toggling of parents + var sessionParentInputs = content.find(".session-parent-toggles input"); + + function maintainSessionParentToggling() { + var checked = []; + sessionParentInputs.filter(":checked").each(function () { + checked.push(".parent-" + this.value); + }); + + sessions.filter(".toggleable").filter(checked.join(",")).show(); + sessions.filter(".toggleable").not(checked.join(",")).hide(); + } + + sessionParentInputs.on("click", maintainSessionParentToggling); + + maintainSessionParentToggling(); }); diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 3fac4309b..14007b6b8 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -5,12 +5,9 @@ {% load ietf_filters %} {% block morecss %} - {# FIXME #} - {% for area in area_list %} - .group-colored-{{ area.upcase_acronym}} { - border-color: {{ area.fg_color}}; - color:{{ area.fg_color }}; - background-color: {{ area.bg_color }} + {% for parent in session_parents %} + .parent-{{ parent.acronym }} { + background: linear-gradient(to right, {{ parent.scheduling_color }} 0.4em, #fff 0.5em); } {% endfor %} {% endblock morecss %} @@ -60,50 +57,67 @@ {% endif %}

- - - - - {% for r in rooms %} - - {% endfor %} - - +
+ {# note: in order for all this to align properly, make sure there's the same markup in all columns #} -
- {% for start_time, end_time, hours, room_timeslots in timeslot_matrix %} - {% ifchanged %} - - - - {% endifchanged %} +
+
 
-
- + {% for d in time_labels %} +
+ {{ d.day|date:"D" }}
+ {{ d.day|date:"Y-m-d" }} +
- {% for r, timeslot in room_timeslots %} - +
+ {% for t, vertical_alignment, vertical_offset, horizontal_alignment in d.labels %} +
+ {{ t|date:"H:i" }} +
{% endfor %} -
+ {% endfor %} - -
{{ r.name }}{% if r.capacity %} - {{ r.capacity }} {% endif %}
- {{ start_time|date:"l, F j, Y" }} -
-
{{ start_time|date:"G:i" }}-{{ end_time|date:"G:i" }}
-
- {% for assignment, session in timeslot.session_assignments %} - {% include "meeting/edit_meeting_schedule_session.html" %} - {% endfor %} -
+ + + {% for r in room_columns %} +
+
{{ r.room.name }}{% if r.room.capacity %} ({{ r.room.capacity }} {% endif %})
+ + {% for d in r.days %} +
+ {{ d.day|date:"D" }}
+ {{ d.day|date:"Y-m-d" }} +
+ +
+ {% for t in d.timeslots %} +
+ {% for assignment, session in t.timeslot.session_assignments %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
-

Unscheduled

+
Not yet assigned
+
{% for session in unassigned_sessions %} {% include "meeting/edit_meeting_schedule_session.html" %} {% endfor %}
+ +
+ Show: + {% for p in session_parents %} + + {% endfor %} +
diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html index 7d9828a49..c64616ba1 100644 --- a/ietf/templates/meeting/edit_meeting_schedule_session.html +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -1,3 +1,3 @@ -
+
{{ session.scheduling_label }} {% if session.comments %}{% endif %}
From e5943f814d60271c62b2851fb26505db4935202b Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 23 Mar 2020 17:55:36 +0000 Subject: [PATCH 4/9] Add support for displaying constraint hints when scheduling a task and for displaying violated constraints in the new schedule editor, with the old of a new field, ConstraintName.editor_label. Add support for displaying room capacity violations. Add support for selecting a session and displaying information about it similar to the existing scheduling editor. Add support for sorting unassigned sessions. Clean up markup and styles a bit, and fix some bugs. Expand HTML-based test and add JS test that exercises the Javascript-based functionality. Switch to using Chrome driver instead of PhantomJS since the HTML engine in PhantomJS is apparently too old to support the constructs in the new schema editor. Add a workaround for LiveServerTestCase clashing with IetfTestRunner fixture loading. - Legacy-Id: 17519 --- docker/Dockerfile | 10 +- ietf/meeting/test_data.py | 14 +- ietf/meeting/tests_js.py | 245 ++++++++++++++---- ietf/meeting/tests_views.py | 94 +++++-- ietf/meeting/views.py | 160 ++++++++---- ietf/name/fixtures/names.json | 4 + .../0010_constraintname_editor_label.py | 36 +++ ietf/name/models.py | 3 +- ietf/static/ietf/css/ietf.css | 175 ++++++++++--- ietf/static/ietf/js/edit-meeting-schedule.js | 243 +++++++++++++++-- .../meeting/copy_meeting_schedule.html | 2 +- .../meeting/edit_meeting_schedule.html | 72 +++-- .../edit_meeting_schedule_session.html | 63 ++++- ietf/utils/test_runner.py | 49 +++- 14 files changed, 911 insertions(+), 259 deletions(-) create mode 100644 ietf/name/migrations/0010_constraintname_editor_label.py diff --git a/docker/Dockerfile b/docker/Dockerfile index db0ad6176..6e52f7b50 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -54,6 +54,7 @@ RUN apt-get install -qy \ build-essential \ bzip2 \ ca-certificates \ + chromium-driver \ colordiff \ gawk \ gcc \ @@ -139,15 +140,6 @@ RUN wget -q https://bootstrap.pypa.io/get-pip.py && python get-pip.py && rm get- RUN pip install certifi RUN pip install virtualenv -# Phantomjs -WORKDIR /usr/local - -RUN wget -qN https://tools.ietf.org/tar/phantomjs-1.9.8-linux-x86_64.tar.bz2 -RUN tar xjf phantomjs-1.9.8-linux-x86_64.tar.bz2 - -WORKDIR /usr/local/bin -RUN ln -s /usr/local/phantomjs-1.9.8-linux-x86_64/bin/phantomjs . - # idnits and dependencies ADD https://tools.ietf.org/tools/idnits/idnits /usr/local/bin/ RUN chmod +rx /usr/local/bin/idnits diff --git a/ietf/meeting/test_data.py b/ietf/meeting/test_data.py index 2a61c9913..7deb2c47f 100644 --- a/ietf/meeting/test_data.py +++ b/ietf/meeting/test_data.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2013-2019, All Rights Reserved +# Copyright The IETF Trust 2013-2020, All Rights Reserved # -*- coding: utf-8 -*- @@ -101,11 +101,11 @@ def make_meeting_test_data(meeting=None): # slots session_date = meeting.date + datetime.timedelta(days=1) slot1 = TimeSlot.objects.create(meeting=meeting, type_id='regular', location=room, - duration=datetime.timedelta(minutes=30), + duration=datetime.timedelta(minutes=60), time=datetime.datetime.combine(session_date, datetime.time(9, 30))) slot2 = TimeSlot.objects.create(meeting=meeting, type_id='regular', location=room, - duration=datetime.timedelta(minutes=30), - time=datetime.datetime.combine(session_date, datetime.time(10, 30))) + duration=datetime.timedelta(minutes=60), + time=datetime.datetime.combine(session_date, datetime.time(10, 50))) breakfast_slot = TimeSlot.objects.create(meeting=meeting, type_id="lead", location=breakfast_room, duration=datetime.timedelta(minutes=90), time=datetime.datetime.combine(session_date, datetime.time(7,0))) @@ -118,7 +118,7 @@ def make_meeting_test_data(meeting=None): # mars WG mars = Group.objects.get(acronym='mars') mars_session = Session.objects.create(meeting=meeting, group=mars, - attendees=10, requested_duration=datetime.timedelta(minutes=20), + attendees=10, requested_duration=datetime.timedelta(minutes=50), type_id='regular') SchedulingEvent.objects.create(session=mars_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=slot1, session=mars_session, schedule=schedule) @@ -127,7 +127,7 @@ def make_meeting_test_data(meeting=None): # ames WG ames_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="ames"), attendees=10, - requested_duration=datetime.timedelta(minutes=20), + requested_duration=datetime.timedelta(minutes=60), type_id='regular') SchedulingEvent.objects.create(session=ames_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=slot2, session=ames_session, schedule=schedule) @@ -136,7 +136,7 @@ def make_meeting_test_data(meeting=None): # IESG breakfast iesg_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="iesg"), name="IESG Breakfast", attendees=25, - requested_duration=datetime.timedelta(minutes=20), + requested_duration=datetime.timedelta(minutes=60), type_id="lead") SchedulingEvent.objects.create(session=iesg_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=breakfast_slot, session=iesg_session, schedule=schedule) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index bf756ddcd..f96fd2d9a 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2014-2019, All Rights Reserved +# Copyright The IETF Trust 2014-2020, All Rights Reserved # -*- coding: utf-8 -*- @@ -6,10 +6,10 @@ from __future__ import absolute_import, print_function, unicode_literals import sys import time +import datetime from pyquery import PyQuery from unittest import skipIf -from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.urls import reverse as urlreverse #from django.test.utils import override_settings @@ -19,9 +19,8 @@ from ietf.doc.factories import DocumentFactory from ietf.group import colors from ietf.meeting.factories import SessionFactory from ietf.meeting.test_data import make_meeting_test_data -from ietf.meeting.models import SchedTimeSessAssignment -from ietf.name.models import SessionStatusName -from ietf.utils.test_runner import set_coverage_checking +from ietf.meeting.models import Schedule, SchedTimeSessAssignment, Session, Room, TimeSlot, Constraint, ConstraintName +from ietf.utils.test_runner import IetfLiveServerTestCase from ietf.utils.pipe import pipe from ietf import settings @@ -33,43 +32,38 @@ else: try: from selenium import webdriver from selenium.webdriver.common.action_chains import ActionChains + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions except ImportError as e: skip_selenium = True skip_message = "Skipping selenium tests: %s" % e - code, out, err = pipe('phantomjs -v') - if not code == 0: + + executable_name = 'chromedriver' + code, out, err = pipe('{} --version'.format(executable_name)) + if code != 0: skip_selenium = True - skip_message = "Skipping selenium tests: 'phantomjs' executable not found." + skip_message = "Skipping selenium tests: '{}' executable not found.".format(executable_name) if skip_selenium: sys.stderr.write(" "+skip_message+'\n') -def condition_data(): - make_meeting_test_data() - colors.fg_group_colors['FARFUT'] = 'blue' - colors.bg_group_colors['FARFUT'] = 'white' +def start_web_driver(): + options = webdriver.ChromeOptions() + options.add_argument("headless") + options.add_argument("disable-extensions") + options.add_argument("disable-gpu") # headless needs this + return webdriver.Chrome(options=options, service_log_path=settings.TEST_GHOSTDRIVER_LOG_PATH) - @skipIf(skip_selenium, skip_message) -class ScheduleEditTests(StaticLiveServerTestCase): - @classmethod - def setUpClass(cls): - set_coverage_checking(False) - super(ScheduleEditTests, cls).setUpClass() - - @classmethod - def tearDownClass(cls): - super(ScheduleEditTests, cls).tearDownClass() - set_coverage_checking(True) - +class EditMeetingScheduleTests(IetfLiveServerTestCase): def setUp(self): - self.driver = webdriver.PhantomJS(port=0, service_log_path=settings.TEST_GHOSTDRIVER_LOG_PATH) + self.driver = start_web_driver() self.driver.set_window_size(1024,768) - condition_data() def tearDown(self): self.driver.close() - def debugSnapshot(self,filename='debug_this.png'): + def debug_snapshot(self,filename='debug_this.png'): self.driver.execute_script("document.body.bgColor = 'white';") self.driver.save_screenshot(filename) @@ -77,52 +71,197 @@ class ScheduleEditTests(StaticLiveServerTestCase): return '%s%s'%(self.live_server_url,urlreverse(*args,**kwargs)) def login(self): - url = '%s%s'%(self.live_server_url, urlreverse('ietf.ietfauth.views.login')) + url = self.absreverse('ietf.ietfauth.views.login') self.driver.get(url) self.driver.find_element_by_name('username').send_keys('plain') self.driver.find_element_by_name('password').send_keys('plain+password') self.driver.find_element_by_xpath('//button[@type="submit"]').click() - - def testUnschedule(self): + + def test_edit_meeting_schedule(self): + meeting = make_meeting_test_data() + + schedule = Schedule.objects.filter(meeting=meeting, owner__user__username="plain").first() + + room1 = Room.objects.get(name="Test Room") + slot1 = TimeSlot.objects.filter(meeting=meeting, location=room1).order_by('time').first() + + room2 = Room.objects.create(meeting=meeting, name="Test Room2", capacity=1) + room2.session_types.add('regular') + slot2 = TimeSlot.objects.create( + meeting=meeting, + type_id='regular', + location=room2, + duration=datetime.timedelta(hours=2), + time=slot1.time - datetime.timedelta(seconds=10 * 60), + ) + + s1, s2 = Session.objects.filter(meeting=meeting, type='regular') + s2.requested_duration = slot2.duration + datetime.timedelta(minutes=10) + s2.save() + SchedTimeSessAssignment.objects.filter(session=s1).delete() + + Constraint.objects.create( + meeting=meeting, + source=s1.group, + target=s2.group, + name=ConstraintName.objects.get(slug="conflict"), + ) + + self.login() + 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')), 2) + + # select - show session info + s2_element = self.driver.find_element_by_css_selector('#session{}'.format(s2.pk)) + s2_element.click() + + session_info_element = self.driver.find_element_by_css_selector('.session-info-container label') + self.assertIn(s2.group.acronym, session_info_element.text) + + # deselect + self.driver.find_element_by_css_selector('.session-info-container').click() + + self.assertEqual(self.driver.find_elements_by_css_selector('.session-info-container label'), []) + + # unschedule + + # we would like to do + # + # unassigned_sessions_element = self.driver.find_element_by_css_selector('.unassigned-sessions') + # ActionChains(self.driver).drag_and_drop(s2_element, unassigned_sessions_element).perform() + # + # but unfortunately, Selenium does not simulate drag and drop events, see + # + # https://github.com/seleniumhq/selenium-google-code-issue-archive/issues/3604 + # + # so for the time being we inject the Javascript workaround here and do it from JS + # + # https://storage.googleapis.com/google-code-attachments/selenium/issue-3604/comment-9/drag_and_drop_helper.js + + self.driver.execute_script('!function(s){s.fn.simulateDragDrop=function(t){return this.each(function(){new s.simulateDragDrop(this,t)})},s.simulateDragDrop=function(t,a){this.options=a,this.simulateEvent(t,a)},s.extend(s.simulateDragDrop.prototype,{simulateEvent:function(t,a){var e="dragstart",n=this.createEvent(e);this.dispatchEvent(t,e,n),e="drop";var r=this.createEvent(e,{});r.dataTransfer=n.dataTransfer,this.dispatchEvent(s(a.dropTarget)[0],e,r),e="dragend";var i=this.createEvent(e,{});i.dataTransfer=n.dataTransfer,this.dispatchEvent(t,e,i)},createEvent:function(t){var a=document.createEvent("CustomEvent");return a.initCustomEvent(t,!0,!0,null),a.dataTransfer={data:{},setData:function(t,a){this.data[t]=a},getData:function(t){return this.data[t]}},a},dispatchEvent:function(t,a,e){t.dispatchEvent?t.dispatchEvent(e):t.fireEvent&&t.fireEvent("on"+a,e)}})}(jQuery);') + + self.driver.execute_script("jQuery('#session{}').simulateDragDrop({{dropTarget: '.unassigned-sessions'}});".format(s2.pk)) + + WebDriverWait(self.driver, 2).until(expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.unassigned-sessions #session{}'.format(s2.pk)))) + + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(session=s2, schedule=schedule)), []) + + # sorting unassigned + sorted_pks = [s.pk for s in sorted([s1, s2], key=lambda s: s.group.acronym)] + self.driver.find_element_by_css_selector('[name=sort_unassigned] option[value=name]').click() + self.assertTrue(self.driver.find_element_by_css_selector('.unassigned-sessions #session{} + #session{}'.format(*sorted_pks))) + + sorted_pks = [s.pk for s in sorted([s1, s2], key=lambda s: (s.group.parent.acronym, s.group.acronym))] + self.driver.find_element_by_css_selector('[name=sort_unassigned] option[value=parent]').click() + self.assertTrue(self.driver.find_element_by_css_selector('.unassigned-sessions #session{} + #session{}'.format(*sorted_pks))) - self.assertEqual(SchedTimeSessAssignment.objects.filter(session__meeting__number=72,session__group__acronym='mars',schedule__name='test-schedule').count(),1) + sorted_pks = [s.pk for s in sorted([s1, s2], key=lambda s: (s.requested_duration, s.group.parent.acronym, s.group.acronym))] + self.driver.find_element_by_css_selector('[name=sort_unassigned] option[value=duration]').click() + self.assertTrue(self.driver.find_element_by_css_selector('.unassigned-sessions #session{} + #session{}'.format(*sorted_pks))) + + sorted_pks = [s.pk for s in sorted([s1, s2], key=lambda s: (bool(s.comments), s.group.parent.acronym, s.group.acronym))] + self.driver.find_element_by_css_selector('[name=sort_unassigned] option[value=comments]').click() + self.assertTrue(self.driver.find_element_by_css_selector('.unassigned-sessions #session{} + #session{}'.format(*sorted_pks))) + + # schedule + self.driver.execute_script("jQuery('#session{}').simulateDragDrop({{dropTarget: '#timeslot{}'}});".format(s2.pk, slot1.pk)) + + WebDriverWait(self.driver, 2).until(expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '#timeslot{} #session{}'.format(slot1.pk, s2.pk)))) + + assignment = SchedTimeSessAssignment.objects.get(session=s2, schedule=schedule) + self.assertEqual(assignment.timeslot, slot1) + + # reschedule + self.driver.execute_script("jQuery('#session{}').simulateDragDrop({{dropTarget: '#timeslot{}'}});".format(s2.pk, slot2.pk)) + + WebDriverWait(self.driver, 2).until(expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '#timeslot{} #session{}'.format(slot2.pk, s2.pk)))) + + assignment = SchedTimeSessAssignment.objects.get(session=s2, schedule=schedule) + self.assertEqual(assignment.timeslot, slot2) + + # too many attendees warning + self.assertTrue(self.driver.find_elements_by_css_selector('#session{}.too-many-attendees'.format(s2.pk))) + + # overfull timeslot + self.assertTrue(self.driver.find_elements_by_css_selector('#timeslot{}.overfull'.format(slot2.pk))) + + # constraint hints + s1_element = self.driver.find_element_by_css_selector('#session{}'.format(s1.pk)) + s1_element.click() + + constraint_element = s2_element.find_element_by_css_selector(".constraints span[data-sessions=\"{}\"].selected-hint".format(s1.pk)) + self.assertTrue(constraint_element.is_displayed()) + + # current constraint violations + self.driver.execute_script("jQuery('#session{}').simulateDragDrop({{dropTarget: '#timeslot{}'}});".format(s1.pk, slot1.pk)) + + WebDriverWait(self.driver, 2).until(expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '#timeslot{} #session{}'.format(slot1.pk, s1.pk)))) + + constraint_element = s2_element.find_element_by_css_selector(".constraints span[data-sessions=\"{}\"].violated-hint".format(s1.pk)) + self.assertTrue(constraint_element.is_displayed()) + + # hide sessions in area + self.assertTrue(s1_element.is_displayed()) + self.driver.find_element_by_css_selector(".session-parent-toggles [value=\"{}\"]".format(s1.group.parent.acronym)).click() + self.assertTrue(not s1_element.is_displayed()) + +@skipIf(skip_selenium, skip_message) +class ScheduleEditTests(IetfLiveServerTestCase): + def setUp(self): + self.driver = start_web_driver() + self.driver.set_window_size(1024,768) + + def tearDown(self): + self.driver.close() + + def debug_snapshot(self,filename='debug_this.png'): + self.driver.execute_script("document.body.bgColor = 'white';") + self.driver.save_screenshot(filename) + + def absreverse(self,*args,**kwargs): + return '%s%s'%(self.live_server_url,urlreverse(*args,**kwargs)) + + def login(self): + url = self.absreverse('ietf.ietfauth.views.login') + self.driver.get(url) + self.driver.find_element_by_name('username').send_keys('plain') + self.driver.find_element_by_name('password').send_keys('plain+password') + self.driver.find_element_by_xpath('//button[@type="submit"]').click() + + def testUnschedule(self): + meeting = make_meeting_test_data() + colors.fg_group_colors['FARFUT'] = 'blue' + colors.bg_group_colors['FARFUT'] = 'white' + + self.assertEqual(SchedTimeSessAssignment.objects.filter(session__meeting=meeting, session__group__acronym='mars', schedule__name='test-schedule').count(),1) self.login() url = self.absreverse('ietf.meeting.views.edit_schedule',kwargs=dict(num='72',name='test-schedule',owner='plain@example.com')) self.driver.get(url) - q = PyQuery(self.driver.page_source) - self.assertEqual(len(q('#sortable-list #session_1')),0) + s1 = Session.objects.filter(group__acronym='mars', meeting=meeting).first() - element = self.driver.find_element_by_id('session_1') + time.sleep(0.1) + + self.assertEqual(self.driver.find_elements_by_css_selector("#sortable-list #session_{}".format(s1.pk)), []) + + element = self.driver.find_element_by_id('session_{}'.format(s1.pk)) target = self.driver.find_element_by_id('sortable-list') ActionChains(self.driver).drag_and_drop(element,target).perform() - q = PyQuery(self.driver.page_source) - self.assertTrue(len(q('#sortable-list #session_1'))>0) + self.assertTrue(self.driver.find_elements_by_css_selector("#sortable-list #session_{}".format(s1.pk))) time.sleep(0.1) # The API that modifies the database runs async self.assertEqual(SchedTimeSessAssignment.objects.filter(session__meeting__number=72,session__group__acronym='mars',schedule__name='test-schedule').count(),0) @skipIf(skip_selenium, skip_message) -class SlideReorderTests(StaticLiveServerTestCase): - @classmethod - def setUpClass(cls): - set_coverage_checking(False) - super(SlideReorderTests, cls).setUpClass() - - @classmethod - def tearDownClass(cls): - super(SlideReorderTests, cls).tearDownClass() - set_coverage_checking(True) - +class SlideReorderTests(IetfLiveServerTestCase): def setUp(self): - self.driver = webdriver.PhantomJS(port=0, service_log_path=settings.TEST_GHOSTDRIVER_LOG_PATH) + self.driver = start_web_driver() self.driver.set_window_size(1024,768) - # this is a temporary fix - we should have these name in the - # database already at this point - SessionStatusName.objects.get_or_create(slug='schedw') - SessionStatusName.objects.get_or_create(slug='sched') self.session = SessionFactory(meeting__type_id='ietf', status_id='sched') self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='one'),order=1) self.session.sessionpresentation_set.create(document=DocumentFactory(type_id='slides',name='two'),order=2) @@ -175,7 +314,7 @@ class SlideReorderTests(StaticLiveServerTestCase): #from ietf.utils.test_utils import TestCase #class LookAtCrashTest(TestCase): # def setUp(self): -# condition_data() +# make_meeting_test_data() # # def testOpenSchedule(self): # url = urlreverse('ietf.meeting.views.edit_schedule', kwargs=dict(num='72',name='test-schedule')) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 369a6dfec..1d5fe63ff 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -34,7 +34,7 @@ from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_r from ietf.meeting.helpers import send_interim_approval_request from ietf.meeting.helpers import send_interim_cancellation_notice from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates -from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room +from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room, Constraint, ConstraintName from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting from ietf.meeting.utils import finalize, condition_slide_order from ietf.meeting.utils import add_event_info_to_session_qs @@ -921,29 +921,86 @@ class EditTests(TestCase): self.client.login(username="secretary", password="secretary+password") - # check we have the grid and everything set up + s1 = Session.objects.filter(meeting=meeting, type='regular').first() + s2 = Session.objects.filter(meeting=meeting, type='regular').exclude(group=s1.group).first() + s1.comments = "Hello world!" + s1.attendees = 1234 + s1.save() + + Constraint.objects.create( + meeting=meeting, + source=s1.group, + target=s2.group, + name=ConstraintName.objects.get(slug="conflict"), + ) + + p = Person.objects.all().first() + + Constraint.objects.create( + meeting=meeting, + source=s1.group, + person=p, + name=ConstraintName.objects.get(slug="bethere"), + ) + + Constraint.objects.create( + meeting=meeting, + source=s2.group, + person=p, + name=ConstraintName.objects.get(slug="bethere"), + ) + + # check we have the grid and everything set up as a baseline - + # the Javascript tests check that the Javascript can work with + # it url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number)) r = self.client.get(url) q = PyQuery(r.content) room = Room.objects.get(meeting=meeting, session_types='regular') - self.assertTrue(q("h5:contains(\"{}\")".format(room.name))) - self.assertTrue(q("h5:contains(\"{}\")".format(room.capacity))) + 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') - self.assertTrue(q("div.timeslot[data-timeslot=\"{}\"]".format(timeslots[0].pk))) + self.assertTrue(q("#timeslot{}".format(timeslots[0].pk))) - sessions = Session.objects.filter(meeting=meeting, type='regular') - for s in sessions: - self.assertIn(s.group.acronym, q("#session{}".format(s.pk)).text()) + for s in [s1, s2]: + e = q("#session{}".format(s.pk)) - self.assertIn("You can't edit this schedule", r.content) + # info in the movable entity + self.assertIn(s.group.acronym, e.find(".session-label").text()) + if s.comments: + self.assertTrue(e.find(".comments")) + if s.attendees is not None: + self.assertIn(str(s.attendees), e.find(".attendees").text()) + self.assertTrue(e.hasClass("parent-{}".format(s.group.parent.acronym))) + + # session info for the panel + self.assertIn(str(s.requested_duration.total_seconds() / 60.0 / 60), e.find(".session-info label").text()) + + event = SchedulingEvent.objects.filter(session=s).order_by("id").first() + if event: + self.assertTrue(e.find("div:contains(\"{}\")".format(event.by.plain_name()))) + + if s.comments: + self.assertIn(s.comments, e.find(".comments").text()) + + # constraints + constraints = e.find(".constraints > span") + s_other = s2 if s == s1 else s1 + self.assertEqual(len(constraints), 2) + self.assertEqual(constraints.eq(0).attr("data-sessions"), str(s_other.pk)) + self.assertEqual(constraints.eq(1).attr("data-sessions"), str(s_other.pk)) + self.assertEqual(constraints.find(".encircled").text(), "1") + self.assertEqual(constraints.find(".fa-user-o").parent().text(), "1") # 1 person in the constraint + + self.assertTrue(q("em:contains(\"You can't edit this schedule\")")) # can't change anything r = self.client.post(url, { 'action': 'assign', 'timeslot': timeslots[0].pk, - 'session': sessions[0].pk, + 'session': s1.pk, }) self.assertEqual(r.status_code, 403) @@ -953,35 +1010,36 @@ class EditTests(TestCase): url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name)) r = self.client.get(url) - self.assertNotIn("You can't edit this schedule", r.content) + q = PyQuery(r.content) + self.assertTrue(not q("em:contains(\"You can't edit this schedule\")")) - SchedTimeSessAssignment.objects.filter(session=sessions[0]).delete() + SchedTimeSessAssignment.objects.filter(session=s1).delete() # assign r = self.client.post(url, { 'action': 'assign', 'timeslot': timeslots[0].pk, - 'session': sessions[0].pk, + 'session': s1.pk, }) self.assertEqual(r.content, "OK") - self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=sessions[0]).timeslot, timeslots[0]) + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=s1).timeslot, timeslots[0]) # move assignment r = self.client.post(url, { 'action': 'assign', 'timeslot': timeslots[1].pk, - 'session': sessions[0].pk, + 'session': s1.pk, }) self.assertEqual(r.content, "OK") - self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=sessions[0]).timeslot, timeslots[1]) + self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=s1).timeslot, timeslots[1]) # unassign r = self.client.post(url, { 'action': 'unassign', - 'session': sessions[0].pk, + 'session': s1.pk, }) self.assertEqual(r.content, "OK") - self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule, session=sessions[0])), []) + self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule, session=s1)), []) def test_copy_meeting_schedule(self): diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 3876ccbca..b51806d6b 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -44,7 +44,7 @@ from django.template.loader import render_to_string from django.utils.functional import curry from django.views.decorators.cache import cache_page from django.utils.text import slugify -from django.utils.html import escape +from django.utils.html import format_html from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt from django.views.generic import RedirectView @@ -54,9 +54,11 @@ from ietf.doc.models import Document, State, DocEvent, NewRevisionDocEvent, DocA from ietf.group.models import Group from ietf.group.utils import can_manage_session_materials from ietf.person.models import Person +from ietf.person.name import plain_name from ietf.ietfauth.utils import role_required, has_role from ietf.mailtrigger.utils import gather_address_lists -from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission, SessionStatusName, SchedulingEvent, SchedTimeSessAssignment +from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission +from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Constraint, ConstraintName from ietf.meeting.helpers import get_areas, get_person_by_email, get_schedule_by_name from ietf.meeting.helpers import build_all_agenda_slices, get_wg_name_list from ietf.meeting.helpers import get_all_assignments_from_schedule @@ -469,7 +471,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): requested_time=True, requested_by=True, ).exclude(current_status__in=['notmeet', 'disappr', 'deleted', 'apprw']).prefetch_related( - 'resources', 'group', 'group__parent', + 'resources', 'group', 'group__parent', 'group__type', ) if request.method == 'POST': @@ -585,8 +587,9 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): ts.session_assignments = [] timeslots_by_pk = {ts.pk: ts for ts in timeslots_qs} + # group parent colors def cubehelix(i, total, hue=1.2, start_angle=0.5): - # https://arxiv.org/pdf/1108.5083.pdf + # theory in https://arxiv.org/pdf/1108.5083.pdf rotations = total // 4 x = float(i + 1) / (total + 1) phi = 2 * math.pi * (start_angle / 3 + rotations * x) @@ -606,68 +609,127 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): rgb_color = cubehelix(i, len(session_parents)) p.scheduling_color = "#" + "".join(chr(int(round(x * 255))).encode('hex') for x in rgb_color) - unassigned_sessions = [] - session_data = [] + # dig out historic AD names + ad_names = {} + session_groups = set(s.group for s in sessions if s.group and s.group.parent.type_id == 'area') + meeting_time = datetime.datetime.combine(meeting.date, datetime.time(0, 0, 0)) + + for group_id, history_time, name in Person.objects.filter(rolehistory__name='ad', rolehistory__group__group__in=session_groups, rolehistory__group__time__lte=meeting_time).values_list('rolehistory__group__group', 'rolehistory__group__time', 'name').order_by('rolehistory__group__time'): + ad_names[group_id] = plain_name(name) + + for group_id, name in Person.objects.filter(role__name='ad', role__group__in=session_groups, role__group__time__lte=meeting_time).values_list('role__group', 'name'): + ad_names[group_id] = plain_name(name) + + # requesters + requested_by_lookup = {p.pk: p for p in Person.objects.filter(pk__in=set(s.requested_by for s in sessions if s.requested_by))} + + # constraints + constraints = Constraint.objects.filter(meeting=meeting) + person_needed_for_groups = defaultdict(set) + for c in constraints: + if c.name_id == 'bethere' and c.person_id is not None: + person_needed_for_groups[c.person_id].add(c.source_id) + + sessions_for_group = defaultdict(list) for s in sessions: - d = { - 'id': s.pk, - } + if s.group_id is not None: + sessions_for_group[s.group_id].append(s.pk) - if s.requested_by: - d['requested_by'] = s.requested_by - if s.requested_time: - d['requested_time'] = s.requested_time.isoformat() + constraint_names = {n.pk: n for n in ConstraintName.objects.all()} + constraint_names_with_count = set() + constraint_label_replacements = [ + (re.compile(r"\(person\)"), lambda match_groups: format_html("")), + (re.compile(r"\(([^()])\)"), lambda match_groups: format_html("{}", match_groups[0])), + ] + for n in list(constraint_names.itervalues()): + # spiff up the labels a bit + for pattern, replacer in constraint_label_replacements: + m = pattern.match(n.editor_label) + if m: + n.editor_label = replacer(m.groups()) - room_resources = s.resources.all() - if room_resources: - d['room_resources'] = [ - { - 'name': escape(r.name.name), - } - for r in room_resources - ] + # add reversed version of the name + reverse_n = ConstraintName( + slug=n.slug + "-reversed", + name="{} - reversed".format(n.name), + ) + reverse_n.editor_label = format_html("{}", n.editor_label) + constraint_names[reverse_n.slug] = reverse_n + constraints_for_sessions = defaultdict(list) + + def add_group_constraints(g1_pk, g2_pk, name_id, person_id): + if g1_pk != g2_pk: + for s1_pk in sessions_for_group.get(g1_pk, []): + for s2_pk in sessions_for_group.get(g2_pk, []): + if s1_pk != s2_pk: + constraints_for_sessions[s1_pk].append((name_id, s2_pk, person_id)) + + reverse_constraints = [] + seen_forward_constraints_for_groups = set() + + for c in constraints: + if c.target_id: + add_group_constraints(c.source_id, c.target_id, c.name_id, c.person_id) + seen_forward_constraints_for_groups.add((c.source_id, c.target_id, c.name_id)) + reverse_constraints.append(c) + + elif c.person_id: + constraint_names_with_count.add(c.name_id) + + for g in person_needed_for_groups.get(c.person_id): + add_group_constraints(c.source_id, g, c.name_id, c.person_id) + + for c in reverse_constraints: + # suppress reverse constraints in case we have a forward one already + if (c.target_id, c.source_id, c.name_id) not in seen_forward_constraints_for_groups: + add_group_constraints(c.target_id, c.source_id, c.name_id + "-reversed", c.person_id) + + unassigned_sessions = [] + for s in sessions: + s.requested_by_person = requested_by_lookup.get(s.requested_by) + + s.scheduling_label = "???" if s.group: - label = escape(s.group.acronym) + s.scheduling_label = s.group.acronym elif s.name: - label = escape(s.name) - else: - label = "???" - - s.scheduling_label = label - - if s.attendees is not None: - d['attendees'] = s.attendees - - if s.group and s.group.is_bof(): - d['bof'] = True - - if s.group and s.group.parent: - d['group_parent'] = escape(s.group.parent.acronym.upper()) - - if s.comments: - d['comments'] = s.comments + s.scheduling_label = s.name s.requested_duration_in_hours = s.requested_duration.seconds / 60.0 / 60.0 - s.layout_height = timedelta_to_css_ems(s.requested_duration) + + session_layout_margin = 0.2 + s.layout_height = timedelta_to_css_ems(s.requested_duration) - 2 * session_layout_margin s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else "" + s.historic_group_ad_name = ad_names.get(s.group_id) - scheduled = False + # compress the constraints, so similar constraint explanations + # are shared between the conflicting sessions they cover + constrained_sessions_grouped_by_explanation = defaultdict(set) + for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]): + ts = list(ts) + session_pks = (t[1] for t in ts) + constraint_name = constraint_names[name_id] + if name_id in constraint_names_with_count: + for session_pk, grouped_session_pks in itertools.groupby(session_pks): + count = sum(1 for i in grouped_session_pks) + constrained_sessions_grouped_by_explanation[format_html("{}{}", constraint_name.editor_label, count)].add(session_pk) + else: + constrained_sessions_grouped_by_explanation[constraint_name.editor_label].update(session_pks) + + s.constrained_sessions = list(constrained_sessions_grouped_by_explanation.iteritems()) + + assigned = False for a in assignments_by_session.get(s.pk, []): timeslot = timeslots_by_pk.get(a.timeslot_id) if timeslot: timeslot.session_assignments.append((a, s)) - scheduled = True + assigned = True - session_data.append(d) - - if not scheduled: + if not assigned: unassigned_sessions.append(s) - schedule_data = { - 'schedule': schedule.id, - 'sessions': session_data, + js_data = { 'can_edit': can_edit, 'urls': { 'assign': request.get_full_path() @@ -678,7 +740,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): 'meeting': meeting, 'schedule': schedule, 'can_edit': can_edit, - 'schedule_data': json.dumps(schedule_data, indent=2), + 'js_data': json.dumps(js_data, indent=2), 'time_labels': time_labels, 'rooms': rooms, 'room_columns': room_columns, diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index a5ea76b91..6c71b4d27 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -5555,6 +5555,7 @@ { "fields": { "desc": "", + "editor_label": "(person)", "name": "Person must be present", "order": 0, "penalty": 200000, @@ -5566,6 +5567,7 @@ { "fields": { "desc": "", + "editor_label": "(2)", "name": "Conflicts with (secondary)", "order": 0, "penalty": 10000, @@ -5577,6 +5579,7 @@ { "fields": { "desc": "", + "editor_label": "(3)", "name": "Conflicts with (tertiary)", "order": 0, "penalty": 1000, @@ -5587,6 +5590,7 @@ }, { "fields": { + "editor_label": "(1)", "desc": "", "name": "Conflicts with", "order": 0, diff --git a/ietf/name/migrations/0010_constraintname_editor_label.py b/ietf/name/migrations/0010_constraintname_editor_label.py new file mode 100644 index 000000000..0d05c8461 --- /dev/null +++ b/ietf/name/migrations/0010_constraintname_editor_label.py @@ -0,0 +1,36 @@ +# Copyright The IETF Trust 2020, All Rights Reserved +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0009_add_verified_errata_to_doctagname'), + ] + + def fill_in_editor_labels(apps, schema_editor): + ConstraintName = apps.get_model('name', 'ConstraintName') + for cn in ConstraintName.objects.all(): + cn.editor_label = { + 'conflict': "(1)", + 'conflic2': "(2)", + 'conflic3': "(3)", + 'bethere': "(person)", + }.get(cn.slug, cn.slug) + cn.save() + + def noop(apps, schema_editor): + pass + + operations = [ + migrations.AddField( + model_name='constraintname', + name='editor_label', + field=models.CharField(blank=True, help_text='Very short label for producing warnings inline in the sessions in the schedule editor.', max_length=32), + ), + migrations.RunPython(fill_in_editor_labels, noop, elidable=True), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 9e85999a8..0d3f11609 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2010-2019, All Rights Reserved +# Copyright The IETF Trust 2010-2020, All Rights Reserved # -*- coding: utf-8 -*- @@ -73,6 +73,7 @@ class TimeSlotTypeName(NameModel): class ConstraintName(NameModel): """Conflict""" penalty = models.IntegerField(default=0, help_text="The penalty for violating this kind of constraint; for instance 10 (small penalty) or 10000 (large penalty)") + editor_label = models.CharField(max_length=32, blank=True, help_text="Very short label for producing warnings inline in the sessions in the schedule editor.") class LiaisonStatementPurposeName(NameModel): """For action, For comment, For information, In response, Other""" class NomineePositionStateName(NameModel): diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 9177d288a..cc279531b 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -986,26 +986,32 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { /* === Edit Meeting Schedule ====================================== */ -.edit-meeting-schedule { - padding-bottom: 10em; /* ensure there's room for the scheduling panel */ -} - .edit-meeting-schedule .edit-grid { display: flex; } -.edit-meeting-schedule .schedule-column h5 { +.edit-meeting-schedule .schedule-column .room-name { + height: 2em; + font-weight: bold; text-align: center; margin: 0; white-space: nowrap; } -.edit-meeting-schedule .schedule-column .day { - position: relative; - margin-bottom: 3em; +.edit-meeting-schedule .schedule-column .day-label { + height: 2.5em; + max-width: 5em; /* let it stick out and overlap the other columns */ + white-space: nowrap; + font-style: italic; + margin-top: 1em; } -.edit-meeting-schedule .schedule-column .day > div { +.edit-meeting-schedule .schedule-column > .day { + position: relative; + margin-bottom: 2em; +} + +.edit-meeting-schedule .schedule-column > .day > div { position: absolute; } @@ -1014,10 +1020,6 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { padding-right: 0.5em; } -.edit-meeting-schedule .time-labels-column .time-label { - width: 100%; -} - .edit-meeting-schedule .time-labels-column .time-label.top-aligned { border-top: 1px solid #ccc; } @@ -1035,53 +1037,77 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { flex-grow: 1; } -.edit-meeting-schedule .room-column .day-label { - visibility: hidden; /* it's there to take up the space, but not shown */ -} - .edit-meeting-schedule .timeslot { display: flex; flex-direction: column; - background-color: #f6f6f6; + background-color: #eee; width: 100%; - border-right: 0.2em solid #fff; - border-left: 0.2em solid #fff; + border-left: 0.15em solid #fff; overflow: hidden; } .edit-meeting-schedule .timeslot.dropping { - background-color: #f0f0f0; + background-color: #ccc; transition: background-color 0.2s; } .edit-meeting-schedule .timeslot.overfull { - border-bottom: 2px dashed #ddd; + border-bottom: 2px dashed #fff; /* cut-off illusion */ +} + +.edit-meeting-schedule { + /* this is backwards-compatible measure - if the browser doesn't + support position: sticky but only position: fixed, we ensure there's room for the scheduling + panel */ + padding-bottom: 5em; } .edit-meeting-schedule .scheduling-panel { position: fixed; /* backwards compatibility */ + z-index: 1; + position: sticky; + display: flex; bottom: 0; left: 0; - margin: 0; - padding: 0 1em; width: 100%; - border-top: 0.2em solid #eee; + border-top: 0.2em solid #ccc; background-color: #fff; opacity: 0.95; - z-index: 1; +} + +.edit-meeting-schedule .scheduling-panel .unassigned-container { + flex-grow: 1; } .edit-meeting-schedule .unassigned-sessions { min-height: 4em; - background-color: #f6f6f6; + max-height: 13em; + overflow-y: auto; + display: flex; + flex-wrap: wrap; + background-color: #eee; + margin-top: 0.5em; } .edit-meeting-schedule .unassigned-sessions.dropping { - background-color: #f0f0f0; + background-color: #e5e5e5; transition: background-color 0.2s; } +.edit-meeting-schedule .scheduling-panel .preferences { + margin: 0.5em 0; +} + +.edit-meeting-schedule .scheduling-panel .preferences > span { + margin-right: 1em; +} + +.edit-meeting-schedule .sort-unassigned select { + width: auto; + display: inline-block; +} + .edit-meeting-schedule .session-parent-toggles { margin-top: 1em; } @@ -1094,19 +1120,34 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { cursor: pointer; } +.edit-meeting-schedule .scheduling-panel .session-info-container { + padding-left: 0.5em; + flex: 0 0 20em; + max-height: 15em; + overflow-y: auto; +} + +.edit-meeting-schedule .scheduling-panel .session-info-container .comments { + font-style: italic; +} + /* sessions */ .edit-meeting-schedule .session { + display: flex; background-color: #fff; padding: 0 0.2em; padding-left: 0.5em; - border: 0.2em solid #f6f6f6; /* this compensates for sessions being relatively smaller than they should */ + margin: 0.2em; border-radius: 0.4em; - text-align: center; overflow: hidden; } +.edit-meeting-schedule .session.selected { + border: 1px solid #bbb; +} + .edit-meeting-schedule .session[draggable] { - cursor: grabbing; + cursor: pointer; } .edit-meeting-schedule .session.dragging { @@ -1114,20 +1155,68 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { transition: opacity 0.4s; } -.edit-meeting-schedule .session .color { - display: inline-block; - width: 1em; - height: 1em; - vertical-align: middle; -} - -.edit-meeting-schedule .session i.fa-comment-o { - width: 0; /* prevent icon from participating in text centering */ +.edit-meeting-schedule .timeslot.overfull .session { + border-radius: 0.4em 0.4em 0 0; /* remove bottom rounding to illude to being cut off */ + margin-bottom: 0; } .edit-meeting-schedule .unassigned-sessions .session { - vertical-align: top; - display: inline-block; min-width: 6em; - margin-right: 0.4em; + margin-right: 0.3em; +} + +.edit-meeting-schedule .session .session-label { + flex-grow: 1; + margin-left: 0.1em; +} + +.edit-meeting-schedule .session.too-many-attendees .attendees { + color: #f33; +} + +.edit-meeting-schedule .session .constraints { + margin-right: 0.2em; + text-align: right; + flex-shrink: 1; +} + +.edit-meeting-schedule .session .constraints > span { + display: none; + font-size: smaller; +} + +.edit-meeting-schedule .session .constraints > span .encircled { + border: 1px solid #fdd; + border-radius: 1em; + min-width: 1.3em; + text-align: center; + display: inline-block; +} + +.edit-meeting-schedule .session .constraints > span.violated-hint { + display: inline-block; + color: #f99; +} + +.edit-meeting-schedule .session .constraints > span.selected-hint { + display: inline-block; + color: #f33; +} + +.edit-meeting-schedule .session .constraints > span.selected-hint .encircled { + border: 1px solid #fbb; +} + +.edit-meeting-schedule .unassigned-sessions .session .constraints > span { + display: none; +} + + +.edit-meeting-schedule .session .comments { + font-size: smaller; + margin-right: 0.1em; +} + +.edit-meeting-schedule .session .session-info { + display: none; } diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index 730752dbb..6762ddf59 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -1,20 +1,60 @@ jQuery(document).ready(function () { - if (!ietfScheduleData.can_edit) + if (!ietfData.can_edit) return; - var content = jQuery(".edit-meeting-schedule"); + let content = jQuery(".edit-meeting-schedule"); function failHandler(xhr, textStatus, error) { alert("Error: " + error); } - var sessions = content.find(".session"); - var timeslots = content.find(".timeslot"); + let sessions = content.find(".session"); + let timeslots = content.find(".timeslot"); + + // selecting + function selectSessionElement(element) { + if (element) { + sessions.not(element).removeClass("selected"); + jQuery(element).addClass("selected"); + showConstraintHints(element.id.slice("session".length)); + content.find(".scheduling-panel .session-info-container").html(jQuery(element).find(".session-info").html()); + } + else { + sessions.removeClass("selected"); + showConstraintHints(); + content.find(".scheduling-panel .session-info-container").html(""); + } + } + + function showConstraintHints(sessionIdStr) { + sessions.find(".constraints > span").each(function () { + if (!sessionIdStr) { + jQuery(this).removeClass("selected-hint"); + return; + } + + let sessionIds = this.dataset.sessions; + if (sessionIds) + jQuery(this).toggleClass("selected-hint", sessionIds.split(",").indexOf(sessionIdStr) != -1); + }); + } + + content.on("click", function (event) { + selectSessionElement(null); + }); + + sessions.on("click", function (event) { + event.stopPropagation(); + selectSessionElement(this); + }); + // dragging sessions.on("dragstart", function (event) { event.originalEvent.dataTransfer.setData("text/plain", this.id); jQuery(this).addClass("dragging"); + + selectSessionElement(this); }); sessions.on("dragend", function () { jQuery(this).removeClass("dragging"); @@ -24,14 +64,11 @@ jQuery(document).ready(function () { sessions.prop('draggable', true); // dropping - var dropElements = content.find(".timeslot,.unassigned-sessions"); + let dropElements = content.find(".timeslot,.unassigned-sessions"); dropElements.on('dragenter', function (event) { if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") return; - if (jQuery(this).hasClass("disabled")) - return; - event.preventDefault(); // default action is signalling that this is not a valid target jQuery(this).addClass("dropping"); }); @@ -44,17 +81,21 @@ jQuery(document).ready(function () { }); dropElements.on('dragleave', function (event) { + // skip dragleave events if they are to children + if (event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget)) + return; + jQuery(this).removeClass("dropping"); }); dropElements.on('drop', function (event) { jQuery(this).removeClass("dropping"); - var sessionId = event.originalEvent.dataTransfer.getData("text/plain"); + let sessionId = event.originalEvent.dataTransfer.getData("text/plain"); if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") return; - var sessionElement = sessions.filter("#" + sessionId); + let sessionElement = sessions.filter("#" + sessionId); if (sessionElement.length == 0) return; @@ -63,16 +104,18 @@ jQuery(document).ready(function () { if (sessionElement.parent().is(this)) return; - var dropElement = jQuery(this); + let dropElement = jQuery(this); function done() { dropElement.append(sessionElement); // move element - maintainTimeSlotHints(); + updateCurrentSchedulingHints(); + if (dropElement.hasClass("unassigned-sessions")) + sortUnassigned(); } if (dropElement.hasClass("unassigned-sessions")) { jQuery.ajax({ - url: ietfScheduleData.urls.assign, + url: ietfData.urls.assign, method: "post", data: { action: "unassign", @@ -82,22 +125,82 @@ jQuery(document).ready(function () { } else { jQuery.ajax({ - url: ietfScheduleData.urls.assign, + url: ietfData.urls.assign, method: "post", data: { action: "assign", session: sessionId.slice("session".length), - timeslot: dropElement.data("timeslot") + timeslot: dropElement.attr("id").slice("timeslot".length) } }).fail(failHandler).done(done); } }); + // hints for the current schedule - // hints - function maintainTimeSlotHints() { + function updateCurrentSessionConstraintViolations() { + // do a sweep on sessions sorted by start time + let scheduledSessions = []; + + sessions.each(function () { + let timeslot = jQuery(this).closest(".timeslot"); + if (timeslot.length == 1) + scheduledSessions.push({ + start: timeslot.data("start"), + end: timeslot.data("end"), + id: this.id.slice("session".length), + element: jQuery(this), + timeslot: timeslot.get(0) + }); + }); + + scheduledSessions.sort(function (a, b) { + if (a.start < b.start) + return -1; + if (a.start > b.start) + return 1; + return 0; + }); + + let currentlyOpen = {}; + let openedIndex = 0; + for (let i = 0; i < scheduledSessions.length; ++i) { + let s = scheduledSessions[i]; + + // prune + for (let sessionIdStr in currentlyOpen) { + if (currentlyOpen[sessionIdStr].end <= s.start) + delete currentlyOpen[sessionIdStr]; + } + + // expand + while (openedIndex < scheduledSessions.length && scheduledSessions[openedIndex].start < s.end) { + let toAdd = scheduledSessions[openedIndex]; + currentlyOpen[toAdd.id] = toAdd; + ++openedIndex; + } + + // check for violated constraints + s.element.find(".constraints > span").each(function () { + let sessionIds = this.dataset.sessions; + + let violated = sessionIds && sessionIds.split(",").filter(function (v) { + return (v != s.id + && v in currentlyOpen + // ignore errors within the same timeslot + // under the assumption that the sessions + // in the timeslot happen sequentially + && s.timeslot != currentlyOpen[v].timeslot); + }).length > 0; + + jQuery(this).toggleClass("violated-hint", violated); + }); + } + } + + function updateTimeSlotDurationViolations() { timeslots.each(function () { - var total = 0; + let total = 0; jQuery(this).find(".session").each(function () { total += +jQuery(this).data("duration"); }); @@ -106,23 +209,109 @@ jQuery(document).ready(function () { }); } - maintainTimeSlotHints(); + function updateAttendeesViolations() { + sessions.each(function () { + let roomCapacity = jQuery(this).closest(".room-column").data("roomcapacity"); + if (roomCapacity && this.dataset.attendees) + jQuery(this).toggleClass("too-many-attendees", +this.dataset.attendees > +roomCapacity); + }); + } - // toggling of parents - var sessionParentInputs = content.find(".session-parent-toggles input"); + function updateCurrentSchedulingHints() { + updateCurrentSessionConstraintViolations(); + updateAttendeesViolations(); + updateTimeSlotDurationViolations(); + } - function maintainSessionParentToggling() { - var checked = []; + updateCurrentSchedulingHints(); + + // sorting unassigned + function sortArrayWithKeyFunctions(array, keyFunctions) { + function compareArrays(a, b) { + for (let i = 1; i < a.length; ++i) { + let ai = a[i]; + let bi = b[i]; + + if (ai > bi) + return 1; + else if (ai < bi) + return -1; + } + + return 0; + } + + let arrayWithSortKeys = array.map(function (a) { + let res = [a]; + for (let i = 0; i < keyFunctions.length; ++i) + res.push(keyFunctions[i](a)); + return res; + }); + + arrayWithSortKeys.sort(compareArrays); + + return arrayWithSortKeys.map(function (l) { + return l[0]; + }); + } + + function sortUnassigned() { + let sortBy = content.find("select[name=sort_unassigned]").val(); + + function extractName(e) { + return e.querySelector(".session-label").innerHTML; + } + + function extractParent(e) { + return e.querySelector(".session-parent").innerHTML; + } + + function extractDuration(e) { + return +e.dataset.duration; + } + + function extractComments(e) { + return e.querySelector(".session-info .comments") ? 0 : 1; + } + + let keyFunctions = []; + if (sortBy == "name") + keyFunctions = [extractName, extractDuration]; + else if (sortBy == "parent") + keyFunctions = [extractParent, extractName, extractDuration]; + else if (sortBy == "duration") + keyFunctions = [extractDuration, extractParent, extractName]; + else if (sortBy == "comments") + keyFunctions = [extractComments, extractParent, extractName, extractDuration]; + + let unassignedSessionsContainer = content.find(".unassigned-sessions"); + + let sortedSessions = sortArrayWithKeyFunctions(unassignedSessionsContainer.children(".session").toArray(), keyFunctions); + for (let i = 0; i < sortedSessions.length; ++i) + unassignedSessionsContainer.append(sortedSessions[i]); + } + + content.find("select[name=sort_unassigned]").on("change click", function () { + sortUnassigned(); + }); + + sortUnassigned(); + + // toggling of sessions + let sessionParentInputs = content.find(".session-parent-toggles input"); + + function updateSessionParentToggling() { + let checked = []; sessionParentInputs.filter(":checked").each(function () { checked.push(".parent-" + this.value); }); - sessions.filter(".toggleable").filter(checked.join(",")).show(); - sessions.filter(".toggleable").not(checked.join(",")).hide(); + sessions.not(".untoggleable").filter(checked.join(",")).show(); + sessions.not(".untoggleable").not(checked.join(",")).hide(); } - sessionParentInputs.on("click", maintainSessionParentToggling); + sessionParentInputs.on("click", updateSessionParentToggling); - maintainSessionParentToggling(); + updateSessionParentToggling(); }); diff --git a/ietf/templates/meeting/copy_meeting_schedule.html b/ietf/templates/meeting/copy_meeting_schedule.html index ad40ede01..14c1365b5 100644 --- a/ietf/templates/meeting/copy_meeting_schedule.html +++ b/ietf/templates/meeting/copy_meeting_schedule.html @@ -14,7 +14,7 @@ {% bootstrap_form form %} {% buttons %} - + {% endbuttons %} {% endblock %} diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 14007b6b8..df745c431 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -7,7 +7,7 @@ {% block morecss %} {% for parent in session_parents %} .parent-{{ parent.acronym }} { - background: linear-gradient(to right, {{ parent.scheduling_color }} 0.4em, #fff 0.5em); + background: linear-gradient(to right, {{ parent.scheduling_color }} 0.4em, transparent 0.5em); } {% endfor %} {% endblock morecss %} @@ -15,20 +15,10 @@ {% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting schedule{% endblock %} {% block js %} - - {% endblock js %} @@ -58,16 +48,13 @@

- {# note: in order for all this to align properly, make sure there's the same markup in all columns #} + {# in order for all this to align properly vertically, we have the same structure in all columns #}
-
 
+
{% for d in time_labels %} -
- {{ d.day|date:"D" }}
- {{ d.day|date:"Y-m-d" }} -
+
{{ d.day|date:"l, F j, Y" }}
{% for t, vertical_alignment, vertical_offset, horizontal_alignment in d.labels %} @@ -80,18 +67,15 @@
{% for r in room_columns %} -
-
{{ r.room.name }}{% if r.room.capacity %} ({{ r.room.capacity }} {% endif %})
+
+
{{ r.room.name }}{% if r.room.capacity %} ({{ r.room.capacity }} {% endif %})
{% for d in r.days %} -
- {{ d.day|date:"D" }}
- {{ d.day|date:"Y-m-d" }} -
+
{# for spacing purposes #}
{% for t in d.timeslots %} -
+
{% for assignment, session in t.timeslot.session_assignments %} {% include "meeting/edit_meeting_schedule_session.html" %} {% endfor %} @@ -104,20 +88,34 @@
-
Not yet assigned
+
+
+ {% for session in unassigned_sessions %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} +
-
- {% for session in unassigned_sessions %} - {% include "meeting/edit_meeting_schedule_session.html" %} - {% endfor %} +
+ + Sort unassigned: + + + + + Show: + {% for p in session_parents %} + + {% endfor %} + +
-
- Show: - {% for p in session_parents %} - - {% endfor %} -
+
diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html index c64616ba1..07f5190a1 100644 --- a/ietf/templates/meeting/edit_meeting_schedule_session.html +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -1,3 +1,62 @@ -
- {{ session.scheduling_label }} {% if session.comments %}{% endif %} +
+
+ {{ session.scheduling_label }} +
+ + {% if session.constrained_sessions %} +
+ {% for explanation, sessions in session.constrained_sessions %} + {{ explanation }} + {% endfor %} +
+ {% endif %} + + {% if session.comments %} +
+ {% endif %} + + {% if session.attendees != None %} +
{{ session.attendees }}
+ {% endif %} + + +
+ + + {% if session.group %} +
+ {{ session.group.name }} + {% if session.group.parent %} + · {{ session.group.parent.acronym }} + {% if session.historic_group_ad_name %} ({{ session.historic_group_ad_name }}){% endif %} + {% endif %} +
+ {% endif %} + + {% if session.requested_by_person %} +
+ {{ session.requested_by_person.plain_name }} {% if session.requested_time %}({{ session.requested_time|date:"Y-m-d" }}){% endif %} +
+ {% endif %} + + {% if session.resources.all %} +
+ Resources: + {% for r in session.resources.all %} + {{ r.name }}{% if not forloop.last %},{% endif %} + {% endfor %} +
+ {% endif %} + + {% if session.comments %} +
+ {{ session.comments|linebreaksbr }} +
+ {% endif %} +
diff --git a/ietf/utils/test_runner.py b/ietf/utils/test_runner.py index f17e89527..519f29b9a 100644 --- a/ietf/utils/test_runner.py +++ b/ietf/utils/test_runner.py @@ -1,4 +1,4 @@ -# Copyright The IETF Trust 2009-2019, All Rights Reserved +# Copyright The IETF Trust 2009-2020, All Rights Reserved # -*- coding: utf-8 -*- # # Portion Copyright (C) 2009 Nokia Corporation and/or its subsidiary(-ies). @@ -89,6 +89,18 @@ template_coverage_collection = None code_coverage_collection = None url_coverage_collection = None +def load_and_run_fixtures(verbosity): + loadable = [f for f in settings.GLOBAL_TEST_FIXTURES if "." not in f] + call_command('loaddata', *loadable, verbosity=int(verbosity)-1, commit=False, database="default") + + for f in settings.GLOBAL_TEST_FIXTURES: + if f not in loadable: + # try to execute the fixture + components = f.split(".") + module_name = ".".join(components[:-1]) + module = importlib.import_module(module_name) + fn = getattr(module, components[-1]) + fn() def safe_create_test_db(self, verbosity, *args, **kwargs): global test_database_name, old_create @@ -102,17 +114,7 @@ def safe_create_test_db(self, verbosity, *args, **kwargs): if settings.GLOBAL_TEST_FIXTURES: print(" Loading global test fixtures: %s" % ", ".join(settings.GLOBAL_TEST_FIXTURES)) - loadable = [f for f in settings.GLOBAL_TEST_FIXTURES if "." not in f] - call_command('loaddata', *loadable, verbosity=int(verbosity)-1, commit=False, database="default") - - for f in settings.GLOBAL_TEST_FIXTURES: - if f not in loadable: - # try to execute the fixture - components = f.split(".") - module_name = ".".join(components[:-1]) - module = importlib.import_module(module_name) - fn = getattr(module, components[-1]) - fn() + load_and_run_fixtures(verbosity) return test_database_name @@ -766,3 +768,26 @@ class IetfTestRunner(DiscoverRunner): os.unlink(settings.UTILS_TEST_RANDOM_STATE_FILE) return failures + +class IetfLiveServerTestCase(StaticLiveServerTestCase): + @classmethod + def setUpClass(cls): + set_coverage_checking(False) + super(IetfLiveServerTestCase, cls).setUpClass() + + # LiveServerTestCase uses TransactionTestCase which seems to + # somehow interfere with the fixture loading process in + # IetfTestRunner when running multiple tests (the first test + # is fine, in the next ones the fixtures have been wiped) - + # this is no doubt solvable somehow, but until then we simply + # recreate them here + from ietf.person.models import Person + if not Person.objects.exists(): + load_and_run_fixtures(verbosity=0) + + @classmethod + def tearDownClass(cls): + super(IetfLiveServerTestCase, cls).tearDownClass() + set_coverage_checking(True) + + From fa9b19e3a8a6323f0b9cff891255d3d45588dfaa Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 30 Mar 2020 18:16:37 +0000 Subject: [PATCH 5/9] When a schedule is read only, only disable the part of the JS that actually changes things so that the rest is still working - Legacy-Id: 17560 --- ietf/meeting/views.py | 5 +- ietf/static/ietf/css/ietf.css | 5 +- ietf/static/ietf/js/edit-meeting-schedule.js | 149 +++++++++---------- 3 files changed, 79 insertions(+), 80 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index b51806d6b..9f8e066dd 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -623,7 +623,10 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): # requesters requested_by_lookup = {p.pk: p for p in Person.objects.filter(pk__in=set(s.requested_by for s in sessions if s.requested_by))} - # constraints + # constraints - convert the human-readable rules in the database + # to constraints on the actual sessions, compress them and output + # them, so that the JS simply has to detect violations and show + # the relevant preprocessed label constraints = Constraint.objects.filter(meeting=meeting) person_needed_for_groups = defaultdict(set) for c in constraints: diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index cc279531b..bc6c9deda 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1140,16 +1140,13 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { margin: 0.2em; border-radius: 0.4em; overflow: hidden; + cursor: pointer; } .edit-meeting-schedule .session.selected { border: 1px solid #bbb; } -.edit-meeting-schedule .session[draggable] { - cursor: pointer; -} - .edit-meeting-schedule .session.dragging { opacity: 0.3; transition: opacity 0.4s; diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index 6762ddf59..359a3cb75 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -1,7 +1,4 @@ jQuery(document).ready(function () { - if (!ietfData.can_edit) - return; - let content = jQuery(".edit-meeting-schedule"); function failHandler(xhr, textStatus, error) { @@ -49,92 +46,94 @@ jQuery(document).ready(function () { }); - // dragging - sessions.on("dragstart", function (event) { - event.originalEvent.dataTransfer.setData("text/plain", this.id); - jQuery(this).addClass("dragging"); + if (ietfData.can_edit) { + // dragging + sessions.on("dragstart", function (event) { + event.originalEvent.dataTransfer.setData("text/plain", this.id); + jQuery(this).addClass("dragging"); - selectSessionElement(this); - }); - sessions.on("dragend", function () { - jQuery(this).removeClass("dragging"); + selectSessionElement(this); + }); + sessions.on("dragend", function () { + jQuery(this).removeClass("dragging"); - }); + }); - sessions.prop('draggable', true); + sessions.prop('draggable', true); - // dropping - let dropElements = content.find(".timeslot,.unassigned-sessions"); - dropElements.on('dragenter', function (event) { - if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") - return; + // dropping + let dropElements = content.find(".timeslot,.unassigned-sessions"); + dropElements.on('dragenter', function (event) { + if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") + return; - event.preventDefault(); // default action is signalling that this is not a valid target - jQuery(this).addClass("dropping"); - }); + event.preventDefault(); // default action is signalling that this is not a valid target + jQuery(this).addClass("dropping"); + }); - dropElements.on('dragover', function (event) { - // we don't actually need this event, except we need to signal - // that this is a valid drop target, by cancelling the default - // action - event.preventDefault(); - }); - - dropElements.on('dragleave', function (event) { - // skip dragleave events if they are to children - if (event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget)) - return; + dropElements.on('dragover', function (event) { + // we don't actually need this event, except we need to signal + // that this is a valid drop target, by cancelling the default + // action + event.preventDefault(); + }); - jQuery(this).removeClass("dropping"); - }); - - dropElements.on('drop', function (event) { - jQuery(this).removeClass("dropping"); + dropElements.on('dragleave', function (event) { + // skip dragleave events if they are to children + if (event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget)) + return; - let sessionId = event.originalEvent.dataTransfer.getData("text/plain"); - if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") - return; + jQuery(this).removeClass("dropping"); + }); - let sessionElement = sessions.filter("#" + sessionId); - if (sessionElement.length == 0) - return; + dropElements.on('drop', function (event) { + jQuery(this).removeClass("dropping"); - event.preventDefault(); // prevent opening as link + let sessionId = event.originalEvent.dataTransfer.getData("text/plain"); + if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session") + return; - if (sessionElement.parent().is(this)) - return; + let sessionElement = sessions.filter("#" + sessionId); + if (sessionElement.length == 0) + return; - let dropElement = jQuery(this); + event.preventDefault(); // prevent opening as link - function done() { - dropElement.append(sessionElement); // move element - updateCurrentSchedulingHints(); - if (dropElement.hasClass("unassigned-sessions")) - sortUnassigned(); - } + if (sessionElement.parent().is(this)) + return; - if (dropElement.hasClass("unassigned-sessions")) { - jQuery.ajax({ - url: ietfData.urls.assign, - method: "post", - data: { - action: "unassign", - session: sessionId.slice("session".length) - } - }).fail(failHandler).done(done); - } - else { - jQuery.ajax({ - url: ietfData.urls.assign, - method: "post", - data: { - action: "assign", - session: sessionId.slice("session".length), - timeslot: dropElement.attr("id").slice("timeslot".length) - } - }).fail(failHandler).done(done); - } - }); + let dropElement = jQuery(this); + + function done() { + dropElement.append(sessionElement); // move element + updateCurrentSchedulingHints(); + if (dropElement.hasClass("unassigned-sessions")) + sortUnassigned(); + } + + if (dropElement.hasClass("unassigned-sessions")) { + jQuery.ajax({ + url: ietfData.urls.assign, + method: "post", + data: { + action: "unassign", + session: sessionId.slice("session".length) + } + }).fail(failHandler).done(done); + } + else { + jQuery.ajax({ + url: ietfData.urls.assign, + method: "post", + data: { + action: "assign", + session: sessionId.slice("session".length), + timeslot: dropElement.attr("id").slice("timeslot".length) + } + }).fail(failHandler).done(done); + } + }); + } // hints for the current schedule From 8cc85e9fc28d97db6bf61310ba470758e457eaeb Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 9 Apr 2020 10:53:44 +0000 Subject: [PATCH 6/9] Make possible constraint violations purple to make it easier to tell them apart from the current constraint violations - Legacy-Id: 17607 --- ietf/static/ietf/css/ietf.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index bc6c9deda..d758e79d5 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1183,7 +1183,7 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { } .edit-meeting-schedule .session .constraints > span .encircled { - border: 1px solid #fdd; + border: 1px solid #f99; border-radius: 1em; min-width: 1.3em; text-align: center; @@ -1192,16 +1192,16 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { .edit-meeting-schedule .session .constraints > span.violated-hint { display: inline-block; - color: #f99; + color: #f55; } .edit-meeting-schedule .session .constraints > span.selected-hint { display: inline-block; - color: #f33; + color: #8432d4; } .edit-meeting-schedule .session .constraints > span.selected-hint .encircled { - border: 1px solid #fbb; + border: 1px solid #b35eff; } .edit-meeting-schedule .unassigned-sessions .session .constraints > span { From b8b1b67e6d6f00cc2032faacbba15fb4fa1a0925 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 9 Apr 2020 11:23:05 +0000 Subject: [PATCH 7/9] Improve error handling when editing in the meeting scheduler, add timeouts and output more error information. - Legacy-Id: 17608 --- ietf/static/ietf/js/edit-meeting-schedule.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index 359a3cb75..7a92c17d0 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -2,7 +2,10 @@ jQuery(document).ready(function () { let content = jQuery(".edit-meeting-schedule"); function failHandler(xhr, textStatus, error) { - alert("Error: " + error); + let errorText = error; + if (xhr && xhr.responseText) + errorText += "\n\n" + xhr.responseText; + alert("Error: " + errorText); } let sessions = content.find(".session"); @@ -104,7 +107,12 @@ jQuery(document).ready(function () { let dropElement = jQuery(this); - function done() { + function done(response) { + if (response != "OK") { + failHandler(null, null, response); + return; + } + dropElement.append(sessionElement); // move element updateCurrentSchedulingHints(); if (dropElement.hasClass("unassigned-sessions")) @@ -115,6 +123,7 @@ jQuery(document).ready(function () { jQuery.ajax({ url: ietfData.urls.assign, method: "post", + timeout: 5 * 1000, data: { action: "unassign", session: sessionId.slice("session".length) @@ -129,7 +138,8 @@ jQuery(document).ready(function () { action: "assign", session: sessionId.slice("session".length), timeslot: dropElement.attr("id").slice("timeslot".length) - } + }, + timeout: 5 * 1000 }).fail(failHandler).done(done); } }); From 6c48575042ea2fa3713b8b81557470c5984b1dd5 Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Thu, 9 Apr 2020 18:16:56 +0000 Subject: [PATCH 8/9] Swap the axes in the meeting schedule editor and rework it to allow flowing the days. Add JS workaround for missing position sticky support, instead of the CSS workaround which added an annoying padding for everyone. - Legacy-Id: 17616 --- ietf/meeting/views.py | 86 +++--- ietf/static/ietf/css/ietf.css | 255 +++++++++--------- ietf/static/ietf/js/edit-meeting-schedule.js | 8 +- .../meeting/edit_meeting_schedule.html | 66 +++-- .../edit_meeting_schedule_session.html | 32 +-- 5 files changed, 234 insertions(+), 213 deletions(-) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 9f8e066dd..8d94aa569 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -508,17 +508,14 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): for a in assignments: assignments_by_session[a.session_id].append(a) - # Prepare timeslot layout. We arrange time slots in columns per - # room where everything inside is grouped by day. Things inside - # the days are then layouted proportionally to the actual time of - # day, to ensure that everything lines up, even if the time slots - # are not the same in the different rooms. + # Prepare timeslot layout, making a timeline per day scaled in + # browser em units to ensure that everything lines up even if the + # timeslots are not the same in the different rooms def timedelta_to_css_ems(timedelta): - css_ems_per_hour = 1.8 + css_ems_per_hour = 5 return timedelta.seconds / 60.0 / 60.0 * css_ems_per_hour - # time labels column timeslots_by_day = defaultdict(list) for t in timeslots_qs: timeslots_by_day[t.time.date()].append(t) @@ -526,10 +523,19 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): day_min_max = [] for day, timeslots in sorted(timeslots_by_day.iteritems()): day_min_max.append((day, min(t.time for t in timeslots), max(t.end_time() for t in timeslots))) - - time_labels = [] + + timeslots_by_room_and_day = defaultdict(list) + room_has_timeslots = set() + for t in timeslots_qs: + room_has_timeslots.add(t.location_id) + timeslots_by_room_and_day[(t.location_id, t.time.date())].append(t) + + days = [] for day, day_min_time, day_max_time in day_min_max: day_labels = [] + day_width = timedelta_to_css_ems(day_max_time - day_min_time) + + label_width = 4 # em hourly_delta = 2 @@ -540,47 +546,42 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): end = day_max_time.replace(hour=last_hour, minute=0, second=0, microsecond=0) while t <= end: - day_labels.append((t, 'top', timedelta_to_css_ems(t - day_min_time), 'left')) + left_offset = timedelta_to_css_ems(t - day_min_time) + right_offset = day_width - left_offset + if right_offset > label_width: + # there's room for the label + day_labels.append((t, 'left', left_offset)) + else: + day_labels.append((t, 'right', right_offset)) + t += datetime.timedelta(seconds=hourly_delta * 60 * 60) if not day_labels: - day_labels.append((day_min_time, 'top', 0, 'left')) + day_labels.append((day_min_time, 'left', 0)) - time_labels.append({ - 'day': day, - 'height': timedelta_to_css_ems(day_max_time - day_min_time), - 'labels': day_labels, - }) + room_timeslots = [] + for r in rooms: + if r.pk not in room_has_timeslots: + continue - # room columns - timeslots_by_room_and_day = defaultdict(list) - for t in timeslots_qs: - timeslots_by_room_and_day[(t.location_id, t.time.date())].append(t) - - room_columns = [] - for r in rooms: - room_days = [] - - for day, day_min_time, day_max_time in day_min_max: - day_timeslots = [] + timeslots = [] for t in timeslots_by_room_and_day.get((r.pk, day), []): - day_timeslots.append({ + timeslots.append({ 'timeslot': t, 'offset': timedelta_to_css_ems(t.time - day_min_time), - 'height': timedelta_to_css_ems(t.end_time() - t.time), + 'width': timedelta_to_css_ems(t.end_time() - t.time), }) - room_days.append({ - 'day': day, - 'timeslots': day_timeslots, - 'height': timedelta_to_css_ems(day_max_time - day_min_time), - }) + room_timeslots.append((r, timeslots)) - if any(d['timeslots'] for d in room_days): - room_columns.append({ - 'room': r, - 'days': room_days, - }) + days.append({ + 'day': day, + 'width': day_width, + 'time_labels': day_labels, + 'room_timeslots': room_timeslots, + }) + + room_labels = [[r for r in rooms if r.pk in room_has_timeslots] for i in range(len(days))] # prepare sessions for ts in timeslots_qs: @@ -701,7 +702,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): s.requested_duration_in_hours = s.requested_duration.seconds / 60.0 / 60.0 session_layout_margin = 0.2 - s.layout_height = timedelta_to_css_ems(s.requested_duration) - 2 * session_layout_margin + s.layout_width = timedelta_to_css_ems(s.requested_duration) - 2 * session_layout_margin s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else "" s.historic_group_ad_name = ad_names.get(s.group_id) @@ -744,9 +745,8 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): 'schedule': schedule, 'can_edit': can_edit, 'js_data': json.dumps(js_data, indent=2), - 'time_labels': time_labels, - 'rooms': rooms, - 'room_columns': room_columns, + 'days': days, + 'room_labels': room_labels, 'unassigned_sessions': unassigned_sessions, 'session_parents': session_parents, 'hide_menu': True, diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index d758e79d5..aed0d09d2 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -974,7 +974,7 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { .fc-button { /* same as button-primary */ - background-image: linear-gradient(rgb(107, 91, 173) 0px, rgb(80, 68, 135) 100%) + background-image: linear-gradient(rgb(107, 91, 173) 0px, rgb(80, 68, 135) 100%); } /* === Edit Milestones============================================= */ @@ -987,62 +987,77 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { /* === Edit Meeting Schedule ====================================== */ .edit-meeting-schedule .edit-grid { + position: relative; display: flex; } -.edit-meeting-schedule .schedule-column .room-name { - height: 2em; - font-weight: bold; - text-align: center; - margin: 0; - white-space: nowrap; +.edit-meeting-schedule .edit-grid .room-label-column { + /* make sure we cut this column off - the time slots will determine + how much of it is shown */ + position: absolute; + top: 0; + bottom: 0; + left: 0; + overflow: hidden; + width: 8em; } -.edit-meeting-schedule .schedule-column .day-label { - height: 2.5em; - max-width: 5em; /* let it stick out and overlap the other columns */ - white-space: nowrap; - font-style: italic; - margin-top: 1em; -} - -.edit-meeting-schedule .schedule-column > .day { - position: relative; +.edit-meeting-schedule .edit-grid .day { + margin-right: 2.5em; margin-bottom: 2em; } -.edit-meeting-schedule .schedule-column > .day > div { +.edit-meeting-schedule .edit-grid .day-label { + height: 3em; + border-bottom: 2px solid transparent; +} + +.edit-meeting-schedule .edit-grid .day-flow { + margin-left: 8em; + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.edit-meeting-schedule .edit-grid .day-flow .day-label { + border-bottom: 2px solid #eee; +} + +.edit-meeting-schedule .edit-grid .timeline { + position: relative; + height: 1.6em; +} + +.edit-meeting-schedule .edit-grid .timeline > div { position: absolute; } -.edit-meeting-schedule .time-labels-column > div { - min-width: 5em; - padding-right: 0.5em; +.edit-meeting-schedule .edit-grid .timeline.timeslots { + height: 3.3em; } -.edit-meeting-schedule .time-labels-column .time-label.top-aligned { - border-top: 1px solid #ccc; +.edit-meeting-schedule .edit-grid .timeline .time-label { + font-size: smaller; + border-left: 2px solid #eee; + border-right: 2px solid #eee; + padding: 0 0.2em; + height: 1.3em; } -.edit-meeting-schedule .time-labels-column .time-label.text-left span { - background-color: #fff; - padding-right: 0.2em; +.edit-meeting-schedule .edit-grid .timeline .time-label.text-left { + border-right: none; } -.edit-meeting-schedule .time-labels-column .time-label.bottom-aligned { - border-bottom: 1px solid #ccc; -} - -.edit-meeting-schedule .room-column { - flex-grow: 1; +.edit-meeting-schedule .edit-grid .timeline .time-label.text-right { + border-left: none; } .edit-meeting-schedule .timeslot { display: flex; - flex-direction: column; + flex-direction: row; background-color: #eee; - width: 100%; - border-left: 0.15em solid #fff; + height: 100%; + border-bottom: 0.15em solid #fff; overflow: hidden; } @@ -1052,99 +1067,23 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { } .edit-meeting-schedule .timeslot.overfull { - border-bottom: 2px dashed #fff; /* cut-off illusion */ -} - -.edit-meeting-schedule { - /* this is backwards-compatible measure - if the browser doesn't - support position: sticky but only position: fixed, we ensure there's room for the scheduling - panel */ - padding-bottom: 5em; -} - -.edit-meeting-schedule .scheduling-panel { - position: fixed; /* backwards compatibility */ - z-index: 1; - - position: sticky; - display: flex; - bottom: 0; - left: 0; - width: 100%; - border-top: 0.2em solid #ccc; - background-color: #fff; - opacity: 0.95; -} - -.edit-meeting-schedule .scheduling-panel .unassigned-container { - flex-grow: 1; -} - -.edit-meeting-schedule .unassigned-sessions { - min-height: 4em; - max-height: 13em; - overflow-y: auto; - display: flex; - flex-wrap: wrap; - background-color: #eee; - margin-top: 0.5em; -} - -.edit-meeting-schedule .unassigned-sessions.dropping { - background-color: #e5e5e5; - transition: background-color 0.2s; -} - -.edit-meeting-schedule .scheduling-panel .preferences { - margin: 0.5em 0; -} - -.edit-meeting-schedule .scheduling-panel .preferences > span { - margin-right: 1em; -} - -.edit-meeting-schedule .sort-unassigned select { - width: auto; - display: inline-block; -} - -.edit-meeting-schedule .session-parent-toggles { - margin-top: 1em; -} - -.edit-meeting-schedule .session-parent-toggles label { - font-weight: normal; - margin-right: 1em; - padding: 0 1em; - border: 0.1em solid #eee; - cursor: pointer; -} - -.edit-meeting-schedule .scheduling-panel .session-info-container { - padding-left: 0.5em; - flex: 0 0 20em; - max-height: 15em; - overflow-y: auto; -} - -.edit-meeting-schedule .scheduling-panel .session-info-container .comments { - font-style: italic; + border-right: 2px dashed #fff; /* cut-off illusion */ } /* sessions */ .edit-meeting-schedule .session { - display: flex; background-color: #fff; - padding: 0 0.2em; - padding-left: 0.5em; margin: 0.2em; + padding-right: 0.2em; + padding-left: 0.5em; + line-height: 1.3em; border-radius: 0.4em; overflow: hidden; cursor: pointer; } .edit-meeting-schedule .session.selected { - border: 1px solid #bbb; + background-color: #fcfcfc; } .edit-meeting-schedule .session.dragging { @@ -1153,13 +1092,8 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { } .edit-meeting-schedule .timeslot.overfull .session { - border-radius: 0.4em 0.4em 0 0; /* remove bottom rounding to illude to being cut off */ - margin-bottom: 0; -} - -.edit-meeting-schedule .unassigned-sessions .session { - min-width: 6em; - margin-right: 0.3em; + border-radius: 0.4em 0 0 0.4em; /* remove bottom rounding to illude to being cut off */ + margin-right: 0; } .edit-meeting-schedule .session .session-label { @@ -1208,12 +1142,75 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { display: none; } - -.edit-meeting-schedule .session .comments { - font-size: smaller; - margin-right: 0.1em; -} - .edit-meeting-schedule .session .session-info { display: none; } + +/* scheduling panel */ +.edit-meeting-schedule .scheduling-panel { + position: sticky; + display: flex; + bottom: 0; + left: 0; + width: 100%; + border-top: 0.2em solid #ccc; + background-color: #fff; + opacity: 0.95; +} + +.edit-meeting-schedule .scheduling-panel .unassigned-container { + flex-grow: 1; +} + +.edit-meeting-schedule .unassigned-sessions { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + margin-top: 0.5em; + min-height: 4em; + max-height: 13em; + overflow-y: auto; + background-color: #eee; +} + +.edit-meeting-schedule .unassigned-sessions.dropping { + background-color: #e5e5e5; + transition: background-color 0.2s; +} + +.edit-meeting-schedule .scheduling-panel .preferences { + margin: 0.5em 0; +} + +.edit-meeting-schedule .scheduling-panel .preferences > span { + margin-right: 1em; +} + +.edit-meeting-schedule .sort-unassigned select { + width: auto; + display: inline-block; +} + +.edit-meeting-schedule .session-parent-toggles { + margin-top: 1em; +} + +.edit-meeting-schedule .session-parent-toggles label { + font-weight: normal; + margin-right: 1em; + padding: 0 1em; + border: 0.1em solid #eee; + cursor: pointer; +} + +.edit-meeting-schedule .scheduling-panel .session-info-container { + padding-left: 0.5em; + flex: 0 0 20em; + max-height: 15em; + overflow-y: auto; +} + +.edit-meeting-schedule .scheduling-panel .session-info-container .comments { + font-style: italic; +} + diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index 7a92c17d0..98eea1026 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -11,6 +11,12 @@ jQuery(document).ready(function () { let sessions = content.find(".session"); let timeslots = content.find(".timeslot"); + // hack to work around lack of position sticky support in old browsers, see https://caniuse.com/#feat=css-sticky + if (content.find(".scheduling-panel").css("position") != "sticky") { + content.find(".scheduling-panel").css("position", "fixed"); + content.css("padding-bottom", "14em"); + } + // selecting function selectSessionElement(element) { if (element) { @@ -220,7 +226,7 @@ jQuery(document).ready(function () { function updateAttendeesViolations() { sessions.each(function () { - let roomCapacity = jQuery(this).closest(".room-column").data("roomcapacity"); + let roomCapacity = jQuery(this).closest(".timeline").data("roomcapacity"); if (roomCapacity && this.dataset.attendees) jQuery(this).toggleClass("too-many-attendees", +this.dataset.attendees > +roomCapacity); }); diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index df745c431..0008653e6 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -48,43 +48,59 @@

- {# in order for all this to align properly vertically, we have the same structure in all columns #} -
-
+ {# using the same markup in both room labels and the actual days ensures they are aligned #} +
+ {% for labels in room_labels %} +
+
+  
+   +
- {% for d in time_labels %} -
{{ d.day|date:"l, F j, Y" }}
+
-
- {% for t, vertical_alignment, vertical_offset, horizontal_alignment in d.labels %} -
- {{ t|date:"H:i" }} + {% for room in labels %} +
+
+ {{ room.name }}
+ {% if room.capacity %}{{ room.capacity }} {% endif %} +
{% endfor %}
{% endfor %}
- {% for r in room_columns %} -
-
{{ r.room.name }}{% if r.room.capacity %} ({{ r.room.capacity }} {% endif %})
+
+ {% for day in days %} +
+
+ {{ day.day|date:"l" }}
+ {{ day.day|date:"N j, Y" }} +
- {% for d in r.days %} -
{# for spacing purposes #} - -
- {% for t in d.timeslots %} -
- {% for assignment, session in t.timeslot.session_assignments %} - {% include "meeting/edit_meeting_schedule_session.html" %} - {% endfor %} -
+
+ {% for t, left_or_right, offset in day.time_labels %} +
{{ t|date:"H:i" }}
{% endfor %}
- {% endfor %} -
- {% endfor %} + + {% for room, timeslots in day.room_timeslots %} +
+ + {% for t in timeslots %} +
+ {% for assignment, session in t.timeslot.session_assignments %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html index 07f5190a1..d835cad4f 100644 --- a/ietf/templates/meeting/edit_meeting_schedule_session.html +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -1,25 +1,27 @@ -
+
{{ session.scheduling_label }}
- {% if session.constrained_sessions %} -
- {% for explanation, sessions in session.constrained_sessions %} - {{ explanation }} - {% endfor %} -
- {% endif %} +
+ {% if session.attendees != None %} + {{ session.attendees }} + {% endif %} - {% if session.comments %} -
- {% endif %} - - {% if session.attendees != None %} -
{{ session.attendees }}
- {% endif %} + {% if session.comments %} + + {% endif %} + {% if session.constrained_sessions %} + + {% for explanation, sessions in session.constrained_sessions %} + {{ explanation }} + {% endfor %} + + {% endif %} +
+ {# this is shown elsewhere on the page with JS - we just include it here for convenience #}