diff --git a/docker/Dockerfile b/docker/Dockerfile index 0b0189d13..c962f3720 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/install-extras b/docker/install-extras index 5ebbfa853..26eeeb180 100755 --- a/docker/install-extras +++ b/docker/install-extras @@ -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/*" - \ No newline at end of file diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index e7ebbe324..13db30b92 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -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.") diff --git a/ietf/meeting/test_data.py b/ietf/meeting/test_data.py index 5ce91cd86..f15bc3dfe 100644 --- a/ietf/meeting/test_data.py +++ b/ietf/meeting/test_data.py @@ -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) diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index de96f1424..5760b396a 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -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')) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 9a34bf1e8..749067609 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -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() diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 89f546648..0ff45079c 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -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\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[a-z]+)$', views.agenda_by_type), @@ -77,6 +79,7 @@ type_ietf_only_patterns_id_optional = [ url(r'^agenda(?P.txt)$', views.agenda), url(r'^agenda(?P.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), diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index b28bd829a..60606edf0 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -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("")), + (re.compile(r"\(([^()])\)"), lambda match_groups: format_html("{}", 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("{}", 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, diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index d5aa737f6..3ec6640d6 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -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, diff --git a/ietf/name/migrations/0010_constraintname_editor_label.py b/ietf/name/migrations/0010_constraintname_editor_label.py new file mode 100644 index 000000000..0d05c8461 --- /dev/null +++ b/ietf/name/migrations/0010_constraintname_editor_label.py @@ -0,0 +1,36 @@ +# Copyright The IETF Trust 2020, All Rights Reserved +# -*- coding: utf-8 -*- + +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0009_add_verified_errata_to_doctagname'), + ] + + def fill_in_editor_labels(apps, schema_editor): + ConstraintName = apps.get_model('name', 'ConstraintName') + for cn in ConstraintName.objects.all(): + cn.editor_label = { + 'conflict': "(1)", + 'conflic2': "(2)", + 'conflic3': "(3)", + 'bethere': "(person)", + }.get(cn.slug, cn.slug) + cn.save() + + def noop(apps, schema_editor): + pass + + operations = [ + migrations.AddField( + model_name='constraintname', + name='editor_label', + field=models.CharField(blank=True, help_text='Very short label for producing warnings inline in the sessions in the schedule editor.', max_length=32), + ), + migrations.RunPython(fill_in_editor_labels, noop, elidable=True), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 2c82ad43b..19e53d28a 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -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): diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 28dc5082c..251cadb83 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -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; +} + diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js new file mode 100644 index 000000000..98eea1026 --- /dev/null +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -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(); +}); + diff --git a/ietf/templates/meeting/copy_meeting_schedule.html b/ietf/templates/meeting/copy_meeting_schedule.html new file mode 100644 index 000000000..14c1365b5 --- /dev/null +++ b/ietf/templates/meeting/copy_meeting_schedule.html @@ -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 %} +

{% block title %}Copy schedule {{ schedule.name }}{% endblock %}

+ +
+ {% csrf_token %} + {% bootstrap_form form %} + + {% buttons %} + + {% endbuttons %} +
+{% endblock %} diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html new file mode 100644 index 000000000..0008653e6 --- /dev/null +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -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 %} + + +{% endblock js %} + + +{% block content %} + {% origin %} +
+ +

+ Copy schedule + · + + All schedules for meeting +

+ +

+ Schedule name: {{ schedule.name }} + + · + + Owner: {{ schedule.owner }} + + {% if not can_edit %} + · + + You can't edit this schedule. Take a copy first. + {% endif %} +

+ +
+ + {# using the same markup in both room labels and the actual days ensures they are aligned #} +
+ {% for labels in room_labels %} +
+
+  
+   +
+ +
+ + {% for room in labels %} +
+
+ {{ room.name }}
+ {% if room.capacity %}{{ room.capacity }} {% endif %} +
+
+ {% endfor %} +
+ {% endfor %} +
+ +
+ {% for day in days %} +
+
+ {{ day.day|date:"l" }}
+ {{ day.day|date:"N j, Y" }} +
+ +
+ {% for t, left_or_right, offset in day.time_labels %} +
{{ t|date:"H:i" }}
+ {% endfor %} +
+ + {% for room, timeslots in day.room_timeslots %} +
+ + {% for t in timeslots %} +
+ {% for assignment, session in t.timeslot.session_assignments %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+
+ +
+
+
+ {% for session in unassigned_sessions %} + {% include "meeting/edit_meeting_schedule_session.html" %} + {% endfor %} +
+ +
+ + Sort unassigned: + + + + + Show: + {% for p in session_parents %} + + {% endfor %} + +
+
+ +
+
+ +
+{% endblock %} diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html new file mode 100644 index 000000000..d835cad4f --- /dev/null +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -0,0 +1,64 @@ +
+
+ {{ session.scheduling_label }} +
+ +
+ {% if session.attendees != None %} + {{ session.attendees }} + {% endif %} + + {% if session.comments %} + + {% endif %} + + {% if session.constrained_sessions %} + + {% for explanation, sessions in session.constrained_sessions %} + {{ explanation }} + {% endfor %} + + {% endif %} +
+ + {# this is shown elsewhere on the page with JS - we just include it here for convenience #} +
+ + + {% if session.group %} +
+ {{ session.group.name }} + {% if session.group.parent %} + · {{ session.group.parent.acronym }} + {% if session.historic_group_ad_name %} ({{ session.historic_group_ad_name }}){% endif %} + {% endif %} +
+ {% endif %} + + {% if session.requested_by_person %} +
+ {{ session.requested_by_person.plain_name }} {% if session.requested_time %}({{ session.requested_time|date:"Y-m-d" }}){% endif %} +
+ {% endif %} + + {% if session.resources.all %} +
+ Resources: + {% for r in session.resources.all %} + {{ r.name }}{% if not forloop.last %},{% endif %} + {% endfor %} +
+ {% endif %} + + {% if session.comments %} +
+ {{ session.comments|linebreaksbr }} +
+ {% endif %} +
+
diff --git a/ietf/utils/test_runner.py b/ietf/utils/test_runner.py index 7579bfd24..fad705ddb 100644 --- a/ietf/utils/test_runner.py +++ b/ietf/utils/test_runner.py @@ -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) + +