Merged in ^/branch/dash/automatic-scheduler@17395, which adds groundwor for
upcoming automatic scheduling assistance:
. Added a management command to create a dummy IETF 999 meeting.
. Added display of new constraints and joint sessions to agenda builder
interface.
. The new timerange, time_relation and wg_adjacent constraints, along with
the joint_with_groups option, are now reflected in the special requests
field. This allows them to be taken into account while scheduling
sessions.
. Clarified the wording in the session request form regarding conflicts with
BOFs.
. Added support for structured entry and storage of joint sessions in
meetings:
- Also adds additional tests for the SessionForm
- Fixes a javascript error in session requests for non-WG groups,
that could cause incorrect form behaviour.
- Expands the tests added in [17289] a bit.
. Added support for the timerange, wg_adjacent and time_relation constraints.
This adds three new constraints to the database and relevant UIs:
- timerange: "This WG can't meet during these timeframes"
- wg_adjacent: "Schedule adjacent to another WG (directly following,
no breaks, same room)"
- time_relation: schedule the two sessions of one WG on subsequent
days or with at least one day seperation
- Legacy-Id: 17605
Note: SVN reference [17289] has been migrated to Git commit a227813dc5
This commit is contained in:
commit
b143dd407f
4651
ietf/meeting/management/commands/create_dummy_meeting.py
Normal file
4651
ietf/meeting/management/commands/create_dummy_meeting.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,52 @@
|
|||
# Copyright The IETF Trust 2020, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.27 on 2020-02-11 04:47
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
ConstraintName = apps.get_model("name", "ConstraintName")
|
||||
ConstraintName.objects.create(slug="timerange", desc="", penalty=100000,
|
||||
name="Can't meet within timerange")
|
||||
ConstraintName.objects.create(slug="time_relation", desc="", penalty=1000,
|
||||
name="Preference for time between sessions")
|
||||
ConstraintName.objects.create(slug="wg_adjacent", desc="", penalty=10000,
|
||||
name="Request for adjacent scheduling with another WG")
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
ConstraintName = apps.get_model("name", "ConstraintName")
|
||||
ConstraintName.objects.filter(slug__in=["timerange", "time_relation", "wg_adjacent"]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('name', '0010_timerangename'),
|
||||
('meeting', '0026_cancel_107_sessions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='constraint',
|
||||
name='day',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='constraint',
|
||||
name='time_relation',
|
||||
field=models.CharField(blank=True, choices=[('subsequent-days', 'Schedule the sessions on subsequent days'), ('one-day-seperation', 'Leave at least one free day in between the two sessions')], max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='constraint',
|
||||
name='timeranges',
|
||||
field=models.ManyToManyField(to='name.TimerangeName'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='session',
|
||||
name='joint_with_groups',
|
||||
field=models.ManyToManyField(related_name='sessions_joint_in', to='group.Group'),
|
||||
),
|
||||
migrations.RunPython(forward, reverse),
|
||||
]
|
|
@ -28,7 +28,7 @@ from ietf.dbtemplate.models import DBTemplate
|
|||
from ietf.doc.models import Document
|
||||
from ietf.group.models import Group
|
||||
from ietf.group.utils import can_manage_materials
|
||||
from ietf.name.models import MeetingTypeName, TimeSlotTypeName, SessionStatusName, ConstraintName, RoomResourceName, ImportantDateName
|
||||
from ietf.name.models import MeetingTypeName, TimeSlotTypeName, SessionStatusName, ConstraintName, RoomResourceName, ImportantDateName, TimerangeName
|
||||
from ietf.person.models import Person
|
||||
from ietf.utils.decorators import memoize
|
||||
from ietf.utils.storage import NoLocationMigrationFileSystemStorage
|
||||
|
@ -815,19 +815,27 @@ class SchedTimeSessAssignment(models.Model):
|
|||
class Constraint(models.Model):
|
||||
"""
|
||||
Specifies a constraint on the scheduling.
|
||||
One type (name=conflic?) of constraint is between source WG and target WG,
|
||||
e.g. some kind of conflict.
|
||||
Another type (name=bethere) of constraint is between source WG and
|
||||
availability of a particular Person, usually an AD.
|
||||
A third type (name=avoidday) of constraint is between source WG and
|
||||
a particular day of the week, specified in day.
|
||||
Available types are:
|
||||
- conflict/conflic2/conflic3: a conflict between source and target WG/session,
|
||||
with varying priority. The first is used for a chair conflict, the second for
|
||||
technology overlap, third for key person conflict
|
||||
- bethere: a constraint between source WG and a particular person
|
||||
- timerange: can not meet during these times
|
||||
- time_relation: preference for a time difference between sessions
|
||||
- wg_adjacent: request for source WG to be adjacent (directly before or after,
|
||||
no breaks, same room) the target WG
|
||||
"""
|
||||
TIME_RELATION_CHOICES = (
|
||||
('subsequent-days', 'Schedule the sessions on subsequent days'),
|
||||
('one-day-seperation', 'Leave at least one free day in between the two sessions'),
|
||||
)
|
||||
meeting = ForeignKey(Meeting)
|
||||
source = ForeignKey(Group, related_name="constraint_source_set")
|
||||
target = ForeignKey(Group, related_name="constraint_target_set", null=True)
|
||||
person = ForeignKey(Person, null=True, blank=True)
|
||||
day = models.DateTimeField(null=True, blank=True)
|
||||
name = ForeignKey(ConstraintName)
|
||||
time_relation = models.CharField(max_length=200, choices=TIME_RELATION_CHOICES, blank=True)
|
||||
timeranges = models.ManyToManyField(TimerangeName)
|
||||
|
||||
active_status = None
|
||||
|
||||
|
@ -835,7 +843,14 @@ class Constraint(models.Model):
|
|||
return u"%s %s target=%s person=%s" % (self.source, self.name.name.lower(), self.target, self.person)
|
||||
|
||||
def brief_display(self):
|
||||
if self.target and self.person:
|
||||
if self.name.slug == "wg_adjacent":
|
||||
return "Adjacent with %s" % self.target.acronym
|
||||
elif self.name.slug == "time_relation":
|
||||
return self.get_time_relation_display()
|
||||
elif self.name.slug == "timerange":
|
||||
timeranges_str = ", ".join([t.desc for t in self.timeranges.all()])
|
||||
return "Can't meet %s" % timeranges_str
|
||||
elif self.target and self.person:
|
||||
return "%s ; %s" % (self.target.acronym, self.person)
|
||||
elif self.target and not self.person:
|
||||
return "%s " % (self.target.acronym)
|
||||
|
@ -857,6 +872,13 @@ class Constraint(models.Model):
|
|||
if self.target is not None:
|
||||
ct1['target_href'] = urljoin(host_scheme, self.target.json_url())
|
||||
ct1['meeting_href'] = urljoin(host_scheme, self.meeting.json_url())
|
||||
if self.time_relation:
|
||||
ct1['time_relation'] = self.time_relation
|
||||
ct1['time_relation_display'] = self.get_time_relation_display()
|
||||
if self.timeranges.count():
|
||||
ct1['timeranges_cant_meet'] = [t.slug for t in self.timeranges.all()]
|
||||
timeranges_str = ", ".join([t.desc for t in self.timeranges.all()])
|
||||
ct1['timeranges_display'] = "Can't meet %s" % timeranges_str
|
||||
return ct1
|
||||
|
||||
|
||||
|
@ -890,6 +912,7 @@ class Session(models.Model):
|
|||
short = models.CharField(blank=True, max_length=32, help_text="Short version of 'name' above, for use in filenames.")
|
||||
type = ForeignKey(TimeSlotTypeName)
|
||||
group = ForeignKey(Group) # The group type historically determined the session type. BOFs also need to be added as a group. Note that not all meeting requests have a natural group to associate with.
|
||||
joint_with_groups = models.ManyToManyField(Group, related_name='sessions_joint_in')
|
||||
attendees = models.IntegerField(null=True, blank=True)
|
||||
agenda_note = models.CharField(blank=True, max_length=255)
|
||||
requested_duration = models.DurationField(default=datetime.timedelta(0))
|
||||
|
@ -1013,6 +1036,9 @@ class Session(models.Model):
|
|||
|
||||
def is_material_submission_cutoff(self):
|
||||
return datetime.date.today() > self.meeting.get_submission_correction_date()
|
||||
|
||||
def joint_with_groups_acronyms(self):
|
||||
return [group.acronym for group in self.joint_with_groups.all()]
|
||||
|
||||
def __str__(self):
|
||||
if self.meeting.type_id == "interim":
|
||||
|
@ -1108,6 +1134,7 @@ class Session(models.Model):
|
|||
sess1['bof'] = str(self.group.is_bof())
|
||||
sess1['agenda_note'] = self.agenda_note
|
||||
sess1['attendees'] = str(self.attendees)
|
||||
sess1['joint_with_groups'] = self.joint_with_groups_acronyms()
|
||||
|
||||
# fish out scheduling information - eventually, we should pick
|
||||
# this out in the caller instead
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright The IETF Trust 2013-2019, All Rights Reserved
|
||||
# Copyright The IETF Trust 2013-2020, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
|
@ -12,6 +12,7 @@ from django.urls import reverse as urlreverse
|
|||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.name.models import TimerangeName
|
||||
from ietf.group.models import Group
|
||||
from ietf.meeting.models import Schedule, TimeSlot, Session, SchedTimeSessAssignment, Meeting, Constraint
|
||||
from ietf.meeting.test_data import make_meeting_test_data
|
||||
|
@ -119,10 +120,23 @@ class ApiTests(TestCase):
|
|||
person=Person.objects.get(user__username="ad"),
|
||||
name_id="bethere")
|
||||
|
||||
c_adjacent = Constraint.objects.create(meeting=meeting, source=session.group,
|
||||
target=Group.objects.get(acronym="irg"),
|
||||
name_id="wg_adjacent")
|
||||
|
||||
c_time_relation = Constraint.objects.create(meeting=meeting, source=session.group,
|
||||
time_relation='subsequent-days',
|
||||
name_id="time_relation")
|
||||
|
||||
c_timerange = Constraint.objects.create(meeting=meeting, source=session.group,
|
||||
name_id="timerange")
|
||||
c_timerange.timeranges.set(TimerangeName.objects.filter(slug__startswith='monday'))
|
||||
|
||||
r = self.client.get(urlreverse("ietf.meeting.ajax.session_constraints", kwargs=dict(num=meeting.number, sessionid=session.pk)))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
constraints = r.json()
|
||||
self.assertEqual(set([c_ames.pk, c_person.pk]), set(c["constraint_id"] for c in constraints))
|
||||
expected_keys = set([c_ames.pk, c_person.pk, c_adjacent.pk, c_time_relation.pk, c_timerange.pk])
|
||||
self.assertEqual(expected_keys, set(c["constraint_id"] for c in constraints))
|
||||
|
||||
def test_meeting_json(self):
|
||||
meeting = make_meeting_test_data()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright The IETF Trust 2016-2019, All Rights Reserved
|
||||
# Copyright The IETF Trust 2010-2020, All Rights Reserved
|
||||
from django.contrib import admin
|
||||
|
||||
from ietf.name.models import (
|
||||
|
@ -10,7 +10,7 @@ from ietf.name.models import (
|
|||
LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName,
|
||||
ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName,
|
||||
SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, TopicAudienceName,
|
||||
DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName)
|
||||
DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName, TimerangeName)
|
||||
|
||||
from ietf.stats.models import CountryAlias
|
||||
|
||||
|
@ -79,5 +79,6 @@ admin.site.register(SessionStatusName, NameAdmin)
|
|||
admin.site.register(StdLevelName, NameAdmin)
|
||||
admin.site.register(StreamName, NameAdmin)
|
||||
admin.site.register(TimeSlotTypeName, NameAdmin)
|
||||
admin.site.register(TimerangeName, NameAdmin)
|
||||
admin.site.register(TopicAudienceName, NameAdmin)
|
||||
admin.site.register(DocUrlTagName, NameAdmin)
|
||||
|
|
|
@ -5609,6 +5609,39 @@
|
|||
"model": "name.constraintname",
|
||||
"pk": "conflict"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Preference for time between sessions",
|
||||
"order": 0,
|
||||
"penalty": 100000,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.constraintname",
|
||||
"pk": "time_relation"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Can't meet within timerange",
|
||||
"order": 0,
|
||||
"penalty": 100000,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.constraintname",
|
||||
"pk": "timerange"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
"name": "Request for adjacent scheduling with another WG",
|
||||
"order": 0,
|
||||
"penalty": 100000,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.constraintname",
|
||||
"pk": "wg_adjacent"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
|
@ -11701,6 +11734,156 @@
|
|||
"model": "name.streamname",
|
||||
"pk": "legacy"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Friday early afternoon",
|
||||
"name": "friday-afternoon-early",
|
||||
"order": 13,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "friday-afternoon-early"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Friday late afternoon",
|
||||
"name": "friday-afternoon-late",
|
||||
"order": 14,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "friday-afternoon-late"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Friday morning",
|
||||
"name": "friday-morning",
|
||||
"order": 12,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "friday-morning"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Monday early afternoon",
|
||||
"name": "monday-afternoon-early",
|
||||
"order": 1,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "monday-afternoon-early"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Monday late afternoon",
|
||||
"name": "monday-afternoon-late",
|
||||
"order": 2,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "monday-afternoon-late"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Monday morning",
|
||||
"name": "monday-morning",
|
||||
"order": 0,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "monday-morning"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Thursday early afternoon",
|
||||
"name": "thursday-afternoon-early",
|
||||
"order": 10,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "thursday-afternoon-early"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Thursday late afternoon",
|
||||
"name": "thursday-afternoon-late",
|
||||
"order": 11,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "thursday-afternoon-late"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Thursday morning",
|
||||
"name": "thursday-morning",
|
||||
"order": 9,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "thursday-morning"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Tuesday early afternoon",
|
||||
"name": "tuesday-afternoon-early",
|
||||
"order": 4,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "tuesday-afternoon-early"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Tuesday late afternoon",
|
||||
"name": "tuesday-afternoon-late",
|
||||
"order": 5,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "tuesday-afternoon-late"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Tuesday morning",
|
||||
"name": "tuesday-morning",
|
||||
"order": 3,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "tuesday-morning"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Wednesday early afternoon",
|
||||
"name": "wednesday-afternoon-early",
|
||||
"order": 7,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "wednesday-afternoon-early"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Wednesday late afternoon",
|
||||
"name": "wednesday-afternoon-late",
|
||||
"order": 8,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "wednesday-afternoon-late"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "Wednesday morning",
|
||||
"name": "wednesday-morning",
|
||||
"order": 6,
|
||||
"used": true
|
||||
},
|
||||
"model": "name.timerangename",
|
||||
"pk": "wednesday-morning"
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"desc": "",
|
||||
|
|
58
ietf/name/migrations/0010_timerangename.py
Normal file
58
ietf/name/migrations/0010_timerangename.py
Normal file
|
@ -0,0 +1,58 @@
|
|||
# Copyright The IETF Trust 2020, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.27 on 2020-02-04 05:43
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TimerangeName = apps.get_model('name', 'TimerangeName')
|
||||
timeranges = [
|
||||
('monday-morning', 'Monday morning'),
|
||||
('monday-afternoon-early', 'Monday early afternoon'),
|
||||
('monday-afternoon-late', 'Monday late afternoon'),
|
||||
('tuesday-morning', 'Tuesday morning'),
|
||||
('tuesday-afternoon-early', 'Tuesday early afternoon'),
|
||||
('tuesday-afternoon-late', 'Tuesday late afternoon'),
|
||||
('wednesday-morning', 'Wednesday morning'),
|
||||
('wednesday-afternoon-early', 'Wednesday early afternoon'),
|
||||
('wednesday-afternoon-late', 'Wednesday late afternoon'),
|
||||
('thursday-morning', 'Thursday morning'),
|
||||
('thursday-afternoon-early', 'Thursday early afternoon'),
|
||||
('thursday-afternoon-late', 'Thursday late afternoon'),
|
||||
('friday-morning', 'Friday morning'),
|
||||
('friday-afternoon-early', 'Friday early afternoon'),
|
||||
('friday-afternoon-late', 'Friday late afternoon'),
|
||||
]
|
||||
for order, (slug, desc) in enumerate(timeranges):
|
||||
TimerangeName.objects.create(slug=slug, name=slug, desc=desc, used=True, order=order)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('name', '0009_add_verified_errata_to_doctagname'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TimerangeName',
|
||||
fields=[
|
||||
('slug', models.CharField(max_length=32, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('desc', models.TextField(blank=True)),
|
||||
('used', models.BooleanField(default=True)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', 'name'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.RunPython(forward, reverse),
|
||||
]
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright The IETF Trust 2010-2019, All Rights Reserved
|
||||
# Copyright The IETF Trust 2010-2020, All Rights Reserved
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
|
@ -71,8 +71,10 @@ class SessionStatusName(NameModel):
|
|||
class TimeSlotTypeName(NameModel):
|
||||
"""Session, Break, Registration, Other, Reserved, unavail"""
|
||||
class ConstraintName(NameModel):
|
||||
"""Conflict"""
|
||||
"""conflict, conflic2, conflic3, bethere, timerange, time_relation, wg_adjacent"""
|
||||
penalty = models.IntegerField(default=0, help_text="The penalty for violating this kind of constraint; for instance 10 (small penalty) or 10000 (large penalty)")
|
||||
class TimerangeName(NameModel):
|
||||
"""(monday|tuesday|wednesday|thursday|friday)-(morning|afternoon-early|afternoon-late)"""
|
||||
class LiaisonStatementPurposeName(NameModel):
|
||||
"""For action, For comment, For information, In response, Other"""
|
||||
class NomineePositionStateName(NameModel):
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright The IETF Trust 2016-2019, All Rights Reserved
|
||||
# Copyright The IETF Trust 2014-2020, All Rights Reserved
|
||||
# Autogenerated by the makeresources management command 2015-08-27 11:01 PDT
|
||||
from ietf.api import ModelResource
|
||||
from ietf.api import ToOneField # pyflakes:ignore
|
||||
|
@ -17,7 +17,7 @@ from ietf.name.models import ( AgendaTypeName, BallotPositionName, ConstraintNam
|
|||
LiaisonStatementState, LiaisonStatementTagName, MeetingTypeName, NomineePositionStateName,
|
||||
ReviewAssignmentStateName, ReviewRequestStateName, ReviewResultName, ReviewTypeName,
|
||||
RoleName, RoomResourceName, SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName,
|
||||
TopicAudienceName, ReviewerQueuePolicyName)
|
||||
TopicAudienceName, ReviewerQueuePolicyName, TimerangeName)
|
||||
|
||||
class TimeSlotTypeNameResource(ModelResource):
|
||||
class Meta:
|
||||
|
@ -598,3 +598,20 @@ class AgendaTypeNameResource(ModelResource):
|
|||
api.name.register(AgendaTypeNameResource())
|
||||
|
||||
|
||||
|
||||
|
||||
class TimerangeNameResource(ModelResource):
|
||||
class Meta:
|
||||
queryset = TimerangeName.objects.all()
|
||||
serializer = api.Serializer()
|
||||
cache = SimpleCache()
|
||||
#resource_name = 'timerangename'
|
||||
ordering = ['slug', ]
|
||||
filtering = {
|
||||
"slug": ALL,
|
||||
"name": ALL,
|
||||
"desc": ALL,
|
||||
"used": ALL,
|
||||
"order": ALL,
|
||||
}
|
||||
api.name.register(TimerangeNameResource())
|
||||
|
|
|
@ -8,8 +8,9 @@ from django import forms
|
|||
|
||||
import debug # pyflakes:ignore
|
||||
|
||||
from ietf.name.models import TimerangeName
|
||||
from ietf.group.models import Group
|
||||
from ietf.meeting.models import ResourceAssociation
|
||||
from ietf.meeting.models import ResourceAssociation, Constraint
|
||||
from ietf.person.fields import SearchablePersonsField
|
||||
from ietf.utils.html import clean_text_field
|
||||
|
||||
|
@ -20,6 +21,8 @@ from ietf.utils.html import clean_text_field
|
|||
NUM_SESSION_CHOICES = (('','--Please select'),('1','1'),('2','2'))
|
||||
# LENGTH_SESSION_CHOICES = (('','--Please select'),('1800','30 minutes'),('3600','1 hour'),('5400','1.5 hours'), ('7200','2 hours'),('9000','2.5 hours'))
|
||||
LENGTH_SESSION_CHOICES = (('','--Please select'),('1800','30 minutes'),('3600','1 hour'),('5400','1.5 hours'), ('7200','2 hours'))
|
||||
SESSION_TIME_RELATION_CHOICES = (('', 'No preference'),) + Constraint.TIME_RELATION_CHOICES
|
||||
JOINT_FOR_SESSION_CHOICES = (('1', 'First session'), ('2', 'Second session'), ('3', 'Third session'), )
|
||||
|
||||
# -------------------------------------------------
|
||||
# Helper Functions
|
||||
|
@ -65,10 +68,17 @@ class GroupSelectForm(forms.Form):
|
|||
super(GroupSelectForm, self).__init__(*args,**kwargs)
|
||||
self.fields['group'].widget.choices = choices
|
||||
|
||||
|
||||
class NameModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, name):
|
||||
return name.desc
|
||||
|
||||
|
||||
class SessionForm(forms.Form):
|
||||
num_session = forms.ChoiceField(choices=NUM_SESSION_CHOICES)
|
||||
length_session1 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES)
|
||||
length_session2 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES,required=False)
|
||||
session_time_relation = forms.ChoiceField(choices=SESSION_TIME_RELATION_CHOICES, required=False)
|
||||
length_session3 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES,required=False)
|
||||
attendees = forms.IntegerField()
|
||||
# FIXME: it would cleaner to have these be
|
||||
|
@ -77,13 +87,19 @@ class SessionForm(forms.Form):
|
|||
conflict1 = forms.CharField(max_length=255,required=False)
|
||||
conflict2 = forms.CharField(max_length=255,required=False)
|
||||
conflict3 = forms.CharField(max_length=255,required=False)
|
||||
joint_with_groups = forms.CharField(max_length=255,required=False)
|
||||
joint_for_session = forms.ChoiceField(choices=JOINT_FOR_SESSION_CHOICES, required=False)
|
||||
comments = forms.CharField(max_length=200,required=False)
|
||||
wg_selector1 = forms.ChoiceField(choices=[],required=False)
|
||||
wg_selector2 = forms.ChoiceField(choices=[],required=False)
|
||||
wg_selector3 = forms.ChoiceField(choices=[],required=False)
|
||||
wg_selector4 = forms.ChoiceField(choices=[],required=False)
|
||||
third_session = forms.BooleanField(required=False)
|
||||
resources = forms.MultipleChoiceField(widget=forms.CheckboxSelectMultiple,required=False)
|
||||
bethere = SearchablePersonsField(label="Must be present", required=False)
|
||||
timeranges = NameModelMultipleChoiceField(widget=forms.CheckboxSelectMultiple, required=False,
|
||||
queryset=TimerangeName.objects.all())
|
||||
adjacent_with_wg = forms.ChoiceField(required=False)
|
||||
|
||||
def __init__(self, group, *args, **kwargs):
|
||||
if 'hidden' in kwargs:
|
||||
|
@ -100,8 +116,10 @@ class SessionForm(forms.Form):
|
|||
self.fields['length_session3'].widget.attrs['onClick'] = "if (check_third_session()) { this.disabled=true;}"
|
||||
self.fields['comments'].widget = forms.Textarea(attrs={'rows':'3','cols':'65'})
|
||||
|
||||
group_acronym_choices = [('','--Select WG(s)')] + list(allowed_conflicting_groups().exclude(pk=group.pk).values_list('acronym','acronym').order_by('acronym'))
|
||||
for i in range(1, 4):
|
||||
other_groups = list(allowed_conflicting_groups().exclude(pk=group.pk).values_list('acronym', 'acronym').order_by('acronym'))
|
||||
self.fields['adjacent_with_wg'].choices = [('', '--No preference')] + other_groups
|
||||
group_acronym_choices = [('','--Select WG(s)')] + other_groups
|
||||
for i in range(1, 5):
|
||||
self.fields['wg_selector{}'.format(i)].choices = group_acronym_choices
|
||||
|
||||
# disabling handleconflictfield (which only enables or disables form elements) while we're hacking the meaning of the three constraints currently in use:
|
||||
|
@ -111,6 +129,7 @@ class SessionForm(forms.Form):
|
|||
self.fields['wg_selector1'].widget.attrs['onChange'] = "document.form_post.conflict1.value=document.form_post.conflict1.value + ' ' + this.options[this.selectedIndex].value; return 1;"
|
||||
self.fields['wg_selector2'].widget.attrs['onChange'] = "document.form_post.conflict2.value=document.form_post.conflict2.value + ' ' + this.options[this.selectedIndex].value; return 1;"
|
||||
self.fields['wg_selector3'].widget.attrs['onChange'] = "document.form_post.conflict3.value=document.form_post.conflict3.value + ' ' + this.options[this.selectedIndex].value; return 1;"
|
||||
self.fields['wg_selector4'].widget.attrs['onChange'] = "document.form_post.joint_with_groups.value=document.form_post.joint_with_groups.value + ' ' + this.options[this.selectedIndex].value; return 1;"
|
||||
|
||||
# disabling check_prior_conflict javascript while we're hacking the meaning of the three constraints currently in use
|
||||
#self.fields['wg_selector2'].widget.attrs['onClick'] = "return check_prior_conflict(2);"
|
||||
|
@ -129,6 +148,7 @@ class SessionForm(forms.Form):
|
|||
for key in list(self.fields.keys()):
|
||||
self.fields[key].widget = forms.HiddenInput()
|
||||
self.fields['resources'].widget = forms.MultipleHiddenInput()
|
||||
self.fields['timeranges'].widget = forms.MultipleHiddenInput()
|
||||
|
||||
def clean_conflict1(self):
|
||||
conflict = self.cleaned_data['conflict1']
|
||||
|
@ -144,7 +164,12 @@ class SessionForm(forms.Form):
|
|||
conflict = self.cleaned_data['conflict3']
|
||||
check_conflict(conflict, self.group)
|
||||
return conflict
|
||||
|
||||
|
||||
def clean_joint_with_groups(self):
|
||||
groups = self.cleaned_data['joint_with_groups']
|
||||
check_conflict(groups, self.group)
|
||||
return groups
|
||||
|
||||
def clean_comments(self):
|
||||
return clean_text_field(self.cleaned_data['comments'])
|
||||
|
||||
|
@ -168,10 +193,20 @@ class SessionForm(forms.Form):
|
|||
if data.get('num_session','') == '2':
|
||||
if not data['length_session2']:
|
||||
raise forms.ValidationError('You must enter a length for all sessions')
|
||||
|
||||
else:
|
||||
if data.get('session_time_relation'):
|
||||
raise forms.ValidationError('Time between sessions can only be used when two '
|
||||
'sessions are requested.')
|
||||
if data['joint_for_session'] == '2':
|
||||
raise forms.ValidationError('The second session can not be the joint session, '
|
||||
'because you have not requested a second session.')
|
||||
|
||||
if data.get('third_session',False):
|
||||
if not data['length_session2'] or not data.get('length_session3',None):
|
||||
raise forms.ValidationError('You must enter a length for all sessions')
|
||||
elif data['joint_for_session'] == '3':
|
||||
raise forms.ValidationError('The third session can not be the joint session, '
|
||||
'because you have not requested a third session.')
|
||||
|
||||
return data
|
||||
|
||||
|
|
|
@ -15,7 +15,9 @@ 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.person.models import Person
|
||||
from ietf.secr.sreq.forms import SessionForm
|
||||
from ietf.utils.mail import outbox, empty_outbox
|
||||
|
||||
from pyquery import PyQuery
|
||||
|
@ -80,6 +82,9 @@ class SessionRequestTestCase(TestCase):
|
|||
def test_edit(self):
|
||||
meeting = MeetingFactory(type_id='ietf', date=datetime.date.today())
|
||||
mars = RoleFactory(name_id='chair', person__user__username='marschairman', group__acronym='mars').group
|
||||
group2 = GroupFactory()
|
||||
group3 = GroupFactory()
|
||||
group4 = GroupFactory()
|
||||
SessionFactory(meeting=meeting,group=mars,status_id='sched')
|
||||
|
||||
url = reverse('ietf.secr.sreq.views.edit', kwargs={'acronym':'mars'})
|
||||
|
@ -92,10 +97,64 @@ class SessionRequestTestCase(TestCase):
|
|||
'attendees':'10',
|
||||
'conflict1':'',
|
||||
'comments':'need lights',
|
||||
'session_time_relation': 'subsequent-days',
|
||||
'adjacent_with_wg': group2.acronym,
|
||||
'joint_with_groups': group3.acronym + ' ' + group4.acronym,
|
||||
'joint_for_session': '2',
|
||||
'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'],
|
||||
'submit': 'Continue'}
|
||||
r = self.client.post(url, post_data, HTTP_HOST='example.com')
|
||||
self.assertRedirects(r,reverse('ietf.secr.sreq.views.view', kwargs={'acronym':'mars'}))
|
||||
|
||||
redirect_url = reverse('ietf.secr.sreq.views.view', kwargs={'acronym': 'mars'})
|
||||
self.assertRedirects(r, redirect_url)
|
||||
|
||||
# Check whether updates were stored in the database
|
||||
sessions = Session.objects.filter(meeting=meeting, group=mars)
|
||||
self.assertEqual(len(sessions), 2)
|
||||
session = sessions[0]
|
||||
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(
|
||||
list(session.constraints().get(name='timerange').timeranges.all().values('name')),
|
||||
list(TimerangeName.objects.filter(name__in=['thursday-afternoon-early', 'thursday-afternoon-late']).values('name'))
|
||||
)
|
||||
self.assertFalse(sessions[0].joint_with_groups.count())
|
||||
self.assertEqual(list(sessions[1].joint_with_groups.all()), [group3, group4])
|
||||
|
||||
# Check whether the updated data is visible on the view page
|
||||
r = self.client.get(redirect_url)
|
||||
self.assertContains(r, 'Schedule the sessions on subsequent days')
|
||||
self.assertContains(r, 'Thursday early afternoon, Thursday late afternoon')
|
||||
self.assertContains(r, group2.acronym)
|
||||
self.assertContains(r, 'Second session with: {} {}'.format(group3.acronym, group4.acronym))
|
||||
|
||||
# Edit again, changing the joint sessions and clearing some fields. The behaviour of
|
||||
# edit is different depending on whether previous joint sessions were recorded.
|
||||
post_data = {'num_session':'2',
|
||||
'length_session1':'3600',
|
||||
'length_session2':'3600',
|
||||
'attendees':'10',
|
||||
'conflict1':'',
|
||||
'comments':'need lights',
|
||||
'joint_with_groups': group2.acronym,
|
||||
'joint_for_session': '1',
|
||||
'submit': 'Continue'}
|
||||
r = self.client.post(url, post_data, HTTP_HOST='example.com')
|
||||
self.assertRedirects(r, redirect_url)
|
||||
|
||||
# Check whether updates were stored in the database
|
||||
sessions = Session.objects.filter(meeting=meeting, group=mars)
|
||||
self.assertEqual(len(sessions), 2)
|
||||
session = sessions[0]
|
||||
self.assertFalse(session.constraints().filter(name='time_relation'))
|
||||
self.assertFalse(session.constraints().filter(name='wg_adjacent'))
|
||||
self.assertFalse(session.constraints().filter(name='timerange'))
|
||||
self.assertEqual(list(sessions[0].joint_with_groups.all()), [group2])
|
||||
self.assertFalse(sessions[1].joint_with_groups.count())
|
||||
|
||||
# Check whether the updated data is visible on the view page
|
||||
r = self.client.get(redirect_url)
|
||||
self.assertContains(r, 'First session with: {}'.format(group2.acronym))
|
||||
|
||||
def test_tool_status(self):
|
||||
MeetingFactory(type_id='ietf', date=datetime.date.today())
|
||||
url = reverse('ietf.secr.sreq.views.tool_status')
|
||||
|
@ -111,6 +170,9 @@ class SubmitRequestCase(TestCase):
|
|||
ad = Person.objects.get(user__username='ad')
|
||||
area = RoleFactory(name_id='ad', person=ad, group__type_id='area').group
|
||||
group = GroupFactory(parent=area)
|
||||
group2 = GroupFactory(parent=area)
|
||||
group3 = GroupFactory(parent=area)
|
||||
group4 = GroupFactory(parent=area)
|
||||
session_count_before = Session.objects.filter(meeting=meeting, group=group).count()
|
||||
url = reverse('ietf.secr.sreq.views.new',kwargs={'acronym':group.acronym})
|
||||
confirm_url = reverse('ietf.secr.sreq.views.confirm',kwargs={'acronym':group.acronym})
|
||||
|
@ -120,10 +182,20 @@ class SubmitRequestCase(TestCase):
|
|||
'attendees':'10',
|
||||
'conflict1':'',
|
||||
'comments':'need projector',
|
||||
'adjacent_with_wg': group2.acronym,
|
||||
'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'],
|
||||
'joint_with_groups': group3.acronym + ' ' + group4.acronym,
|
||||
'joint_for_session': '1',
|
||||
'submit': 'Continue'}
|
||||
self.client.login(username="secretary", password="secretary+password")
|
||||
r = self.client.post(url,post_data)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
# Verify the contents of the confirm view
|
||||
self.assertContains(r, 'Thursday early afternoon, Thursday late afternoon')
|
||||
self.assertContains(r, group2.acronym)
|
||||
self.assertContains(r, 'First session with: {} {}'.format(group3.acronym, group4.acronym))
|
||||
|
||||
post_data['submit'] = 'Submit'
|
||||
r = self.client.post(confirm_url,post_data)
|
||||
self.assertRedirects(r, main_url)
|
||||
|
@ -135,6 +207,15 @@ class SubmitRequestCase(TestCase):
|
|||
self.assertRedirects(r, main_url)
|
||||
session_count_after = Session.objects.filter(meeting=meeting, group=group, type='regular').count()
|
||||
self.assertEqual(session_count_after, session_count_before + 1)
|
||||
|
||||
# Verify database content
|
||||
session = Session.objects.get(meeting=meeting, group=group)
|
||||
self.assertEqual(session.constraints().get(name='wg_adjacent').target.acronym, group2.acronym)
|
||||
self.assertEqual(
|
||||
list(session.constraints().get(name='timerange').timeranges.all().values('name')),
|
||||
list(TimerangeName.objects.filter(name__in=['thursday-afternoon-early', 'thursday-afternoon-late']).values('name'))
|
||||
)
|
||||
self.assertEqual(list(session.joint_with_groups.all()), [group3, group4])
|
||||
|
||||
def test_submit_request_invalid(self):
|
||||
MeetingFactory(type_id='ietf', date=datetime.date.today())
|
||||
|
@ -205,6 +286,8 @@ class SubmitRequestCase(TestCase):
|
|||
area = GroupFactory(type_id='area')
|
||||
RoleFactory(name_id='ad', person=ad, group=area)
|
||||
group = GroupFactory(acronym='ames', parent=area)
|
||||
group2 = GroupFactory(acronym='ames2', parent=area)
|
||||
group3 = GroupFactory(acronym='ames2', parent=area)
|
||||
RoleFactory(name_id='chair', group=group, person__user__username='ameschairman')
|
||||
resource = ResourceAssociation.objects.create(name_id='project')
|
||||
# Bit of a test data hack - the fixture now has no used resources to pick from
|
||||
|
@ -214,20 +297,26 @@ class SubmitRequestCase(TestCase):
|
|||
url = reverse('ietf.secr.sreq.views.new',kwargs={'acronym':group.acronym})
|
||||
confirm_url = reverse('ietf.secr.sreq.views.confirm',kwargs={'acronym':group.acronym})
|
||||
len_before = len(outbox)
|
||||
post_data = {'num_session':'1',
|
||||
post_data = {'num_session':'2',
|
||||
'length_session1':'3600',
|
||||
'length_session2':'3600',
|
||||
'attendees':'10',
|
||||
'bethere':str(ad.pk),
|
||||
'conflict1':'',
|
||||
'comments':'',
|
||||
'resources': resource.pk,
|
||||
'session_time_relation': 'subsequent-days',
|
||||
'adjacent_with_wg': group2.acronym,
|
||||
'joint_with_groups': group3.acronym,
|
||||
'joint_for_session': '2',
|
||||
'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'],
|
||||
'submit': 'Continue'}
|
||||
self.client.login(username="ameschairman", password="ameschairman+password")
|
||||
# submit
|
||||
r = self.client.post(url,post_data)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
q = PyQuery(r.content)
|
||||
self.assertTrue('Confirm' in six.text_type(q("title")))
|
||||
self.assertTrue('Confirm' in six.text_type(q("title")), r.context['form'].errors)
|
||||
# confirm
|
||||
post_data['submit'] = 'Submit'
|
||||
r = self.client.post(confirm_url,post_data)
|
||||
|
@ -235,11 +324,24 @@ class SubmitRequestCase(TestCase):
|
|||
self.assertEqual(len(outbox),len_before+1)
|
||||
notification = outbox[-1]
|
||||
notification_payload = six.text_type(notification.get_payload(decode=True),"utf-8","replace")
|
||||
session = Session.objects.get(meeting=meeting,group=group)
|
||||
sessions = Session.objects.filter(meeting=meeting,group=group)
|
||||
self.assertEqual(len(sessions), 2)
|
||||
session = sessions[0]
|
||||
|
||||
self.assertEqual(session.resources.count(),1)
|
||||
self.assertEqual(session.people_constraints.count(),1)
|
||||
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(
|
||||
list(session.constraints().get(name='timerange').timeranges.all().values('name')),
|
||||
list(TimerangeName.objects.filter(name__in=['thursday-afternoon-early', 'thursday-afternoon-late']).values('name'))
|
||||
)
|
||||
resource = session.resources.first()
|
||||
self.assertTrue(resource.desc in notification_payload)
|
||||
self.assertTrue('Schedule the sessions on subsequent days' in notification_payload)
|
||||
self.assertTrue(group2.acronym in notification_payload)
|
||||
self.assertTrue("Can't meet: Thursday early afternoon, Thursday late" in notification_payload)
|
||||
self.assertTrue('Second session joint with: {}'.format(group3.acronym) in notification_payload)
|
||||
self.assertTrue(ad.ascii_name() in notification_payload)
|
||||
|
||||
class LockAppTestCase(TestCase):
|
||||
|
@ -311,9 +413,133 @@ class NotMeetingCase(TestCase):
|
|||
class RetrievePreviousCase(TestCase):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
# test error if already scheduled
|
||||
# test get previous exists/doesn't exist
|
||||
# test that groups scheduled and unscheduled add up to total groups
|
||||
# test access by unauthorized
|
||||
|
||||
|
||||
class SessionFormTest(TestCase):
|
||||
def setUp(self):
|
||||
self.group1 = GroupFactory()
|
||||
self.group2 = GroupFactory()
|
||||
self.group3 = GroupFactory()
|
||||
self.group4 = GroupFactory()
|
||||
self.group5 = GroupFactory()
|
||||
self.group6 = GroupFactory()
|
||||
|
||||
self.valid_form_data = {
|
||||
'num_session': '2',
|
||||
'third_session': 'true',
|
||||
'length_session1': '3600',
|
||||
'length_session2': '3600',
|
||||
'length_session3': '3600',
|
||||
'attendees': '10',
|
||||
'conflict1': self.group2.acronym,
|
||||
'conflict2': self.group3.acronym,
|
||||
'conflict3': self.group4.acronym,
|
||||
'comments': 'need lights',
|
||||
'session_time_relation': 'subsequent-days',
|
||||
'adjacent_with_wg': self.group5.acronym,
|
||||
'joint_with_groups': self.group6.acronym,
|
||||
'joint_for_session': '3',
|
||||
'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'],
|
||||
'submit': 'Continue'
|
||||
}
|
||||
|
||||
def test_valid(self):
|
||||
# Test with three sessions
|
||||
form = SessionForm(data=self.valid_form_data, group=self.group1)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
# Test with two sessions
|
||||
self.valid_form_data.update({
|
||||
'length_session3': '',
|
||||
'third_session': '',
|
||||
'joint_for_session': '2'
|
||||
})
|
||||
form = SessionForm(data=self.valid_form_data, group=self.group1)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
# Test with one session
|
||||
self.valid_form_data.update({
|
||||
'length_session2': '',
|
||||
'num_session': 1,
|
||||
'joint_for_session': '1',
|
||||
'session_time_relation': '',
|
||||
})
|
||||
form = SessionForm(data=self.valid_form_data, group=self.group1)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_invalid_groups(self):
|
||||
new_form_data = {
|
||||
'conflict1': 'doesnotexist',
|
||||
'conflict2': 'doesnotexist',
|
||||
'conflict3': '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_invalid_group_appears_in_multiple_conflicts(self):
|
||||
new_form_data = {
|
||||
'conflict1': self.group2.acronym,
|
||||
'conflict2': self.group2.acronym,
|
||||
}
|
||||
form = self._invalid_test_helper(new_form_data)
|
||||
self.assertEqual(form.non_field_errors(), ['%s appears in conflicts more than once' % self.group2.acronym])
|
||||
|
||||
def test_invalid_conflict_with_self(self):
|
||||
new_form_data = {
|
||||
'conflict1': self.group1.acronym,
|
||||
}
|
||||
self._invalid_test_helper(new_form_data)
|
||||
|
||||
def test_invalid_session_time_relation(self):
|
||||
form = self._invalid_test_helper({
|
||||
'third_session': '',
|
||||
'length_session2': '',
|
||||
'num_session': 1,
|
||||
'joint_for_session': '1',
|
||||
})
|
||||
self.assertEqual(form.non_field_errors(), ['Time between sessions can only be used when two '
|
||||
'sessions are requested.'])
|
||||
|
||||
def test_invalid_joint_for_session(self):
|
||||
form = self._invalid_test_helper({
|
||||
'third_session': '',
|
||||
'num_session': 2,
|
||||
'joint_for_session': '3',
|
||||
})
|
||||
self.assertEqual(form.non_field_errors(), ['The third session can not be the joint session, '
|
||||
'because you have not requested a third session.'])
|
||||
|
||||
form = self._invalid_test_helper({
|
||||
'third_session': '',
|
||||
'length_session2': '',
|
||||
'num_session': 1,
|
||||
'joint_for_session': '2',
|
||||
'session_time_relation': '',
|
||||
})
|
||||
self.assertEqual(form.non_field_errors(), ['The second session can not be the joint session, '
|
||||
'because you have not requested a second session.'])
|
||||
|
||||
def test_invalid_missing_session_length(self):
|
||||
form = self._invalid_test_helper({
|
||||
'length_session2': '',
|
||||
'third_session': 'true',
|
||||
})
|
||||
self.assertEqual(form.non_field_errors(), ['You must enter a length for all sessions'])
|
||||
|
||||
form = self._invalid_test_helper({'length_session2': ''})
|
||||
self.assertEqual(form.non_field_errors(), ['You must enter a length for all sessions'])
|
||||
|
||||
form = self._invalid_test_helper({'length_session3': ''})
|
||||
self.assertEqual(form.non_field_errors(), ['You must enter a length for all sessions'])
|
||||
|
||||
def _invalid_test_helper(self, new_form_data):
|
||||
form_data = dict(self.valid_form_data, **new_form_data)
|
||||
form = SessionForm(data=form_data, group=self.group1)
|
||||
self.assertFalse(form.is_valid())
|
||||
return form
|
|
@ -21,7 +21,7 @@ from ietf.meeting.models import Meeting, Session, Constraint, ResourceAssociatio
|
|||
from ietf.meeting.helpers import get_meeting
|
||||
from ietf.meeting.utils import add_event_info_to_session_qs
|
||||
from ietf.name.models import SessionStatusName, ConstraintName
|
||||
from ietf.secr.sreq.forms import SessionForm, ToolStatusForm, allowed_conflicting_groups
|
||||
from ietf.secr.sreq.forms import SessionForm, ToolStatusForm, allowed_conflicting_groups, JOINT_FOR_SESSION_CHOICES
|
||||
from ietf.secr.utils.decorators import check_permissions
|
||||
from ietf.secr.utils.group import get_my_groups
|
||||
from ietf.utils.mail import send_mail
|
||||
|
@ -80,6 +80,19 @@ def get_initial_session(sessions, prune_conflicts=False):
|
|||
initial['comments'] = sessions[0].comments
|
||||
initial['resources'] = sessions[0].resources.all()
|
||||
initial['bethere'] = [x.person for x in sessions[0].constraints().filter(name='bethere').select_related("person")]
|
||||
wg_adjacent = conflicts.filter(name__slug='wg_adjacent')
|
||||
initial['adjacent_with_wg'] = wg_adjacent[0].target.acronym if wg_adjacent else None
|
||||
time_relation = conflicts.filter(name__slug='time_relation')
|
||||
initial['session_time_relation'] = time_relation[0].time_relation if time_relation else None
|
||||
initial['session_time_relation_display'] = time_relation[0].get_time_relation_display if time_relation else None
|
||||
timeranges = conflicts.filter(name__slug='timerange')
|
||||
initial['timeranges'] = timeranges[0].timeranges.all() if timeranges else []
|
||||
initial['timeranges_display'] = [t.desc for t in initial['timeranges']]
|
||||
for idx, session in enumerate(sessions):
|
||||
if session.joint_with_groups.count():
|
||||
initial['joint_with_groups'] = ' '.join(session.joint_with_groups_acronyms())
|
||||
initial['joint_for_session'] = str(idx + 1)
|
||||
initial['joint_for_session_display'] = dict(JOINT_FOR_SESSION_CHOICES)[initial['joint_for_session']]
|
||||
return initial
|
||||
|
||||
def get_lock_message(meeting=None):
|
||||
|
@ -165,7 +178,8 @@ def session_conflicts_as_string(group, meeting):
|
|||
Takes a Group object and Meeting object and returns a string of other groups which have
|
||||
a conflict with this one
|
||||
'''
|
||||
group_list = [ g.source.acronym for g in group.constraint_target_set.filter(meeting=meeting) ]
|
||||
groups = group.constraint_target_set.filter(meeting=meeting, name__in=['conflict', 'conflic2', 'conflic3'])
|
||||
group_list = [g.source.acronym for g in groups]
|
||||
return ', '.join(group_list)
|
||||
|
||||
# -------------------------------------------------
|
||||
|
@ -262,6 +276,12 @@ def confirm(request, acronym):
|
|||
if 'bethere' in session_data:
|
||||
person_id_list = [ id for id in form.data['bethere'].split(',') if id ]
|
||||
session_data['bethere'] = Person.objects.filter(pk__in=person_id_list)
|
||||
if session_data.get('session_time_relation'):
|
||||
session_data['session_time_relation_display'] = dict(Constraint.TIME_RELATION_CHOICES)[session_data['session_time_relation']]
|
||||
if session_data.get('joint_for_session'):
|
||||
session_data['joint_for_session_display'] = dict(JOINT_FOR_SESSION_CHOICES)[session_data['joint_for_session']]
|
||||
if form.cleaned_data.get('timeranges'):
|
||||
session_data['timeranges_display'] = [t.desc for t in form.cleaned_data['timeranges']]
|
||||
session_data['resources'] = [ ResourceAssociation.objects.get(pk=pk) for pk in request.POST.getlist('resources') ]
|
||||
|
||||
button_text = request.POST.get('submit', '')
|
||||
|
@ -301,12 +321,27 @@ def confirm(request, acronym):
|
|||
)
|
||||
if 'resources' in form.data:
|
||||
new_session.resources.set(session_data['resources'])
|
||||
if int(form.data.get('joint_for_session', '-1')) == count:
|
||||
groups_split = form.cleaned_data.get('joint_with_groups').replace(',',' ').split()
|
||||
joint = Group.objects.filter(acronym__in=groups_split)
|
||||
new_session.joint_with_groups.set(joint)
|
||||
session_changed(new_session)
|
||||
|
||||
# write constraint records
|
||||
save_conflicts(group,meeting,form.data.get('conflict1',''),'conflict')
|
||||
save_conflicts(group,meeting,form.data.get('conflict2',''),'conflic2')
|
||||
save_conflicts(group,meeting,form.data.get('conflict3',''),'conflic3')
|
||||
save_conflicts(group, meeting, form.data.get('adjacent_with_wg', ''), 'wg_adjacent')
|
||||
|
||||
if form.cleaned_data.get('session_time_relation'):
|
||||
cn = ConstraintName.objects.get(slug='time_relation')
|
||||
Constraint.objects.create(source=group, meeting=meeting, name=cn,
|
||||
time_relation=form.cleaned_data['session_time_relation'])
|
||||
|
||||
if form.cleaned_data.get('timeranges'):
|
||||
cn = ConstraintName.objects.get(slug='timerange')
|
||||
constraint = Constraint.objects.create(source=group, meeting=meeting, name=cn)
|
||||
constraint.timeranges.set(form.cleaned_data['timeranges'])
|
||||
|
||||
if 'bethere' in form.data:
|
||||
bethere_cn = ConstraintName.objects.get(slug='bethere')
|
||||
|
@ -445,7 +480,30 @@ def edit(request, acronym, num=None):
|
|||
session.save()
|
||||
session_changed(session)
|
||||
|
||||
# New sessions may have been created, refresh the sessions list
|
||||
sessions = add_event_info_to_session_qs(
|
||||
Session.objects.filter(group=group, meeting=meeting)).filter(
|
||||
Q(current_status__isnull=True) | ~Q(
|
||||
current_status__in=['canceled', 'notmeet'])).order_by('id')
|
||||
|
||||
if 'joint_with_groups' in form.changed_data or 'joint_for_session' in form.changed_data:
|
||||
joint_with_groups_list = form.cleaned_data.get('joint_with_groups').replace(',', ' ').split()
|
||||
new_joint_with_groups = Group.objects.filter(acronym__in=joint_with_groups_list)
|
||||
new_joint_for_session_idx = int(form.data.get('joint_for_session', '-1')) - 1
|
||||
current_joint_for_session_idx = None
|
||||
current_joint_with_groups = None
|
||||
for idx, session in enumerate(sessions):
|
||||
if session.joint_with_groups.count():
|
||||
current_joint_for_session_idx = idx
|
||||
current_joint_with_groups = session.joint_with_groups.all()
|
||||
|
||||
if current_joint_with_groups != new_joint_with_groups or current_joint_for_session_idx != new_joint_for_session_idx:
|
||||
if current_joint_for_session_idx is not None:
|
||||
sessions[current_joint_for_session_idx].joint_with_groups.clear()
|
||||
session_changed(sessions[current_joint_for_session_idx])
|
||||
sessions[new_joint_for_session_idx].joint_with_groups.set(new_joint_with_groups)
|
||||
session_changed(sessions[new_joint_for_session_idx])
|
||||
|
||||
if 'attendees' in form.changed_data:
|
||||
sessions.update(attendees=form.cleaned_data['attendees'])
|
||||
if 'comments' in form.changed_data:
|
||||
|
@ -459,6 +517,9 @@ def edit(request, acronym, num=None):
|
|||
if 'conflict3' in form.changed_data:
|
||||
Constraint.objects.filter(meeting=meeting,source=group,name='conflic3').delete()
|
||||
save_conflicts(group,meeting,form.cleaned_data['conflict3'],'conflic3')
|
||||
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')
|
||||
|
||||
if 'resources' in form.changed_data:
|
||||
new_resource_ids = form.cleaned_data['resources']
|
||||
|
@ -472,6 +533,20 @@ def edit(request, acronym, num=None):
|
|||
for p in form.cleaned_data['bethere']:
|
||||
Constraint.objects.create(name=bethere_cn, source=group, person=p, meeting=session.meeting)
|
||||
|
||||
if 'session_time_relation' in form.changed_data:
|
||||
Constraint.objects.filter(meeting=meeting, source=group, name='time_relation').delete()
|
||||
if form.cleaned_data['session_time_relation']:
|
||||
cn = ConstraintName.objects.get(slug='time_relation')
|
||||
Constraint.objects.create(source=group, meeting=meeting, name=cn,
|
||||
time_relation=form.cleaned_data['session_time_relation'])
|
||||
|
||||
if 'timeranges' in form.changed_data:
|
||||
Constraint.objects.filter(meeting=meeting, source=group, name='timerange').delete()
|
||||
if form.cleaned_data['timeranges']:
|
||||
cn = ConstraintName.objects.get(slug='timerange')
|
||||
constraint = Constraint.objects.create(source=group, meeting=meeting, name=cn)
|
||||
constraint.timeranges.set(form.cleaned_data['timeranges'])
|
||||
|
||||
# deprecated
|
||||
# log activity
|
||||
#add_session_activity(group,'Session Request was updated',meeting,user)
|
||||
|
|
|
@ -15,23 +15,34 @@ function stat_ls (val){
|
|||
if (val == 0) {
|
||||
document.form_post.length_session1.disabled = true;
|
||||
document.form_post.length_session2.disabled = true;
|
||||
document.form_post.length_session3.disabled = true;
|
||||
if (document.form_post.length_session3) { document.form_post.length_session3.disabled = true; }
|
||||
document.form_post.session_time_relation.disabled = true;
|
||||
document.form_post.joint_for_session.disabled = true;
|
||||
document.form_post.length_session1.value = 0;
|
||||
document.form_post.length_session2.value = 0;
|
||||
document.form_post.length_session3.value = 0;
|
||||
document.form_post.session_time_relation.value = '';
|
||||
document.form_post.joint_for_session.value = '';
|
||||
document.form_post.third_session.checked=false;
|
||||
}
|
||||
if (val == 1) {
|
||||
document.form_post.length_session1.disabled = false;
|
||||
document.form_post.length_session2.disabled = true;
|
||||
document.form_post.length_session3.disabled = true;
|
||||
if (document.form_post.length_session3) { document.form_post.length_session3.disabled = true; }
|
||||
document.form_post.session_time_relation.disabled = true;
|
||||
document.form_post.joint_for_session.disabled = true;
|
||||
document.form_post.length_session2.value = 0;
|
||||
document.form_post.length_session3.value = 0;
|
||||
document.form_post.session_time_relation.value = '';
|
||||
document.form_post.joint_for_session.value = '1';
|
||||
document.form_post.third_session.checked=false;
|
||||
}
|
||||
if (val == 2) {
|
||||
document.form_post.length_session1.disabled = false;
|
||||
document.form_post.length_session2.disabled = false;
|
||||
if (document.form_post.length_session3) { document.form_post.length_session3.disabled = false; }
|
||||
document.form_post.session_time_relation.disabled = false;
|
||||
document.form_post.joint_for_session.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,6 +122,15 @@ function delete_last3 () {
|
|||
document.form_post.conflict3.value = b;
|
||||
document.form_post.wg_selector3.selectedIndex=0;
|
||||
}
|
||||
function delete_last_joint_with_groups () {
|
||||
var b = document.form_post.joint_with_groups.value;
|
||||
var temp = new Array();
|
||||
temp = b.split(' ');
|
||||
temp.pop();
|
||||
b = temp.join(' ');
|
||||
document.form_post.joint_with_groups.value = b;
|
||||
document.form_post.wg_selector4.selectedIndex=0;
|
||||
}
|
||||
|
||||
// Not calling check_prior_confict (see ietf/secr/sreq/forms.py definition of SessionForm)
|
||||
// while we are hacking the use of the current three constraint types around. We could bring
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
Working Group Name: {{ group.name }}
|
||||
Area Name: {{ group.parent }}
|
||||
Session Requester: {{ login }}
|
||||
{% if session.joint_with_groups %}{{ session.joint_for_session_display }} joint with: {{ session.joint_with_groups }}{% endif %}
|
||||
|
||||
Number of Sessions: {{ session.num_session }}
|
||||
Length of Session(s): {{ session.length_session1|display_duration }}{% if session.length_session2 %}, {{ session.length_session2|display_duration }}{% endif %}{% if session.length_session3 %}, {{ session.length_session3|display_duration }}{% endif %}
|
||||
|
@ -11,6 +12,9 @@ Conflicts to Avoid:
|
|||
{% if session.conflict1 %} Chair Conflict: {{ session.conflict1 }}{% endif %}
|
||||
{% if session.conflict2 %} Technology Overlap: {{ session.conflict2 }}{% endif %}
|
||||
{% if session.conflict3 %} Key Participant Conflict: {{ session.conflict3 }}{% endif %}
|
||||
{% if session.session_time_relation_display %} {{ session.session_time_relation_display }}{% endif %}
|
||||
{% if session.adjacent_with_wg %} Adjacent with WG: {{ session.adjacent_with_wg }}{% endif %}
|
||||
{% if session.timeranges_display %} Can't meet: {{ session.timeranges_display|join:", " }}{% endif %}
|
||||
|
||||
|
||||
People who must be present:
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
<tr class="bg1"><td>Number of Sessions:<span class="required">*</span></td><td>{{ form.num_session.errors }}{{ form.num_session }}</td></tr>
|
||||
<tr class="bg2"><td>Length of Session 1:<span class="required">*</span></td><td>{{ form.length_session1.errors }}{{ form.length_session1 }}</td></tr>
|
||||
<tr class="bg2"><td>Length of Session 2:<span class="required">*</span></td><td>{{ form.length_session2.errors }}{{ form.length_session2 }}</td></tr>
|
||||
<tr class="bg2"><td>Time between two sessions:</td><td>{{ form.session_time_relation.errors }}{{ form.session_time_relation }}</td></tr>
|
||||
{% if group.type.slug == "wg" %}
|
||||
<tr class="bg2"><td>Additional Session Request:</td><td>{{ form.third_session }} Check this box to request an additional session.<br>
|
||||
Additional slot may be available after agenda scheduling has closed and with the approval of an Area Director.<br>
|
||||
|
@ -49,7 +50,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">BOF or IRTF Sessions:</td>
|
||||
<td>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>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
@ -60,7 +61,34 @@
|
|||
</td>
|
||||
</tr>
|
||||
<tr class="bg1">
|
||||
<td valign="top">Special Requests:<br /> <br />i.e. restrictions on meeting times / days, etc.<br /> (limit 200 characters)</td>
|
||||
<td valign="top">Times during which this WG can <strong>not</strong> meet:</td>
|
||||
<td>{{ form.timeranges.errors }}{{ form.timeranges }}</td>
|
||||
</tr>
|
||||
<tr class="bg2">
|
||||
<td valign="top">
|
||||
Plan session adjacent with another WG:<br />
|
||||
(Immediately before or after another WG, no break in between, in the same room.)
|
||||
</td>
|
||||
<td>{{ form.adjacent_with_wg.errors }}{{ form.adjacent_with_wg }}</td>
|
||||
</tr>
|
||||
<tr class="bg1">
|
||||
<td>
|
||||
Joint session with:<br />
|
||||
(To request one session for multiple WGs together.)
|
||||
</td>
|
||||
<td>{{ form.wg_selector4 }}
|
||||
<input type="button" value="Delete the last entry" onClick="delete_last_joint_with_groups(); return 1;"><br>
|
||||
{{ form.joint_with_groups.errors }}{{ form.joint_with_groups }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="bg1">
|
||||
<td>
|
||||
Of the sessions requested by this WG, the joint session, if applicable, is:
|
||||
</td>
|
||||
<td>{{ form.joint_for_session.errors }}{{ form.joint_for_session }}</td>
|
||||
</tr>
|
||||
<tr class="bg2">
|
||||
<td valign="top">Special Requests:<br /> <br />i.e. restrictions on meeting times / days, etc.</td> (limit 200 characters)</td>
|
||||
<td>{{ form.comments.errors }}{{ form.comments }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
<tr class="row2"><td>Length of Session 1:</td><td>{{ session.length_session1|display_duration }}</td></tr>
|
||||
{% if session.length_session2 %}
|
||||
<tr class="row2"><td>Length of Session 2:</td><td>{{ session.length_session2|display_duration }}</td></tr>
|
||||
<tr class="row2"><td>Time between sessions:</td><td>{% if session.session_time_relation_display %}{{ session.session_time_relation_display }}{% else %}No preference{% endif %}</td></tr>
|
||||
{% endif %}
|
||||
{% if session.length_session3 %}
|
||||
<tr class="row2"><td>Length of Session 3:</td><td>{{ session.length_session3|display_duration }}</td></tr>
|
||||
|
@ -33,5 +34,23 @@
|
|||
<tr class="row1">
|
||||
<td>People who must be present:</td>
|
||||
<td>{% if session.bethere %}<ul>{% for person in session.bethere %}<li>{{ person }}</li>{% endfor %}</ul>{% else %}<i>None</i>{% endif %}</td>
|
||||
<tr class="row2"><td>Special Requests:</td><td>{{ session.comments }}</td></tr>
|
||||
<tr class="row2">
|
||||
<td>Can not meet on:</td>
|
||||
<td>{% if session.timeranges_display %}{{ session.timeranges_display|join:', ' }}{% else %}No constraints{% endif %}</td>
|
||||
</tr>
|
||||
<tr class="row1">
|
||||
<td>Adjacent with WG:</td>
|
||||
<td>{{ session.adjacent_with_wg|default:'No preference' }}</td>
|
||||
</tr>
|
||||
<tr class="row2">
|
||||
<td>Joint session:</td>
|
||||
<td>
|
||||
{% if session.joint_with_groups %}
|
||||
{{ session.joint_for_session_display }} with: {{ session.joint_with_groups }}
|
||||
{% else %}
|
||||
Not a joint session
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="row1"><td>Special Requests:</td><td>{{ session.comments }}</td></tr>
|
||||
</table>
|
||||
|
|
|
@ -1225,12 +1225,30 @@ Session.prototype.generate_info_table = function() {
|
|||
if(!read_only) {
|
||||
$("#info_location").html(generate_select_box()+"<button id='info_location_set'>set</button>");
|
||||
}
|
||||
|
||||
if("comments" in this && this.comments.length > 0 && this.comments != "None") {
|
||||
$("#special_requests").text(this.comments);
|
||||
} else {
|
||||
$("#special_requests").text("Special requests: None");
|
||||
|
||||
var special_requests_text = '';
|
||||
if(this.joint_with_groups) {
|
||||
special_requests_text += 'Joint session with ' + this.joint_with_groups.join(', ') + '. ';
|
||||
}
|
||||
if(this.constraints.wg_adjacent) {
|
||||
for (var target_href in this.constraints.wg_adjacent) {
|
||||
if (this.constraints.wg_adjacent.hasOwnProperty(target_href)) {
|
||||
special_requests_text += 'Schedule adjacent with ' + this.constraints.wg_adjacent[target_href].othergroup.acronym + '. ';
|
||||
}
|
||||
}
|
||||
}
|
||||
if(this.constraints.time_relation) {
|
||||
special_requests_text += this.constraints.time_relation.time_relation.time_relation_display + '. ';
|
||||
}
|
||||
if(this.constraints.timerange) {
|
||||
special_requests_text += this.constraints.timerange.timerange.timeranges_display + '. ';
|
||||
}
|
||||
if("comments" in this && this.comments.length > 0 && this.comments != "None") {
|
||||
special_requests_text += this.comments;
|
||||
} else {
|
||||
special_requests_text += "Special requests: None";
|
||||
}
|
||||
$("#special_requests").text(special_requests_text);
|
||||
|
||||
this.selectit();
|
||||
|
||||
|
@ -1721,13 +1739,17 @@ Session.prototype.add_constraint_obj = function(obj) {
|
|||
obj.person = person;
|
||||
});
|
||||
} else {
|
||||
// must be conflic*
|
||||
// must be conflic*, timerange, time_relation or wg_adjacent
|
||||
var ogroupname;
|
||||
if(obj.source_href == this.group_href) {
|
||||
obj.thisgroup = this.group;
|
||||
obj.othergroup = find_group_by_href(obj.target_href, "constraint src"+obj.href);
|
||||
obj.direction = 'ours';
|
||||
ogroupname = obj.target_href;
|
||||
if (obj.target_href) {
|
||||
ogroupname = obj.target_href;
|
||||
} else {
|
||||
ogroupname = obj.name;
|
||||
}
|
||||
if(this.constraints[listname][ogroupname]) {
|
||||
console.log("Found multiple instances of",this.group_href,listname,ogroupname);
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
<th>Size</th>
|
||||
<th>Requester</th>
|
||||
<th>AD</th>
|
||||
<th>Conflicts</th>
|
||||
<th>Constraints</th>
|
||||
<th>Special requests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
@ -62,6 +62,9 @@
|
|||
<a href="{% url "ietf.secr.sreq.views.edit" num=meeting.number acronym=session.group.acronym %}">
|
||||
{{session.group.acronym}}
|
||||
</a>
|
||||
{% if session.joint_with_groups.count %}
|
||||
joint with {{ session.joint_with_groups_acronyms|join:' ' }}
|
||||
{% endif %}
|
||||
</th>
|
||||
|
||||
<td class="text-right">
|
||||
|
|
Loading…
Reference in a new issue