Allow configuration of group conflict types used for each meeting Fixes #2770. Commit ready for merge.

- Legacy-Id: 19266
This commit is contained in:
Jennifer Richards 2021-07-30 17:50:24 +00:00
parent ec86d98bb7
commit 336d762123
18 changed files with 1816 additions and 1407 deletions

View file

@ -9,7 +9,7 @@ import datetime
from django.core.files.base import ContentFile
from ietf.meeting.models import Meeting, Session, SchedulingEvent, Schedule, TimeSlot, SessionPresentation, FloorPlan, Room, SlideSubmission
from ietf.name.models import SessionStatusName
from ietf.name.models import ConstraintName, SessionStatusName
from ietf.group.factories import GroupFactory
from ietf.person.factories import PersonFactory
@ -75,6 +75,24 @@ class MeetingFactory(factory.DjangoModelFactory):
obj.schedule = ScheduleFactory(meeting=obj)
obj.save()
@factory.post_generation
def group_conflicts(obj, create, extracted, **kwargs): # pulint: disable=no-self-argument
"""Add conflict types
Pass a list of ConflictNames as group_conflicts to specify which are enabled.
"""
if extracted is None:
extracted = [
ConstraintName.objects.get(slug=s) for s in [
'chair_conflict', 'tech_overlap', 'key_participant'
]]
if create:
for cn in extracted:
obj.group_conflict_types.add(
cn if isinstance(cn, ConstraintName) else ConstraintName.objects.get(slug=cn)
)
class SessionFactory(factory.DjangoModelFactory):
class Meta:
model = Session

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
# Generated by Django 2.2.20 on 2021-05-20 12:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('name', '0026_add_conflict_constraintnames'),
('meeting', '0041_assign_correct_constraintnames'),
]
operations = [
migrations.AddField(
model_name='meeting',
name='group_conflict_types',
field=models.ManyToManyField(blank=True, limit_choices_to={'is_group_conflict': True}, help_text='Types of scheduling conflict between groups to consider', to='name.ConstraintName'),
),
]

View file

@ -0,0 +1,42 @@
# Generated by Django 2.2.20 on 2021-05-20 12:30
from django.db import migrations
from django.db.models import IntegerField
from django.db.models.functions import Cast
def forward(apps, schema_editor):
Meeting = apps.get_model('meeting', 'Meeting')
ConstraintName = apps.get_model('name', 'ConstraintName')
# old for pre-106
old_constraints = ConstraintName.objects.filter(slug__in=['conflict', 'conflic2', 'conflic3'])
new_constraints = ConstraintName.objects.filter(slug__in=['chair_conflict', 'tech_overlap', 'key_participant'])
# get meetings with numeric 'number' field to avoid lexicographic ordering
ietf_meetings = Meeting.objects.filter(
type='ietf'
).annotate(
number_as_int=Cast('number', output_field=IntegerField())
)
for mtg in ietf_meetings.filter(number_as_int__lt=106):
for cn in old_constraints:
mtg.group_conflict_types.add(cn)
for mtg in ietf_meetings.filter(number_as_int__gte=106):
for cn in new_constraints:
mtg.group_conflict_types.add(cn)
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('meeting', '0042_meeting_group_conflict_types'),
]
operations = [
migrations.RunPython(forward, reverse),
]

View file

@ -17,7 +17,7 @@ import debug # pyflakes:ignore
from django.core.validators import MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Max, Subquery, OuterRef, TextField, Value
from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q
from django.db.models.functions import Coalesce
from django.conf import settings
# mostly used by json_dict()
@ -111,6 +111,9 @@ class Meeting(models.Model):
show_important_dates = models.BooleanField(default=False)
attendees = models.IntegerField(blank=True, null=True, default=None,
help_text="Number of Attendees for backfilled meetings, leave it blank for new meetings, and then it is calculated from the registrations")
group_conflict_types = models.ManyToManyField(
ConstraintName, blank=True, limit_choices_to=dict(is_group_conflict=True),
help_text='Types of scheduling conflict between groups to consider')
def __str__(self):
if self.type_id == "ietf":
@ -197,6 +200,15 @@ class Meeting(models.Model):
else:
return self.date + datetime.timedelta(days=self.submission_correction_day_offset)
def enabled_constraint_names(self):
return ConstraintName.objects.filter(
Q(is_group_conflict=False) # any non-group-conflict constraints
| Q(is_group_conflict=True, meeting=self) # or specifically enabled for this meeting
)
def enabled_constraints(self):
return self.constraint_set.filter(name__in=self.enabled_constraint_names())
def get_schedule_by_name(self, name):
return self.schedule_set.filter(name=name).first()

View file

@ -13,6 +13,7 @@ import shutil
from unittest import skipIf
from mock import patch
from pyquery import PyQuery
from lxml.etree import tostring
from io import StringIO, BytesIO
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urlsplit
@ -1984,6 +1985,121 @@ class EditTests(TestCase):
assignment.session.refresh_from_db()
self.assertEqual(assignment.session.agenda_note, "New Test Note")
def test_edit_meeting_schedule_conflict_types(self):
"""The meeting schedule editor should show the constraint types enabled for the meeting"""
meeting = MeetingFactory(
type_id='ietf',
group_conflicts=[], # show none to start with
)
s1 = SessionFactory(
meeting=meeting,
type_id='regular',
attendees=12,
comments='chair conflict',
)
s2 = SessionFactory(
meeting=meeting,
type_id='regular',
attendees=34,
comments='old-fashioned conflict',
)
Constraint.objects.create(
meeting=meeting,
source=s1.group,
target=s2.group,
name=ConstraintName.objects.get(slug="chair_conflict"),
)
Constraint.objects.create(
meeting=meeting,
source=s2.group,
target=s1.group,
name=ConstraintName.objects.get(slug="conflict"),
)
# log in as secretary so we have access
self.client.login(username="secretary", password="secretary+password")
url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number))
# Should have no conflict constraints listed because the meeting has all disabled
r = self.client.get(url)
q = PyQuery(r.content)
self.assertEqual(len(q('#session{} span.constraints > span'.format(s1.pk))), 0)
self.assertEqual(len(q('#session{} span.constraints > span'.format(s2.pk))), 0)
# Now enable the 'chair_conflict' constraint only
chair_conflict = ConstraintName.objects.get(slug='chair_conflict')
chair_conf_label = b'<i class="fa fa-gavel"/>' # result of etree.tostring(etree.fromstring(editor_label))
meeting.group_conflict_types.add(chair_conflict)
r = self.client.get(url)
q = PyQuery(r.content)
# verify that there is a constraint pointing from 1 to 2
#
# The constraint is represented in the HTML as
# <div id="session<pk>">
# [...]
# <span class="constraints">
# <span data-sessions="<other pk>">[constraint label]</span>
# </span>
# </div>
#
# Where the constraint label is the editor_label for the ConstraintName.
# If this pk is the constraint target, the editor_label includes a
# '-' prefix, which may be before the editor_label or inserted inside
# it.
#
# For simplicity, this test is tied to the current values of editor_label.
# It also assumes the order of constraints will be constant.
# If those change, the test will need to be updated.
s1_constraints = q('#session{} span.constraints > span'.format(s1.pk))
s2_constraints = q('#session{} span.constraints > span'.format(s2.pk))
# Check the forward constraint
self.assertEqual(len(s1_constraints), 1)
self.assertEqual(s1_constraints[0].attrib['data-sessions'], str(s2.pk))
self.assertEqual(s1_constraints[0].text, None) # no '-' prefix on the source
self.assertEqual(tostring(s1_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
# And the reverse constraint
self.assertEqual(len(s2_constraints), 1)
self.assertEqual(s2_constraints[0].attrib['data-sessions'], str(s1.pk))
self.assertEqual(s2_constraints[0].text, '-') # '-' prefix on the target
self.assertEqual(tostring(s2_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
# Now also enable the 'conflict' constraint
conflict = ConstraintName.objects.get(slug='conflict')
conf_label = b'<span class="encircled">1</span>'
conf_label_reversed = b'<span class="encircled">-1</span>' # the '-' is inside the span!
meeting.group_conflict_types.add(conflict)
r = self.client.get(url)
q = PyQuery(r.content)
s1_constraints = q('#session{} span.constraints > span'.format(s1.pk))
s2_constraints = q('#session{} span.constraints > span'.format(s2.pk))
# Check the forward constraint
self.assertEqual(len(s1_constraints), 2)
self.assertEqual(s1_constraints[0].attrib['data-sessions'], str(s2.pk))
self.assertEqual(s1_constraints[0].text, None) # no '-' prefix on the source
self.assertEqual(tostring(s1_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
self.assertEqual(s1_constraints[1].attrib['data-sessions'], str(s2.pk))
self.assertEqual(tostring(s1_constraints[1][0]), conf_label_reversed) # [0][0] is the innermost <span>
# And the reverse constraint
self.assertEqual(len(s2_constraints), 2)
self.assertEqual(s2_constraints[0].attrib['data-sessions'], str(s1.pk))
self.assertEqual(s2_constraints[0].text, '-') # '-' prefix on the target
self.assertEqual(tostring(s2_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
self.assertEqual(s2_constraints[1].attrib['data-sessions'], str(s1.pk))
self.assertEqual(tostring(s2_constraints[1][0]), conf_label) # [0][0] is the innermost <span>
def test_new_meeting_schedule(self):
meeting = make_meeting_test_data()

View file

@ -296,7 +296,7 @@ def reverse_editor_label(label):
def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
# process constraint names - we synthesize extra names to be able
# to treat the concepts in the same manner as the modelled ones
constraint_names = {n.pk: n for n in ConstraintName.objects.all()}
constraint_names = {n.pk: n for n in meeting.enabled_constraint_names()}
joint_with_groups_constraint_name = ConstraintName(
slug='joint_with_groups',
@ -327,7 +327,7 @@ def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
n.countless_formatted_editor_label = format_html(n.formatted_editor_label, count="") if "{count}" in n.formatted_editor_label else n.formatted_editor_label
# convert human-readable rules in the database to constraints on actual sessions
constraints = list(Constraint.objects.filter(meeting=meeting).prefetch_related('target', 'person', 'timeranges'))
constraints = list(meeting.enabled_constraints().prefetch_related('target', 'person', 'timeranges'))
# synthesize AD constraints - we can treat them as a special kind of 'bethere'
responsible_ad_for_group = {}

View file

@ -99,7 +99,9 @@ class MeetingModelForm(forms.ModelForm):
class Meta:
model = Meeting
exclude = ('type', 'schedule', 'session_request_lock_message')
widgets = {
'group_conflict_types': forms.CheckboxSelectMultiple(),
}
def __init__(self,*args,**kwargs):
super(MeetingModelForm, self).__init__(*args,**kwargs)
@ -118,6 +120,9 @@ class MeetingModelForm(forms.ModelForm):
meeting.type_id = 'ietf'
if commit:
meeting.save()
# must call save_m2m() because we saved with commit=False above, see:
# https://docs.djangoproject.com/en/2.2/topics/forms/modelforms/#the-save-method
self.save_m2m()
return meeting
class MeetingRoomForm(forms.ModelForm):

View file

@ -14,9 +14,10 @@ from django.conf import settings
from django.urls import reverse
from ietf.group.models import Group, GroupEvent
from ietf.meeting.factories import MeetingFactory
from ietf.meeting.models import Meeting, Room, TimeSlot, SchedTimeSessAssignment, Session, SchedulingEvent
from ietf.meeting.test_data import make_meeting_test_data
from ietf.name.models import SessionStatusName
from ietf.name.models import ConstraintName, SessionStatusName
from ietf.person.models import Person
from ietf.secr.meetings.forms import get_times
from ietf.utils.mail import outbox
@ -73,7 +74,8 @@ class SecrMeetingTestCase(TestCase):
number = int(meeting.number) + 1
count = Meeting.objects.count()
url = reverse('ietf.secr.meetings.views.add')
post_data = dict(number=number,city='Toronto',date='2014-07-20',country='CA',
post_data = dict(
number=number,city='Toronto',date='2014-07-20',country='CA',
time_zone='America/New_York',venue_name='Hilton',
days=6,
venue_addr='100 First Ave',
@ -84,7 +86,9 @@ class SecrMeetingTestCase(TestCase):
submission_start_day_offset=90,
submission_cutoff_day_offset=26,
submission_correction_day_offset=50,
group_conflict_types=('conflict', 'conflic2', 'key_participant'),
)
self.client.login(username='secretary', password='secretary+password')
response = self.client.post(url, post_data, follow=True)
self.assertEqual(response.status_code, 200)
@ -96,6 +100,38 @@ class SecrMeetingTestCase(TestCase):
self.assertTrue(new_meeting.schedule.base)
self.assertEqual(new_meeting.schedule.base.name, 'base')
self.assertEqual(new_meeting.attendees, None)
self.assertCountEqual(
[cn.slug for cn in new_meeting.group_conflict_types.all()],
post_data['group_conflict_types'],
)
def test_add_meeting_default_conflict_types(self):
"""Add meeting should default to same conflict types as previous meeting"""
def _run_test(mtg):
url = reverse('ietf.secr.meetings.views.add')
r = self.client.get(url)
q = PyQuery(r.content)
selected_items = q('#id_group_conflict_types input[checked]')
selected_values = [si.value for si in selected_items]
expected_values = [cn.slug for cn in mtg.group_conflict_types.all()]
self.assertCountEqual(selected_values, expected_values)
self.client.login(username='secretary', password='secretary+password')
meeting = MeetingFactory(type_id='ietf', group_conflicts=[]) # start with no conflicts selected
_run_test(meeting)
# enable one
meeting.group_conflict_types.add(ConstraintName.objects.filter(is_group_conflict=True).first())
self.assertEqual(meeting.group_conflict_types.count(), 1)
_run_test(meeting)
# enable a few ([::2] selects every other)
meeting.group_conflict_types.clear()
for cn in ConstraintName.objects.filter(is_group_conflict=True)[::2]:
meeting.group_conflict_types.add(cn)
self.assertGreater(meeting.group_conflict_types.count(), 1)
_run_test(meeting)
def test_edit_meeting(self):
"Edit Meeting"
@ -111,13 +147,25 @@ class SecrMeetingTestCase(TestCase):
submission_cutoff_day_offset=26,
submission_correction_day_offset=50,
attendees=1234,
group_conflict_types=[
cn.slug for cn in ConstraintName.objects.filter(
is_group_conflict=True
).exclude(
meeting=meeting, # replace original set with those not assigned to the meeting
)
]
)
self.assertGreater(len(post_data['group_conflict_types']), 0) # test should include at least one
self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url, post_data, follow=True)
self.assertEqual(response.status_code, 200)
meeting = Meeting.objects.get(number=meeting.number)
self.assertEqual(meeting.city,'Toronto')
self.assertEqual(meeting.attendees,1234)
self.assertCountEqual(
[cn.slug for cn in meeting.group_conflict_types.all()],
post_data['group_conflict_types'],
)
def test_blue_sheets_upload(self):
"Test Bluesheets"

View file

@ -7,7 +7,8 @@ import time
from django.conf import settings
from django.contrib import messages
from django.db.models import Max
from django.db.models import IntegerField
from django.db.models.functions import Cast
from django.forms.models import inlineformset_factory
from django.shortcuts import render, get_object_or_404, redirect
from django.utils.text import slugify
@ -249,8 +250,17 @@ def add(request):
return redirect('ietf.secr.meetings.views.main')
else:
# display initial forms
max_number = Meeting.objects.filter(type='ietf').aggregate(Max('number'))['number__max']
form = MeetingModelForm(initial={'number':int(max_number) + 1})
last_ietf_meeting = Meeting.objects.filter(
type='ietf'
).annotate(
number_as_int=Cast('number', output_field=IntegerField())
).order_by('-number_as_int').first()
initial = dict()
# fill in defaults if we can
if last_ietf_meeting is not None:
initial['number'] = last_ietf_meeting.number_as_int + 1
initial['group_conflict_types'] = [cn.pk for cn in last_ietf_meeting.group_conflict_types.all()]
form = MeetingModelForm(initial=initial)
return render(request, 'meetings/add.html', {
'form': form},

View file

@ -6,7 +6,7 @@ from django import forms
import debug # pyflakes:ignore
from ietf.name.models import TimerangeName
from ietf.name.models import TimerangeName, ConstraintName
from ietf.group.models import Group
from ietf.meeting.models import ResourceAssociation, Constraint
from ietf.person.fields import SearchablePersonsField
@ -106,7 +106,7 @@ class SessionForm(forms.Form):
# Set up constraints for the meeting
self._wg_field_data = []
for constraintname in meeting.session_constraintnames.all():
for constraintname in meeting.group_conflict_types.all():
# two fields for each constraint: a CharField for the group list and a selector to add entries
constraint_field = forms.CharField(max_length=255, required=False)
constraint_field.widget.attrs['data-slug'] = constraintname.slug
@ -126,6 +126,27 @@ class SessionForm(forms.Form):
self.fields[cselector_id] = selector_field
self._wg_field_data.append((constraintname, cfield_id, cselector_id))
# Show constraints that are not actually used by the meeting so these don't get lost
self._inactive_wg_field_data = []
inactive_cnames = ConstraintName.objects.filter(
is_group_conflict=True # Only collect group conflicts...
).exclude(
meeting=meeting # ...that are not enabled for this meeting...
).filter(
constraint__source=group, # ...but exist for this group...
constraint__meeting=meeting, # ... at this meeting.
).distinct()
for inactive_constraint_name in inactive_cnames:
field_id = 'delete_{}'.format(inactive_constraint_name.slug)
self.fields[field_id] = forms.BooleanField(required=False, label='Delete this conflict', help_text='Delete this inactive conflict?')
constraints = group.constraint_source_set.filter(meeting=meeting, name=inactive_constraint_name)
self._inactive_wg_field_data.append(
(inactive_constraint_name,
' '.join([c.target.acronym for c in constraints]),
field_id)
)
self.fields['joint_with_groups_selector'].widget.attrs['onChange'] = "document.form_post.joint_with_groups.value=document.form_post.joint_with_groups.value + ' ' + this.options[this.selectedIndex].value; return 1;"
self.fields['third_session'].widget.attrs['onClick'] = "if (document.form_post.num_session.selectedIndex < 2) { alert('Cannot use this field - Number of Session is not set to 2'); return false; } else { if (this.checked==true) { document.form_post.length_session3.disabled=false; } else { document.form_post.length_session3.value=0;document.form_post.length_session3.disabled=true; } }"
self.fields["resources"].choices = [(x.pk,x.desc) for x in ResourceAssociation.objects.filter(name__used=True).order_by('name__order') ]
@ -150,11 +171,27 @@ class SessionForm(forms.Form):
for cname, cfield_id, cselector_id in self._wg_field_data:
yield cname, self[cfield_id], self[cselector_id]
def wg_constraint_count(self):
"""How many wg constraints are there?"""
return len(self._wg_field_data)
def wg_constraint_field_ids(self):
"""Iterates over wg constraint field IDs"""
for cname, cfield_id, _ in self._wg_field_data:
yield cname, cfield_id
def inactive_wg_constraints(self):
for cname, value, field_id in self._inactive_wg_field_data:
yield cname, value, self[field_id]
def inactive_wg_constraint_count(self):
return len(self._inactive_wg_field_data)
def inactive_wg_constraint_field_ids(self):
"""Iterates over wg constraint field IDs"""
for cname, _, field_id in self._inactive_wg_field_data:
yield cname, field_id
@staticmethod
def _add_widget_class(widget, new_class):
"""Add a new class, taking care in case some already exist"""

View file

@ -12,7 +12,7 @@ from ietf.utils.test_utils import TestCase
from ietf.group.factories import GroupFactory, RoleFactory
from ietf.meeting.models import Session, ResourceAssociation, SchedulingEvent, Constraint
from ietf.meeting.factories import MeetingFactory, SessionFactory
from ietf.name.models import TimerangeName
from ietf.name.models import ConstraintName, TimerangeName
from ietf.person.models import Person
from ietf.secr.sreq.forms import SessionForm
from ietf.utils.mail import outbox, empty_outbox, get_payload_text
@ -94,7 +94,7 @@ class SessionRequestTestCase(TestCase):
'length_session1':'3600',
'length_session2':'3600',
'attendees':'10',
'constraint_conflict': iabprog.acronym,
'constraint_chair_conflict':iabprog.acronym,
'comments':'need lights',
'session_time_relation': 'subsequent-days',
'adjacent_with_wg': group2.acronym,
@ -111,7 +111,7 @@ class SessionRequestTestCase(TestCase):
self.assertEqual(len(sessions), 2)
session = sessions[0]
self.assertEqual(session.constraints().get(name='conflict').target.acronym, iabprog.acronym)
self.assertEqual(session.constraints().get(name='chair_conflict').target.acronym, iabprog.acronym)
self.assertEqual(session.constraints().get(name='time_relation').time_relation, 'subsequent-days')
self.assertEqual(session.constraints().get(name='wg_adjacent').target.acronym, group2.acronym)
self.assertEqual(
@ -134,7 +134,7 @@ class SessionRequestTestCase(TestCase):
'length_session1':'3600',
'length_session2':'3600',
'attendees':'10',
'constraint_conflict':'',
'constraint_chair_conflict':'',
'comments':'need lights',
'joint_with_groups': group2.acronym,
'joint_for_session': '1',
@ -156,6 +156,50 @@ class SessionRequestTestCase(TestCase):
r = self.client.get(redirect_url)
self.assertContains(r, 'First session with: {}'.format(group2.acronym))
def test_edit_inactive_conflicts(self):
"""Inactive conflicts should be displayed and removable"""
meeting = MeetingFactory(type_id='ietf', date=datetime.date.today(), group_conflicts=['chair_conflict'])
mars = RoleFactory(name_id='chair', person__user__username='marschairman', group__acronym='mars').group
SessionFactory(meeting=meeting, group=mars, status_id='sched')
other_group = GroupFactory()
Constraint.objects.create(
meeting=meeting,
name_id='conflict', # not in group_conflicts for the meeting
source=mars,
target=other_group,
)
url = reverse('ietf.secr.sreq.views.edit', kwargs=dict(acronym='mars'))
self.client.login(username='marschairman', password='marschairman+password')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
# check that the inactive session is displayed
found = q('input#id_delete_conflict[type="checkbox"]')
self.assertEqual(len(found), 1)
delete_checkbox = found[0]
# check that the label on the checkbox is correct
self.assertIn('Delete this conflict', delete_checkbox.tail)
# check that the target is displayed correctly in the UI
self.assertIn(other_group.acronym, delete_checkbox.find('../input[@type="text"]').value)
post_data = {
'num_session': '1',
'length_session1': '3600',
'attendees': '10',
'constraint_chair_conflict':'',
'comments':'',
'joint_with_groups': '',
'joint_for_session': '',
'submit': 'Save',
'delete_conflict': 'on',
}
r = self.client.post(url, post_data, HTTP_HOST='example.com')
redirect_url = reverse('ietf.secr.sreq.views.view', kwargs={'acronym': 'mars'})
self.assertRedirects(r, redirect_url)
self.assertEqual(len(mars.constraint_source_set.filter(name_id='conflict')), 0)
def test_tool_status(self):
MeetingFactory(type_id='ietf', date=datetime.date.today())
url = reverse('ietf.secr.sreq.views.tool_status')
@ -166,38 +210,34 @@ class SessionRequestTestCase(TestCase):
self.assertRedirects(r,reverse('ietf.secr.sreq.views.main'))
def test_new_req_constraint_types(self):
"""ITEF meetings 106 and later use different constraint types
"""Configurable constraint types should be handled correctly in a new request
Relies on SessionForm representing constraint values with element IDs
like id_constraint_<ConstraintName slug>
"""
should_have_pre106 = ['conflict', 'conflic2', 'conflic3']
should_have = ['chair_conflict', 'tech_overlap', 'key_participant']
meeting = MeetingFactory(type_id='ietf', date=datetime.date.today())
RoleFactory(name_id='chair', person__user__username='marschairman', group__acronym='mars')
url = reverse('ietf.secr.sreq.views.new', kwargs=dict(acronym='mars'))
self.client.login(username="marschairman", password="marschairman+password")
for meeting_number in ['95', '100', '105', '106', '111', '125']:
meeting.number = meeting_number
meeting.save()
for expected in [
['conflict', 'conflic2', 'conflic3'],
['chair_conflict', 'tech_overlap', 'key_participant'],
]:
meeting.group_conflict_types.clear()
for slug in expected:
meeting.group_conflict_types.add(ConstraintName.objects.get(slug=slug))
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
expected = should_have if int(meeting.number) >= 106 else should_have_pre106
self.assertCountEqual(
[elt.attr('id') for elt in q.items('*[id^=id_constraint_]')],
['id_constraint_{}'.format(conf_name) for conf_name in expected],
'Unexpected constraints for meeting number {}'.format(meeting_number),
)
def test_edit_req_constraint_types(self):
"""Editing a request constraint should show the expected constraints"""
should_have_pre106 = ['conflict', 'conflic2', 'conflic3']
should_have = ['chair_conflict', 'tech_overlap', 'key_participant']
meeting = MeetingFactory(type_id='ietf', date=datetime.date.today())
SessionFactory(group__acronym='mars',
status_id='schedw',
@ -208,18 +248,20 @@ class SessionRequestTestCase(TestCase):
url = reverse('ietf.secr.sreq.views.edit', kwargs=dict(acronym='mars'))
self.client.login(username='marschairman', password='marschairman+password')
for meeting_number in ['95', '100', '105', '106', '111', '125']:
meeting.number = meeting_number
meeting.save()
for expected in [
['conflict', 'conflic2', 'conflic3'],
['chair_conflict', 'tech_overlap', 'key_participant'],
]:
meeting.group_conflict_types.clear()
for slug in expected:
meeting.group_conflict_types.add(ConstraintName.objects.get(slug=slug))
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
expected = should_have if int(meeting.number) >= 106 else should_have_pre106
self.assertCountEqual(
[elt.attr('id') for elt in q.items('*[id^=id_constraint_]')],
['id_constraint_{}'.format(conf_name) for conf_name in expected],
'Unexpected constraints for meeting number {}'.format(meeting_number),
)
class SubmitRequestCase(TestCase):
@ -244,7 +286,7 @@ class SubmitRequestCase(TestCase):
post_data = {'num_session':'1',
'length_session1':'3600',
'attendees':'10',
'constraint_conflict':'',
'constraint_chair_conflict':'',
'comments':'need projector',
'adjacent_with_wg': group2.acronym,
'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'],
@ -290,7 +332,7 @@ class SubmitRequestCase(TestCase):
post_data = {'num_session':'2',
'length_session1':'3600',
'attendees':'10',
'constraint_conflict':'',
'constraint_chair_conflict':'',
'comments':'need projector'}
self.client.login(username="secretary", password="secretary+password")
r = self.client.post(url,post_data)
@ -301,7 +343,8 @@ class SubmitRequestCase(TestCase):
def test_submit_request_check_constraints(self):
m1 = MeetingFactory(type_id='ietf', date=datetime.date.today() - datetime.timedelta(days=100))
MeetingFactory(type_id='ietf', date=datetime.date.today())
MeetingFactory(type_id='ietf', date=datetime.date.today(),
group_conflicts=['chair_conflict', 'conflic2', 'conflic3'])
ad = Person.objects.get(user__username='ad')
area = RoleFactory(name_id='ad', person=ad, group__type_id='area').group
group = GroupFactory(parent=area)
@ -310,7 +353,7 @@ class SubmitRequestCase(TestCase):
meeting=m1,
source=group,
target=still_active_group,
name_id='conflict',
name_id='chair_conflict',
)
inactive_group = GroupFactory(parent=area, state_id='conclude')
inactive_group.save()
@ -318,7 +361,7 @@ class SubmitRequestCase(TestCase):
meeting=m1,
source=group,
target=inactive_group,
name_id='conflict',
name_id='chair_conflict',
)
SessionFactory(group=group, meeting=m1)
@ -328,14 +371,14 @@ class SubmitRequestCase(TestCase):
r = self.client.get(url + '?previous')
self.assertEqual(r.status_code, 200)
q = PyQuery(r.content)
conflict1 = q('[name="constraint_conflict"]').val()
conflict1 = q('[name="constraint_chair_conflict"]').val()
self.assertIn(still_active_group.acronym, conflict1)
self.assertNotIn(inactive_group.acronym, conflict1)
post_data = {'num_session':'1',
'length_session1':'3600',
'attendees':'10',
'constraint_conflict': group.acronym,
'constraint_chair_conflict': group.acronym,
'comments':'need projector',
'submit': 'Continue'}
r = self.client.post(url,post_data)
@ -366,7 +409,7 @@ class SubmitRequestCase(TestCase):
'length_session2':'3600',
'attendees':'10',
'bethere':str(ad.pk),
'constraint_conflict':'',
'constraint_chair_conflict':'',
'comments':'',
'resources': resource.pk,
'session_time_relation': 'subsequent-days',
@ -485,10 +528,6 @@ class RetrievePreviousCase(TestCase):
class SessionFormTest(TestCase):
def setUp(self):
# Ensure meeting numbers are predictable. Temporarily needed while basing
# constraint types on meeting number, expected to go away when #2770 is resolved.
MeetingFactory.reset_sequence(0)
self.meeting = MeetingFactory(type_id='ietf')
self.group1 = GroupFactory()
self.group2 = GroupFactory()
@ -504,9 +543,9 @@ class SessionFormTest(TestCase):
'length_session2': '3600',
'length_session3': '3600',
'attendees': '10',
'constraint_conflict': self.group2.acronym,
'constraint_conflic2': self.group3.acronym,
'constraint_conflic3': self.group4.acronym,
'constraint_chair_conflict': self.group2.acronym,
'constraint_tech_overlap': self.group3.acronym,
'constraint_key_participant': self.group4.acronym,
'comments': 'need lights',
'session_time_relation': 'subsequent-days',
'adjacent_with_wg': self.group5.acronym,
@ -542,16 +581,30 @@ class SessionFormTest(TestCase):
def test_invalid_groups(self):
new_form_data = {
'constraint_conflict': 'doesnotexist',
'constraint_conflic2': 'doesnotexist',
'constraint_conflic3': 'doesnotexist',
'constraint_chair_conflict': 'doesnotexist',
'constraint_tech_overlap': 'doesnotexist',
'constraint_key_participant': 'doesnotexist',
'adjacent_with_wg': 'doesnotexist',
'joint_with_groups': 'doesnotexist',
}
form = self._invalid_test_helper(new_form_data)
self.assertEqual(set(form.errors.keys()), set(new_form_data.keys()))
def test_valid_group_appears_in_multiple_conflicts(self):
"""Some conflict types allow overlapping groups"""
new_form_data = {
'constraint_chair_conflict': self.group2.acronym,
'constraint_tech_overlap': self.group2.acronym,
}
self.valid_form_data.update(new_form_data)
form = SessionForm(data=self.valid_form_data, group=self.group1, meeting=self.meeting)
self.assertTrue(form.is_valid())
def test_invalid_group_appears_in_multiple_conflicts(self):
"""Some conflict types do not allow overlapping groups"""
self.meeting.group_conflict_types.clear()
self.meeting.group_conflict_types.add(ConstraintName.objects.get(slug='conflict'))
self.meeting.group_conflict_types.add(ConstraintName.objects.get(slug='conflic2'))
new_form_data = {
'constraint_conflict': self.group2.acronym,
'constraint_conflic2': self.group2.acronym,
@ -561,7 +614,7 @@ class SessionFormTest(TestCase):
def test_invalid_conflict_with_self(self):
new_form_data = {
'constraint_conflict': self.group1.acronym,
'constraint_chair_conflict': self.group1.acronym,
}
self._invalid_test_helper(new_form_data)

View file

@ -545,6 +545,11 @@ def edit(request, acronym, num=None):
Constraint.objects.filter(meeting=meeting, source=group, name=cname.slug).delete()
save_conflicts(group, meeting, form.cleaned_data[cfield_id], cname.slug)
# see if any inactive constraints should be deleted
for cname, field_id in form.inactive_wg_constraint_field_ids():
if form.cleaned_data[field_id]:
Constraint.objects.filter(meeting=meeting, source=group, name=cname.slug).delete()
if 'adjacent_with_wg' in form.changed_data:
Constraint.objects.filter(meeting=meeting, source=group, name='wg_adjacent').delete()
save_conflicts(group, meeting, form.cleaned_data['adjacent_with_wg'], 'wg_adjacent')

View file

@ -657,6 +657,10 @@ table#sessions-new-table td {
input.wg_constraint { width: 37em; }
input.wg_constraint:disabled {
background-color: #ffe0e0;
cursor: not-allowed;
}
ul.session-buttons {
padding-left: 2px;
margin-left: 0;

View file

@ -23,18 +23,33 @@
<table>
<tr>
<td colspan="2">Other WGs that included {{ group.name }} in their conflict lists:</td>
<td>{{ session_conflicts.inbound }}</td>
<td>{{ session_conflicts.inbound|default:"None" }}</td>
</tr>
{% for cname, cfield, cselector in form.wg_constraint_fields %}
<tr class="bg1">
{% if forloop.first %}<td rowspan="3" valign="top" width="220">WG Sessions:<br>You may select multiple WGs within each category</td>{% endif %}
{% if forloop.first %}<td rowspan="{{ form.wg_constraint_count }}" valign="top" width="220">WG Sessions:<br>You may select multiple WGs within each category</td>{% endif %}
<td width="320">{{ cname|title }}</td>
<td>{{ cselector }}
<input type="button" id="wg_delete_{{ cname.slug }}" value="Delete the last entry" onClick="ietf_sessions.delete_wg_constraint_clicked('{{ cname.slug }}')"><br>
{{ cfield.errors }}{{ cfield }}
</td>
</tr>
{% empty %}{# shown if there are no constraint fields #}
<tr class="bg1"><td width="220"></td><td colspan="2">No constraints are enabled for this meeting.</td></tr>
{% endfor %}
{% if form.inactive_wg_constraints %}
{% for cname, value, field in form.inactive_wg_constraints %}
<tr class="bg1">
{% if forloop.first %}
<td rowspan="{{ form.inactive_wg_constraint_count }}" valign="top" width="220">
Disabled for this meeting
</td>
{% endif %}
<td width="320">{{ cname|title }}</td>
<td><input type="text" value="{{ value }}" maxlength="255" class="wg_constraint" disabled><br>{{ field }} {{ field.label }}</td>
{% endfor %}
</tr>
{% endif %}
<tr>
<td colspan="2">BOF Sessions:</td>
<td>If the sessions can not be found in the fields above, please enter free form requests in the Special Requests field below.</td>

View file

@ -53,7 +53,7 @@
{% elif item.timeslot.location.video_stream_url %}
<a class=""
href="{{item.timeslot.location.video_stream_url|format:session }}"
title="Meetecho session"><span class="fa fa-fw fa-video-camera"></span>
title="Meetecho video stream"><span class="fa fa-fw fa-video-camera"></span>
</a>
{% else %}
<span class="">
@ -93,6 +93,10 @@
<a class="" href="{{ href }}" title="{{ r.title }}"><span class="fa fa-fw fa-file-o"></span></a>
{% endif %}
{% endwith %}{% endfor %}
{% elif item.timeslot.location.video_stream_url %}
<a class=""
href="http://www.meetecho.com/ietf{{meeting.number}}/recordings#{{acronym.upper}}"
title="Meetecho session recording"><span class="fd fa-fw fd-meetecho"></span></a>
{% elif show_empty %}
<span class="fa fa-fw"></span>
{% endif %}

View file

@ -43,7 +43,7 @@
{% if timeslot.location.video_stream_url %}
<a class=""
href="{{timeslot.location.video_stream_url|format:session }}"
title="Meetecho session"><span class="fa fa-fw fa-video-camera"></span>
title="Meetecho video stream"><span class="fa fa-fw fa-video-camera"></span>
</a>
{% endif %}
<!-- Audio stream -->
@ -108,6 +108,11 @@
{% endfor %}
{% endif %}
{% endwith %}
{% if timeslot.location.video_stream_url %}
<a class=""
href="http://www.meetecho.com/ietf{{meeting.number}}/recordings#{{acronym.upper}}"
title="Meetecho session recording"><span class="fd fa-fw fd-meetecho"></span></a>
{% endif %}
{% endif %}
{% endif %}
{% endwith %}

View file

@ -15,7 +15,7 @@ from ietf.group.models import Group, GroupHistory, Role, RoleHistory
from ietf.iesg.models import TelechatDate
from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateName, IprLicenseTypeName
from ietf.meeting.models import Meeting, ResourceAssociation
from ietf.name.models import StreamName, DocRelationshipName, RoomResourceName
from ietf.name.models import StreamName, DocRelationshipName, RoomResourceName, ConstraintName
from ietf.person.models import Person, Email
from ietf.group.utils import setup_default_community_list_for_group
from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ReviewTypeName, ReviewTeamSettings )
@ -361,7 +361,7 @@ def make_test_data():
)
# meeting
Meeting.objects.create(
ietf72 = Meeting.objects.create(
number="72",
type_id="ietf",
date=datetime.date.today() + datetime.timedelta(days=180),
@ -371,6 +371,9 @@ def make_test_data():
break_area="Lounge",
reg_area="Lobby",
)
# Use the "old" conflict names to avoid having to update tests
for slug in ['conflict', 'conflic2', 'conflic3']:
ietf72.group_conflict_types.add(ConstraintName.objects.get(slug=slug))
# interim meeting
Meeting.objects.create(