From e5943f814d60271c62b2851fb26505db4935202b Mon Sep 17 00:00:00 2001 From: Ole Laursen Date: Mon, 23 Mar 2020 17:55:36 +0000 Subject: [PATCH] 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) + +