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
This commit is contained in:
Ole Laursen 2020-03-23 17:55:36 +00:00
parent 5faccf5379
commit e5943f814d
14 changed files with 911 additions and 259 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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("<i class=\"fa fa-user-o\"></i>")),
(re.compile(r"\(([^()])\)"), lambda match_groups: format_html("<span class=\"encircled\">{}</span>", 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("<i>{}</i>", 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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-default">Create schedule</button>
<button type="submit" class="btn btn-default">Copy schedule</button>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -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 %}
<script>
jQuery.ajaxSetup({
crossDomain: false, // obviates need for sameOrigin test
beforeSend: function(xhr, settings) {
if (!csrfSafeMethod(settings.type)) {
xhr.setRequestHeader("X-CSRFToken", $.cookie('csrftoken'));
}
}
});
<script type='text/javascript'>
var ietfData = {{ js_data|safe }};
</script>
<script type="text/javascript" src="{% static 'ietf/js/edit-meeting-schedule.js' %}"></script>
<script type='text/javascript'>
var ietfScheduleData = {{ schedule_data|safe }};
</script>
{% endblock js %}
@ -58,16 +48,13 @@
</p>
<div class="edit-grid">
{# 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 #}
<div class="time-labels-column schedule-column">
<h5>&nbsp;</h5>
<div class="room-name"></div>
{% for d in time_labels %}
<div class="day-label">
<strong>{{ d.day|date:"D" }}</strong><br>
<i>{{ d.day|date:"Y-m-d" }}</i>
</div>
<div class="day-label">{{ d.day|date:"l, F j, Y" }}</div>
<div class="day" style="height: {{ d.height }}em;">
{% for t, vertical_alignment, vertical_offset, horizontal_alignment in d.labels %}
@ -80,18 +67,15 @@
</div>
{% for r in room_columns %}
<div class="room-column schedule-column">
<h5>{{ r.room.name }}{% if r.room.capacity %} ({{ r.room.capacity }} <i class="fa fa-user-o"></i>{% endif %})</h5>
<div class="room-column schedule-column" data-roomcapacity="{{ r.room.capacity }}">
<div class="room-name">{{ r.room.name }}{% if r.room.capacity %} ({{ r.room.capacity }} <i class="fa fa-user-o"></i>{% endif %})</div>
{% for d in r.days %}
<div class="day-label">
<strong>{{ d.day|date:"D" }}</strong><br>
<i>{{ d.day|date:"Y-m-d" }}</i>
</div>
<div class="day-label"></div> {# for spacing purposes #}
<div class="day" style="height: {{ d.height }}em;">
{% for t in d.timeslots %}
<div class="timeslot" data-timeslot="{{ t.timeslot.pk }}" data-duration="{{ t.timeslot.duration.total_seconds }}" style="top: {{ t.offset }}em; height: {{ t.height }}em;">
<div id="timeslot{{ t.timeslot.pk }}" class="timeslot" data-start="{{ t.timeslot.time.isoformat }}" data-end="{{ t.timeslot.end_time.isoformat }}" data-duration="{{ t.timeslot.duration.total_seconds }}" style="top: {{ t.offset }}em; height: {{ t.height }}em;">
{% for assignment, session in t.timeslot.session_assignments %}
{% include "meeting/edit_meeting_schedule_session.html" %}
{% endfor %}
@ -104,20 +88,34 @@
</div>
<div class="scheduling-panel">
<h5>Not yet assigned</h5>
<div class="unassigned-container">
<div class="unassigned-sessions">
{% for session in unassigned_sessions %}
{% include "meeting/edit_meeting_schedule_session.html" %}
{% endfor %}
</div>
<div class="unassigned-sessions">
{% for session in unassigned_sessions %}
{% include "meeting/edit_meeting_schedule_session.html" %}
{% endfor %}
<div class="preferences">
<span class="sort-unassigned">
Sort unassigned:
<select name="sort_unassigned" class="form-control">
<option value="name" selected="selected">By name</option>
<option value="parent">By area</option>
<option value="duration">By duration</option>
<option value="comments">Special requests</option>
</select>
</span>
<span class="session-parent-toggles">
Show:
{% for p in session_parents %}
<label class="parent-{{ p.acronym }}"><input type="checkbox" checked value="{{ p.acronym }}"> {{ p.acronym }}</label>
{% endfor %}
</span>
</div>
</div>
<div class="session-parent-toggles">
Show:
{% for p in session_parents %}
<label class="parent-{{ p.acronym }}"><input type="checkbox" checked value="{{ p.acronym }}"> {{ p.acronym }}</label>
{% endfor %}
</div>
<div class="session-info-container"></div>
</div>
</div>

View file

@ -1,3 +1,62 @@
<div id="session{{ session.pk }}" class="session {% if session.group.parent.scheduling_color %}toggleable {% if session.parent_acronym %}parent-{{ session.parent_acronym }}{% endif %}{% endif %}" style="height:{{ session.layout_height }}em;" data-duration="{{ session.requested_duration.total_seconds }}">
{{ session.scheduling_label }} {% if session.comments %}<i class="fa fa-comment-o"></i>{% endif %}
<div id="session{{ session.pk }}" class="session {% if not session.group.parent.scheduling_color %}untoggleable{% endif %} {% if session.parent_acronym %}parent-{{ session.parent_acronym }}{% endif %}" style="height:{{ session.layout_height }}em;" data-duration="{{ session.requested_duration.total_seconds }}" {% if session.attendees != None %}data-attendees="{{ session.attendees }}"{% endif %}>
<div class="session-label">
{{ session.scheduling_label }}
</div>
{% if session.constrained_sessions %}
<div class="constraints">
{% for explanation, sessions in session.constrained_sessions %}
<span data-sessions="{{ sessions|join:"," }}">{{ explanation }}</span>
{% endfor %}
</div>
{% endif %}
{% if session.comments %}
<div class="comments"><i class="fa fa-comment-o"></i></div>
{% endif %}
{% if session.attendees != None %}
<div class="attendees">{{ session.attendees }}</div>
{% endif %}
<div class="session-info">
<label>
{{ session.scheduling_label }}
&middot; {{ session.requested_duration_in_hours }} h
{% if session.group %}&middot; {% if session.group.is_bof %}BoF{% else %}{{ session.group.type.name }}{% endif %}{% endif %}
{% if session.attendees != None %}&middot; {{ session.attendees }} <i class="fa fa-user-o"></i>{% endif %}
</label>
{% if session.group %}
<div>
{{ session.group.name }}
{% if session.group.parent %}
&middot; <span class="session-parent">{{ session.group.parent.acronym }}</span>
{% if session.historic_group_ad_name %} ({{ session.historic_group_ad_name }}){% endif %}
{% endif %}
</div>
{% endif %}
{% if session.requested_by_person %}
<div>
<i class="fa fa-user-circle-o"></i> {{ session.requested_by_person.plain_name }} {% if session.requested_time %}({{ session.requested_time|date:"Y-m-d" }}){% endif %}
</div>
{% endif %}
{% if session.resources.all %}
<div>
Resources:
{% for r in session.resources.all %}
{{ r.name }}{% if not forloop.last %},{% endif %}
{% endfor %}
</div>
{% endif %}
{% if session.comments %}
<div class="comments">
{{ session.comments|linebreaksbr }}
</div>
{% endif %}
</div>
</div>

View file

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