+ {# 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 %}
-
{{ 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 #}