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 django.core.files.base import ContentFile
from ietf.meeting.models import Meeting, Session, SchedulingEvent, Schedule, TimeSlot, SessionPresentation, FloorPlan, Room, SlideSubmission 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.group.factories import GroupFactory
from ietf.person.factories import PersonFactory from ietf.person.factories import PersonFactory
@ -75,6 +75,24 @@ class MeetingFactory(factory.DjangoModelFactory):
obj.schedule = ScheduleFactory(meeting=obj) obj.schedule = ScheduleFactory(meeting=obj)
obj.save() 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 SessionFactory(factory.DjangoModelFactory):
class Meta: class Meta:
model = Session 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.core.validators import MinValueValidator, RegexValidator
from django.db import models 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.db.models.functions import Coalesce
from django.conf import settings from django.conf import settings
# mostly used by json_dict() # mostly used by json_dict()
@ -111,6 +111,9 @@ class Meeting(models.Model):
show_important_dates = models.BooleanField(default=False) show_important_dates = models.BooleanField(default=False)
attendees = models.IntegerField(blank=True, null=True, default=None, 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") 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): def __str__(self):
if self.type_id == "ietf": if self.type_id == "ietf":
@ -197,6 +200,15 @@ class Meeting(models.Model):
else: else:
return self.date + datetime.timedelta(days=self.submission_correction_day_offset) 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): def get_schedule_by_name(self, name):
return self.schedule_set.filter(name=name).first() return self.schedule_set.filter(name=name).first()

View file

@ -13,6 +13,7 @@ import shutil
from unittest import skipIf from unittest import skipIf
from mock import patch from mock import patch
from pyquery import PyQuery from pyquery import PyQuery
from lxml.etree import tostring
from io import StringIO, BytesIO from io import StringIO, BytesIO
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from urllib.parse import urlparse, urlsplit from urllib.parse import urlparse, urlsplit
@ -1984,6 +1985,121 @@ class EditTests(TestCase):
assignment.session.refresh_from_db() assignment.session.refresh_from_db()
self.assertEqual(assignment.session.agenda_note, "New Test Note") 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): def test_new_meeting_schedule(self):
meeting = make_meeting_test_data() 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): def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
# process constraint names - we synthesize extra names to be able # process constraint names - we synthesize extra names to be able
# to treat the concepts in the same manner as the modelled ones # 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( joint_with_groups_constraint_name = ConstraintName(
slug='joint_with_groups', 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 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 # 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' # synthesize AD constraints - we can treat them as a special kind of 'bethere'
responsible_ad_for_group = {} responsible_ad_for_group = {}

View file

@ -99,7 +99,9 @@ class MeetingModelForm(forms.ModelForm):
class Meta: class Meta:
model = Meeting model = Meeting
exclude = ('type', 'schedule', 'session_request_lock_message') exclude = ('type', 'schedule', 'session_request_lock_message')
widgets = {
'group_conflict_types': forms.CheckboxSelectMultiple(),
}
def __init__(self,*args,**kwargs): def __init__(self,*args,**kwargs):
super(MeetingModelForm, self).__init__(*args,**kwargs) super(MeetingModelForm, self).__init__(*args,**kwargs)
@ -118,6 +120,9 @@ class MeetingModelForm(forms.ModelForm):
meeting.type_id = 'ietf' meeting.type_id = 'ietf'
if commit: if commit:
meeting.save() 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 return meeting
class MeetingRoomForm(forms.ModelForm): class MeetingRoomForm(forms.ModelForm):

View file

