Merged in ^/branch/iola/meeting-improvement-r17214@17617, which provides a new meeting schedule editor.
- Legacy-Id: 17701
This commit is contained in:
commit
42995fadea
|
@ -139,15 +139,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
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
#!/bin/bash
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
su - -c "apt-get update \
|
||||
&& apt-get install -qy graphviz ghostscript apache2-utils \
|
||||
sudo su - -c "apt-get update \
|
||||
&& apt-get install -qy graphviz ghostscript apache2-utils chromium-driver \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*"
|
||||
|
|
@ -15,7 +15,7 @@ from urllib.parse import urljoin
|
|||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.validators import MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Max
|
||||
from django.conf import settings
|
||||
|
@ -620,7 +620,7 @@ class Schedule(models.Model):
|
|||
schedule, others may copy it
|
||||
"""
|
||||
meeting = ForeignKey(Meeting, null=True, related_name='schedule_set')
|
||||
name = models.CharField(max_length=16, blank=False)
|
||||
name = models.CharField(max_length=16, blank=False, help_text="Letters, numbers and -:_ allowed.", validators=[RegexValidator(r'^[A-Za-z0-9-:_]*$')])
|
||||
owner = ForeignKey(Person)
|
||||
visible = models.BooleanField(default=True, help_text="Make this agenda available to those who know about it.")
|
||||
public = models.BooleanField(default=True, help_text="Make this agenda publically available.")
|
||||
|
|
|
@ -99,11 +99,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)))
|
||||
|
@ -116,7 +116,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)
|
||||
|
@ -125,7 +125,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)
|
||||
|
@ -134,7 +134,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)
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
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
|
||||
|
||||
|
@ -17,57 +17,49 @@ 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
|
||||
|
||||
skip_selenium = getattr(settings,'SKIP_SELENIUM',None)
|
||||
skip_selenium = False
|
||||
skip_message = ""
|
||||
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
|
||||
|
||||
executable_name = 'chromedriver'
|
||||
code, out, err = pipe('{} --version'.format(executable_name))
|
||||
if code != 0:
|
||||
skip_selenium = True
|
||||
skip_message = "Skipping selenium tests: '{}' executable not found.".format(executable_name)
|
||||
if skip_selenium:
|
||||
skip_message = "settings.SKIP_SELENIUM = %s" % skip_selenium
|
||||
else:
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
except ImportError as e:
|
||||
skip_selenium = True
|
||||
skip_message = "Skipping selenium tests: %s" % e
|
||||
code, out, err = pipe('phantomjs -v')
|
||||
if not code == 0:
|
||||
skip_selenium = True
|
||||
skip_message = "Skipping selenium tests: 'phantomjs' executable not found."
|
||||
if skip_selenium:
|
||||
sys.stderr.write(" "+skip_message+'\n')
|
||||
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
|
||||
options.add_argument("no-sandbox") # docker 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)
|
||||
|
||||
|
@ -75,52 +67,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)
|
||||
|
@ -173,7 +310,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'))
|
||||
|
|
|
@ -32,7 +32,7 @@ from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_r
|
|||
from ietf.meeting.helpers import send_interim_approval_request
|
||||
from ietf.meeting.helpers import send_interim_cancellation_notice
|
||||
from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates
|
||||
from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent
|
||||
from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room, 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
|
||||
|
@ -946,10 +946,161 @@ class EditTests(TestCase):
|
|||
|
||||
def test_edit_schedule(self):
|
||||
meeting = make_meeting_test_data()
|
||||
|
||||
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.get(urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number)))
|
||||
self.assertContains(r, "load_assignments")
|
||||
|
||||
def test_edit_meeting_schedule(self):
|
||||
meeting = make_meeting_test_data()
|
||||
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
|
||||
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(".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("#timeslot{}".format(timeslots[0].pk)))
|
||||
|
||||
for s in [s1, s2]:
|
||||
e = q("#session{}".format(s.pk))
|
||||
|
||||
# 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': s1.pk,
|
||||
})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
# turn us into owner
|
||||
meeting.schedule.owner = Person.objects.get(user__username="secretary")
|
||||
meeting.schedule.save()
|
||||
|
||||
url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name))
|
||||
r = self.client.get(url)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue(not q("em:contains(\"You can't edit this schedule\")"))
|
||||
|
||||
SchedTimeSessAssignment.objects.filter(session=s1).delete()
|
||||
|
||||
# assign
|
||||
r = self.client.post(url, {
|
||||
'action': 'assign',
|
||||
'timeslot': timeslots[0].pk,
|
||||
'session': s1.pk,
|
||||
})
|
||||
self.assertEqual(r.content, b"OK")
|
||||
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': s1.pk,
|
||||
})
|
||||
self.assertEqual(r.content, b"OK")
|
||||
self.assertEqual(SchedTimeSessAssignment.objects.get(schedule=meeting.schedule, session=s1).timeslot, timeslots[1])
|
||||
|
||||
# unassign
|
||||
r = self.client.post(url, {
|
||||
'action': 'unassign',
|
||||
'session': s1.pk,
|
||||
})
|
||||
self.assertEqual(r.content, b"OK")
|
||||
self.assertEqual(list(SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule, session=s1)), [])
|
||||
|
||||
|
||||
def test_copy_meeting_schedule(self):
|
||||
meeting = make_meeting_test_data()
|
||||
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
|
||||
url = urlreverse("ietf.meeting.views.copy_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name))
|
||||
r = self.client.get(url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# copy
|
||||
r = self.client.post(url, {
|
||||
'name': "newtest",
|
||||
'public': "on",
|
||||
})
|
||||
self.assertNoFormPostErrors(r)
|
||||
|
||||
new_schedule = Schedule.objects.get(meeting=meeting, owner__user__username='secretary', name='newtest')
|
||||
self.assertEqual(new_schedule.public, True)
|
||||
self.assertEqual(new_schedule.visible, False)
|
||||
|
||||
old_assignments = {(a.session_id, a.timeslot_id) for a in SchedTimeSessAssignment.objects.filter(schedule=meeting.schedule)}
|
||||
for a in SchedTimeSessAssignment.objects.filter(schedule=new_schedule):
|
||||
self.assertIn((a.session_id, a.timeslot_id), old_assignments)
|
||||
# FIXME: test extendedfrom is copied correctly
|
||||
|
||||
def test_save_agenda_as_and_read_permissions(self):
|
||||
meeting = make_meeting_test_data()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright The IETF Trust 2007-2019, All Rights Reserved
|
||||
# Copyright The IETF Trust 2007-2020, All Rights Reserved
|
||||
|
||||
from django.conf.urls import include
|
||||
from django.views.generic import RedirectView
|
||||
|
@ -27,6 +27,7 @@ safe_for_all_meeting_types = [
|
|||
|
||||
type_ietf_only_patterns = [
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s/edit$' % settings.URL_REGEXPS, views.edit_schedule),
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s/edit/$' % settings.URL_REGEXPS, views.edit_meeting_schedule),
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s/details$' % settings.URL_REGEXPS, views.edit_schedule_properties),
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s/delete$' % settings.URL_REGEXPS, views.delete_schedule),
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s/make_official$' % settings.URL_REGEXPS, views.make_schedule_official),
|
||||
|
@ -40,6 +41,7 @@ type_ietf_only_patterns = [
|
|||
url(r'^agenda/%(owner)s/%(schedule_name)s/session/(?P<assignment_id>\d+).json$' % settings.URL_REGEXPS, ajax.assignment_json),
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s/sessions.json$' % settings.URL_REGEXPS, ajax.assignments_json),
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s.json$' % settings.URL_REGEXPS, ajax.schedule_infourl),
|
||||
url(r'^agenda/%(owner)s/%(schedule_name)s/copy/$' % settings.URL_REGEXPS, views.copy_meeting_schedule),
|
||||
url(r'^agenda/by-room$', views.agenda_by_room),
|
||||
url(r'^agenda/by-type$', views.agenda_by_type),
|
||||
url(r'^agenda/by-type/(?P<type>[a-z]+)$', views.agenda_by_type),
|
||||
|
@ -77,6 +79,7 @@ type_ietf_only_patterns_id_optional = [
|
|||
url(r'^agenda(?P<ext>.txt)$', views.agenda),
|
||||
url(r'^agenda(?P<ext>.csv)$', views.agenda),
|
||||
url(r'^agenda/edit$', views.edit_schedule),
|
||||
url(r'^agenda/edit/$', views.edit_meeting_schedule),
|
||||
url(r'^requests$', views.meeting_requests),
|
||||
url(r'^agenda/agenda\.ics$', views.ical_agenda),
|
||||
url(r'^agenda\.ics$', views.ical_agenda),
|
||||
|
|
|
@ -8,6 +8,7 @@ import glob
|
|||
import io
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import pytz
|
||||
import re
|
||||
|
@ -16,7 +17,7 @@ import markdown2
|
|||
|
||||
|
||||
from calendar import timegm
|
||||
from collections import OrderedDict, Counter, deque
|
||||
from collections import OrderedDict, Counter, deque, defaultdict
|
||||
from urllib.parse import unquote
|
||||
from tempfile import mkstemp
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
@ -40,6 +41,7 @@ from django.utils.encoding import force_str
|
|||
from django.utils.functional import curry
|
||||
from django.utils.text import slugify
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.utils.html import format_html
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
|
@ -49,9 +51,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
|
||||
|
@ -347,6 +351,405 @@ def edit_timeslots(request, num=None):
|
|||
"ts_list":ts_list,
|
||||
})
|
||||
|
||||
class CopyScheduleForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Schedule
|
||||
fields = ['name', 'visible', 'public']
|
||||
|
||||
def __init__(self, schedule, new_owner, *args, **kwargs):
|
||||
super(CopyScheduleForm, self).__init__(*args, **kwargs)
|
||||
|
||||
self.schedule = schedule
|
||||
self.new_owner = new_owner
|
||||
|
||||
username = new_owner.user.username
|
||||
|
||||
name_suggestion = username
|
||||
counter = 2
|
||||
|
||||
existing_names = set(Schedule.objects.filter(meeting=schedule.meeting_id, owner=new_owner).values_list('name', flat=True))
|
||||
while name_suggestion in existing_names:
|
||||
name_suggestion = username + str(counter)
|
||||
counter += 1
|
||||
|
||||
self.fields['name'].initial = name_suggestion
|
||||
self.fields['name'].label = "Name of new schedule"
|
||||
|
||||
def clean_name(self):
|
||||
name = self.cleaned_data.get('name')
|
||||
if name and Schedule.objects.filter(meeting=self.schedule.meeting_id, owner=self.new_owner, name=name):
|
||||
raise forms.ValidationError("Schedule with this name already exists.")
|
||||
return name
|
||||
|
||||
@role_required('Area Director','Secretariat')
|
||||
def copy_meeting_schedule(request, num, owner, name):
|
||||
meeting = get_meeting(num)
|
||||
schedule = get_object_or_404(meeting.schedule_set, owner__email__address=owner, name=name)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = CopyScheduleForm(schedule, request.user.person, request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
new_schedule = form.save(commit=False)
|
||||
new_schedule.meeting = schedule.meeting
|
||||
new_schedule.owner = request.user.person
|
||||
new_schedule.save()
|
||||
|
||||
# keep a mapping so that extendedfrom references can be chased
|
||||
old_pk_to_new_pk = {}
|
||||
extendedfroms = {}
|
||||
for assignment in schedule.assignments.all():
|
||||
extendedfrom_id = assignment.extendedfrom_id
|
||||
|
||||
# clone by resetting primary key
|
||||
old_pk = assignment.pk
|
||||
assignment.pk = None
|
||||
assignment.schedule = new_schedule
|
||||
assignment.extendedfrom = None
|
||||
assignment.save()
|
||||
|
||||
old_pk_to_new_pk[old_pk] = assignment.pk
|
||||
if extendedfrom_id is not None:
|
||||
extendedfroms[assignment.pk] = extendedfrom_id
|
||||
|
||||
for pk, extendedfrom_id in extendedfroms.values():
|
||||
if extendedfrom_id in old_pk_to_new_pk:
|
||||
SchedTimeSessAssignment.objects.filter(pk=pk).update(extendedfrom=old_pk_to_new_pk[extendedfrom_id])
|
||||
|
||||
# now redirect to this new schedule
|
||||
return redirect(edit_meeting_schedule, meeting.number, new_schedule.owner_email(), new_schedule.name)
|
||||
|
||||
else:
|
||||
form = CopyScheduleForm(schedule, request.user.person)
|
||||
|
||||
return render(request, "meeting/copy_meeting_schedule.html", {
|
||||
'meeting': meeting,
|
||||
'schedule': schedule,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def edit_meeting_schedule(request, num=None, owner=None, name=None):
|
||||
meeting = get_meeting(num)
|
||||
if name is None:
|
||||
schedule = meeting.schedule
|
||||
else:
|
||||
schedule = get_schedule_by_name(meeting, get_person_by_email(owner), name)
|
||||
|
||||
if schedule is None:
|
||||
raise Http404("No meeting information for meeting %s owner %s schedule %s available" % (num, owner, name))
|
||||
|
||||
can_see, can_edit, secretariat = schedule_permissions(meeting, schedule, request.user)
|
||||
|
||||
if not can_see:
|
||||
if request.method == 'POST':
|
||||
return HttpResponseForbidden("Can't view this schedule")
|
||||
|
||||
# FIXME: check this
|
||||
return render(request, "meeting/private_schedule.html",
|
||||
{"schedule":schedule,
|
||||
"meeting": meeting,
|
||||
"meeting_base_url": request.build_absolute_uri(meeting.base_url()),
|
||||
"hide_menu": True
|
||||
}, status=403, content_type="text/html")
|
||||
|
||||
assignments = get_all_assignments_from_schedule(schedule)
|
||||
|
||||
rooms = meeting.room_set.filter(session_types__slug='regular').distinct().order_by("capacity")
|
||||
timeslots_qs = meeting.timeslot_set.filter(type='regular').prefetch_related('type', 'sessions').order_by('location', 'time', 'name')
|
||||
|
||||
sessions = add_event_info_to_session_qs(
|
||||
Session.objects.filter(
|
||||
meeting=meeting,
|
||||
# Restrict graphical scheduling to regular meeting requests (Sessions) for now
|
||||
type='regular',
|
||||
),
|
||||
requested_time=True,
|
||||
requested_by=True,
|
||||
).exclude(current_status__in=['notmeet', 'disappr', 'deleted', 'apprw']).prefetch_related(
|
||||
'resources', 'group', 'group__parent', 'group__type',
|
||||
)
|
||||
|
||||
if request.method == 'POST':
|
||||
if not can_edit:
|
||||
return HttpResponseForbidden("Can't edit this schedule")
|
||||
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'assign' and request.POST.get('session', '').isdigit() and request.POST.get('timeslot', '').isdigit():
|
||||
session = get_object_or_404(sessions, pk=request.POST['session'])
|
||||
timeslot = get_object_or_404(timeslots_qs, pk=request.POST['timeslot'])
|
||||
|
||||
existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule)
|
||||
if existing_assignments:
|
||||
existing_assignments.update(timeslot=timeslot, modified=datetime.datetime.now())
|
||||
else:
|
||||
SchedTimeSessAssignment.objects.create(
|
||||
session=session,
|
||||
schedule=schedule,
|
||||
timeslot=timeslot,
|
||||
)
|
||||
|
||||
return HttpResponse("OK")
|
||||
|
||||
elif action == 'unassign' and request.POST.get('session', '').isdigit():
|
||||
session = get_object_or_404(sessions, pk=request.POST['session'])
|
||||
SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule).delete()
|
||||
|
||||
return HttpResponse("OK")
|
||||
|
||||
return HttpResponse("Invalid parameters", status_code=400)
|
||||
|
||||
assignments_by_session = defaultdict(list)
|
||||
for a in assignments:
|
||||
assignments_by_session[a.session_id].append(a)
|
||||
|
||||
# Prepare timeslot layout, making a timeline per day scaled in
|
||||
# browser em units to ensure that everything lines up even if the
|
||||
# timeslots are not the same in the different rooms
|
||||
|
||||
def timedelta_to_css_ems(timedelta):
|
||||
css_ems_per_hour = 5
|
||||
return timedelta.seconds / 60.0 / 60.0 * css_ems_per_hour
|
||||
|
||||
timeslots_by_day = defaultdict(list)
|
||||
for t in timeslots_qs:
|
||||
timeslots_by_day[t.time.date()].append(t)
|
||||
|
||||
day_min_max = []
|
||||
for day, timeslots in sorted(timeslots_by_day.items()):
|
||||
day_min_max.append((day, min(t.time for t in timeslots), max(t.end_time() for t in timeslots)))
|
||||
|
||||
timeslots_by_room_and_day = defaultdict(list)
|
||||
room_has_timeslots = set()
|
||||
for t in timeslots_qs:
|
||||
room_has_timeslots.add(t.location_id)
|
||||
timeslots_by_room_and_day[(t.location_id, t.time.date())].append(t)
|
||||
|
||||
days = []
|
||||
for day, day_min_time, day_max_time in day_min_max:
|
||||
day_labels = []
|
||||
day_width = timedelta_to_css_ems(day_max_time - day_min_time)
|
||||
|
||||
label_width = 4 # em
|
||||
|
||||
hourly_delta = 2
|
||||
|
||||
first_hour = int(math.ceil((day_min_time.hour + day_min_time.minute / 60.0) / hourly_delta) * hourly_delta)
|
||||
t = day_min_time.replace(hour=first_hour, minute=0, second=0, microsecond=0)
|
||||
|
||||
last_hour = int(math.floor((day_max_time.hour + day_max_time.minute / 60.0) / hourly_delta) * hourly_delta)
|
||||
end = day_max_time.replace(hour=last_hour, minute=0, second=0, microsecond=0)
|
||||
|
||||
while t <= end:
|
||||
left_offset = timedelta_to_css_ems(t - day_min_time)
|
||||
right_offset = day_width - left_offset
|
||||
if right_offset > label_width:
|
||||
# there's room for the label
|
||||
day_labels.append((t, 'left', left_offset))
|
||||
else:
|
||||
day_labels.append((t, 'right', right_offset))
|
||||
|
||||
t += datetime.timedelta(seconds=hourly_delta * 60 * 60)
|
||||
|
||||
if not day_labels:
|
||||
day_labels.append((day_min_time, 'left', 0))
|
||||
|
||||
room_timeslots = []
|
||||
for r in rooms:
|
||||
if r.pk not in room_has_timeslots:
|
||||
continue
|
||||
|
||||
timeslots = []
|
||||
for t in timeslots_by_room_and_day.get((r.pk, day), []):
|
||||
timeslots.append({
|
||||
'timeslot': t,
|
||||
'offset': timedelta_to_css_ems(t.time - day_min_time),
|
||||
'width': timedelta_to_css_ems(t.end_time() - t.time),
|
||||
})
|
||||
|
||||
room_timeslots.append((r, timeslots))
|
||||
|
||||
days.append({
|
||||
'day': day,
|
||||
'width': day_width,
|
||||
'time_labels': day_labels,
|
||||
'room_timeslots': room_timeslots,
|
||||
})
|
||||
|
||||
room_labels = [[r for r in rooms if r.pk in room_has_timeslots] for i in range(len(days))]
|
||||
|
||||
# prepare sessions
|
||||
for ts in timeslots_qs:
|
||||
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):
|
||||
# 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)
|
||||
a = hue * x * (1 - x) / 2.0
|
||||
|
||||
return (
|
||||
max(0, min(x + a * (-0.14861 * math.cos(phi) + 1.78277 * math.sin(phi)), 1)),
|
||||
max(0, min(x + a * (-0.29227 * math.cos(phi) + -0.90649 * math.sin(phi)), 1)),
|
||||
max(0, min(x + a * (1.97294 * math.cos(phi)), 1)),
|
||||
)
|
||||
|
||||
session_parents = sorted(set(
|
||||
s.group.parent for s in sessions
|
||||
if s.group and s.group.parent and s.group.parent.type_id == 'area' or s.group.parent.acronym == 'irtf'
|
||||
), key=lambda p: p.acronym)
|
||||
for i, p in enumerate(session_parents):
|
||||
rgb_color = cubehelix(i, len(session_parents))
|
||||
p.scheduling_color = "#" + "".join( hex(int(round(x * 255)))[2:] for x in rgb_color)
|
||||
|
||||
# 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 - convert the human-readable rules in the database
|
||||
# to constraints on the actual sessions, compress them and output
|
||||
# them, so that the JS simply has to detect violations and show
|
||||
# the relevant preprocessed label
|
||||
constraints = Constraint.objects.filter(meeting=meeting)
|
||||
person_needed_for_groups = defaultdict(set)
|
||||
for c in constraints:
|
||||
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:
|
||||
if s.group_id is not None:
|
||||
sessions_for_group[s.group_id].append(s.pk)
|
||||
|
||||
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.values()):
|
||||
# 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())
|
||||
|
||||
# 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:
|
||||
s.scheduling_label = s.group.acronym
|
||||
elif s.name:
|
||||
s.scheduling_label = s.name
|
||||
|
||||
s.requested_duration_in_hours = s.requested_duration.seconds / 60.0 / 60.0
|
||||
|
||||
session_layout_margin = 0.2
|
||||
s.layout_width = timedelta_to_css_ems(s.requested_duration) - 2 * session_layout_margin
|
||||
s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else ""
|
||||
s.historic_group_ad_name = ad_names.get(s.group_id)
|
||||
|
||||
# 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.items())
|
||||
|
||||
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))
|
||||
assigned = True
|
||||
|
||||
if not assigned:
|
||||
unassigned_sessions.append(s)
|
||||
|
||||
js_data = {
|
||||
'can_edit': can_edit,
|
||||
'urls': {
|
||||
'assign': request.get_full_path()
|
||||
}
|
||||
}
|
||||
|
||||
return render(request, "meeting/edit_meeting_schedule.html", {
|
||||
'meeting': meeting,
|
||||
'schedule': schedule,
|
||||
'can_edit': can_edit,
|
||||
'js_data': json.dumps(js_data, indent=2),
|
||||
'days': days,
|
||||
'room_labels': room_labels,
|
||||
'unassigned_sessions': unassigned_sessions,
|
||||
'session_parents': session_parents,
|
||||
'hide_menu': True,
|
||||
})
|
||||
|
||||
|
||||
##############################################################################
|
||||
#@role_required('Area Director','Secretariat')
|
||||
# disable the above security for now, check it below.
|
||||
|
@ -419,6 +822,7 @@ def edit_schedule(request, num=None, owner=None, name=None):
|
|||
"hide_menu": True,
|
||||
})
|
||||
|
||||
|
||||
##############################################################################
|
||||
# show the properties associated with a schedule (visible, public)
|
||||
#
|
||||
|
@ -444,7 +848,7 @@ def edit_schedule_properties(request, num=None, owner=None, name=None):
|
|||
if form.is_valid():
|
||||
form.save()
|
||||
return HttpResponseRedirect(reverse('ietf.meeting.views.list_schedules',kwargs={'num': num}))
|
||||
else:
|
||||
else:
|
||||
form = SchedulePropertiesForm(instance=schedule)
|
||||
return render(request, "meeting/properties_edit.html",
|
||||
{"schedule":schedule,
|
||||
|
|
|
@ -5568,6 +5568,7 @@
|
|||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"editor_label": "(person)",
|
||||
"name": "Person must be present",
|
||||
"order": 0,
|
||||
"penalty": 200000,
|
||||
|
@ -5579,6 +5580,7 @@
|
|||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"editor_label": "(2)",
|
||||
"name": "Conflicts with (secondary)",
|
||||
"order": 0,
|
||||
"penalty": 10000,
|
||||
|
@ -5590,6 +5592,7 @@
|
|||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"editor_label": "(3)",
|
||||
"name": "Conflicts with (tertiary)",
|
||||
"order": 0,
|
||||
"penalty": 1000,
|
||||
|
@ -5600,6 +5603,7 @@
|
|||
},
|
||||
{
|
||||
"fields": {
|
||||
"editor_label": "(1)",
|
||||
"desc": "",
|
||||
"name": "Conflicts with",
|
||||
"order": 0,
|
||||
|
|
36
ietf/name/migrations/0010_constraintname_editor_label.py
Normal file
36
ietf/name/migrations/0010_constraintname_editor_label.py
Normal 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),
|
||||
]
|
|
@ -69,6 +69,7 @@ class TimeSlotTypeName(NameModel):
|
|||
class ConstraintName(NameModel):
|
||||
"""conflict, conflic2, conflic3, bethere, timerange, time_relation, wg_adjacent"""
|
||||
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 TimerangeName(NameModel):
|
||||
"""(monday|tuesday|wednesday|thursday|friday)-(morning|afternoon-early|afternoon-late)"""
|
||||
class LiaisonStatementPurposeName(NameModel):
|
||||
|
|
|
@ -974,7 +974,7 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
|
|||
|
||||
.fc-button {
|
||||
/* same as button-primary */
|
||||
background-image: linear-gradient(rgb(107, 91, 173) 0px, rgb(80, 68, 135) 100%)
|
||||
background-image: linear-gradient(rgb(107, 91, 173) 0px, rgb(80, 68, 135) 100%);
|
||||
}
|
||||
|
||||
/* === Edit Milestones============================================= */
|
||||
|
@ -983,3 +983,241 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
|
|||
padding: 8px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* === Edit Meeting Schedule ====================================== */
|
||||
|
||||
.edit-meeting-schedule .edit-grid {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .room-label-column {
|
||||
/* make sure we cut this column off - the time slots will determine
|
||||
how much of it is shown */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
width: 8em;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .day {
|
||||
margin-right: 2.5em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .day-label {
|
||||
height: 3em;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .day-flow {
|
||||
margin-left: 8em;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 120em) {
|
||||
/* if there's only room for two days, it looks a bit odd with space-between */
|
||||
.edit-meeting-schedule .edit-grid .day-flow {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .day-flow .day-label {
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .timeline {
|
||||
position: relative;
|
||||
height: 1.6em;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .timeline > div {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .timeline.timeslots {
|
||||
height: 3.3em;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .timeline .time-label {
|
||||
font-size: smaller;
|
||||
border-left: 2px solid #eee;
|
||||
border-right: 2px solid #eee;
|
||||
padding: 0 0.2em;
|
||||
height: 1.3em;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .timeline .time-label.text-left {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .edit-grid .timeline .time-label.text-right {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .timeslot {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #eee;
|
||||
height: 100%;
|
||||
border-bottom: 0.15em solid #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .timeslot.dropping {
|
||||
background-color: #ccc;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .timeslot.overfull {
|
||||
border-right: 2px dashed #fff; /* cut-off illusion */
|
||||
}
|
||||
|
||||
/* sessions */
|
||||
.edit-meeting-schedule .session {
|
||||
background-color: #fff;
|
||||
margin: 0.2em;
|
||||
padding-right: 0.2em;
|
||||
padding-left: 0.5em;
|
||||
line-height: 1.3em;
|
||||
border-radius: 0.4em;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session.selected {
|
||||
background-color: #fcfcfc;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session.dragging {
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .timeslot.overfull .session {
|
||||
border-radius: 0.4em 0 0 0.4em; /* remove bottom rounding to illude to being cut off */
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .session-label {
|
||||
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 #f99;
|
||||
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: #f55;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints > span.selected-hint {
|
||||
display: inline-block;
|
||||
color: #8432d4;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .constraints > span.selected-hint .encircled {
|
||||
border: 1px solid #b35eff;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .unassigned-sessions .session .constraints > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session .session-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* scheduling panel */
|
||||
.edit-meeting-schedule .scheduling-panel {
|
||||
position: sticky;
|
||||
display: flex;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-top: 0.2em solid #ccc;
|
||||
background-color: #fff;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .scheduling-panel .unassigned-container {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .unassigned-sessions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
margin-top: 0.5em;
|
||||
min-height: 4em;
|
||||
max-height: 13em;
|
||||
overflow-y: auto;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .unassigned-sessions.dropping {
|
||||
background-color: #e5e5e5;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .scheduling-panel .preferences {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .scheduling-panel .preferences > span {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .sort-unassigned select {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session-parent-toggles {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .session-parent-toggles label {
|
||||
font-weight: normal;
|
||||
margin-right: 1em;
|
||||
padding: 0 1em;
|
||||
border: 0.1em solid #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .scheduling-panel .session-info-container {
|
||||
padding-left: 0.5em;
|
||||
flex: 0 0 20em;
|
||||
max-height: 15em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.edit-meeting-schedule .scheduling-panel .session-info-container .comments {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
|
332
ietf/static/ietf/js/edit-meeting-schedule.js
Normal file
332
ietf/static/ietf/js/edit-meeting-schedule.js
Normal file
|
@ -0,0 +1,332 @@
|
|||
jQuery(document).ready(function () {
|
||||
let content = jQuery(".edit-meeting-schedule");
|
||||
|
||||
function failHandler(xhr, textStatus, error) {
|
||||
let errorText = error;
|
||||
if (xhr && xhr.responseText)
|
||||
errorText += "\n\n" + xhr.responseText;
|
||||
alert("Error: " + errorText);
|
||||
}
|
||||
|
||||
let sessions = content.find(".session");
|
||||
let timeslots = content.find(".timeslot");
|
||||
|
||||
// hack to work around lack of position sticky support in old browsers, see https://caniuse.com/#feat=css-sticky
|
||||
if (content.find(".scheduling-panel").css("position") != "sticky") {
|
||||
content.find(".scheduling-panel").css("position", "fixed");
|
||||
content.css("padding-bottom", "14em");
|
||||
}
|
||||
|
||||
// selecting
|
||||
function selectSessionElement(element) {
|
||||
if (element) {
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
if (ietfData.can_edit) {
|
||||
// dragging
|
||||
sessions.on("dragstart", function (event) {
|
||||
event.originalEvent.dataTransfer.setData("text/plain", this.id);
|
||||
jQuery(this).addClass("dragging");
|
||||
|
||||
selectSessionElement(this);
|
||||
});
|
||||
sessions.on("dragend", function () {
|
||||
jQuery(this).removeClass("dragging");
|
||||
|
||||
});
|
||||
|
||||
sessions.prop('draggable', true);
|
||||
|
||||
// dropping
|
||||
let dropElements = content.find(".timeslot,.unassigned-sessions");
|
||||
dropElements.on('dragenter', function (event) {
|
||||
if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session")
|
||||
return;
|
||||
|
||||
event.preventDefault(); // default action is signalling that this is not a valid target
|
||||
jQuery(this).addClass("dropping");
|
||||
});
|
||||
|
||||
dropElements.on('dragover', function (event) {
|
||||
// we don't actually need this event, except we need to signal
|
||||
// that this is a valid drop target, by cancelling the default
|
||||
// action
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
dropElements.on('dragleave', function (event) {
|
||||
// skip dragleave events if they are to children
|
||||
if (event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget))
|
||||
return;
|
||||
|
||||
jQuery(this).removeClass("dropping");
|
||||
});
|
||||
|
||||
dropElements.on('drop', function (event) {
|
||||
jQuery(this).removeClass("dropping");
|
||||
|
||||
let sessionId = event.originalEvent.dataTransfer.getData("text/plain");
|
||||
if ((event.originalEvent.dataTransfer.getData("text/plain") || "").slice(0, "session".length) != "session")
|
||||
return;
|
||||
|
||||
let sessionElement = sessions.filter("#" + sessionId);
|
||||
if (sessionElement.length == 0)
|
||||
return;
|
||||
|
||||
event.preventDefault(); // prevent opening as link
|
||||
|
||||
if (sessionElement.parent().is(this))
|
||||
return;
|
||||
|
||||
let dropElement = jQuery(this);
|
||||
|
||||
function done(response) {
|
||||
if (response != "OK") {
|
||||
failHandler(null, null, response);
|
||||
return;
|
||||
}
|
||||
|
||||
dropElement.append(sessionElement); // move element
|
||||
updateCurrentSchedulingHints();
|
||||
if (dropElement.hasClass("unassigned-sessions"))
|
||||
sortUnassigned();
|
||||
}
|
||||
|
||||
if (dropElement.hasClass("unassigned-sessions")) {
|
||||
jQuery.ajax({
|
||||
url: ietfData.urls.assign,
|
||||
method: "post",
|
||||
timeout: 5 * 1000,
|
||||
data: {
|
||||
action: "unassign",
|
||||
session: sessionId.slice("session".length)
|
||||
}
|
||||
}).fail(failHandler).done(done);
|
||||
}
|
||||
else {
|
||||
jQuery.ajax({
|
||||
url: ietfData.urls.assign,
|
||||
method: "post",
|
||||
data: {
|
||||
action: "assign",
|
||||
session: sessionId.slice("session".length),
|
||||
timeslot: dropElement.attr("id").slice("timeslot".length)
|
||||
},
|
||||
timeout: 5 * 1000
|
||||
}).fail(failHandler).done(done);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// hints for the current schedule
|
||||
|
||||
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 () {
|
||||
let total = 0;
|
||||
jQuery(this).find(".session").each(function () {
|
||||
total += +jQuery(this).data("duration");
|
||||
});
|
||||
|
||||
jQuery(this).toggleClass("overfull", total > +jQuery(this).data("duration"));
|
||||
});
|
||||
}
|
||||
|
||||
function updateAttendeesViolations() {
|
||||
sessions.each(function () {
|
||||
let roomCapacity = jQuery(this).closest(".timeline").data("roomcapacity");
|
||||
if (roomCapacity && this.dataset.attendees)
|
||||
jQuery(this).toggleClass("too-many-attendees", +this.dataset.attendees > +roomCapacity);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCurrentSchedulingHints() {
|
||||
updateCurrentSessionConstraintViolations();
|
||||
updateAttendeesViolations();
|
||||
updateTimeSlotDurationViolations();
|
||||
}
|
||||
|
||||
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.not(".untoggleable").filter(checked.join(",")).show();
|
||||
sessions.not(".untoggleable").not(checked.join(",")).hide();
|
||||
}
|
||||
|
||||
sessionParentInputs.on("click", updateSessionParentToggling);
|
||||
|
||||
updateSessionParentToggling();
|
||||
});
|
||||
|
20
ietf/templates/meeting/copy_meeting_schedule.html
Normal file
20
ietf/templates/meeting/copy_meeting_schedule.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015-2020, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load staticfiles %}
|
||||
{% load ietf_filters %}
|
||||
{% load bootstrap3 %}
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<h1>{% block title %}Copy schedule {{ schedule.name }}{% endblock %}</h1>
|
||||
|
||||
<form class="form-horizontal" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
|
||||
{% buttons %}
|
||||
<button type="submit" class="btn btn-default">Copy schedule</button>
|
||||
{% endbuttons %}
|
||||
</form>
|
||||
{% endblock %}
|
138
ietf/templates/meeting/edit_meeting_schedule.html
Normal file
138
ietf/templates/meeting/edit_meeting_schedule.html
Normal file
|
@ -0,0 +1,138 @@
|
|||
{% extends "base.html" %}
|
||||
{# Copyright The IETF Trust 2015-2020, All Rights Reserved #}
|
||||
{% load origin %}
|
||||
{% load staticfiles %}
|
||||
{% load ietf_filters %}
|
||||
|
||||
{% block morecss %}
|
||||
{% for parent in session_parents %}
|
||||
.parent-{{ parent.acronym }} {
|
||||
background: linear-gradient(to right, {{ parent.scheduling_color }} 0.4em, transparent 0.5em);
|
||||
}
|
||||
{% endfor %}
|
||||
{% endblock morecss %}
|
||||
|
||||
{% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting schedule{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script type='text/javascript'>
|
||||
var ietfData = {{ js_data|safe }};
|
||||
</script>
|
||||
<script type="text/javascript" src="{% static 'ietf/js/edit-meeting-schedule.js' %}"></script>
|
||||
{% endblock js %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% origin %}
|
||||
<div class="edit-meeting-schedule">
|
||||
|
||||
<p class="pull-right">
|
||||
<a href="{% url "ietf.meeting.views.copy_meeting_schedule" num=meeting.number owner=schedule.owner_email name=schedule.name %}">Copy schedule</a>
|
||||
·
|
||||
|
||||
<a href="{% url "ietf.meeting.views.list_schedules" num=meeting.number %}">All schedules for meeting</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Schedule name: {{ schedule.name }}
|
||||
|
||||
·
|
||||
|
||||
Owner: {{ schedule.owner }}
|
||||
|
||||
{% if not can_edit %}
|
||||
·
|
||||
|
||||
<em>You can't edit this schedule. Take a copy first.</em>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="edit-grid">
|
||||
|
||||
{# using the same markup in both room labels and the actual days ensures they are aligned #}
|
||||
<div class="room-label-column">
|
||||
{% for labels in room_labels %}
|
||||
<div class="day">
|
||||
<div class="day-label">
|
||||
<strong> </strong><br>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="timeline"></div>
|
||||
|
||||
{% for room in labels %}
|
||||
<div class="timeline timeslots">
|
||||
<div class="room-name">
|
||||
<strong>{{ room.name }}</strong><br>
|
||||
{% if room.capacity %}{{ room.capacity }} <i class="fa fa-user-o"></i>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="day-flow">
|
||||
{% for day in days %}
|
||||
<div class="day" style="width: {{ day.width }}em;">
|
||||
<div class="day-label">
|
||||
<strong>{{ day.day|date:"l" }}</strong><br>
|
||||
{{ day.day|date:"N j, Y" }}
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
{% for t, left_or_right, offset in day.time_labels %}
|
||||
<div class="time-label text-{{ left_or_right }}" style="{{ left_or_right }}: {{ offset }}em;">{{ t|date:"H:i" }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% for room, timeslots in day.room_timeslots %}
|
||||
<div class="timeline timeslots" data-roomcapacity="{{ room.capacity }}">
|
||||
|
||||
{% for t in timeslots %}
|
||||
<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="left: {{ t.offset }}em; width: {{ t.width }}em;">
|
||||
{% for assignment, session in t.timeslot.session_assignments %}
|
||||
{% include "meeting/edit_meeting_schedule_session.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scheduling-panel">
|
||||
<div class="unassigned-container">
|
||||
<div class="unassigned-sessions">
|
||||
{% for session in unassigned_sessions %}
|
||||
{% include "meeting/edit_meeting_schedule_session.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<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-info-container"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
64
ietf/templates/meeting/edit_meeting_schedule_session.html
Normal file
64
ietf/templates/meeting/edit_meeting_schedule_session.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
<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="width:{{ session.layout_width }}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>
|
||||
|
||||
<div>
|
||||
{% if session.attendees != None %}
|
||||
<span class="attendees">{{ session.attendees }}</span>
|
||||
{% endif %}
|
||||
|
||||
{% if session.comments %}
|
||||
<span class="comments"><i class="fa fa-comment-o"></i></span>
|
||||
{% endif %}
|
||||
|
||||
{% if session.constrained_sessions %}
|
||||
<span class="constraints">
|
||||
{% for explanation, sessions in session.constrained_sessions %}
|
||||
<span data-sessions="{{ sessions|join:"," }}">{{ explanation }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# this is shown elsewhere on the page with JS - we just include it here for convenience #}
|
||||
<div class="session-info">
|
||||
<label>
|
||||
{{ session.scheduling_label }}
|
||||
· {{ session.requested_duration_in_hours }} h
|
||||
{% if session.group %}· {% if session.group.is_bof %}BoF{% else %}{{ session.group.type.name }}{% endif %}{% endif %}
|
||||
{% if session.attendees != None %}· {{ session.attendees }} <i class="fa fa-user-o"></i>{% endif %}
|
||||
</label>
|
||||
|
||||
{% if session.group %}
|
||||
<div>
|
||||
{{ session.group.name }}
|
||||
{% if session.group.parent %}
|
||||
· <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>
|
|
@ -86,6 +86,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
|
||||
|
@ -99,17 +111,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
|
||||
|
||||
|
@ -774,3 +776,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)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue