Merged in ^/branch/iola/meeting-improvement-r17214@17617, which provides a new meeting schedule editor.

- Legacy-Id: 17701
This commit is contained in:
Henrik Levkowetz 2020-04-28 14:34:34 +00:00
commit 42995fadea
17 changed files with 1645 additions and 102 deletions

View file

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

View file

@ -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/*"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

View 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 %}

View 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>
&middot;
<a href="{% url "ietf.meeting.views.list_schedules" num=meeting.number %}">All schedules for meeting</a>
</p>
<p>
Schedule name: {{ schedule.name }}
&middot;
Owner: {{ schedule.owner }}
{% if not can_edit %}
&middot;
<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>&nbsp;</strong><br>
&nbsp;
</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 %}

View 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 }}
&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

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