@ -14,9 +14,10 @@ from django.conf import settings
from django.urls import reverse from django.urls import reverse
from ietf.group.models import Group, GroupEvent 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.models import Meeting, Room, TimeSlot, SchedTimeSessAssignment, Session, SchedulingEvent
from ietf.meeting.test_data import make_meeting_test_data 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.person.models import Person
from ietf.secr.meetings.forms import get_times from ietf.secr.meetings.forms import get_times
from ietf.utils.mail import outbox from ietf.utils.mail import outbox
@ -73,18 +74,21 @@ class SecrMeetingTestCase(TestCase):
number = int(meeting.number) + 1 number = int(meeting.number) + 1
count = Meeting.objects.count() count = Meeting.objects.count()
url = reverse('ietf.secr.meetings.views.add') url = reverse('ietf.secr.meetings.views.add')
post_data = dict(number=number,city='Toronto',date='2014-07-20',country='CA', post_data = dict(
time_zone='America/New_York',venue_name='Hilton', number=number,city='Toronto',date='2014-07-20',country='CA',
days=6, time_zone='America/New_York',venue_name='Hilton',
venue_addr='100 First Ave', days=6,
idsubmit_cutoff_day_offset_00=13, venue_addr='100 First Ave',
idsubmit_cutoff_day_offset_01=20, idsubmit_cutoff_day_offset_00=13,
idsubmit_cutoff_time_utc =datetime.timedelta(hours=23, minutes=59, seconds=59), idsubmit_cutoff_day_offset_01=20,
idsubmit_cutoff_warning_days =datetime.timedelta(days=21), idsubmit_cutoff_time_utc =datetime.timedelta(hours=23, minutes=59, seconds=59),
submission_start_day_offset=90, idsubmit_cutoff_warning_days =datetime.timedelta(days=21),
submission_cutoff_day_offset=26, submission_start_day_offset=90,
submission_correction_day_offset=50, 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') self.client.login(username='secretary', password='secretary+password')
response = self.client.post(url, post_data, follow=True) response = self.client.post(url, post_data, follow=True)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -96,6 +100,38 @@ class SecrMeetingTestCase(TestCase):
self.assertTrue(new_meeting.schedule.base) self.assertTrue(new_meeting.schedule.base)
self.assertEqual(new_meeting.schedule.base.name, 'base') self.assertEqual(new_meeting.schedule.base.name, 'base')
self.assertEqual(new_meeting.attendees, None) 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): def test_edit_meeting(self):
"Edit Meeting" "Edit Meeting"
@ -111,13 +147,25 @@ class SecrMeetingTestCase(TestCase):
submission_cutoff_day_offset=26, submission_cutoff_day_offset=26,
submission_correction_day_offset=50, submission_correction_day_offset=50,
attendees=1234, 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") self.client.login(username="secretary", password="secretary+password")
response = self.client.post(url, post_data, follow=True) response = self.client.post(url, post_data, follow=True)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
meeting = Meeting.objects.get(number=meeting.number) meeting = Meeting.objects.get(number=meeting.number)
self.assertEqual(meeting.city,'Toronto') self.assertEqual(meeting.city,'Toronto')
self.assertEqual(meeting.attendees,1234) 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): def test_blue_sheets_upload(self):
"Test Bluesheets" "Test Bluesheets"

View file

@ -7,7 +7,8 @@ import time
from django.conf import settings from django.conf import settings
from django.contrib import messages 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.forms.models import inlineformset_factory
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.utils.text import slugify from django.utils.text import slugify
@ -249,8 +250,17 @@ def add(request):
return redirect('ietf.secr.meetings.views.main') return redirect('ietf.secr.meetings.views.main')
else: else:
# display initial forms # display initial forms
max_number = Meeting.objects.filter(type='ietf').aggregate(Max('number'))['number__max'] last_ietf_meeting = Meeting.objects.filter(
form = MeetingModelForm(initial={'number':int(max_number) + 1}) 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', { return render(request, 'meetings/add.html', {
'form': form}, 'form': form},

View file

