Skip to content

Commit 737eff6

Browse files
committed
Merged in [19266] from jennifer@painless-security.com:
Allow configuration of group conflict types used for each meeting Fixes ietf-tools#2770. - Legacy-Id: 19278 Note: SVN reference [19266] has been migrated to Git commit 336d762
2 parents 14a8e3c + 336d762 commit 737eff6

18 files changed

Lines changed: 1816 additions & 1407 deletions

ietf/meeting/factories.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from django.core.files.base import ContentFile
1010

1111
from ietf.meeting.models import Meeting, Session, SchedulingEvent, Schedule, TimeSlot, SessionPresentation, FloorPlan, Room, SlideSubmission
12-
from ietf.name.models import SessionStatusName
12+
from ietf.name.models import ConstraintName, SessionStatusName
1313
from ietf.group.factories import GroupFactory
1414
from ietf.person.factories import PersonFactory
1515

@@ -75,6 +75,24 @@ def populate_schedule(obj, create, extracted, **kwargs): # pylint: disable=no-se
7575
obj.schedule = ScheduleFactory(meeting=obj)
7676
obj.save()
7777

78+
@factory.post_generation
79+
def group_conflicts(obj, create, extracted, **kwargs): # pulint: disable=no-self-argument
80+
"""Add conflict types
81+
82+
Pass a list of ConflictNames as group_conflicts to specify which are enabled.
83+
"""
84+
if extracted is None:
85+
extracted = [
86+
ConstraintName.objects.get(slug=s) for s in [
87+
'chair_conflict', 'tech_overlap', 'key_participant'
88+
]]
89+
if create:
90+
for cn in extracted:
91+
obj.group_conflict_types.add(
92+
cn if isinstance(cn, ConstraintName) else ConstraintName.objects.get(slug=cn)
93+
)
94+
95+
7896
class SessionFactory(factory.DjangoModelFactory):
7997
class Meta:
8098
model = Session

ietf/meeting/management/commands/create_dummy_meeting.py

Lines changed: 1351 additions & 1338 deletions
Large diffs are not rendered by default.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated by Django 2.2.20 on 2021-05-20 12:28
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('name', '0026_add_conflict_constraintnames'),
10+
('meeting', '0041_assign_correct_constraintnames'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='meeting',
16+
name='group_conflict_types',
17+
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'),
18+
),
19+
]
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 2.2.20 on 2021-05-20 12:30
2+
3+
from django.db import migrations
4+
from django.db.models import IntegerField
5+
from django.db.models.functions import Cast
6+
7+
8+
def forward(apps, schema_editor):
9+
Meeting = apps.get_model('meeting', 'Meeting')
10+
ConstraintName = apps.get_model('name', 'ConstraintName')
11+
12+
# old for pre-106
13+
old_constraints = ConstraintName.objects.filter(slug__in=['conflict', 'conflic2', 'conflic3'])
14+
new_constraints = ConstraintName.objects.filter(slug__in=['chair_conflict', 'tech_overlap', 'key_participant'])
15+
16+
# get meetings with numeric 'number' field to avoid lexicographic ordering
17+
ietf_meetings = Meeting.objects.filter(
18+
type='ietf'
19+
).annotate(
20+
number_as_int=Cast('number', output_field=IntegerField())
21+
)
22+
23+
for mtg in ietf_meetings.filter(number_as_int__lt=106):
24+
for cn in old_constraints:
25+
mtg.group_conflict_types.add(cn)
26+
for mtg in ietf_meetings.filter(number_as_int__gte=106):
27+
for cn in new_constraints:
28+
mtg.group_conflict_types.add(cn)
29+
30+
def reverse(apps, schema_editor):
31+
pass
32+
33+
34+
class Migration(migrations.Migration):
35+
36+
dependencies = [
37+
('meeting', '0042_meeting_group_conflict_types'),
38+
]
39+
40+
operations = [
41+
migrations.RunPython(forward, reverse),
42+
]

ietf/meeting/models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from django.core.validators import MinValueValidator, RegexValidator
1919
from django.db import models
20-
from django.db.models import Max, Subquery, OuterRef, TextField, Value
20+
from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q
2121
from django.db.models.functions import Coalesce
2222
from django.conf import settings
2323
# mostly used by json_dict()
@@ -111,6 +111,9 @@ class Meeting(models.Model):
111111
show_important_dates = models.BooleanField(default=False)
112112
attendees = models.IntegerField(blank=True, null=True, default=None,
113113
help_text="Number of Attendees for backfilled meetings, leave it blank for new meetings, and then it is calculated from the registrations")
114+
group_conflict_types = models.ManyToManyField(
115+
ConstraintName, blank=True, limit_choices_to=dict(is_group_conflict=True),
116+
help_text='Types of scheduling conflict between groups to consider')
114117