@ -6,7 +6,7 @@ from django import forms
import debug # pyflakes:ignore 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.group.models import Group
from ietf.meeting.models import ResourceAssociation, Constraint from ietf.meeting.models import ResourceAssociation, Constraint
from ietf.person.fields import SearchablePersonsField from ietf.person.fields import SearchablePersonsField
@ -106,7 +106,7 @@ class SessionForm(forms.Form):
# Set up constraints for the meeting # Set up constraints for the meeting
self._wg_field_data = [] 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 # 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 = forms.CharField(max_length=255, required=False)
constraint_field.widget.attrs['data-slug'] = constraintname.slug constraint_field.widget.attrs['data-slug'] = constraintname.slug
@ -126,6 +126,27 @@ class SessionForm(forms.Form):
self.fields[cselector_id] = selector_field self.fields[cselector_id] = selector_field
self._wg_field_data.append((constraintname, cfield_id, cselector_id)) 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['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['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') ] 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: for cname, cfield_id, cselector_id in self._wg_field_data:
yield cname, self[cfield_id], self[cselector_id] 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): def wg_constraint_field_ids(self):
"""Iterates over wg constraint field IDs""" """Iterates over wg constraint field IDs"""
for cname, cfield_id, _ in self._wg_field_data: for cname, cfield_id, _ in self._wg_field_data:
yield cname, cfield_id 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 @staticmethod
def _add_widget_class(widget, new_class): def _add_widget_class(widget, new_class):
"""Add a new class, taking care in case some already exist""" """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.group.factories import GroupFactory, RoleFactory
from ietf.meeting.models import Session, ResourceAssociation, SchedulingEvent, Constraint from ietf.meeting.models import Session, ResourceAssociation, SchedulingEvent, Constraint
from ietf.meeting.factories import MeetingFactory, SessionFactory 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.person.models import Person
from ietf.secr.sreq.forms import SessionForm from ietf.secr.sreq.forms import SessionForm
from ietf.utils.mail import outbox, empty_outbox, get_payload_text from ietf.utils.mail import outbox, empty_outbox, get_payload_text
@ -94,7 +94,7 @@ class SessionRequestTestCase(TestCase):
'length_session1':'3600', 'length_session1':'3600',
'length_session2':'3600', 'length_session2':'3600',
'attendees':'10', 'attendees':'10',
'constraint_conflict': iabprog.acronym, 'constraint_chair_conflict':iabprog.acronym,
'comments':'need lights', 'comments':'need lights',
'session_time_relation': 'subsequent-days', 'session_time_relation': 'subsequent-days',
'adjacent_with_wg': group2.acronym, 'adjacent_with_wg': group2.acronym,
@ -111,7 +111,7 @@ class SessionRequestTestCase(TestCase):
self.assertEqual(len(sessions), 2) self.assertEqual(len(sessions), 2)
session = sessions[0] 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='time_relation').time_relation, 'subsequent-days')
self.assertEqual(session.constraints().get(name='wg_adjacent').target.acronym, group2.acronym) self.assertEqual(session.constraints().get(name='wg_adjacent').target.acronym, group2.acronym)
self.assertEqual( self.assertEqual(
@ -134,7 +134,7 @@ class SessionRequestTestCase(TestCase):
'length_session1':'3600', 'length_session1':'3600',
'length_session2':'3600', 'length_session2':'3600',
'attendees':'10', 'attendees':'10',
'constraint_conflict':'', 'constraint_chair_conflict':'',
'comments':'need lights', 'comments':'need lights',
'joint_with_groups': group2.acronym, 'joint_with_groups': group2.acronym,
'joint_for_session': '1', 'joint_for_session': '1',
@ -156,6 +156,50 @@ class SessionRequestTestCase(TestCase):
r = self.client.get(redirect_url) r = self.client.get(redirect_url)
self.assertContains(r, 'First session with: {}'.format(group2.acronym)) 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): def test_tool_status(self):
MeetingFactory(type_id='ietf', date=datetime.date.today()) MeetingFactory(type_id='ietf', date=datetime.date.today())
url = reverse('ietf.secr.sreq.views.tool_status') url = reverse('ietf.secr.sreq.views.tool_status')
@ -166,38 +210,34 @@ class SessionRequestTestCase(TestCase):
self.assertRedirects(r,reverse('ietf.secr.sreq.views.main')) self.assertRedirects(r,reverse('ietf.secr.sreq.views.main'))
def test_new_req_constraint_types(self): 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 Relies on SessionForm representing constraint values with element IDs
like id_constraint_<ConstraintName slug> 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()) meeting = MeetingFactory(type_id='ietf', date=datetime.date.today())
RoleFactory(name_id='chair', person__user__username='marschairman', group__acronym='mars') RoleFactory(name_id='chair', person__user__username='marschairman', group__acronym='mars')
url = reverse('ietf.secr.sreq.views.new', kwargs=dict(acronym='mars')) url = reverse('ietf.secr.sreq.views.new', kwargs=dict(acronym='mars'))
self.client.login(username="marschairman", password="marschairman+password") self.client.login(username="marschairman", password="marschairman+password")
for meeting_number in ['95', '100', '105', '106', '111', '125']: for expected in [
meeting.number = meeting_number ['conflict', 'conflic2', 'conflic3'],
meeting.save() ['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) r = self.client.get(url)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
q = PyQuery(r.content) q = PyQuery(r.content)
expected = should_have if int(meeting.number) >= 106 else should_have_pre106
self.assertCountEqual( self.assertCountEqual(
[elt.attr('id') for elt in q.items('*[id^=id_constraint_]')], [elt.attr('id') for elt in q.items('*[id^=id_constraint_]')],
['id_constraint_{}'.format(conf_name) for conf_name in expected], ['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): def test_edit_req_constraint_types(self):
"""Editing a request constraint should show the expected constraints""" """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()) meeting = MeetingFactory(type_id='ietf', date=datetime.date.today())
SessionFactory(group__acronym='mars', SessionFactory(group__acronym='mars',
status_id='schedw', status_id='schedw',
@ -208,18 +248,20 @@ class SessionRequestTestCase(TestCase):
url = reverse('ietf.secr.sreq.views.edit', kwargs=dict(acronym='mars')) url = reverse('ietf.secr.sreq.views.edit', kwargs=dict(acronym='mars'))
self.client.login(username='marschairman', password='marschairman+password') self.client.login(username='marschairman', password='marschairman+password')
for meeting_number in ['95', '100', '105', '106', '111', '125']: for expected in [
meeting.number = meeting_number ['conflict', 'conflic2', 'conflic3'],
meeting.save() ['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) r = self.client.get(url)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
q = PyQuery(r.content) q = PyQuery(r.content)
expected = should_have if int(meeting.number) >= 106 else should_have_pre106
self.assertCountEqual( self.assertCountEqual(
[elt.attr('id') for elt in q.items('*[id^=id_constraint_]')], [elt.attr('id') for elt in q.items('*[id^=id_constraint_]')],
['id_constraint_{}'.format(conf_name) for conf_name in expected], ['id_constraint_{}'.format(conf_name) for conf_name in expected],
'Unexpected constraints for meeting number {}'.format(meeting_number),
) )
class SubmitRequestCase(TestCase): class SubmitRequestCase(TestCase):
@ -244,7 +286,7 @@ class SubmitRequestCase(TestCase):
post_data = {'num_session':'1', post_data = {'num_session':'1',
'length_session1':'3600', 'length_session1':'3600',
'attendees':'10', 'attendees':'10',
'constraint_conflict':'', 'constraint_chair_conflict':'',
'comments':'need projector', 'comments':'need projector',
'adjacent_with_wg': group2.acronym, 'adjacent_with_wg': group2.acronym,
'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'], 'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'],
@ -290,7 +332,7 @@ class SubmitRequestCase(TestCase):
post_data = {'num_session':'2', post_data = {'num_session':'2',
'length_session1':'3600', 'length_session1':'3600',
'attendees':'10', 'attendees':'10',
'constraint_conflict':'', 'constraint_chair_conflict':'',
'comments':'need projector'} 'comments':'need projector'}
self.client.login(username="secretary", password="secretary+password") self.client.login(username="secretary", password="secretary+password")
r = self.client.post(url,post_data) r = self.client.post(url,post_data)
@ -301,7 +343,8 @@ class SubmitRequestCase(TestCase):
def test_submit_request_check_constraints(self): def test_submit_request_check_constraints(self):
m1 = MeetingFactory(type_id='ietf', date=datetime.date.today() - datetime.timedelta(days=100)) 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') ad = Person.objects.get(user__username='ad')
area = RoleFactory(name_id='ad', person=ad, group__type_id='area').group area = RoleFactory(name_id='ad', person=ad, group__type_id='area').group
group = GroupFactory(parent=area) group = GroupFactory(parent=area)
@ -310,7 +353,7 @@ class SubmitRequestCase(TestCase):
meeting=m1, meeting=m1,
source=group, source=group,
target=still_active_group, target=still_active_group,
name_id='conflict', name_id='chair_conflict',
) )
inactive_group = GroupFactory(parent=area, state_id='conclude') inactive_group = GroupFactory(parent=area, state_id='conclude')
inactive_group.save() inactive_group.save()
@ -318,7 +361,7 @@ class SubmitRequestCase(TestCase):
meeting=m1, meeting=m1,
source=group, source=group,
target=inactive_group, target=inactive_group,
name_id='conflict', name_id='chair_conflict',
) )
SessionFactory(group=group, meeting=m1) SessionFactory(group=group, meeting=m1)
@ -328,14 +371,14 @@ class SubmitRequestCase(TestCase):
r = self.client.get(url + '?previous') r = self.client.get(url + '?previous')
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
q = PyQuery(r.content) 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.assertIn(still_active_group.acronym, conflict1)
self.assertNotIn(inactive_group.acronym, conflict1) self.assertNotIn(inactive_group.acronym, conflict1)
post_data = {'num_session':'1', post_data = {'num_session':'1',
'length_session1':'3600', 'length_session1':'3600',
'attendees':'10', 'attendees':'10',
'constraint_conflict': group.acronym, 'constraint_chair_conflict': group.acronym,
'comments':'need projector', 'comments':'need projector',
'submit': 'Continue'} 'submit': 'Continue'}
r = self.client.post(url,post_data) r = self.client.post(url,post_data)
@ -366,7 +409,7 @@ class SubmitRequestCase(TestCase):
'length_session2':'3600', 'length_session2':'3600',
'attendees':'10', 'attendees':'10',
'bethere':str(ad.pk), 'bethere':str(ad.pk),
'constraint_conflict':'', 'constraint_chair_conflict':'',
'comments':'', 'comments':'',
'resources': resource.pk, 'resources': resource.pk,
'session_time_relation': 'subsequent-days', 'session_time_relation': 'subsequent-days',
@ -485,10 +528,6 @@ class RetrievePreviousCase(TestCase):
class SessionFormTest(TestCase): class SessionFormTest(TestCase):
def setUp(self): 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.meeting = MeetingFactory(type_id='ietf')
self.group1 = GroupFactory() self.group1 = GroupFactory()
self.group2 = GroupFactory() self.group2 = GroupFactory()
@ -504,9 +543,9 @@ class SessionFormTest(TestCase):
'length_session2': '3600', 'length_session2': '3600',
'length_session3': '3600', 'length_session3': '3600',
'attendees': '10', 'attendees': '10',
'constraint_conflict': self.group2.acronym, 'constraint_chair_conflict': self.group2.acronym,
'constraint_conflic2': self.group3.acronym, 'constraint_tech_overlap': self.group3.acronym,
'constraint_conflic3': self.group4.acronym, 'constraint_key_participant': self.group4.acronym,
'comments': 'need lights', 'comments': 'need lights',
'session_time_relation': 'subsequent-days', 'session_time_relation': 'subsequent-days',
'adjacent_with_wg': self.group5.acronym, 'adjacent_with_wg': self.group5.acronym,
@ -542,16 +581,30 @@ class SessionFormTest(TestCase):
def test_invalid_groups(self): def test_invalid_groups(self):
new_form_data = { new_form_data = {
'constraint_conflict': 'doesnotexist', 'constraint_chair_conflict': 'doesnotexist',
'constraint_conflic2': 'doesnotexist', 'constraint_tech_overlap': 'doesnotexist',
'constraint_conflic3': 'doesnotexist', 'constraint_key_participant': 'doesnotexist',
'adjacent_with_wg': 'doesnotexist', 'adjacent_with_wg': 'doesnotexist',
'joint_with_groups': 'doesnotexist', 'joint_with_groups': 'doesnotexist',
} }
form = self._invalid_test_helper(new_form_data) form = self._invalid_test_helper(new_form_data)
self.assertEqual(set(form.errors.keys()), set(new_form_data.keys())) 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): 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 = { new_form_data = {
'constraint_conflict': self.group2.acronym, 'constraint_conflict': self.group2.acronym,
'constraint_conflic2': self.group2.acronym, 'constraint_conflic2': self.group2.acronym,
@ -561,7 +614,7 @@ class SessionFormTest(TestCase):
def test_invalid_conflict_with_self(self): def test_invalid_conflict_with_self(self):
new_form_data = { new_form_data = {
'constraint_conflict': self.group1.acronym, 'constraint_chair_conflict': self.group1.acronym,
} }
self._invalid_test_helper(new_form_data) 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() Constraint.objects.filter(meeting=meeting, source=group, name=cname.slug).delete()
save_conflicts(group, meeting, form.cleaned_data[cfield_id], cname.slug) 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: if 'adjacent_with_wg' in form.changed_data:
Constraint.objects.filter(meeting=meeting, source=group, name='wg_adjacent').delete() Constraint.objects.filter(meeting=meeting, source=group, name='wg_adjacent').delete()
save_conflicts(group, meeting, form.cleaned_data['adjacent_with_wg'], 'wg_adjacent') 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 { width: 37em; }
input.wg_constraint:disabled {
background-color: #ffe0e0;
cursor: not-allowed;
}
ul.session-buttons { ul.session-buttons {
padding-left: 2px; padding-left: 2px;
margin-left: 0; margin-left: 0;

View file

@ -23,18 +23,33 @@
<table> <table>
<tr> <tr>
<td colspan="2">Other WGs that included {{ group.name }} in their conflict lists:</td> <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> </tr>
{% for cname, cfield, cselector in form.wg_constraint_fields %} {% for cname, cfield, cselector in form.wg_constraint_fields %}
<tr class="bg1"> <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 width="320">{{ cname|title }}</td>
<td>{{ cselector }} <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> <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 }} {{ cfield.errors }}{{ cfield }}
</td> </td>
</tr> </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 %} {% 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> <tr>
<td colspan="2">BOF Sessions:</td> <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> <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 %} {% elif item.timeslot.location.video_stream_url %}
<a class="" <a class=""
href="{{item.timeslot.location.video_stream_url|format:session }}" 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> </a>
{% else %} {% else %}
<span class=""> <span class="">
@ -93,6 +93,10 @@
<a class="" href="{{ href }}" title="{{ r.title }}"><span class="fa fa-fw fa-file-o"></span></a> <a class="" href="{{ href }}" title="{{ r.title }}"><span class="fa fa-fw fa-file-o"></span></a>
{% endif %} {% endif %}
{% endwith %}{% endfor %} {% 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 %} {% elif show_empty %}
<span class="fa fa-fw"></span> <span class="fa fa-fw"></span>
{% endif %} {% endif %}

View file

@ -43,7 +43,7 @@
{% if timeslot.location.video_stream_url %} {% if timeslot.location.video_stream_url %}
<a class="" <a class=""
href="{{timeslot.location.video_stream_url|format:session }}" 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> </a>
{% endif %} {% endif %}
<!-- Audio stream --> <!-- Audio stream -->
@ -108,6 +108,11 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endwith %} {% 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 %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}

View file

@ -15,7 +15,7 @@ from ietf.group.models import Group, GroupHistory, Role, RoleHistory
from ietf.iesg.models import TelechatDate from ietf.iesg.models import TelechatDate
from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateName, IprLicenseTypeName from ietf.ipr.models import HolderIprDisclosure, IprDocRel, IprDisclosureStateName, IprLicenseTypeName
from ietf.meeting.models import Meeting, ResourceAssociation 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.person.models import Person, Email
from ietf.group.utils import setup_default_community_list_for_group from ietf.group.utils import setup_default_community_list_for_group
from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ReviewTypeName, ReviewTeamSettings ) from ietf.review.models import (ReviewRequest, ReviewerSettings, ReviewResultName, ReviewTypeName, ReviewTeamSettings )
@ -361,7 +361,7 @@ def make_test_data():
) )
# meeting # meeting
Meeting.objects.create( ietf72 = Meeting.objects.create(
number="72", number="72",
type_id="ietf", type_id="ietf",
date=datetime.date.today() + datetime.timedelta(days=180), date=datetime.date.today() + datetime.timedelta(days=180),
@ -371,6 +371,9 @@ def make_test_data():
break_area="Lounge", break_area="Lounge",
reg_area="Lobby", 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 # interim meeting
Meeting.objects.create( Meeting.objects.create(