115118
def __str__(self):
116119
if self.type_id == "ietf":
@@ -197,6 +200,15 @@ def get_submission_correction_date(self):
197200
else:
198201
return self.date + datetime.timedelta(days=self.submission_correction_day_offset)
199202

203+
def enabled_constraint_names(self):
204+
return ConstraintName.objects.filter(
205+
Q(is_group_conflict=False) # any non-group-conflict constraints
206+
| Q(is_group_conflict=True, meeting=self) # or specifically enabled for this meeting
207+
)
208+
209+
def enabled_constraints(self):
210+
return self.constraint_set.filter(name__in=self.enabled_constraint_names())
211+
200212
def get_schedule_by_name(self, name):
201213
return self.schedule_set.filter(name=name).first()
202214

ietf/meeting/tests_views.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from unittest import skipIf
1414
from mock import patch
1515
from pyquery import PyQuery
16+
from lxml.etree import tostring
1617
from io import StringIO, BytesIO
1718
from bs4 import BeautifulSoup
1819
from urllib.parse import urlparse, urlsplit
@@ -1984,6 +1985,121 @@ def test_edit_meeting_timeslots_and_misc_sessions(self):
19841985
assignment.session.refresh_from_db()
19851986
self.assertEqual(assignment.session.agenda_note, "New Test Note")
19861987

1988+
def test_edit_meeting_schedule_conflict_types(self):
1989+
"""The meeting schedule editor should show the constraint types enabled for the meeting"""
1990+
meeting = MeetingFactory(
1991+
type_id='ietf',
1992+
group_conflicts=[], # show none to start with
1993+
)
1994+
s1 = SessionFactory(
1995+
meeting=meeting,
1996+
type_id='regular',
1997+
attendees=12,
1998+
comments='chair conflict',
1999+
)
2000+
2001+
s2 = SessionFactory(
2002+
meeting=meeting,
2003+
type_id='regular',
2004+
attendees=34,
2005+
comments='old-fashioned conflict',
2006+
)
2007+
2008+
Constraint.objects.create(
2009+
meeting=meeting,
2010+
source=s1.group,
2011+
target=s2.group,
2012+
name=ConstraintName.objects.get(slug="chair_conflict"),
2013+
)
2014+
2015+
Constraint.objects.create(
2016+
meeting=meeting,
2017+
source=s2.group,
2018+
target=s1.group,
2019+
name=ConstraintName.objects.get(slug="conflict"),
2020+
)
2021+
2022+
2023+
# log in as secretary so we have access
2024+
self.client.login(username="secretary", password="secretary+password")
2025+
2026+
url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number))
2027+
2028+
# Should have no conflict constraints listed because the meeting has all disabled
2029+
r = self.client.get(url)
2030+
q = PyQuery(r.content)
2031+
2032+
self.assertEqual(len(q('#session{} span.constraints > span'.format(s1.pk))), 0)
2033+
self.assertEqual(len(q('#session{} span.constraints > span'.format(s2.pk))), 0)
2034+
2035+
# Now enable the 'chair_conflict' constraint only
2036+
chair_conflict = ConstraintName.objects.get(slug='chair_conflict')
2037+
chair_conf_label = b'<i class="fa fa-gavel"/>' # result of etree.tostring(etree.fromstring(editor_label))
2038+
meeting.group_conflict_types.add(chair_conflict)
2039+
r = self.client.get(url)
2040+
q = PyQuery(r.content)
2041+
2042+
# verify that there is a constraint pointing from 1 to 2
2043+
#
2044+
# The constraint is represented in the HTML as
2045+
# <div id="session<pk>">
2046+
# [...]
2047+
# <span class="constraints">
2048+
# <span data-sessions="<other pk>">[constraint label]</span>
2049+
# </span>
2050+
# </div>
2051+
#
2052+
# Where the constraint label is the editor_label for the ConstraintName.
2053+
# If this pk is the constraint target, the editor_label includes a
2054+
# '-' prefix, which may be before the editor_label or inserted inside
2055+
# it.
2056+
#
2057+
# For simplicity, this test is tied to the current values of editor_label.
2058+
# It also assumes the order of constraints will be constant.
2059+
# If those change, the test will need to be updated.
2060+
s1_constraints = q('#session{} span.constraints > span'.format(s1.pk))
2061+
s2_constraints = q('#session{} span.constraints > span'.format(s2.pk))
2062+
2063+
# Check the forward constraint
2064+
self.assertEqual(len(s1_constraints), 1)
2065+
self.assertEqual(s1_constraints[0].attrib['data-sessions'], str(s2.pk))
2066+
self.assertEqual(s1_constraints[0].text, None) # no '-' prefix on the source
2067+
self.assertEqual(tostring(s1_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
2068+
2069+
# And the reverse constraint
2070+
self.assertEqual(len(s2_constraints), 1)
2071+
self.assertEqual(s2_constraints[0].attrib['data-sessions'], str(s1.pk))
2072+
self.assertEqual(s2_constraints[0].text, '-') # '-' prefix on the target
2073+
self.assertEqual(tostring(s2_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
2074+
2075+
# Now also enable the 'conflict' constraint
2076+
conflict = ConstraintName.objects.get(slug='conflict')
2077+
conf_label = b'<span class="encircled">1</span>'
2078+
conf_label_reversed = b'<span class="encircled">-1</span>' # the '-' is inside the span!
2079+
meeting.group_conflict_types.add(conflict)
2080+
r = self.client.get(url)
2081+
q = PyQuery(r.content)
2082+
2083+
s1_constraints = q('#session{} span.constraints > span'.format(s1.pk))
2084+
s2_constraints = q('#session{} span.constraints > span'.format(s2.pk))
2085+
2086+
# Check the forward constraint
2087+
self.assertEqual(len(s1_constraints), 2)
2088+
self.assertEqual(s1_constraints[0].attrib['data-sessions'], str(s2.pk))
2089+
self.assertEqual(s1_constraints[0].text, None) # no '-' prefix on the source
2090+
self.assertEqual(tostring(s1_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
2091+
2092+
self.assertEqual(s1_constraints[1].attrib['data-sessions'], str(s2.pk))
2093+
self.assertEqual(tostring(s1_constraints[1][0]), conf_label_reversed) # [0][0] is the innermost <span>
2094+
2095+
# And the reverse constraint
2096+
self.assertEqual(len(s2_constraints), 2)
2097+
self.assertEqual(s2_constraints[0].attrib['data-sessions'], str(s1.pk))
2098+
self.assertEqual(s2_constraints[0].text, '-') # '-' prefix on the target
2099+
self.assertEqual(tostring(s2_constraints[0][0]), chair_conf_label) # [0][0] is the innermost <span>
2100+
2101+
self.assertEqual(s2_constraints[1].attrib['data-sessions'], str(s1.pk))
2102+
self.assertEqual(tostring(s2_constraints[1][0]), conf_label) # [0][0] is the innermost <span>
19872103

19882104
def test_new_meeting_schedule(self):
19892105
meeting = make_meeting_test_data()

ietf/meeting/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ def reverse_editor_label(label):
296296
def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
297297
# process constraint names - we synthesize extra names to be able
298298
# to treat the concepts in the same manner as the modelled ones
299-
constraint_names = {n.pk: n for n in ConstraintName.objects.all()}
299+
constraint_names = {n.pk: n for n in meeting.enabled_constraint_names()}
300300

301301
joint_with_groups_constraint_name = ConstraintName(
302302
slug='joint_with_groups',
@@ -327,7 +327,7 @@ def preprocess_constraints_for_meeting_schedule_editor(meeting, sessions):
327327
n.countless_formatted_editor_label = format_html(n.formatted_editor_label, count="") if "{count}" in n.formatted_editor_label else n.formatted_editor_label
328328

329329
# convert human-readable rules in the database to constraints on actual sessions
330-
constraints = list(Constraint.objects.filter(meeting=meeting).prefetch_related('target', 'person', 'timeranges'))
330+
constraints = list(meeting.enabled_constraints().prefetch_related('target', 'person', 'timeranges'))
331331

332332
# synthesize AD constraints - we can treat them as a special kind of 'bethere'
333333
responsible_ad_for_group = {}

ietf/secr/meetings/forms.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ class MeetingModelForm(forms.ModelForm):
9999
class Meta:
100100
model = Meeting
101101
exclude = ('type', 'schedule', 'session_request_lock_message')
102-
102+
widgets = {
103+
'group_conflict_types': forms.CheckboxSelectMultiple(),
104+
}
103105

104106
def __init__(self,*args,**kwargs):
105107
super(MeetingModelForm, self).__init__(*args,**kwargs)
@@ -118,6 +120,9 @@ def save(self, force_insert=False, force_update=False, commit=True):
118120
meeting.type_id = 'ietf'
119121
if commit:
120122
meeting.save()
123+
# must call save_m2m() because we saved with commit=False above, see:
124+
# https://docs.djangoproject.com/en/2.2/topics/forms/modelforms/#the-save-method
125+
self.save_m2m()
121126
return meeting
122127

123128
class MeetingRoomForm(forms.ModelForm):

0 commit comments

Comments
 (0)