Snapshot of dev work to add session purpose annotation

- Legacy-Id: 19415
This commit is contained in:
Jennifer Richards 2021-10-12 17:08:58 +00:00
parent 3386e59a61
commit 1054f90873
61 changed files with 4342 additions and 735 deletions

View file

@ -613,6 +613,93 @@ def action_holder_badge(action_holder):
else:
return '' # no alert needed
@register.filter
def is_regular_agenda_item(assignment):
"""Is this agenda item a regular session item?
A regular item appears as a sub-entry in a timeslot within the agenda
>>> from collections import namedtuple # use to build mock objects
>>> mock_timeslot = namedtuple('t2', ['slug'])
>>> mock_assignment = namedtuple('t1', ['slot_type']) # slot_type must be a callable
>>> factory = lambda t: mock_assignment(slot_type=lambda: mock_timeslot(slug=t))
>>> is_regular_agenda_item(factory('regular'))
True
>>> any(is_regular_agenda_item(factory(t)) for t in ['plenary', 'break', 'reg', 'other', 'officehours'])
False
"""
return assignment.slot_type().slug == 'regular'
@register.filter
def is_plenary_agenda_item(assignment):
"""Is this agenda item a regular session item?
A regular item appears as a sub-entry in a timeslot within the agenda
>>> from collections import namedtuple # use to build mock objects
>>> mock_timeslot = namedtuple('t2', ['slug'])
>>> mock_assignment = namedtuple('t1', ['slot_type']) # slot_type must be a callable
>>> factory = lambda t: mock_assignment(slot_type=lambda: mock_timeslot(slug=t))
>>> is_plenary_agenda_item(factory('plenary'))
True
>>> any(is_plenary_agenda_item(factory(t)) for t in ['regular', 'break', 'reg', 'other', 'officehours'])
False
"""
return assignment.slot_type().slug == 'plenary'
@register.filter
def is_special_agenda_item(assignment):
"""Is this agenda item a special item?
Special items appear as top-level agenda entries with their own timeslot information.
>>> from collections import namedtuple # use to build mock objects
>>> mock_timeslot = namedtuple('t2', ['slug'])
>>> mock_assignment = namedtuple('t1', ['slot_type']) # slot_type must be a callable
>>> factory = lambda t: mock_assignment(slot_type=lambda: mock_timeslot(slug=t))
>>> all(is_special_agenda_item(factory(t)) for t in ['break', 'reg', 'other', 'officehours'])
True
>>> any(is_special_agenda_item(factory(t)) for t in ['regular', 'plenary'])
False
"""
return assignment.slot_type().slug in [
'break',
'reg',
'other',
'officehours',
]
@register.filter
def should_show_agenda_session_buttons(assignment):
"""Should this agenda item show the session buttons (jabber link, etc)?
In IETF-111 and earlier, office hours sessions were designated by a name ending
with ' office hours' and belonged to the IESG or some other group. This led to
incorrect session buttons being displayed. Suppress session buttons for
when name ends with 'office hours' in the pre-111 meetings.
>>> from collections import namedtuple # use to build mock objects
>>> mock_meeting = namedtuple('t3', ['number'])
>>> mock_session = namedtuple('t2', ['name'])
>>> mock_assignment = namedtuple('t1', ['meeting', 'session']) # meeting must be a callable
>>> factory = lambda num, name: mock_assignment(session=mock_session(name), meeting=lambda: mock_meeting(num))
>>> test_cases = [('105', 'acme office hours'), ('111', 'acme office hours')]
>>> any(should_show_agenda_session_buttons(factory(*tc)) for tc in test_cases)
False
>>> test_cases = [('interim-2020-acme-112', 'acme'), ('112', 'acme'), ('150', 'acme'), ('105', 'acme'),]
>>> test_cases.extend([('111', 'acme'), ('interim-2020-acme-112', 'acme office hours')])
>>> test_cases.extend([('112', 'acme office hours'), ('150', 'acme office hours')])
>>> all(should_show_agenda_session_buttons(factory(*tc)) for tc in test_cases)
True
"""
num = assignment.meeting().number
if num.isdigit() and int(num) <= settings.MEETING_LEGACY_OFFICE_HOURS_END:
return not assignment.session.name.lower().endswith(' office hours')
else:
return True
@register.simple_tag
def absurl(viewname, **kwargs):

View file

@ -188,6 +188,7 @@ class GroupFeaturesAdmin(admin.ModelAdmin):
'customize_workflow',
'is_schedulable',
'show_on_agenda',
'agenda_filter_type',
'req_subm_approval',
'agenda_type',
'material_types',

View file

@ -0,0 +1,20 @@
# Copyright The IETF Trust 2021 All Rights Reserved
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('name', '0033_populate_agendafiltertypename'),
('group', '0048_has_session_materials'),
]
operations = [
migrations.AddField(
model_name='groupfeatures',
name='agenda_filter_type',
field=models.ForeignKey(default='none', on_delete=django.db.models.deletion.PROTECT, to='name.AgendaFilterTypeName'),
),
]

View file

@ -0,0 +1,35 @@
# Copyright The IETF Trust 2021 All Rights Reserved
from django.db import migrations
def forward(apps, schema_editor):
GroupFeatures = apps.get_model('group', 'GroupFeatures')
# map AgendaFilterTypeName slug to group types - unlisted get 'none'
filter_types = dict(
# list previously hard coded in agenda view, plus 'review'
normal={'wg', 'ag', 'rg', 'rag', 'iab', 'program', 'review'},
heading={'area', 'ietf', 'irtf'},
special={'team', 'adhoc'},
)
for ft, group_types in filter_types.items():
for gf in GroupFeatures.objects.filter(type__slug__in=group_types):
gf.agenda_filter_type_id = ft
gf.save()
def reverse(apps, schema_editor):
pass # nothing to do, model will be deleted anyway
class Migration(migrations.Migration):
dependencies = [
('group', '0049_groupfeatures_agenda_filter_type'),
]
operations = [
migrations.RunPython(forward, reverse),
]

View file

@ -0,0 +1,24 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-09-26 11:29
from django.db import migrations
import ietf.group.models
import ietf.name.models
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('group', '0050_populate_groupfeatures_agenda_filter_type'),
('name', '0035_sessionpurposename'),
]
operations = [
migrations.AddField(
model_name='groupfeatures',
name='session_purposes',
field=jsonfield.fields.JSONField(default=[], help_text='Allowed session purposes for this group type', max_length=256, validators=[ietf.group.models.JSONForeignKeyListValidator(ietf.name.models.SessionPurposeName)]),
),
]

View file

@ -0,0 +1,48 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-09-26 11:29
from django.db import migrations
default_purposes = dict(
dir=['presentation', 'social', 'tutorial'],
ietf=['admin', 'presentation', 'social'],
rg=['session'],
team=['coding', 'presentation', 'social', 'tutorial'],
wg=['session'],
)
def forward(apps, schema_editor):
GroupFeatures = apps.get_model('group', 'GroupFeatures')
SessionPurposeName = apps.get_model('name', 'SessionPurposeName')
# verify that we're not about to use an invalid purpose
for purposes in default_purposes.values():
for purpose in purposes:
SessionPurposeName.objects.get(pk=purpose) # throws an exception unless exists
for type_, purposes in default_purposes.items():
GroupFeatures.objects.filter(
type=type_
).update(
session_purposes=purposes
)
def reverse(apps, schema_editor):
GroupFeatures = apps.get_model('group', 'GroupFeatures')
GroupFeatures.objects.update(session_purposes=[]) # clear back out to default
class Migration(migrations.Migration):
dependencies = [
('group', '0051_groupfeatures_session_purposes'),
('name', '0036_populate_sessionpurposename'),
]
operations = [
migrations.RunPython(forward, reverse),
]

View file

@ -13,7 +13,7 @@ from urllib.parse import urljoin
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import models
from django.db.models.deletion import CASCADE
from django.db.models.deletion import CASCADE, PROTECT
from django.dispatch import receiver
#from simple_history.models import HistoricalRecords
@ -21,11 +21,13 @@ from django.dispatch import receiver
import debug # pyflakes:ignore
from ietf.group.colors import fg_group_colors, bg_group_colors
from ietf.name.models import GroupStateName, GroupTypeName, DocTagName, GroupMilestoneStateName, RoleName, AgendaTypeName, ExtResourceName
from ietf.name.models import (GroupStateName, GroupTypeName, DocTagName, GroupMilestoneStateName, RoleName,
AgendaTypeName, AgendaFilterTypeName, ExtResourceName, SessionPurposeName)
from ietf.person.models import Email, Person
from ietf.utils.mail import formataddr, send_mail_text
from ietf.utils import log
from ietf.utils.models import ForeignKey, OneToOneField
from ietf.utils.validators import JSONForeignKeyListValidator
class GroupInfo(models.Model):
@ -250,6 +252,7 @@ validate_comma_separated_roles = RegexValidator(
code='invalid',
)
class GroupFeatures(models.Model):
type = OneToOneField(GroupTypeName, primary_key=True, null=False, related_name='features')
#history = HistoricalRecords()
@ -277,6 +280,7 @@ class GroupFeatures(models.Model):
customize_workflow = models.BooleanField("Workflow", default=False)
is_schedulable = models.BooleanField("Schedulable",default=False)
show_on_agenda = models.BooleanField("On Agenda", default=False)
agenda_filter_type = models.ForeignKey(AgendaFilterTypeName, default='none', on_delete=PROTECT)
req_subm_approval = models.BooleanField("Subm. Approval", default=False)
#
agenda_type = models.ForeignKey(AgendaTypeName, null=True, default="ietf", on_delete=CASCADE)
@ -291,6 +295,9 @@ class GroupFeatures(models.Model):
matman_roles = jsonfield.JSONField(max_length=128, blank=False, default=["ad","chair","delegate","secr"])
role_order = jsonfield.JSONField(max_length=128, blank=False, default=["chair","secr","member"],
help_text="The order in which roles are shown, for instance on photo pages. Enter valid JSON.")
session_purposes = jsonfield.JSONField(max_length=256, blank=False, default=[],
help_text="Allowed session purposes for this group type",
validators=[JSONForeignKeyListValidator(SessionPurposeName)])
class GroupHistory(GroupInfo):

130
ietf/meeting/fields.py Normal file
View file

@ -0,0 +1,130 @@
import json
from collections import namedtuple
from django import forms
from ietf.name.models import SessionPurposeName, TimeSlotTypeName
import debug
class SessionPurposeAndTypeWidget(forms.MultiWidget):
css_class = 'session_purpose_widget' # class to apply to all widgets
def __init__(self, purpose_choices, type_choices, *args, **kwargs):
# Avoid queries on models that need to be migrated into existence - this widget is
# instantiated during Django setup. Attempts to query, e.g., SessionPurposeName will
# prevent migrations from running.
widgets = (
forms.Select(
choices=purpose_choices,
attrs={
'class': self.css_class,
},
),
forms.Select(
choices=type_choices,
attrs={
'class': self.css_class,
'data-allowed-options': None,
},
),
)
super().__init__(widgets=widgets, *args, **kwargs)
# These queryset properties are needed to propagate changes to the querysets after initialization
# down to the widgets. The usual mechanisms in the ModelChoiceFields don't handle this for us
# because the subwidgets are not attached to Fields in the usual way.
@property
def purpose_choices(self):
return self.widgets[0].choices
@purpose_choices.setter
def purpose_choices(self, value):
self.widgets[0].choices = value
@property
def type_choices(self):
return self.widgets[1].choices
@type_choices.setter
def type_choices(self, value):
self.widgets[1].choices = value
def render(self, *args, **kwargs):
# Fill in the data-allowed-options (could not do this in init because it needs to
# query SessionPurposeName, which will break the migration if done during initialization)
self.widgets[1].attrs['data-allowed-options'] = json.dumps(self._allowed_types())
return super().render(*args, **kwargs)
def decompress(self, value):
if value:
return [getattr(val, 'pk', val) for val in value]
else:
return [None, None]
class Media:
js = ('secr/js/session_purpose_and_type_widget.js',)
def _allowed_types(self):
"""Map from purpose to allowed type values"""
return {
purpose.slug: list(purpose.timeslot_types)
for purpose in SessionPurposeName.objects.all()
}
class SessionPurposeAndTypeField(forms.MultiValueField):
"""Field to update Session purpose and type
Uses SessionPurposeAndTypeWidget to coordinate setting the session purpose and type to valid
combinations. Its value should be a tuple with (purpose, type). Its cleaned value is a
namedtuple with purpose and value properties.
"""
def __init__(self, purpose_queryset=None, type_queryset=None, **kwargs):
if purpose_queryset is None:
purpose_queryset = SessionPurposeName.objects.none()
if type_queryset is None:
type_queryset = TimeSlotTypeName.objects.none()
fields = (
forms.ModelChoiceField(queryset=purpose_queryset, label='Purpose'),
forms.ModelChoiceField(queryset=type_queryset, label='Type'),
)
self.widget = SessionPurposeAndTypeWidget(*(field.choices for field in fields))
super().__init__(fields=fields, **kwargs)
@property
def purpose_queryset(self):
return self.fields[0].queryset
@purpose_queryset.setter
def purpose_queryset(self, value):
self.fields[0].queryset = value
self.widget.purpose_choices = self.fields[0].choices
@property
def type_queryset(self):
return self.fields[1].queryset
@type_queryset.setter
def type_queryset(self, value):
self.fields[1].queryset = value
self.widget.type_choices = self.fields[1].choices
def compress(self, data_list):
# Convert data from the cleaned list from the widget into a namedtuple
if data_list:
compressed = namedtuple('CompressedSessionPurposeAndType', 'purpose type')
return compressed(*data_list)
return None
def validate(self, value):
# Additional validation - value has been passed through compress() already
if value.type.pk not in value.purpose.timeslot_types:
raise forms.ValidationError(
'"%(type)s" is not an allowed type for the purpose "%(purpose)s"',
params={'type': value.type, 'purpose': value.purpose},
code='invalid_type',
)

View file

@ -5,9 +5,11 @@
import io
import os
import datetime
import json
from django import forms
from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms import BaseInlineFormSet
@ -21,8 +23,9 @@ from ietf.meeting.models import Session, Meeting, Schedule, countries, timezones
from ietf.meeting.helpers import get_next_interim_number, make_materials_directories
from ietf.meeting.helpers import is_interim_meeting_approved, get_next_agenda_name
from ietf.message.models import Message
from ietf.name.models import TimeSlotTypeName, SessionPurposeName
from ietf.person.models import Person
from ietf.utils.fields import DatepickerDateField, DurationField, MultiEmailField
from ietf.utils.fields import DatepickerDateField, DurationField, MultiEmailField, DatepickerSplitDateTimeWidget
from ietf.utils.validators import ( validate_file_size, validate_mime_type,
validate_file_extension, validate_no_html_frame)
@ -44,7 +47,8 @@ class GroupModelChoiceField(forms.ModelChoiceField):
return obj.acronym
class CustomDurationField(DurationField):
'''Custom DurationField to display as HH:MM (no seconds)'''
"""Custom DurationField to display as HH:MM (no seconds)"""
widget = forms.TextInput(dict(placeholder='HH:MM'))
def prepare_value(self, value):
if isinstance(value, datetime.timedelta):
return duration_string(value)
@ -417,3 +421,212 @@ class SwapTimeslotsForm(forms.Form):
self.fields['origin_timeslot'].queryset = meeting.timeslot_set.all()
self.fields['target_timeslot'].queryset = meeting.timeslot_set.all()
self.fields['rooms'].queryset = meeting.room_set.all()
class TimeSlotDurationField(CustomDurationField):
"""Duration field for TimeSlot edit / create forms"""
default_validators=[
validators.MinValueValidator(datetime.timedelta(seconds=0)),
validators.MaxValueValidator(datetime.timedelta(hours=12)),
]
def __init__(self, **kwargs):
kwargs.setdefault('help_text', 'Duration of timeslot in hours and minutes')
super().__init__(**kwargs)
class TimeSlotEditForm(forms.ModelForm):
class Meta:
model = TimeSlot
fields = ('name', 'type', 'time', 'duration', 'show_location', 'location')
field_classes = dict(
time=forms.SplitDateTimeField,
duration=TimeSlotDurationField
)
widgets = dict(
time=DatepickerSplitDateTimeWidget,
)
def __init__(self, *args, **kwargs):
super(TimeSlotEditForm, self).__init__(*args, **kwargs)
self.fields['location'].queryset = self.instance.meeting.room_set.all()
class TimeSlotCreateForm(forms.Form):
name = forms.CharField(max_length=255)
type = forms.ModelChoiceField(queryset=TimeSlotTypeName.objects.all(), initial='regular')
days = forms.TypedMultipleChoiceField(
label='Meeting days',
widget=forms.CheckboxSelectMultiple,
coerce=lambda s: datetime.date.fromordinal(int(s)),
empty_value=None,
required=False
)
other_date = DatepickerDateField(
required=False,
help_text='Optional date outside the official meeting dates',
date_format="yyyy-mm-dd",
picker_settings={"autoclose": "1"},
)
time = forms.TimeField(
help_text='Time to create timeslot on each selected date',
widget=forms.TimeInput(dict(placeholder='HH:MM'))
)
duration = TimeSlotDurationField()
show_location = forms.BooleanField(required=False, initial=True)
locations = forms.ModelMultipleChoiceField(
queryset=Room.objects.none(),
widget=forms.CheckboxSelectMultiple,
)
def __init__(self, meeting, *args, **kwargs):
super(TimeSlotCreateForm, self).__init__(*args, **kwargs)
meeting_days = [
meeting.date + datetime.timedelta(days=n)
for n in range(meeting.days)
]
# Fill in dynamic field properties
self.fields['days'].choices = self._day_choices(meeting_days)
self.fields['other_date'].widget.attrs['data-date-default-view-date'] = meeting.date
self.fields['other_date'].widget.attrs['data-date-dates-disabled'] = ','.join(
d.isoformat() for d in meeting_days
)
self.fields['locations'].queryset = meeting.room_set.order_by('name')
def clean_other_date(self):
# Because other_date is not required, failed field validation does not automatically
# invalidate the form. It should, otherwise a typo may be silently ignored.
if self.data.get('other_date') and not self.cleaned_data.get('other_date'):
raise ValidationError('Enter a valid date or leave field blank.')
return self.cleaned_data.get('other_date', None)
def clean(self):
# Merge other_date and days fields
try:
other_date = self.cleaned_data.pop('other_date')
except KeyError:
other_date = None
self.cleaned_data['days'] = self.cleaned_data.get('days') or []
if other_date is not None:
self.cleaned_data['days'].append(other_date)
if len(self.cleaned_data['days']) == 0:
self.add_error('days', ValidationError('Please select a day or specify a date'))
@staticmethod
def _day_choices(days):
"""Generates an iterable of value, label pairs for a choice field
Uses toordinal() to represent dates - would prefer to use isoformat(),
but fromisoformat() is not available in python 3.6..
"""
choices = [
(str(day.toordinal()), day.strftime('%A ({})'.format(day.isoformat())))
for day in days
]
return choices
class DurationChoiceField(forms.ChoiceField):
duration_choices = (('3600', '60 minutes'), ('7200', '120 minutes'))
# todo expand range of choices and make configurable
def __init__(self, *args, **kwargs):
super().__init__(
choices=(('','--Please select'), *self.duration_choices),
*args, **kwargs,
)
def prepare_value(self, value):
"""Converts incoming value into string used for the option value"""
if value:
return str(int(value.total_seconds())) if hasattr(value, 'total_seconds') else str(value)
return ''
def clean(self, value):
"""Convert option value string back to a timedelta"""
val = super().clean(value)
if val == '':
return None
return datetime.timedelta(seconds=int(val))
class SessionDetailsForm(forms.ModelForm):
requested_duration = DurationChoiceField()
def __init__(self, group, *args, **kwargs):
session_purposes = group.features.session_purposes
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault(
'purpose',
session_purposes[0] if len(session_purposes) > 0 else None,
)
super().__init__(*args, **kwargs)
self.fields['type'].widget.attrs.update({
'data-allowed-options': json.dumps({
purpose.slug: list(purpose.timeslot_types)
for purpose in SessionPurposeName.objects.all()
}),
})
self.fields['purpose'].queryset = SessionPurposeName.objects.filter(pk__in=session_purposes)
class Meta:
model = Session
fields = ('name', 'short', 'purpose', 'type', 'requested_duration', 'remote_instructions')
labels = {'requested_duration': 'Length'}
class Media:
js = ('ietf/js/meeting/session_details_form.js',)
class SessionDetailsInlineFormset(forms.BaseInlineFormSet):
def __init__(self, group, meeting, queryset=None, *args, **kwargs):
self._meeting = meeting
self.created_instances = []
# Restrict sessions to the meeting and group. The instance
# property handles one of these for free.
kwargs['instance'] = group
if queryset is None:
queryset = Session._default_manager
if self._meeting.pk is not None:
queryset = queryset.filter(meeting=self._meeting)
else:
queryset = queryset.none()
kwargs['queryset'] = queryset.not_deleted()
kwargs.setdefault('form_kwargs', {})
kwargs['form_kwargs'].update({'group': group})
super().__init__(*args, **kwargs)
def save_new(self, form, commit=True):
form.instance.meeting = self._meeting
super().save_new(form, commit)
def save(self, commit=True):
existing_instances = set(form.instance for form in self.forms if form.instance.pk)
saved = super().save(commit)
self.created_instances = [inst for inst in saved if inst not in existing_instances]
return saved
@property
def forms_to_keep(self):
"""Get the not-deleted forms"""
return [f for f in self.forms if f not in self.deleted_forms]
SessionDetailsFormSet = forms.inlineformset_factory(
Group,
Session,
formset=SessionDetailsInlineFormset,
form=SessionDetailsForm,
can_delete=True,
can_order=False,
min_num=1,
max_num=3,
extra=3,
)

View file

@ -30,10 +30,13 @@ from ietf.mailtrigger.utils import gather_address_lists
from ietf.person.models import Person
from ietf.meeting.models import Meeting, Schedule, TimeSlot, SchedTimeSessAssignment, ImportantDate, SchedulingEvent, Session
from ietf.meeting.utils import session_requested_by, add_event_info_to_session_qs
from ietf.name.models import ImportantDateName
from ietf.name.models import ImportantDateName, SessionPurposeName
from ietf.utils import log
from ietf.utils.history import find_history_active_at, find_history_replacements_active_at
from ietf.utils.mail import send_mail
from ietf.utils.pipe import pipe
from ietf.utils.text import xslugify
def find_ads_for_meeting(meeting):
ads = []
@ -253,55 +256,460 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe
return assignments
def is_regular_agenda_filter_group(group):
"""Should this group appear in the 'regular' agenda filter button lists?"""
return group.type_id in ('wg', 'rg', 'ag', 'rag', 'iab', 'program')
def tag_assignments_with_filter_keywords(assignments):
class AgendaKeywordTool:
"""Base class for agenda keyword-related organizers
The purpose of this class is to hold utility methods and data needed by keyword generation
helper classes. It ensures consistency of, e.g., definitions of when to use legacy keywords or what
timeslot types should be used to define filters.
"""
def __init__(self, *, assignments=None, sessions=None):
# n.b., single star argument means only keyword parameters are allowed when calling constructor
if assignments is not None and sessions is None:
self.assignments = assignments
self.sessions = [a.session for a in self.assignments if a.session]
elif sessions is not None and assignments is None:
self.assignments = None
self.sessions = sessions
else:
raise RuntimeError('Exactly one of assignments or sessions must be specified')
self.meeting = self.sessions[0].meeting if len(self.sessions) > 0 else None
def _use_legacy_keywords(self):
"""Should legacy keyword handling be used for this meeting?"""
# Only IETF meetings need legacy handling. These are identified
# by having a purely numeric meeting.number.
return (self.meeting is not None
and self.meeting.number.isdigit()
and int(self.meeting.number) <= settings.MEETING_LEGACY_OFFICE_HOURS_END)
# Helper methods
@staticmethod
def _get_group(s):
"""Get group of a session, handling historic groups"""
return getattr(s, 'historic_group', s.group)
def _get_group_parent(self, s):
"""Get parent of a group or parent of a session's group, handling historic groups"""
g = self._get_group(s) if isinstance(s, Session) else s # accept a group or a session arg
return getattr(g, 'historic_parent', g.parent)
def _purpose_keyword(self, purpose):
"""Get the keyword corresponding to a session purpose"""
return purpose.slug.lower()
def _group_keyword(self, group):
"""Get the keyword corresponding to a session group"""
return group.acronym.lower()
def _session_name_keyword(self, session):
"""Get the keyword identifying a session by name"""
return xslugify(session.name) if session.name else None
@property
def filterable_purposes(self):
return SessionPurposeName.objects.exclude(slug='session').order_by('name')
class AgendaFilterOrganizer(AgendaKeywordTool):
"""Helper class to organize agenda filters given a list of assignments or sessions
Either assignments or sessions must be specified (but not both). Keywords should be applied
to these items before calling either of the 'get_' methods, otherwise some special filters
may not be included (e.g., 'BoF' or 'Plenary'). If historic_group and/or historic_parent
attributes are present, these will be used instead of group/parent.
The organizer will process its inputs once, when one of its get_ methods is first called.
Terminology:
* column: group of related buttons, usually with a heading button.
* heading: button at the top of a column, e.g. an area. Has a keyword that applies to all in its column.
* category: a set of columns displayed as separate from other categories
* group filters: filters whose keywords derive from the group owning the session, such as for working groups
* non-group filters: filters whose keywords come from something other than a session's group
* special filters: group filters of type "special" that have no heading, end up in the catch-all column
* extra filters: ad hoc filters created based on the extra_labels list, go in the catch-all column
* catch-all column: column with no heading where extra filters and special filters are gathered
"""
# group acronyms in this list will never be used as filter buttons
exclude_acronyms = ('iesg', 'ietf', 'secretariat')
# extra keywords to include in the no-heading column if they apply to any sessions
extra_labels = ('BoF', 'Plenary')
# group types whose acronyms should be word-capitalized
capitalized_group_types = ('team',)
# group types whose acronyms should be all-caps
uppercased_group_types = ('area', 'ietf', 'irtf')
# check that the group labeling sets are disjoint
assert(set(capitalized_group_types).isdisjoint(uppercased_group_types))
# group acronyms that need special handling
special_group_labels = dict(edu='EDU', iepg='IEPG')
def __init__(self, *, single_category=False, **kwargs):
super(AgendaFilterOrganizer, self).__init__(**kwargs)
self.single_category = single_category
# filled in when _organize_filters() is called
self.filter_categories = None
self.special_filters = None
def get_non_area_keywords(self):
"""Get list of any 'non-area' (aka 'special') keywords
These are the keywords corresponding to the right-most, headingless button column.
"""
if self.special_filters is None:
self._organize_filters()
return [sf['keyword'] for sf in self.special_filters['children']]
def get_filter_categories(self):
"""Get a list of filter categories
If single_category is True, this will be a list with one element. Otherwise it
may have multiple elements. Each element is a list of filter columns.
"""
if self.filter_categories is None:
self._organize_filters()
return self.filter_categories
def _organize_filters(self):
"""Process inputs to construct and categorize filter lists"""
headings, special = self._group_filter_headings()
self.filter_categories = self._categorize_group_filters(headings)
# Create an additional category with non-group filters and special/extra filters
non_group_category = self._non_group_filters()
# special filters include self.extra_labels and any 'special' group filters
self.special_filters = self._extra_filters()
for g in special:
self.special_filters['children'].append(self._group_filter_entry(g))
if len(self.special_filters['children']) > 0:
self.special_filters['children'].sort(key=self._group_sort_key)
non_group_category.append(self.special_filters)
# if we have any additional filters, add them
if len(non_group_category) > 0:
if self.single_category:
# if a single category is requested, just add them to that category
self.filter_categories[0].extend(non_group_category)
else:
# otherwise add these as a separate category
self.filter_categories.append(non_group_category)
def _group_filter_headings(self):
"""Collect group-based filters
Output is a tuple (dict(group->set), set). The dict keys are groups to be used as headings
with sets of child groups as associated values. The set is 'special' groups that have no
heading group.
"""
# groups in the schedule that have a historic_parent group
groups = set(self._get_group(s) for s in self.sessions
if s
and self._get_group(s))
log.assertion('len(groups) == len(set(g.acronym for g in groups))') # no repeated acros
group_parents = set(self._get_group_parent(g) for g in groups if self._get_group_parent(g))
log.assertion('len(group_parents) == len(set(gp.acronym for gp in group_parents))') # no repeated acros
all_groups = groups.union(group_parents)
all_groups.difference_update([g for g in all_groups if g.acronym in self.exclude_acronyms])
headings = {g: set()
for g in all_groups
if g.features.agenda_filter_type_id == 'heading'}
special = set(g for g in all_groups
if g.features.agenda_filter_type_id == 'special')
for g in groups:
if g.features.agenda_filter_type_id == 'normal':
# normal filter group with a heading parent goes in that column
p = self._get_group_parent(g)
if p in headings:
headings[p].add(g)
else:
# normal filter group with no heading parent is 'special'
special.add(g)
return headings, special
def _categorize_group_filters(self, headings):
"""Categorize the group-based filters
Returns a list of one or more categories of filter columns. When single_category is True,
it will always be only one category.
"""
area_category = [] # headings are area groups
non_area_category = [] # headings are non-area groups
for h in headings:
if h.type_id == 'area' or self.single_category:
area_category.append(self._group_filter_column(h, headings[h]))
else:
non_area_category.append(self._group_filter_column(h, headings[h]))
area_category.sort(key=self._group_sort_key)
if self.single_category:
return [area_category]
non_area_category.sort(key=self._group_sort_key)
return [area_category, non_area_category]
def _non_group_filters(self):
"""Get list of non-group filter columns
Empty columns will be omitted.
"""
if self.sessions is None:
sessions = [a.session for a in self.assignments]
else:
sessions = self.sessions
# Call legacy version for older meetings
if self._use_legacy_keywords():
return self._legacy_non_group_filters()
# Not using legacy version
filter_cols = []
for purpose in self.filterable_purposes:
# Map label to its keyword, discarding duplicate labels.
# This does what we want as long as sessions with the same
# name and purpose belong to the same group.
sessions_by_name = {
session.name: session
for session in sessions if session.purpose == purpose
}
if len(sessions_by_name) > 0:
# keyword needs to match what's tagged in filter_keywords_for_session()
heading_kw = self._purpose_keyword(purpose)
children = []
for name, session in sessions_by_name.items():
children.append(self._filter_entry(
label=name,
keyword=self._session_name_keyword(session),
toggled_by=[self._group_keyword(session.group)] if session.group else None,
is_bof=False,
))
column = self._filter_column(
label=purpose.name,
keyword=heading_kw,
children=children,
)
filter_cols.append(column)
return filter_cols
def _legacy_non_group_filters(self):
"""Get list of non-group filters for older meetings
Returns a list of filter columns
"""
if self.assignments is None:
return [] # can only use timeslot type when we have assignments
office_hours_items = set()
suffix = ' office hours'
for a in self.assignments:
if a.session.name.lower().endswith(suffix):
office_hours_items.add((a.session.name[:-len(suffix)].strip(), a.session.group))
headings = []
# currently we only do office hours
if len(office_hours_items) > 0:
column = self._filter_column(
label='Office Hours',
keyword='officehours',
children=[
self._filter_entry(
label=label,
keyword=f'{label.lower().replace(" ", "")}-officehours',
toggled_by=[self._group_keyword(group)] if group else None,
is_bof=False,
)
for label, group in sorted(office_hours_items, key=lambda item: item[0].upper())
])
headings.append(column)
return headings
def _extra_filters(self):
"""Get list of filters corresponding to self.extra_labels"""
item_source = self.assignments or self.sessions or []
candidates = set(self.extra_labels)
return self._filter_column(
label=None,
keyword=None,
children=[
self._filter_entry(label=label, keyword=xslugify(label), toggled_by=[], is_bof=False)
for label in candidates if any(
# Keep only those that will affect at least one session
[label.lower() in item.filter_keywords for item in item_source]
)]
)
@staticmethod
def _filter_entry(label, keyword, is_bof, toggled_by=None):
"""Construct a filter entry representation"""
# get our own copy of the list for toggled_by
if toggled_by is None:
toggled_by = []
if is_bof:
toggled_by = ['bof'] + toggled_by
return dict(
label=label,
keyword=keyword,
toggled_by=toggled_by,
is_bof=is_bof,
)
def _filter_column(self, label, keyword, children):
"""Construct a filter column given a label, keyword, and list of child entries"""
entry = self._filter_entry(label, keyword, False) # heading
entry['children'] = children
# all children should be controlled by the heading keyword
if keyword:
for child in children:
if keyword not in child['toggled_by']:
child['toggled_by'] = [keyword] + child['toggled_by']
return entry
def _group_label(self, group):
"""Generate a label for a group filter button"""
label = group.acronym
if label in self.special_group_labels:
return self.special_group_labels[label]
elif group.type_id in self.capitalized_group_types:
return label.capitalize()
elif group.type_id in self.uppercased_group_types:
return label.upper()
return label
def _group_filter_entry(self, group):
"""Construct a filter_entry for a group filter button"""
return self._filter_entry(
label=self._group_label(group),
keyword=self._group_keyword(group),
toggled_by=[self._group_keyword(group.parent)] if group.parent else None,
is_bof=group.is_bof(),
)
def _group_filter_column(self, heading, children):
"""Construct a filter column given a heading group and a list of its child groups"""
return self._filter_column(
label=None if heading is None else self._group_label(heading),
keyword=self._group_keyword(heading),
children=sorted([self._group_filter_entry(g) for g in children], key=self._group_sort_key),
)
@staticmethod
def _group_sort_key(g):
return 'zzzzzzzz' if g is None else g['label'].upper() # sort blank to the end
class AgendaKeywordTagger(AgendaKeywordTool):
"""Class for applying keywords to agenda timeslot assignments.
This is the other side of the agenda filtering: AgendaFilterOrganizer generates the
filter buttons, this applies keywords to the entries being filtered.
"""
def apply(self):
"""Apply tags to sessions / assignments"""
if self.assignments is not None:
self._tag_assignments_with_filter_keywords()
else:
self._tag_sessions_with_filter_keywords()
def apply_session_keywords(self):
"""Tag each item with its session-specific keyword"""
if self.assignments is not None:
for a in self.assignments:
a.session_keyword = self.filter_keyword_for_specific_session(a.session)
else:
for s in self.sessions:
s.session_keyword = self.filter_keyword_for_specific_session(s)
def _is_regular_agenda_filter_group(self, group):
"""Should this group appear in the 'regular' agenda filter button lists?"""
parent = self._get_group_parent(group)
return (
group.features.agenda_filter_type_id == 'normal'
and parent
and parent.features.agenda_filter_type_id == 'heading'
)
def _tag_assignments_with_filter_keywords(self):
"""Add keywords for agenda filtering
Keywords are all lower case.
"""
for a in assignments:
a.filter_keywords = {a.timeslot.type.slug.lower()}
a.filter_keywords.update(filter_keywords_for_session(a.session))
for a in self.assignments:
a.filter_keywords = {a.slot_type().slug.lower()}
a.filter_keywords.update(self._filter_keywords_for_assignment(a))
a.filter_keywords = sorted(list(a.filter_keywords))
def filter_keywords_for_session(session):
keywords = {session.type.slug.lower()}
group = getattr(session, 'historic_group', session.group)
def _tag_sessions_with_filter_keywords(self):
for s in self.sessions:
s.filter_keywords = self._filter_keywords_for_session(s)
s.filter_keywords = sorted(list(s.filter_keywords))
@staticmethod
def _legacy_extra_session_keywords(session):
"""Get extra keywords for a session at a legacy meeting"""
office_hours_match = re.match(r'^ *\w+(?: +\w+)* +office hours *$', session.name, re.IGNORECASE)
if office_hours_match is not None:
suffix = 'officehours'
return [
'officehours',
session.name.lower().replace(' ', '')[:-len(suffix)] + '-officehours',
]
return []
def _filter_keywords_for_session(self, session):
keywords = set()
if session.purpose in self.filterable_purposes:
keywords.add(self._purpose_keyword(session.purpose))
group = self._get_group(session)
if group is not None:
if group.state_id == 'bof':
keywords.add('bof')
keywords.add(group.acronym.lower())
specific_kw = filter_keyword_for_specific_session(session)
keywords.add(self._group_keyword(group))
specific_kw = self.filter_keyword_for_specific_session(session)
if specific_kw is not None:
keywords.add(specific_kw)
area = getattr(group, 'historic_parent', group.parent)
kw = self._session_name_keyword(session)
if kw:
keywords.add(kw)
# Only sessions belonging to "regular" groups should respond to the
# parent group filter keyword (often the 'area'). This must match
# the test used by the agenda() view to decide whether a group
# gets an area or non-area filter button.
if is_regular_agenda_filter_group(group) and area is not None:
keywords.add(area.acronym.lower())
office_hours_match = re.match(r'^ *\w+(?: +\w+)* +office hours *$', session.name, re.IGNORECASE)
if office_hours_match is not None:
keywords.update(['officehours', session.name.lower().replace(' ', '')])
return sorted(list(keywords))
if self._is_regular_agenda_filter_group(group):
area = self._get_group_parent(group)
if area is not None:
keywords.add(self._group_keyword(area))
def filter_keyword_for_specific_session(session):
if self._use_legacy_keywords():
keywords.update(self._legacy_extra_session_keywords(session))
return sorted(keywords)
def _filter_keywords_for_assignment(self, assignment):
keywords = self._filter_keywords_for_session(assignment.session)
return sorted(keywords)
def filter_keyword_for_specific_session(self, session):
"""Get keyword that identifies a specific session
Returns None if the session cannot be selected individually.
"""
group = getattr(session, 'historic_group', session.group)
group = self._get_group(session)
if group is None:
return None
kw = group.acronym.lower() # start with this
kw = self._group_keyword(group) # start with this
token = session.docname_token_only_for_multiple()
return kw if token is None else '{}-{}'.format(kw, token)
def read_session_file(type, num, doc):
# XXXX FIXME: the path fragment in the code below should be moved to
# settings.py. The *_PATH settings should be generalized to format()

View file

@ -0,0 +1,21 @@
# Generated by Django 2.2.24 on 2021-09-16 18:04
from django.db import migrations
import django.db.models.deletion
import ietf.utils.models
class Migration(migrations.Migration):
dependencies = [
('name', '0036_populate_sessionpurposename'),
('meeting', '0048_auto_20211008_0907'),
]
operations = [
migrations.AddField(
model_name='session',
name='purpose',
field=ietf.utils.models.ForeignKey(help_text='Purpose of the session', null=True, on_delete=django.db.models.deletion.CASCADE, to='name.SessionPurposeName'),
),
]

View file

@ -26,6 +26,7 @@ from django.conf import settings
# mostly used by json_dict()
#from django.template.defaultfilters import slugify, date as date_format, time as time_format
from django.template.defaultfilters import date as date_format
from django.urls import reverse as urlreverse
from django.utils.text import slugify
from django.utils.safestring import mark_safe
@ -36,6 +37,7 @@ from ietf.group.utils import can_manage_materials
from ietf.name.models import (
MeetingTypeName, TimeSlotTypeName, SessionStatusName, ConstraintName, RoomResourceName,
ImportantDateName, TimerangeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName,
SessionPurposeName,
)
from ietf.person.models import Person
from ietf.utils.decorators import memoize
@ -338,6 +340,13 @@ class Meeting(models.Model):
}
def build_timeslices(self):
"""Get unique day/time/timeslot data for meeting
Returns a list of days, time intervals for each day, and timeslots for each day,
with repeated days/time intervals removed. Ignores timeslots that do not have a
location. The slots return value contains only one TimeSlot for each distinct
time interval.
"""
days = [] # the days of the meetings
time_slices = {} # the times on each day
slots = {}
@ -359,8 +368,9 @@ class Meeting(models.Model):
days.sort()
for ymd in time_slices:
# Make sure these sort the same way
time_slices[ymd].sort()
slots[ymd].sort(key=lambda x: x.time)
slots[ymd].sort(key=lambda x: (x.time, x.duration))
return days,time_slices,slots
# this functions makes a list of timeslices and rooms, and
@ -487,6 +497,18 @@ class Room(models.Model):
'capacity': self.capacity,
}
# floorplan support
def floorplan_url(self):
mtg_num = self.meeting.get_number()
if not mtg_num:
return None
elif mtg_num <= settings.FLOORPLAN_LAST_LEGACY_MEETING:
base_url = settings.FLOORPLAN_LEGACY_BASE_URL.format(meeting=self.meeting)
elif self.floorplan:
base_url = urlreverse('ietf.meeting.views.floor_plan', kwargs=dict(num=mtg_num))
else:
return None
return f'{base_url}?room={xslugify(self.name)}'
def left(self):
return min(self.x1, self.x2) if (self.x1 and self.x2) else 0
def top(self):
@ -878,6 +900,14 @@ class SchedTimeSessAssignment(models.Model):
else:
return None
def meeting(self):
"""Get the meeting to which this assignment belongs"""
return self.session.meeting
def slot_type(self):
"""Get the TimeSlotTypeName that applies to this assignment"""
return self.timeslot.type
def json_url(self):
if not hasattr(self, '_cached_json_url'):
self._cached_json_url = "/meeting/%s/agenda/%s/%s/session/%u.json" % (
@ -928,12 +958,12 @@ class SchedTimeSessAssignment(models.Model):
g = getattr(self.session, "historic_group", None) or self.session.group
if self.timeslot.type_id in ('break', 'reg', 'other'):
if self.timeslot.type.slug in ('break', 'reg', 'other'):
components.append(g.acronym)
components.append(slugify(self.session.name))
if self.timeslot.type_id in ('regular', 'plenary'):
if self.timeslot.type_id == "plenary":
if self.timeslot.type.slug in ('regular', 'plenary'):
if self.timeslot.type.slug == "plenary":
components.append("1plenary")
else:
p = getattr(g, "historic_parent", None) or g.parent
@ -1098,6 +1128,13 @@ class SessionQuerySet(models.QuerySet):
"""
return self.with_current_status().exclude(current_status__in=Session.CANCELED_STATUSES)
def not_deleted(self):
"""Queryset containing all sessions not deleted
Results annotated with current_status
"""
return self.with_current_status().exclude(current_status='deleted')
def that_can_meet(self):
"""Queryset containing sessions that can meet
@ -1120,6 +1157,7 @@ class Session(models.Model):
meeting = ForeignKey(Meeting)
name = models.CharField(blank=True, max_length=255, help_text="Name of session, in case the session has a purpose rather than just being a group meeting.")
short = models.CharField(blank=True, max_length=32, help_text="Short version of 'name' above, for use in filenames.")
purpose = ForeignKey(SessionPurposeName, null=True, help_text='Purpose of the session')
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',blank=True)

View file

@ -5,6 +5,8 @@
from django import template
from django.urls import reverse
from ietf.utils.text import xslugify
register = template.Library()
@ -68,3 +70,85 @@ def webcal_url(context, viewname, *args, **kwargs):
context.request.get_host(),
reverse(viewname, args=args, kwargs=kwargs)
)
@register.simple_tag
def assignment_display_name(assignment):
"""Get name for an assignment"""
if assignment.session.type.slug == 'session' and assignment.session.historic_group:
return assignment.session.historic_group.name
return assignment.session.name or assignment.timeslot.name
class AnchorNode(template.Node):
"""Template node for a conditionally-included anchor
If self.resolve_url() returns a URL, the contents of the nodelist will be rendered inside
<a href="{{ self.resolve_url() }}"> ... </a>. If it returns None, the <a> will be omitted.
The contents will be rendered in either case.
"""
def __init__(self, nodelist):
self.nodelist = nodelist
def resolve_url(self, context):
raise NotImplementedError('Subclasses must define this method')
def render(self, context):
url = self.resolve_url(context)
if url:
return '<a href="{}">{}</a>'.format(url, self.nodelist.render(context))
else:
return self.nodelist.render(context)
class AgendaAnchorNode(AnchorNode):
"""Template node for the agenda_anchor tag"""
def __init__(self, session, *args, **kwargs):
super().__init__(*args, **kwargs)
self.session = template.Variable(session)
def resolve_url(self, context):
sess = self.session.resolve(context)
agenda = sess.agenda()
if agenda:
return agenda.get_href()
return None
@register.tag
def agenda_anchor(parser, token):
"""Block tag that wraps its content in a link to the session agenda, if any"""
try:
tag_name, sess_var = token.split_contents()
except ValueError:
raise template.TemplateSyntaxError('agenda_anchor requires a single argument')
nodelist = parser.parse(('end_agenda_anchor',))
parser.delete_first_token() # delete the end tag
return AgendaAnchorNode(sess_var, nodelist)
class LocationAnchorNode(AnchorNode):
"""Template node for the location_anchor tag"""
def __init__(self, timeslot, *args, **kwargs):
super().__init__(*args, **kwargs)
self.timeslot = template.Variable(timeslot)
def resolve_url(self, context):
ts = self.timeslot.resolve(context)
if ts.show_location and ts.location:
return ts.location.floorplan_url()
return None
@register.tag
def location_anchor(parser, token):
"""Block tag that wraps its content in a link to the timeslot location
If the timeslot has no location information or is marked with show_location=False,
the anchor tag is omitted.
"""
try:
tag_name, ts_var = token.split_contents()
except ValueError:
raise template.TemplateSyntaxError('location_anchor requires a single argument')
nodelist = parser.parse(('end_location_anchor',))
parser.delete_first_token() # delete the end tag
return LocationAnchorNode(ts_var, nodelist)

View file

@ -0,0 +1,20 @@
# Copyright The IETF Trust 2009-2020, All Rights Reserved
# -*- coding: utf-8 -*-
from ietf.meeting.templatetags.agenda_custom_tags import AnchorNode
from ietf.utils.test_utils import TestCase
class AgendaCustomTagsTests(TestCase):
def test_anchor_node_subclasses_implement_resolve_url(self):
"""Check that AnchorNode subclasses implement the resolve_url method
This will only catch errors in subclasses defined in the agenda_custom_tags.py module.
"""
for subclass in AnchorNode.__subclasses__():
try:
subclass.resolve_url(None, None)
except NotImplementedError:
self.fail(f'{subclass.__name__} must implement resolve_url() method')
except:
pass # any other failure ok since we used garbage inputs

View file

@ -1,78 +1,96 @@
# Copyright The IETF Trust 2020, All Rights Reserved
# -*- coding: utf-8 -*-
import copy
from django.test import override_settings
from ietf.group.factories import GroupFactory
from ietf.meeting.factories import SessionFactory, MeetingFactory
from ietf.meeting.helpers import tag_assignments_with_filter_keywords
from ietf.group.models import Group
from ietf.meeting.factories import SessionFactory, MeetingFactory, TimeSlotFactory
from ietf.meeting.helpers import AgendaFilterOrganizer, AgendaKeywordTagger
from ietf.meeting.test_data import make_meeting_test_data
from ietf.utils.test_utils import TestCase
class HelpersTests(TestCase):
def do_test_tag_assignments_with_filter_keywords(self, bof=False, historic=None):
# override the legacy office hours setting to guarantee consistency with the tests
@override_settings(MEETING_LEGACY_OFFICE_HOURS_END=111)
class AgendaKeywordTaggerTests(TestCase):
def do_test_tag_assignments_with_filter_keywords(self, meeting_num, bof=False, historic=None):
"""Assignments should be tagged properly
The historic param can be None, group, or parent, to specify whether to test
with no historic_group, a historic_group but no historic_parent, or both.
"""
meeting_types = ['regular', 'plenary']
session_types = ['regular', 'plenary']
# decide whether meeting should use legacy keywords (for office hours)
legacy_keywords = meeting_num <= 111
# create meeting and groups
meeting = MeetingFactory(type_id='ietf', number=meeting_num)
group_state_id = 'bof' if bof else 'active'
group = GroupFactory(state_id=group_state_id)
historic_group = GroupFactory(state_id=group_state_id)
historic_parent = GroupFactory(type_id='area')
# Set up the historic group and parent if needed. Keep track of these as expected_*
# for later reference. If not using historic group or parent, fall back to the non-historic
# groups.
if historic:
expected_group = GroupFactory(state_id=group_state_id)
if historic == 'parent':
historic_group.historic_parent = historic_parent
# Create meeting and sessions
meeting = MeetingFactory()
for meeting_type in meeting_types:
sess = SessionFactory(group=group, meeting=meeting, type_id=meeting_type)
ts = sess.timeslotassignments.first().timeslot
ts.type = sess.type
ts.save()
# Create an office hours session in the group's area (i.e., parent). This is not
# currently really needed, but will protect against areas and groups diverging
# in a way that breaks keywording.
office_hours = SessionFactory(
name='some office hours',
group=group.parent,
meeting=meeting,
type_id='other'
)
ts = office_hours.timeslotassignments.first().timeslot
ts.type = office_hours.type
ts.save()
assignments = meeting.schedule.assignments.all()
orig_num_assignments = len(assignments)
# Set up historic groups if needed
if historic:
for a in assignments:
if a.session != office_hours:
a.session.historic_group = historic_group
# Execute the method under test
tag_assignments_with_filter_keywords(assignments)
# Assert expected results
self.assertEqual(len(assignments), orig_num_assignments, 'Should not change number of assignments')
if historic:
expected_group = historic_group
expected_area = historic_parent if historic == 'parent' else historic_group.parent
expected_area = GroupFactory(type_id='area')
expected_group.historic_parent = expected_area
else:
expected_area = expected_group.parent
else:
expected_group = group
expected_area = group.parent
# create the ordinary sessions
for session_type in session_types:
sess = SessionFactory(group=group, meeting=meeting, type_id=session_type, add_to_schedule=False)
sess.timeslotassignments.create(
timeslot=TimeSlotFactory(meeting=meeting, type_id=session_type),
schedule=meeting.schedule,
)
# Create an office hours session in the group's area (i.e., parent). Handle this separately
# from other session creation to test legacy office hours naming.
office_hours = SessionFactory(
name='some office hours',
group=Group.objects.get(acronym='iesg') if legacy_keywords else expected_area,
meeting=meeting,
type_id='other' if legacy_keywords else 'officehours',
add_to_schedule=False,
)
office_hours.timeslotassignments.create(
timeslot=TimeSlotFactory(meeting=meeting, type_id=office_hours.type_id),
schedule=meeting.schedule,
)
assignments = meeting.schedule.assignments.all()
orig_num_assignments = len(assignments)
# Set up historic groups if needed. We've already set the office hours group properly
# so skip that session. The expected_group will already have its historic_parent set
# if historic == 'parent'
if historic:
for a in assignments:
if a.session != office_hours:
a.session.historic_group = expected_group
# Execute the method under test
AgendaKeywordTagger(assignments=assignments).apply()
# Assert expected results
self.assertEqual(len(assignments), orig_num_assignments, 'Should not change number of assignments')
for assignment in assignments:
expected_filter_keywords = {assignment.timeslot.type.slug, assignment.session.type.slug}
expected_filter_keywords = {assignment.slot_type().slug, assignment.session.type.slug}
if assignment.session == office_hours:
expected_filter_keywords.update([
group.parent.acronym,
office_hours.group.acronym,
'officehours',
'someofficehours',
'some-officehours' if legacy_keywords else '{}-officehours'.format(expected_area.acronym),
])
else:
expected_filter_keywords.update([
@ -92,9 +110,149 @@ class HelpersTests(TestCase):
)
def test_tag_assignments_with_filter_keywords(self):
self.do_test_tag_assignments_with_filter_keywords()
self.do_test_tag_assignments_with_filter_keywords(historic='group')
self.do_test_tag_assignments_with_filter_keywords(historic='parent')
self.do_test_tag_assignments_with_filter_keywords(bof=True)
self.do_test_tag_assignments_with_filter_keywords(bof=True, historic='group')
self.do_test_tag_assignments_with_filter_keywords(bof=True, historic='parent')
# use distinct meeting numbers > 111 for non-legacy keyword tests
self.do_test_tag_assignments_with_filter_keywords(112)
self.do_test_tag_assignments_with_filter_keywords(113, historic='group')
self.do_test_tag_assignments_with_filter_keywords(114, historic='parent')
self.do_test_tag_assignments_with_filter_keywords(115, bof=True)
self.do_test_tag_assignments_with_filter_keywords(116, bof=True, historic='group')
self.do_test_tag_assignments_with_filter_keywords(117, bof=True, historic='parent')
def test_tag_assignments_with_filter_keywords_legacy(self):
# use distinct meeting numbers <= 111 for legacy keyword tests
self.do_test_tag_assignments_with_filter_keywords(101)
self.do_test_tag_assignments_with_filter_keywords(102, historic='group')
self.do_test_tag_assignments_with_filter_keywords(103, historic='parent')
self.do_test_tag_assignments_with_filter_keywords(104, bof=True)
self.do_test_tag_assignments_with_filter_keywords(105, bof=True, historic='group')
self.do_test_tag_assignments_with_filter_keywords(106, bof=True, historic='parent')
class AgendaFilterOrganizerTests(TestCase):
def test_get_filter_categories(self):
# set up
meeting = make_meeting_test_data()
# create extra groups for testing
iab = Group.objects.get(acronym='iab')
iab_child = GroupFactory(type_id='iab', parent=iab)
irtf = Group.objects.get(acronym='irtf')
irtf_child = GroupFactory(parent=irtf, state_id='bof')
# non-area group sessions
SessionFactory(group=iab_child, meeting=meeting)
SessionFactory(group=irtf_child, meeting=meeting)
# office hours session
SessionFactory(
group=Group.objects.get(acronym='farfut'),
name='FARFUT office hours',
meeting=meeting
)
expected = [
[
# area category
{'label': 'FARFUT', 'keyword': 'farfut', 'is_bof': False,
'children': [
{'label': 'ames', 'keyword': 'ames', 'is_bof': False},
{'label': 'mars', 'keyword': 'mars', 'is_bof': False},
]},
],
[
# non-area category
{'label': 'IAB', 'keyword': 'iab', 'is_bof': False,
'children': [
{'label': iab_child.acronym, 'keyword': iab_child.acronym, 'is_bof': False},
]},
{'label': 'IRTF', 'keyword': 'irtf', 'is_bof': False,
'children': [
{'label': irtf_child.acronym, 'keyword': irtf_child.acronym, 'is_bof': True},
]},
],
[
# non-group category
{'label': 'Office Hours', 'keyword': 'officehours', 'is_bof': False,
'children': [
{'label': 'FARFUT', 'keyword': 'farfut-officehours', 'is_bof': False}
]},
{'label': None, 'keyword': None,'is_bof': False,
'children': [
{'label': 'BoF', 'keyword': 'bof', 'is_bof': False},
{'label': 'Plenary', 'keyword': 'plenary', 'is_bof': False},
]},
],
]
# when using sessions instead of assignments, won't get timeslot-type based filters
expected_with_sessions = copy.deepcopy(expected)
expected_with_sessions[-1].pop(0) # pops 'office hours' column
# put all the above together for single-column tests
expected_single_category = [
sorted(sum(expected, []), key=lambda col: col['label'] or 'zzzzz')
]
expected_single_category_with_sessions = [
sorted(sum(expected_with_sessions, []), key=lambda col: col['label'] or 'zzzzz')
]
###
# test using sessions
sessions = meeting.session_set.all()
AgendaKeywordTagger(sessions=sessions).apply()
# default
filter_organizer = AgendaFilterOrganizer(sessions=sessions)
self.assertEqual(filter_organizer.get_filter_categories(), expected_with_sessions)
# single-column
filter_organizer = AgendaFilterOrganizer(sessions=sessions, single_category=True)
self.assertEqual(filter_organizer.get_filter_categories(), expected_single_category_with_sessions)
###
# test again using assignments
assignments = meeting.schedule.assignments.all()
AgendaKeywordTagger(assignments=assignments).apply()
# default
filter_organizer = AgendaFilterOrganizer(assignments=assignments)
self.assertEqual(filter_organizer.get_filter_categories(), expected)
# single-column
filter_organizer = AgendaFilterOrganizer(assignments=assignments, single_category=True)
self.assertEqual(filter_organizer.get_filter_categories(), expected_single_category)
def test_get_non_area_keywords(self):
# set up
meeting = make_meeting_test_data()
# create a session in a 'special' group, which should then appear in the non-area keywords
team = GroupFactory(type_id='team')
SessionFactory(group=team, meeting=meeting)
# and a BoF
bof = GroupFactory(state_id='bof')
SessionFactory(group=bof, meeting=meeting)
expected = sorted(['bof', 'plenary', team.acronym.lower()])
###
# by sessions
sessions = meeting.session_set.all()
AgendaKeywordTagger(sessions=sessions).apply()
filter_organizer = AgendaFilterOrganizer(sessions=sessions)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
filter_organizer = AgendaFilterOrganizer(sessions=sessions, single_category=True)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
###
# by assignments
assignments = meeting.schedule.assignments.all()
AgendaKeywordTagger(assignments=assignments).apply()
filter_organizer = AgendaFilterOrganizer(assignments=assignments)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
filter_organizer = AgendaFilterOrganizer(assignments=assignments, single_category=True)
self.assertEqual(filter_organizer.get_non_area_keywords(), expected)

View file

@ -1197,7 +1197,7 @@ class AgendaTests(IetfSeleniumTestCase):
for item in self.get_expected_items():
if item.session.name:
label = item.session.name
elif item.timeslot.type_id == 'break':
elif item.slot_type().slug == 'break':
label = item.timeslot.name
elif item.session.group:
label = item.session.group.name
@ -1308,6 +1308,7 @@ class AgendaTests(IetfSeleniumTestCase):
self.assert_agenda_item_visibility([group_acronym])
# Click the group button again
group_button = self.get_agenda_filter_group_button(wait, group_acronym)
group_button.click()
# Check visibility
@ -1479,7 +1480,7 @@ class AgendaTests(IetfSeleniumTestCase):
ics_url = self.absreverse('ietf.meeting.views.agenda_ical')
# parse out the events
agenda_rows = self.driver.find_elements_by_css_selector('[id^="row-"]')
agenda_rows = self.driver.find_elements_by_css_selector('[id^="row-"]:not(.info)')
visible_rows = [r for r in agenda_rows if r.is_displayed()]
sessions = [self.session_from_agenda_row_id(row.get_attribute("id"))
for row in visible_rows]
@ -1736,6 +1737,16 @@ class AgendaTests(IetfSeleniumTestCase):
self.fail('iframe href not updated to contain selected time zone')
def test_agenda_session_selection(self):
# create a second mars session to test selection of specific sessions
SessionFactory(
meeting=self.meeting,
group__acronym='mars',
add_to_schedule=False,
).timeslotassignments.create(
timeslot=TimeSlotFactory(meeting=self.meeting, duration=datetime.timedelta(minutes=60)),
schedule=self.meeting.schedule,
)
wait = WebDriverWait(self.driver, 2)
url = self.absreverse('ietf.meeting.views.agenda_personalize', kwargs={'num': self.meeting.number})
self.driver.get(url)
@ -1752,47 +1763,52 @@ class AgendaTests(IetfSeleniumTestCase):
'Sessions were selected before being clicked',
)
mars_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="mars"]')
mars_sessa_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="mars-sessa"]')
mars_sessb_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="mars-sessb"]')
farfut_button = self.driver.find_element_by_css_selector('button[data-filter-item="farfut"]')
break_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="secretariat-sessb"]')
registration_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="secretariat-sessa"]')
secretariat_button = self.driver.find_element_by_css_selector('button[data-filter-item="secretariat"]')
mars_checkbox.click() # select mars session
mars_sessa_checkbox.click() # select mars session
try:
wait.until(
lambda driver: all('?show=mars' in el.get_attribute('href') for el in elements_to_check)
lambda driver: all('?show=mars-sessa' in el.get_attribute('href') for el in elements_to_check)
)
except TimeoutException:
self.fail('Some agenda links were not updated when mars session was selected')
self.assertTrue(mars_checkbox.is_selected(), 'mars session checkbox was not selected after being clicked')
self.assertTrue(mars_sessa_checkbox.is_selected(), 'mars session A checkbox was not selected after being clicked')
self.assertFalse(mars_sessb_checkbox.is_selected(), 'mars session B checkbox was selected without being clicked')
self.assertFalse(break_checkbox.is_selected(), 'break checkbox was selected without being clicked')
self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was selected without being clicked')
mars_checkbox.click() # deselect mars session
mars_sessa_checkbox.click() # deselect mars session
try:
wait.until(
lambda driver: not any('?show=mars' in el.get_attribute('href') for el in elements_to_check)
lambda driver: not any('?show=mars-sessa' in el.get_attribute('href') for el in elements_to_check)
)
except TimeoutException:
self.fail('Some agenda links were not updated when mars session was de-selected')
self.assertFalse(mars_checkbox.is_selected(), 'mars session checkbox was still selected after being clicked')
self.assertFalse(mars_sessa_checkbox.is_selected(), 'mars session A checkbox was still selected after being clicked')
self.assertFalse(mars_sessb_checkbox.is_selected(), 'mars session B checkbox was selected without being clicked')
self.assertFalse(break_checkbox.is_selected(), 'break checkbox was selected without being clicked')
self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was selected without being clicked')
secretariat_button.click() # turn on all secretariat sessions
farfut_button.click() # turn on all farfut area sessions
mars_sessa_checkbox.click() # but turn off mars session a
break_checkbox.click() # also select the break
try:
wait.until(
lambda driver: all(
'?show=secretariat&hide=secretariat-sessb' in el.get_attribute('href')
'?show=farfut,secretariat-sessb&hide=mars-sessa' in el.get_attribute('href')
for el in elements_to_check
))
except TimeoutException:
self.fail('Some agenda links were not updated when secretariat group but not break was selected')
self.assertFalse(mars_checkbox.is_selected(), 'mars session checkbox was unexpectedly selected')
self.assertFalse(break_checkbox.is_selected(), 'break checkbox was unexpectedly selected')
self.assertTrue(registration_checkbox.is_selected(), 'registration checkbox was expected to be selected')
self.fail('Some agenda links were not updated when farfut area was selected')
self.assertFalse(mars_sessa_checkbox.is_selected(), 'mars session A checkbox was unexpectedly selected')
self.assertTrue(mars_sessb_checkbox.is_selected(), 'mars session B checkbox was expected to be selected')
self.assertTrue(break_checkbox.is_selected(), 'break checkbox was expected to be selected')
self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was unexpectedly selected')
@ifSeleniumEnabled
class WeekviewTests(IetfSeleniumTestCase):
@ -1814,7 +1830,7 @@ class WeekviewTests(IetfSeleniumTestCase):
for item in self.get_expected_items():
if item.session.name:
expected_name = item.session.name
elif item.timeslot.type_id == 'break':
elif item.slot_type().slug == 'break':
expected_name = item.timeslot.name
else:
expected_name = item.session.group.name
@ -1839,7 +1855,7 @@ class WeekviewTests(IetfSeleniumTestCase):
for item in self.get_expected_items():
if item.session.name:
expected_name = item.session.name
elif item.timeslot.type_id == 'break':
elif item.slot_type().slug == 'break':
expected_name = item.timeslot.name
else:
expected_name = item.session.group.name
@ -2580,6 +2596,165 @@ class ProceedingsMaterialTests(IetfSeleniumTestCase):
'URL field should be shown by default')
class EditTimeslotsTests(IetfSeleniumTestCase):
"""Test the timeslot editor"""
def setUp(self):
super().setUp()
self.meeting: Meeting = MeetingFactory(
type_id='ietf',
number=120,
date=datetime.datetime.today() + datetime.timedelta(days=10),
populate_schedule=False,
)
self.edit_timeslot_url = self.absreverse(
'ietf.meeting.views.edit_timeslots',
kwargs=dict(num=self.meeting.number),
)
self.wait = WebDriverWait(self.driver, 2)
def do_delete_test(self, selector, keep, delete, cancel=False):
self.login('secretary')
self.driver.get(self.edit_timeslot_url)
delete_button = self.wait.until(
expected_conditions.element_to_be_clickable(
(By.CSS_SELECTOR, selector)
))
delete_button.click()
if cancel:
cancel_button = self.wait.until(
expected_conditions.element_to_be_clickable(
(By.CSS_SELECTOR, '#delete-modal button[data-dismiss="modal"]')
))
cancel_button.click()
else:
confirm_button = self.wait.until(
expected_conditions.element_to_be_clickable(
(By.CSS_SELECTOR, '#confirm-delete-button')
))
confirm_button.click()
self.wait.until(
expected_conditions.invisibility_of_element_located(
(By.CSS_SELECTOR, '#delete-modal')
))
if cancel:
keep.extend(delete)
delete = []
self.assertEqual(
TimeSlot.objects.filter(pk__in=[ts.pk for ts in delete]).count(),
0,
'Not all expected timeslots deleted',
)
self.assertEqual(
TimeSlot.objects.filter(pk__in=[ts.pk for ts in keep]).count(),
len(keep),
'Not all expected timeslots kept'
)
def do_delete_timeslot_test(self, cancel=False):
delete = [TimeSlotFactory(meeting=self.meeting)]
keep = [TimeSlotFactory(meeting=self.meeting)]
self.do_delete_test(
'#timeslot-table #timeslot{} .delete-button'.format(delete[0].pk),
keep,
delete
)
def test_delete_timeslot(self):
"""Delete button for a timeslot should delete that timeslot"""
self.do_delete_timeslot_test(cancel=False)
def test_delete_timeslot_cancel(self):
"""Timeslot should not be deleted on cancel"""
self.do_delete_timeslot_test(cancel=True)
def do_delete_time_interval_test(self, cancel=False):
delete_day = self.meeting.date.date()
delete_time = datetime.time(hour=10)
other_day = self.meeting.get_meeting_date(1).date()
other_time = datetime.time(hour=12)
duration = datetime.timedelta(minutes=60)
delete: [TimeSlot] = TimeSlotFactory.create_batch(
2,
meeting=self.meeting,
time=datetime.datetime.combine(delete_day, delete_time),
duration=duration)
keep: [TimeSlot] = [
TimeSlotFactory(
meeting=self.meeting,
time=datetime.datetime.combine(day, time),
duration=duration
)
for (day, time) in (
# combinations of day/time that should not be deleted
(delete_day, other_time),
(other_day, delete_time),
(other_day, other_time),
)
]
selector = (
'#timeslot-table '
'.delete-button[data-delete-scope="column"]'
'[data-col-id="{}T{}-{}"]'.format(
delete_day.isoformat(),
delete_time.strftime('%H:%M'),
(datetime.datetime.combine(delete_day, delete_time) + duration).strftime(
'%H:%M'
))
)
self.do_delete_test(selector, keep, delete, cancel)
def test_delete_time_interval(self):
"""Delete button for a time interval should delete all timeslots in that interval"""
self.do_delete_time_interval_test(cancel=False)
def test_delete_time_interval_cancel(self):
"""Should not delete a time interval on cancel"""
self.do_delete_time_interval_test(cancel=True)
def do_delete_day_test(self, cancel=False):
delete_day = self.meeting.date.date()
times = [datetime.time(hour=10), datetime.time(hour=12)]
other_days = [self.meeting.get_meeting_date(d).date() for d in range(1, 3)]
delete: [TimeSlot] = [
TimeSlotFactory(
meeting=self.meeting,
time=datetime.datetime.combine(delete_day, time),
) for time in times
]
keep: [TimeSlot] = [
TimeSlotFactory(
meeting=self.meeting,
time=datetime.datetime.combine(day, time),
) for day in other_days for time in times
]
selector = (
'#timeslot-table '
'.delete-button[data-delete-scope="day"][data-date-id="{}"]'.format(
delete_day.isoformat()
)
)
self.do_delete_test(selector, keep, delete, cancel)
def test_delete_day(self):
"""Delete button for a day should delete all timeslots on that day"""
self.do_delete_day_test(cancel=False)
def test_delete_day_cancel(self):
"""Should not delete a day on cancel"""
self.do_delete_day_test(cancel=True)
# The following are useful debugging tools
# If you add this to a LiveServerTestCase and run just this test, you can browse

File diff suppressed because it is too large Load diff

View file

@ -53,6 +53,8 @@ type_ietf_only_patterns = [
url(r'^agendas/diff/$', views.diff_schedules),
url(r'^agenda/new/$', views.new_meeting_schedule),
url(r'^timeslots/edit$', views.edit_timeslots),
url(r'^timeslot/new$', views.create_timeslot),
url(r'^timeslot/(?P<slot_id>\d+)/edit$', views.edit_timeslot),
url(r'^timeslot/(?P<slot_id>\d+)/edittype$', views.edit_timeslot_type),
url(r'^rooms$', ajax.timeslot_roomsurl),
url(r'^room/(?P<roomid>\d+).json$', ajax.timeslot_roomurl),

View file

@ -529,6 +529,14 @@ def swap_meeting_schedule_timeslot_assignments(schedule, source_timeslots, targe
for a in lts_assignments:
a.delete()
def bulk_create_timeslots(meeting, times, locations, other_props):
"""Creates identical timeslots for Cartesian product of times and locations"""
for time in times:
for loc in locations:
properties = dict(time=time, location=loc)
properties.update(other_props)
meeting.timeslot_set.create(**properties)
def preprocess_meeting_important_dates(meetings):
for m in meetings:
m.cached_updated = m.updated()

View file

@ -57,16 +57,17 @@ from ietf.ietfauth.utils import role_required, has_role, user_is_person
from ietf.mailtrigger.utils import gather_address_lists
from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission
from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName
from ietf.meeting.forms import CustomDurationField, SwapDaysForm, SwapTimeslotsForm
from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm,
TimeSlotCreateForm, TimeSlotEditForm )
from ietf.meeting.helpers import get_areas, get_person_by_email, get_schedule_by_name
from ietf.meeting.helpers import build_all_agenda_slices, get_wg_name_list
from ietf.meeting.helpers import get_all_assignments_from_schedule
from ietf.meeting.helpers import get_modified_from_assignments
from ietf.meeting.helpers import get_wg_list, find_ads_for_meeting
from ietf.meeting.helpers import get_meeting, get_ietf_meeting, get_current_ietf_meeting_num
from ietf.meeting.helpers import get_schedule, schedule_permissions, is_regular_agenda_filter_group
from ietf.meeting.helpers import get_schedule, schedule_permissions
from ietf.meeting.helpers import preprocess_assignments_for_agenda, read_agenda_file
from ietf.meeting.helpers import filter_keywords_for_session, tag_assignments_with_filter_keywords, filter_keyword_for_specific_session
from ietf.meeting.helpers import AgendaFilterOrganizer, AgendaKeywordTagger
from ietf.meeting.helpers import convert_draft_to_pdf, get_earliest_session_date
from ietf.meeting.helpers import can_view_interim_request, can_approve_interim_request
from ietf.meeting.helpers import can_edit_interim_request
@ -84,7 +85,7 @@ from ietf.meeting.utils import current_session_status
from ietf.meeting.utils import data_for_meetings_overview
from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_editor
from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_objects
from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments
from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments, bulk_create_timeslots
from ietf.meeting.utils import preprocess_meeting_important_dates
from ietf.message.utils import infer_message
from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName
@ -365,15 +366,48 @@ def edit_timeslots(request, num=None):
meeting = get_meeting(num)
time_slices,date_slices,slots = meeting.build_timeslices()
if request.method == 'POST':
# handle AJAX requests
action = request.POST.get('action')
if action == 'delete':
# delete a timeslot
# Parameters:
# slot_id: comma-separated list of TimeSlot PKs to delete
slot_id = request.POST.get('slot_id')
if slot_id is None:
return HttpResponseBadRequest('missing slot_id')
slot_ids = [id.strip() for id in slot_id.split(',')]
try:
timeslots = meeting.timeslot_set.filter(pk__in=slot_ids)
except ValueError:
return HttpResponseBadRequest('invalid slot_id specification')
missing_ids = set(slot_ids).difference(str(ts.pk) for ts in timeslots)
if len(missing_ids) != 0:
return HttpResponseNotFound('TimeSlot ids not found in meeting {}: {}'.format(
meeting.number,
', '.join(sorted(missing_ids))
))
timeslots.delete()
return HttpResponse(content='; '.join('Deleted TimeSlot {}'.format(id) for id in slot_ids))
else:
return HttpResponseBadRequest('unknown action')
# Labels here differ from those in the build_timeslices() method. The labels here are
# relative to the table: time_slices are the row headings (ie, days), date_slices are
# the column headings (i.e., time intervals), and slots are the per-day list of time slots
# (with only one time slot per unique time/duration)
time_slices, date_slices, slots = meeting.build_timeslices()
ts_list = deque()
rooms = meeting.room_set.order_by("capacity","name","id")
for room in rooms:
for day in time_slices:
for slice in date_slices[day]:
ts_list.append(room.timeslot_set.filter(time=slice[0],duration=datetime.timedelta(seconds=slice[2])).first())
ts_list.append(room.timeslot_set.filter(time=slice[0],duration=datetime.timedelta(seconds=slice[2])))
# Grab these in one query each to identify sessions that are in use and should be handled with care
ts_with_official_assignments = meeting.timeslot_set.filter(sessionassignments__schedule=meeting.schedule)
ts_with_any_assignments = meeting.timeslot_set.filter(sessionassignments__isnull=False)
return render(request, "meeting/timeslot_edit.html",
{"rooms":rooms,
@ -382,6 +416,8 @@ def edit_timeslots(request, num=None):
"date_slices":date_slices,
"meeting":meeting,
"ts_list":ts_list,
"ts_with_official_assignments": ts_with_official_assignments,
"ts_with_any_assignments": ts_with_any_assignments,
})
class NewScheduleForm(forms.ModelForm):
@ -496,7 +532,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
assignments = SchedTimeSessAssignment.objects.filter(
schedule__in=[schedule, schedule.base],
timeslot__location__isnull=False,
session__type='regular',
# session__type='regular',
).order_by('timeslot__time','timeslot__name')
assignments_by_session = defaultdict(list)
@ -508,7 +544,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
sessions = add_event_info_to_session_qs(
Session.objects.filter(
meeting=meeting,
type='regular',
# type='regular',
).order_by('pk'),
requested_time=True,
requested_by=True,
@ -519,7 +555,10 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
'resources', 'group', 'group__parent', 'group__type', 'joint_with_groups',
)
timeslots_qs = TimeSlot.objects.filter(meeting=meeting, type='regular').prefetch_related('type').order_by('location', 'time', 'name')
timeslots_qs = TimeSlot.objects.filter(
meeting=meeting,
# type='regular',
).prefetch_related('type').order_by('location', 'time', 'name')
min_duration = min(t.duration for t in timeslots_qs)
max_duration = max(t.duration for t in timeslots_qs)
@ -552,7 +591,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
s.requested_by_person = requested_by_lookup.get(s.requested_by)
s.scheduling_label = "???"
if s.group:
if (s.purpose is None or s.purpose.slug == 'regular') and s.group:
s.scheduling_label = s.group.acronym
elif s.name:
s.scheduling_label = s.name
@ -1035,10 +1074,13 @@ class TimeSlotForm(forms.Form):
if not short:
self.add_error('short', 'When scheduling this type of time slot, a short name is required')
if self.timeslot and self.timeslot.type_id == 'regular' and self.active_assignment and ts_type.pk != self.timeslot.type_id:
if self.timeslot and self.timeslot.type.slug == 'regular' and self.active_assignment and ts_type.slug != self.timeslot.type.slug:
self.add_error('type', "Can't change type on time slots for regular sessions when a session has been assigned")
if self.active_assignment and self.active_assignment.session.group != self.cleaned_data.get('group') and self.active_assignment.session.materials.exists() and self.timeslot.type_id != 'regular':
if (self.active_assignment
and self.active_assignment.session.group != self.cleaned_data.get('group')
and self.active_assignment.session.materials.exists()
and self.timeslot.type.slug != 'regular'):
self.add_error('group', "Can't change group after materials have been uploaded")
@ -1450,6 +1492,7 @@ def list_schedules(request, num):
return render(request, "meeting/schedule_list.html", {
'meeting': meeting,
'schedule_groups': schedule_groups,
'can_edit_timeslots': is_secretariat,
})
class DiffSchedulesForm(forms.Form):
@ -1523,110 +1566,6 @@ def get_assignments_for_agenda(schedule):
)
def extract_groups_hierarchy(prepped_assignments):
"""Extract groups hierarchy for agenda display
It's a little bit complicated because we can be dealing with historic groups.
"""
seen = set()
groups = [a.session.historic_group for a in prepped_assignments
if a.session
and a.session.historic_group
and is_regular_agenda_filter_group(a.session.historic_group)
and a.session.historic_group.historic_parent]
group_parents = []
for g in groups:
if g.historic_parent.acronym not in seen:
group_parents.append(g.historic_parent)
seen.add(g.historic_parent.acronym)
seen = set()
for p in group_parents:
p.group_list = []
for g in groups:
if g.acronym not in seen and g.historic_parent.acronym == p.acronym:
p.group_list.append(g)
seen.add(g.acronym)
p.group_list.sort(key=lambda g: g.acronym)
return group_parents
def prepare_filter_keywords(tagged_assignments, group_parents):
#
# The agenda_filter template expects a list of categorized header buttons, each
# with a list of children. Make two categories: the IETF areas and the other parent groups.
# We also pass a list of 'extra' buttons - currently Office Hours and miscellaneous filters.
# All but the last of these are additionally used by the agenda.html template to make
# a list of filtered ical buttons. The last group is ignored for this.
area_group_filters = []
other_group_filters = []
extra_filters = []
for p in group_parents:
new_filter = dict(
label=p.acronym.upper(),
keyword=p.acronym.lower(),
children=[
dict(
label=g.acronym,
keyword=g.acronym.lower(),
is_bof=g.is_bof(),
) for g in p.group_list
]
)
if p.type.slug == 'area':
area_group_filters.append(new_filter)
else:
other_group_filters.append(new_filter)
office_hours_labels = set()
for a in tagged_assignments:
suffix = ' office hours'
if a.session.name.lower().endswith(suffix):
office_hours_labels.add(a.session.name[:-len(suffix)].strip())
if len(office_hours_labels) > 0:
# keyword needs to match what's tagged in filter_keywords_for_session()
extra_filters.append(dict(
label='Office Hours',
keyword='officehours',
children=[
dict(
label=label,
keyword=label.lower().replace(' ', '')+'officehours',
is_bof=False,
) for label in office_hours_labels
]
))
# Keywords that should appear in 'non-area' column
non_area_labels = [
'BOF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools',
]
# Remove any unused non-area keywords
non_area_filters = [
dict(label=label, keyword=label.lower(), is_bof=False)
for label in non_area_labels if any([
label.lower() in assignment.filter_keywords
for assignment in tagged_assignments
])
]
if len(non_area_filters) > 0:
extra_filters.append(dict(
label=None,
keyword=None,
children=non_area_filters,
))
area_group_filters.sort(key=lambda p:p['label'])
other_group_filters.sort(key=lambda p:p['label'])
filter_categories = [category
for category in [area_group_filters, other_group_filters, extra_filters]
if len(category) > 0]
return filter_categories, non_area_labels
@ensure_csrf_cookie
def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""):
base = base if base else 'agenda'
@ -1667,17 +1606,13 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""
get_assignments_for_agenda(schedule),
meeting
)
tag_assignments_with_filter_keywords(filtered_assignments)
AgendaKeywordTagger(assignments=filtered_assignments).apply()
# Done processing for CSV output
if ext == ".csv":
return agenda_csv(schedule, filtered_assignments)
# Now prep the filter UI
filter_categories, non_area_labels = prepare_filter_keywords(
filtered_assignments,
extract_groups_hierarchy(filtered_assignments),
)
filter_organizer = AgendaFilterOrganizer(assignments=filtered_assignments)
is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num())
@ -1685,8 +1620,8 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""
"schedule": schedule,
"filtered_assignments": filtered_assignments,
"updated": updated,
"filter_categories": filter_categories,
"non_area_keywords": [label.lower() for label in non_area_labels],
"filter_categories": filter_organizer.get_filter_categories(),
"non_area_keywords": filter_organizer.get_non_area_keywords(),
"now": datetime.datetime.now().astimezone(pytz.UTC),
"timezone": meeting.time_zone,
"is_current_meeting": is_current_meeting,
@ -1728,23 +1663,23 @@ def agenda_csv(schedule, filtered_assignments):
row.append(item.timeslot.time.strftime("%H%M"))
row.append(item.timeslot.end_time().strftime("%H%M"))
if item.timeslot.type_id == "break":
row.append(item.timeslot.type.name)
if item.slot_type().slug == "break":
row.append(item.slot_type().name)
row.append(schedule.meeting.break_area)
row.append("")
row.append("")
row.append("")
row.append(item.timeslot.name)
row.append("b{}".format(item.timeslot.pk))
elif item.timeslot.type_id == "reg":
row.append(item.timeslot.type.name)
elif item.slot_type().slug == "reg":
row.append(item.slot_type().name)
row.append(schedule.meeting.reg_area)
row.append("")
row.append("")
row.append("")
row.append(item.timeslot.name)
row.append("r{}".format(item.timeslot.pk))
elif item.timeslot.type_id == "other":
elif item.slot_type().slug == "other":
row.append("None")
row.append(item.timeslot.location.name if item.timeslot.location else "")
row.append("")
@ -1752,7 +1687,7 @@ def agenda_csv(schedule, filtered_assignments):
row.append(item.session.historic_group.historic_parent.acronym.upper() if item.session.historic_group.historic_parent else "")
row.append(item.session.name)
row.append(item.session.pk)
elif item.timeslot.type_id == "plenary":
elif item.slot_type().slug == "plenary":
row.append(item.session.name)
row.append(item.timeslot.location.name if item.timeslot.location else "")
row.append("")
@ -1762,7 +1697,7 @@ def agenda_csv(schedule, filtered_assignments):
row.append(item.session.pk)
row.append(agenda_field(item))
row.append(slides_field(item))
elif item.timeslot.type_id == 'regular':
elif item.slot_type().slug == 'regular':
row.append(item.timeslot.name)
row.append(item.timeslot.location.name if item.timeslot.location else "")
row.append(item.session.historic_group.historic_parent.acronym.upper() if item.session.historic_group.historic_parent else "")
@ -1842,16 +1777,12 @@ def agenda_personalize(request, num):
get_assignments_for_agenda(meeting.schedule),
meeting
)
tag_assignments_with_filter_keywords(filtered_assignments)
for assignment in filtered_assignments:
# may be None for some sessions
assignment.session_keyword = filter_keyword_for_specific_session(assignment.session)
tagger = AgendaKeywordTagger(assignments=filtered_assignments)
tagger.apply() # annotate assignments with filter_keywords attribute
tagger.apply_session_keywords() # annotate assignments with session_keyword attribute
# Now prep the filter UI
filter_categories, non_area_labels = prepare_filter_keywords(
filtered_assignments,
extract_groups_hierarchy(filtered_assignments),
)
filter_organizer = AgendaFilterOrganizer(assignments=filtered_assignments)
is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num())
@ -1862,8 +1793,8 @@ def agenda_personalize(request, num):
'schedule': meeting.schedule,
'updated': meeting.updated(),
'filtered_assignments': filtered_assignments,
'filter_categories': filter_categories,
'non_area_labels': non_area_labels,
'filter_categories': filter_organizer.get_filter_categories(),
'non_area_labels': filter_organizer.get_non_area_keywords(),
'timezone': meeting.time_zone,
'is_current_meeting': is_current_meeting,
'cache_time': 150 if is_current_meeting else 3600,
@ -1989,7 +1920,7 @@ def week_view(request, num=None, name=None, owner=None):
timeslot__type__private=False,
)
filtered_assignments = preprocess_assignments_for_agenda(filtered_assignments, meeting)
tag_assignments_with_filter_keywords(filtered_assignments)
AgendaKeywordTagger(assignments=filtered_assignments).apply()
items = []
for a in filtered_assignments:
@ -1998,7 +1929,7 @@ def week_view(request, num=None, name=None, owner=None):
"key": str(a.timeslot.pk),
"utc_time": a.timeslot.utc_start_time().strftime("%Y%m%dT%H%MZ"), # ISO8601 compliant
"duration": a.timeslot.duration.seconds,
"type": a.timeslot.type.name,
"type": a.slot_type().name,
"filter_keywords": ",".join(a.filter_keywords),
}
@ -2008,10 +1939,10 @@ def week_view(request, num=None, name=None, owner=None):
if a.session.name:
item["name"] = a.session.name
elif a.timeslot.type_id == "break":
elif a.slot_type().slug == "break":
item["name"] = a.timeslot.name
item["area"] = a.timeslot.type_id
item["group"] = a.timeslot.type_id
item["area"] = a.slot_type().slug
item["group"] = a.slot_type().slug
elif a.session.historic_group:
item["name"] = a.session.historic_group.name
if a.session.historic_group.state_id == "bof":
@ -2172,7 +2103,7 @@ def agenda_ical(request, num=None, name=None, acronym=None, session_id=None):
timeslot__type__private=False,
)
assignments = preprocess_assignments_for_agenda(assignments, meeting)
tag_assignments_with_filter_keywords(assignments)
AgendaKeywordTagger(assignments=assignments).apply()
try:
filt_params = parse_agenda_filter_params(request.GET)
@ -2333,7 +2264,7 @@ def meeting_requests(request, num=None):
sessions = add_event_info_to_session_qs(
Session.objects.filter(
meeting__number=meeting.number,
type__slug='regular',
# type__slug='regular',
group__parent__isnull=False
),
requested_by=True,
@ -3625,37 +3556,12 @@ def upcoming(request):
)
).filter(current_status__in=('sched','canceled'))
# get groups for group UI display - same algorithm as in agenda(), but
# using group / parent instead of historic_group / historic_parent
groups = [s.group for s in interim_sessions
if s.group
and is_regular_agenda_filter_group(s.group)
and s.group.parent]
group_parents = {g.parent for g in groups if g.parent}
seen = set()
for p in group_parents:
p.group_list = []
for g in groups:
if g.acronym not in seen and g.parent.acronym == p.acronym:
p.group_list.append(g)
seen.add(g.acronym)
# only one category
filter_categories = [[
dict(
label=p.acronym,
keyword=p.acronym.lower(),
children=[dict(
label=g.acronym,
keyword=g.acronym.lower(),
is_bof=g.is_bof(),
) for g in p.group_list]
) for p in group_parents
]]
for session in interim_sessions:
session.historic_group = session.group
session.filter_keywords = filter_keywords_for_session(session)
# Set up for agenda filtering - only one filter_category here
AgendaKeywordTagger(sessions=interim_sessions).apply()
filter_organizer = AgendaFilterOrganizer(sessions=interim_sessions, single_category=True)
entries = list(ietf_meetings)
entries.extend(list(interim_sessions))
@ -3694,7 +3600,7 @@ def upcoming(request):
return render(request, 'meeting/upcoming.html', {
'entries': entries,
'filter_categories': filter_categories,
'filter_categories': filter_organizer.get_filter_categories(),
'menu_actions': actions,
'menu_entries': menu_entries,
'selected_menu_entry': selected_menu_entry,
@ -3728,7 +3634,7 @@ def upcoming_ical(request):
'session__group', 'session__group__parent', 'timeslot', 'schedule', 'schedule__meeting'
).distinct())
tag_assignments_with_filter_keywords(assignments)
AgendaKeywordTagger(assignments=assignments).apply()
# apply filters
if filter_params is not None:
@ -3820,7 +3726,7 @@ def proceedings(request, num=None):
plenaries = sessions.filter(name__icontains='plenary').exclude(current_status='notmeet')
ietf = sessions.filter(group__parent__type__slug = 'area').exclude(group__acronym='edu')
irtf = sessions.filter(group__parent__acronym = 'irtf')
training = sessions.filter(group__acronym__in=['edu','iaoc'], type_id__in=['regular', 'other', ]).exclude(current_status='notmeet')
training = sessions.filter(group__acronym__in=['edu','iaoc'], type_id__in=['regular', 'other',]).exclude(current_status='notmeet')
iab = sessions.filter(group__parent__acronym = 'iab').exclude(current_status='notmeet')
cache_version = Document.objects.filter(session__meeting__number=meeting.number).aggregate(Max('time'))["time__max"]
@ -4120,6 +4026,60 @@ def edit_timeslot_type(request, num, slot_id):
return render(request, 'meeting/edit_timeslot_type.html', {'timeslot':timeslot,'form':form,'sessions':sessions})
@role_required('Secretariat')
def edit_timeslot(request, num, slot_id):
timeslot = get_object_or_404(TimeSlot, id=slot_id)
meeting = get_object_or_404(Meeting, number=num)
if timeslot.meeting != meeting:
raise Http404()
if request.method == 'POST':
form = TimeSlotEditForm(instance=timeslot, data=request.POST)
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse('ietf.meeting.views.edit_timeslots', kwargs={'num': num}))
else:
form = TimeSlotEditForm(instance=timeslot)
sessions = timeslot.sessions.filter(
timeslotassignments__schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None])
return render(
request,
'meeting/edit_timeslot.html',
{'timeslot': timeslot, 'form': form, 'sessions': sessions},
status=400 if form.errors else 200,
)
@role_required('Secretariat')
def create_timeslot(request, num):
meeting = get_object_or_404(Meeting, number=num)
if request.method == 'POST':
form = TimeSlotCreateForm(meeting, data=request.POST)
if form.is_valid():
bulk_create_timeslots(
meeting,
[datetime.datetime.combine(day, form.cleaned_data['time'])
for day in form.cleaned_data.get('days', [])],
form.cleaned_data['locations'],
dict(
name=form.cleaned_data['name'],
type=form.cleaned_data['type'],
duration=form.cleaned_data['duration'],
show_location=form.cleaned_data['show_location'],
)
)
return HttpResponseRedirect(reverse('ietf.meeting.views.edit_timeslots',kwargs={'num':num}))
else:
form = TimeSlotCreateForm(meeting)
return render(
request,
'meeting/create_timeslot.html',
dict(meeting=meeting, form=form),
status=400 if form.errors else 200,
)
@role_required('Secretariat')
def request_minutes(request, num=None):

View file

@ -11,7 +11,8 @@ from ietf.name.models import (
ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName,
SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, TopicAudienceName,
DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName, TimerangeName,
ExtResourceName, ExtResourceTypeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName)
ExtResourceName, ExtResourceTypeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName,
AgendaFilterTypeName, SessionPurposeName )
from ietf.stats.models import CountryAlias
@ -56,6 +57,7 @@ class ProceedingsMaterialTypeNameAdmin(NameAdmin):
list_display = ["slug", "name", "desc", "used", "order",]
admin.site.register(ProceedingsMaterialTypeName, ProceedingsMaterialTypeNameAdmin)
admin.site.register(AgendaFilterTypeName, NameAdmin)
admin.site.register(AgendaTypeName, NameAdmin)
admin.site.register(BallotPositionName, NameAdmin)
admin.site.register(ConstraintName, NameAdmin)
@ -94,3 +96,4 @@ admin.site.register(TopicAudienceName, NameAdmin)
admin.site.register(DocUrlTagName, NameAdmin)
admin.site.register(ExtResourceTypeName, NameAdmin)
admin.site.register(SlideSubmissionStatusName, NameAdmin)
admin.site.register(SessionPurposeName, NameAdmin)

View file

@ -2592,6 +2592,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "special",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": false,
@ -2629,6 +2630,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "none",
"agenda_type": "ietf",
"create_wiki": false,
"custom_group_roles": false,
@ -2664,6 +2666,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": true,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "normal",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": true,
@ -2702,6 +2705,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"ad\"\n]",
"agenda_filter_type": "heading",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": true,
@ -2739,6 +2743,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\",\n \"secr\"\n]",
"agenda_filter_type": "none",
"agenda_type": "ad",
"create_wiki": true,
"custom_group_roles": true,
@ -2776,6 +2781,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "normal",
"agenda_type": "ietf",
"create_wiki": false,
"custom_group_roles": true,
@ -2850,6 +2856,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "none",
"agenda_type": "ietf",
"create_wiki": false,
"custom_group_roles": false,
@ -2885,6 +2892,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "none",
"agenda_type": "ad",
"create_wiki": false,
"custom_group_roles": true,
@ -2920,6 +2928,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\",\n \"lead\"\n]",
"agenda_filter_type": "heading",
"agenda_type": "ietf",
"create_wiki": false,
"custom_group_roles": true,
@ -2957,6 +2966,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "none",
"agenda_type": null,
"create_wiki": false,
"custom_group_roles": true,
@ -2994,6 +3004,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "heading",
"agenda_type": "ietf",
"create_wiki": false,
"custom_group_roles": true,
@ -3031,6 +3042,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\",\n \"lead\"\n]",
"agenda_filter_type": "none",
"agenda_type": "ad",
"create_wiki": false,
"custom_group_roles": true,
@ -3066,6 +3078,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "none",
"agenda_type": null,
"create_wiki": false,
"custom_group_roles": true,
@ -3103,6 +3116,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\",\n \"advisor\"\n]",
"agenda_filter_type": "none",
"agenda_type": "side",
"create_wiki": true,
"custom_group_roles": true,
@ -3140,6 +3154,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"lead\"\n]",
"agenda_filter_type": "normal",
"agenda_type": "ad",
"create_wiki": false,
"custom_group_roles": true,
@ -3177,6 +3192,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": true,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "normal",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": true,
@ -3214,6 +3230,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\",\n \"secr\"\n]",
"agenda_filter_type": "normal",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": true,
@ -3251,6 +3268,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "none",
"agenda_type": "side",
"create_wiki": false,
"custom_group_roles": true,
@ -3286,6 +3304,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": true,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "normal",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": false,
@ -3323,6 +3342,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "none",
"agenda_type": null,
"create_wiki": false,
"custom_group_roles": true,
@ -3361,6 +3381,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": false,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "special",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": true,
@ -3398,6 +3419,7 @@
"about_page": "ietf.group.views.group_about",
"acts_like_wg": true,
"admin_roles": "[\n \"chair\"\n]",
"agenda_filter_type": "normal",
"agenda_type": "ietf",
"create_wiki": true,
"custom_group_roles": false,
@ -6095,6 +6117,46 @@
"model": "meeting.businessconstraint",
"pk": "sessions_out_of_order"
},
{
"fields": {
"desc": "Column heading button",
"name": "Heading",
"order": 2,
"used": true
},
"model": "name.agendafiltertypename",
"pk": "heading"
},
{
"fields": {
"desc": "Not used except for a timeslot-type column (e.g., officehours)",
"name": "None",
"order": 0,
"used": true
},
"model": "name.agendafiltertypename",
"pk": "none"
},
{
"fields": {
"desc": "Non-heading filter button",
"name": "Normal",
"order": 1,
"used": true
},
"model": "name.agendafiltertypename",
"pk": "normal"
},
{
"fields": {
"desc": "Button in the catch-all column",
"name": "Special",
"order": 3,
"used": true
},
"model": "name.agendafiltertypename",
"pk": "special"
},
{
"fields": {
"desc": "",
@ -13129,6 +13191,17 @@
"model": "name.timeslottypename",
"pk": "offagenda"
},
{
"fields": {
"desc": "Office hours timeslot",
"name": "Office Hours",
"order": 0,
"private": false,
"used": true
},
"model": "name.timeslottypename",
"pk": "officehours"
},
{
"fields": {
"desc": "",

View file

@ -0,0 +1,27 @@
# Generated by Django 2.2.19 on 2021-04-02 12:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('name', '0031_add_procmaterials'),
]
operations = [
migrations.CreateModel(
name='AgendaFilterTypeName',
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,
},
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 2.2.20 on 2021-04-20 13:56
from django.db import migrations
def forward(apps, schema_editor):
AgendaFilterTypeName = apps.get_model('name', 'AgendaFilterTypeName')
names = (
('none', 'None', 'Not used except for a timeslot-type column (e.g., officehours)'),
('normal', 'Normal', 'Non-heading filter button'),
('heading', 'Heading', 'Column heading button'),
('special', 'Special', 'Button in the catch-all column'),
)
for order, (slug, name, desc) in enumerate(names):
AgendaFilterTypeName.objects.get_or_create(
slug=slug,
defaults=dict(name=name, desc=desc, order=order, used=True)
)
def reverse(apps, schema_editor):
pass # nothing to do, model about to be destroyed anyway
class Migration(migrations.Migration):
dependencies = [
('name', '0032_agendafiltertypename'),
]
operations = [
migrations.RunPython(forward, reverse),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 2.2.19 on 2021-03-29 08:28
from django.db import migrations
def forward(apps, schema_editor):
TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName')
TimeSlotTypeName.objects.get_or_create(
slug='officehours',
defaults=dict(
name='Office Hours',
desc='Office hours timeslot',
used=True,
)
)
def reverse(apps, schema_editor):
pass # don't remove the name when migrating
class Migration(migrations.Migration):
dependencies = [
('name', '0033_populate_agendafiltertypename'),
]
operations = [
migrations.RunPython(forward, reverse),
]

View file

@ -0,0 +1,32 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-09-16 09:42
from django.db import migrations, models
import ietf.name.models
import jsonfield
class Migration(migrations.Migration):
dependencies = [
('name', '0034_add_officehours_timeslottypename'),
]
operations = [
migrations.CreateModel(
name='SessionPurposeName',
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)),
('timeslot_types', jsonfield.fields.JSONField(default=[], help_text='Allowed TimeSlotTypeNames', max_length=256, validators=[ietf.name.models.JSONForeignKeyListValidator('name.TimeSlotTypeName')])),
],
options={
'ordering': ['order', 'name'],
'abstract': False,
},
),
]

View file

@ -0,0 +1,48 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-09-16 09:42
from django.db import migrations
def forward(apps, schema_editor):
SessionPurposeName = apps.get_model('name', 'SessionPurposeName')
TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName')
for order, (slug, name, desc, tstypes) in enumerate((
('session', 'Session', 'Group session', ['regular']),
('tutorial', 'Tutorial', 'Tutorial or training session', ['other']),
('officehours', 'Office hours', 'Office hours session', ['other']),
('coding', 'Coding', 'Coding session', ['other']),
('admin', 'Administrative', 'Meeting administration', ['other', 'reg']),
('social', 'Social', 'Social event or activity', ['other']),
('presentation', 'Presentation', 'Presentation session', ['other', 'regular'])
)):
# verify that we're not about to use an invalid purpose
for ts_type in tstypes:
TimeSlotTypeName.objects.get(pk=ts_type) # throws an exception unless exists
SessionPurposeName.objects.create(
slug=slug,
name=name,
desc=desc,
used=True,
order=order,
timeslot_types = tstypes
)
def reverse(apps, schema_editor):
SessionPurposeName = apps.get_model('name', 'SessionPurposeName')
SessionPurposeName.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('name', '0035_sessionpurposename'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -1,10 +1,13 @@
# Copyright The IETF Trust 2010-2020, All Rights Reserved
# -*- coding: utf-8 -*-
import jsonfield
from django.db import models
from ietf.utils.models import ForeignKey
from ietf.utils.validators import JSONForeignKeyListValidator
class NameModel(models.Model):
slug = models.CharField(max_length=32, primary_key=True)
@ -64,8 +67,17 @@ class ProceedingsMaterialTypeName(NameModel):
"""social_event, host_speaker_series, supporters, wiki, additional_information"""
class AgendaTypeName(NameModel):
"""ietf, ad, side, workshop, ..."""
class AgendaFilterTypeName(NameModel):
"""none, normal, heading, special"""
class SessionStatusName(NameModel):
"""Waiting for Approval, Approved, Waiting for Scheduling, Scheduled, Cancelled, Disapproved"""
class SessionPurposeName(NameModel):
"""Regular, Tutorial, Office Hours, Coding, Social, Admin"""
timeslot_types = jsonfield.JSONField(
max_length=256, blank=False, default=[],
help_text='Allowed TimeSlotTypeNames',
validators=[JSONForeignKeyListValidator('name.TimeSlotTypeName')],
)
class TimeSlotTypeName(NameModel):
"""Session, Break, Registration, Other, Reserved, unavail"""
private = models.BooleanField(default=False, help_text="Whether sessions of this type should be kept off the public agenda")

View file

@ -8,7 +8,7 @@ from tastypie.cache import SimpleCache
from ietf import api
from ietf.name.models import ( AgendaTypeName, BallotPositionName, ConstraintName,
from ietf.name.models import ( AgendaFilterTypeName, AgendaTypeName, BallotPositionName, ConstraintName,
ContinentName, CountryName, DBTemplateTypeName, DocRelationshipName, DocReminderTypeName,
DocTagName, DocTypeName, DocUrlTagName, DraftSubmissionStateName, FeedbackTypeName,
FormalLanguageName, GroupMilestoneStateName, GroupStateName, GroupTypeName,
@ -684,3 +684,20 @@ class ProceedingsMaterialTypeNameResource(ModelResource):
"order": ALL,
}
api.name.register(ProceedingsMaterialTypeNameResource())
class AgendaFilterTypeNameResource(ModelResource):
class Meta:
queryset = AgendaFilterTypeName.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'agendafiltertypename'
ordering = ['slug', ]
filtering = {
"slug": ALL,
"name": ALL,
"desc": ALL,
"used": ALL,
"order": ALL,
}
api.name.register(AgendaFilterTypeNameResource())

View file

@ -8,8 +8,9 @@ from django.db.models import Q
import debug # pyflakes:ignore
from ietf.group.models import Group
from ietf.meeting.fields import SessionPurposeAndTypeField
from ietf.meeting.models import Meeting, Room, TimeSlot, Session, SchedTimeSessAssignment
from ietf.name.models import TimeSlotTypeName
from ietf.name.models import TimeSlotTypeName, SessionPurposeName
import ietf.utils.fields
@ -130,6 +131,13 @@ class MeetingRoomForm(forms.ModelForm):
model = Room
exclude = ['resources']
class MeetingRoomOptionsForm(forms.Form):
copy_timeslots = forms.BooleanField(
required=False,
initial=False,
label='Duplicate timeslots from previous meeting for new rooms?',
)
class TimeSlotForm(forms.Form):
day = forms.ChoiceField()
time = forms.TimeField()
@ -163,7 +171,10 @@ class TimeSlotForm(forms.Form):
class MiscSessionForm(TimeSlotForm):
short = forms.CharField(max_length=32,label='Short Name',help_text='Enter an abbreviated session name (used for material file names)',required=False)
type = forms.ModelChoiceField(queryset=TimeSlotTypeName.objects.filter(used=True).exclude(slug__in=('regular',)),empty_label=None)
purpose = SessionPurposeAndTypeField(
purpose_queryset=SessionPurposeName.objects.none(),
type_queryset=TimeSlotTypeName.objects.none(),
)
group = forms.ModelChoiceField(
queryset=Group.objects.filter(
Q(type__in=['ietf','team','area'],state='active')|
@ -187,8 +198,13 @@ class MiscSessionForm(TimeSlotForm):
self.meeting = kwargs.pop('meeting')
if 'session' in kwargs:
self.session = kwargs.pop('session')
initial = kwargs.setdefault('initial', dict())
initial['purpose'] = (initial.pop('purpose', ''), initial.pop('type', ''))
super(MiscSessionForm, self).__init__(*args,**kwargs)
self.fields['location'].queryset = Room.objects.filter(meeting=self.meeting)
self.fields['purpose'].purpose_queryset = SessionPurposeName.objects.filter(
used=True).exclude(slug='session').order_by('name')
self.fields['purpose'].type_queryset = TimeSlotTypeName.objects.filter(used=True)
def clean(self):
super(MiscSessionForm, self).clean()
@ -196,13 +212,15 @@ class MiscSessionForm(TimeSlotForm):
return
cleaned_data = self.cleaned_data
group = cleaned_data['group']
type = cleaned_data['type']
type = cleaned_data['purpose'].type
short = cleaned_data['short']
if type.slug in ('other','plenary','lead') and not group:
raise forms.ValidationError('ERROR: a group selection is required')
if type.slug in ('other','plenary','lead') and not short:
raise forms.ValidationError('ERROR: a short name is required')
cleaned_data['purpose'] = cleaned_data['purpose'].purpose
cleaned_data['type'] = type
return cleaned_data
def clean_group(self):

View file

@ -26,7 +26,7 @@ from ietf.group.models import Group, GroupEvent
from ietf.secr.meetings.blue_sheets import create_blue_sheets
from ietf.secr.meetings.forms import ( BaseMeetingRoomFormSet, MeetingModelForm, MeetingSelectForm,
MeetingRoomForm, MiscSessionForm, TimeSlotForm, RegularSessionEditForm,
UploadBlueSheetForm )
UploadBlueSheetForm, MeetingRoomOptionsForm )
from ietf.secr.proceedings.utils import handle_upload_file
from ietf.secr.sreq.views import get_initial_session
from ietf.secr.utils.meeting import get_session, get_timeslot
@ -406,9 +406,11 @@ def misc_sessions(request, meeting_id, schedule_name):
name = form.cleaned_data['name']
short = form.cleaned_data['short']
type = form.cleaned_data['type']
purpose = form.cleaned_data['purpose']
group = form.cleaned_data['group']
duration = form.cleaned_data['duration']
location = form.cleaned_data['location']
remote_instructions = form.cleaned_data['remote_instructions']
# create TimeSlot object
timeslot = TimeSlot.objects.create(type=type,
@ -427,7 +429,9 @@ def misc_sessions(request, meeting_id, schedule_name):
name=name,
short=short,
group=group,
type=type)
type=type,
purpose=purpose,
remote_instructions=remote_instructions)
SchedulingEvent.objects.create(
session=session,
@ -537,6 +541,7 @@ def misc_session_edit(request, meeting_id, schedule_name, slot_id):
name = form.cleaned_data['name']
short = form.cleaned_data['short']
duration = form.cleaned_data['duration']
session_purpose = form.cleaned_data['purpose']
slot_type = form.cleaned_data['type']
show_location = form.cleaned_data['show_location']
remote_instructions = form.cleaned_data['remote_instructions']
@ -553,6 +558,8 @@ def misc_session_edit(request, meeting_id, schedule_name, slot_id):
session.name = name
session.short = short
session.remote_instructions = remote_instructions
session.purpose = session_purpose
session.type = slot_type
session.save()
messages.success(request, 'Location saved')
@ -570,7 +577,8 @@ def misc_session_edit(request, meeting_id, schedule_name, slot_id):
'time':slot.time.strftime('%H:%M'),
'duration':duration_string(slot.duration),
'show_location':slot.show_location,
'type':slot.type,
'purpose': session.purpose,
'type': session.type,
'remote_instructions': session.remote_instructions,
}
form = MiscSessionForm(initial=initial, meeting=meeting, session=session)
@ -637,9 +645,12 @@ def rooms(request, meeting_id, schedule_name):
return redirect('ietf.secr.meetings.views.main', meeting_id=meeting_id,schedule_name=schedule_name)
formset = RoomFormset(request.POST, instance=meeting, prefix='room')
if formset.is_valid():
options_form = MeetingRoomOptionsForm(request.POST)
if formset.is_valid() and options_form.is_valid():
formset.save()
# only create timeslots on request
if options_form.cleaned_data['copy_timeslots']:
# if we are creating rooms for the first time create full set of timeslots
if first_time:
build_timeslots(meeting)
@ -655,11 +666,13 @@ def rooms(request, meeting_id, schedule_name):
return redirect('ietf.secr.meetings.views.rooms', meeting_id=meeting_id, schedule_name=schedule_name)
else:
formset = RoomFormset(instance=meeting, prefix='room')
options_form = MeetingRoomOptionsForm()
return render(request, 'meetings/rooms.html', {
'meeting': meeting,
'schedule': schedule,
'formset': formset,
'options_form': options_form,
'selected': 'rooms'}
)

View file

@ -8,6 +8,7 @@ import debug # pyflakes:ignore
from ietf.name.models import TimerangeName, ConstraintName
from ietf.group.models import Group
from ietf.meeting.forms import SessionDetailsFormSet
from ietf.meeting.models import ResourceAssociation, Constraint
from ietf.person.fields import SearchablePersonsField
from ietf.utils.html import clean_text_field
@ -64,10 +65,8 @@ class NameModelMultipleChoiceField(forms.ModelMultipleChoiceField):
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 fields are added in __init__()
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
# ModelMultipleChoiceField, and just customize the widgetry, that
@ -84,19 +83,16 @@ class SessionForm(forms.Form):
queryset=TimerangeName.objects.all())
adjacent_with_wg = forms.ChoiceField(required=False)
def __init__(self, group, meeting, *args, **kwargs):
def __init__(self, group, meeting, data=None, *args, **kwargs):
if 'hidden' in kwargs:
self.hidden = kwargs.pop('hidden')
else:
self.hidden = False
self.group = group
self.session_forms = SessionDetailsFormSet(group=self.group, meeting=meeting, data=data)
super(SessionForm, self).__init__(data=data, *args, **kwargs)
super(SessionForm, self).__init__(*args, **kwargs)
self.fields['num_session'].widget.attrs['onChange'] = "ietf_sessions.stat_ls(this.selectedIndex);"
self.fields['length_session1'].widget.attrs['onClick'] = "if (ietf_sessions.check_num_session(1)) this.disabled=true;"
self.fields['length_session2'].widget.attrs['onClick'] = "if (ietf_sessions.check_num_session(2)) this.disabled=true;"
self.fields['length_session3'].widget.attrs['onClick'] = "if (ietf_sessions.check_third_session()) { this.disabled=true;}"
self.fields['comments'].widget = forms.Textarea(attrs={'rows':'3','cols':'65'})
other_groups = list(allowed_conflicting_groups().exclude(pk=group.pk).values_list('acronym', 'acronym').order_by('acronym'))
@ -148,15 +144,8 @@ class SessionForm(forms.Form):
)
self.fields['joint_with_groups_selector'].widget.attrs['onChange'] = "document.form_post.joint_with_groups.value=document.form_post.joint_with_groups.value + ' ' + this.options[this.selectedIndex].value; return 1;"
self.fields['third_session'].widget.attrs['onClick'] = "if (document.form_post.num_session.selectedIndex < 2) { alert('Cannot use this field - Number of Session is not set to 2'); return false; } else { if (this.checked==true) { document.form_post.length_session3.disabled=false; } else { document.form_post.length_session3.value=0;document.form_post.length_session3.disabled=true; } }"
self.fields["resources"].choices = [(x.pk,x.desc) for x in ResourceAssociation.objects.filter(name__used=True).order_by('name__order') ]
# check third_session checkbox if instance and length_session3
# assert False, (self.instance, self.fields['length_session3'].initial)
if self.initial and 'length_session3' in self.initial:
if self.initial['length_session3'] != '0' and self.initial['length_session3'] != None:
self.fields['third_session'].initial = True
if self.hidden:
for key in list(self.fields.keys()):
self.fields[key].widget = forms.HiddenInput()
@ -235,8 +224,13 @@ class SessionForm(forms.Form):
def clean_comments(self):
return clean_text_field(self.cleaned_data['comments'])
def is_valid(self):
return super().is_valid() and self.session_forms.is_valid()
def clean(self):
super(SessionForm, self).clean()
self.session_forms.clean()
data = self.cleaned_data
# Validate the individual conflict fields
@ -254,44 +248,45 @@ class SessionForm(forms.Form):
for error in self._validate_duplicate_conflicts(data):
self.add_error(None, error)
# verify session_length and num_session correspond
# Verify expected number of session entries are present
num_sessions_with_data = len(self.session_forms.forms_to_keep)
num_sessions_expected = -1
try:
num_sessions_expected = int(data.get('num_session', ''))
except ValueError:
self.add_error('num_session', 'Invalid value for number of sessions')
if len(self.session_forms.errors) == 0 and num_sessions_with_data < num_sessions_expected:
self.add_error('num_session', 'Must provide data for all sessions')
# if default (empty) option is selected, cleaned_data won't include num_session key
if data.get('num_session','') == '2':
if not data['length_session2']:
self.add_error('length_session2', forms.ValidationError('You must enter a length for all sessions'))
else:
if num_sessions_expected != 2 and num_sessions_expected is not None:
if data.get('session_time_relation'):
self.add_error(
'session_time_relation',
forms.ValidationError('Time between sessions can only be used when two sessions are requested.')
)
if data.get('joint_for_session') == '2':
self.add_error(
'joint_for_session',
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.get('length_session3',None):
self.add_error('length_session3', forms.ValidationError('You must enter a length for all sessions'))
elif data.get('joint_for_session') == '3':
joint_session = data.get('joint_for_session', '')
if joint_session != '':
joint_session = int(joint_session)
if joint_session > num_sessions_with_data:
self.add_error(
'joint_for_session',
forms.ValidationError(
'The third session can not be the joint session, because you have not requested a third session.'
f'Session {joint_session} can not be the joint session, the session has not been requested.'
)
)
return data
@property
def media(self):
# get media for our formset
return super().media + self.session_forms.media
class VirtualSessionForm(SessionForm):
'''A SessionForm customized for special virtual meeting requirements'''
length_session1 = forms.ChoiceField(choices=VIRTUAL_LENGTH_SESSION_CHOICES)
length_session2 = forms.ChoiceField(choices=VIRTUAL_LENGTH_SESSION_CHOICES,required=False)
length_session3 = forms.ChoiceField(choices=VIRTUAL_LENGTH_SESSION_CHOICES,required=False)
attendees = forms.IntegerField(required=False)

View file

@ -25,15 +25,17 @@ def display_duration(value):
"""
Maps a session requested duration from select index to
label."""
map = {'0':'None',
'1800':'30 Minutes',
'3000':'50 Minutes',
'3600':'1 Hour',
'5400':'1.5 Hours',
'6000':'100 Minutes',
'7200':'2 Hours',
'9000':'2.5 Hours'}
value = int(value)
map = {0: 'None',
1800: '30 Minutes',
3600: '1 Hour',
5400: '1.5 Hours',
7200: '2 Hours',
9000: '2.5 Hours'}
if value in map:
return map[value]
else:
return "%d Hours %d Minutes %d Seconds"%(value//3600,(value%3600)//60,value%60)
@register.filter
def get_published_date(doc):

View file

@ -62,15 +62,6 @@ def get_initial_session(sessions, prune_conflicts=False):
# even if there are three sessions requested, the old form has 2 in this field
initial['num_session'] = min(sessions.count(), 2)
# accessing these foreign key fields throw errors if they are unset so we
# need to catch these
initial['length_session1'] = str(sessions[0].requested_duration.seconds)
try:
initial['length_session2'] = str(sessions[1].requested_duration.seconds)
initial['length_session3'] = str(sessions[2].requested_duration.seconds)
except IndexError:
pass
initial['attendees'] = sessions[0].attendees
def valid_conflict(conflict):
@ -316,22 +307,20 @@ def confirm(request, acronym):
if request.method == 'POST' and button_text == 'Submit':
# delete any existing session records with status = canceled or notmeet
add_event_info_to_session_qs(Session.objects.filter(group=group, meeting=meeting)).filter(current_status__in=['canceled', 'notmeet']).delete()
# create new session records
count = 0
# lenth_session2 and length_session3 fields might be disabled by javascript and so
# wouldn't appear in form data
for duration in (form.data.get('length_session1',None),form.data.get('length_session2',None),form.data.get('length_session3',None)):
count += 1
if duration:
num_sessions = int(form.cleaned_data['num_session']) + (1 if form.cleaned_data['third_session'] else 0)
# Create new session records
# Should really use sess_form.save(), but needs data from the main form as well. Need to sort that out properly.
for count, sess_form in enumerate(form.session_forms[:num_sessions]):
slug = 'apprw' if count == 3 else 'schedw'
new_session = Session.objects.create(
meeting=meeting,
group=group,
attendees=form.cleaned_data['attendees'],
requested_duration=datetime.timedelta(0,int(duration)),
requested_duration=sess_form.cleaned_data['requested_duration'],
name=sess_form.cleaned_data['name'],
comments=form.cleaned_data['comments'],
type_id='regular',
purpose=sess_form.cleaned_data['purpose'],
type=sess_form.cleaned_data['type'],
)
SchedulingEvent.objects.create(
session=new_session,
@ -418,7 +407,11 @@ def edit(request, acronym, num=None):
'''
meeting = get_meeting(num,days=14)
group = get_object_or_404(Group, acronym=acronym)
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')
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', 'deleted'])
).order_by('id')
sessions_count = sessions.count()
initial = get_initial_session(sessions)
FormClass = get_session_form_class()
@ -449,67 +442,68 @@ def edit(request, acronym, num=None):
form = FormClass(group, meeting, request.POST, initial=initial)
if form.is_valid():
if form.has_changed():
form.session_forms.save() # todo maintain event creation in commented-out old code!!
# might be cleaner to simply delete and rewrite all records (but maintain submitter?)
# adjust duration or add sessions
# session 1
if 'length_session1' in form.changed_data:
session = sessions[0]
session.requested_duration = datetime.timedelta(0,int(form.cleaned_data['length_session1']))
session.save()
session_changed(session)
# session 2
if 'length_session2' in form.changed_data:
length_session2 = form.cleaned_data['length_session2']
if length_session2 == '':
sessions[1].delete()
elif sessions_count < 2:
duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
new_session = Session.objects.create(
meeting=meeting,
group=group,
attendees=form.cleaned_data['attendees'],
requested_duration=duration,
comments=form.cleaned_data['comments'],
type_id='regular',
)
SchedulingEvent.objects.create(
session=new_session,
status=SessionStatusName.objects.get(slug='schedw'),
by=request.user.person,
)
else:
duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
session = sessions[1]
session.requested_duration = duration
session.save()
# session 3
if 'length_session3' in form.changed_data:
length_session3 = form.cleaned_data['length_session3']
if length_session3 == '':
sessions[2].delete()
elif sessions_count < 3:
duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
new_session = Session.objects.create(
meeting=meeting,
group=group,
attendees=form.cleaned_data['attendees'],
requested_duration=duration,
comments=form.cleaned_data['comments'],
type_id='regular',
)
SchedulingEvent.objects.create(
session=new_session,
status=SessionStatusName.objects.get(slug='apprw'),
by=request.user.person,
)
else:
duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
session = sessions[2]
session.requested_duration = duration
session.save()
session_changed(session)
# if 'length_session1' in form.changed_data:
# session = sessions[0]
# session.requested_duration = datetime.timedelta(0,int(form.cleaned_data['length_session1']))
# session.save()
# session_changed(session)
#
# # session 2
# if 'length_session2' in form.changed_data:
# length_session2 = form.cleaned_data['length_session2']
# if length_session2 == '':
# sessions[1].delete()
# elif sessions_count < 2:
# duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
# new_session = Session.objects.create(
# meeting=meeting,
# group=group,
# attendees=form.cleaned_data['attendees'],
# requested_duration=duration,
# comments=form.cleaned_data['comments'],
# type_id='regular',
# )
# SchedulingEvent.objects.create(
# session=new_session,
# status=SessionStatusName.objects.get(slug='schedw'),
# by=request.user.person,
# )
# else:
# duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
# session = sessions[1]
# session.requested_duration = duration
# session.save()
#
# # session 3
# if 'length_session3' in form.changed_data:
# length_session3 = form.cleaned_data['length_session3']
# if length_session3 == '':
# sessions[2].delete()
# elif sessions_count < 3:
# duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
# new_session = Session.objects.create(
# meeting=meeting,
# group=group,
# attendees=form.cleaned_data['attendees'],
# requested_duration=duration,
# comments=form.cleaned_data['comments'],
# type_id='regular',
# )
# SchedulingEvent.objects.create(
# session=new_session,
# status=SessionStatusName.objects.get(slug='apprw'),
# by=request.user.person,
# )
# else:
# duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
# session = sessions[2]
# session.requested_duration = duration
# session.save()
# session_changed(session)
# New sessions may have been created, refresh the sessions list
sessions = add_event_info_to_session_qs(

View file

@ -0,0 +1,83 @@
/* Copyright The IETF Trust 2021, All Rights Reserved
*
* JS support for the SessionPurposeAndTypeWidget
* */
(function() {
'use strict';
/* Find elements that are parts of the session details widgets. This is an
* HTMLCollection that will update if the DOM changes, so ok to evaluate immediately. */
const widget_elements = document.getElementsByClassName('session_purpose_widget');
/* Find the id prefix for each widget. Individual elements have a _<number> suffix. */
function get_widget_ids(elements) {
const ids = new Set();
for (let ii=0; ii < elements.length; ii++) {
const parts = elements[ii].id.split('_');
parts.pop();
ids.add(parts.join('_'));
}
return ids;
}
/* Set the 'type' element to a type valid for the currently selected purpose, if possible */
function set_valid_type(type_elt, purpose, allowed_types) {
const valid_types = allowed_types[purpose] || [];
if (valid_types.indexOf(type_elt.value) === -1) {
type_elt.value = (valid_types.length > 0) ? valid_types[0] : '';
}
}
/* Hide any type options not allowed for the selected purpose */
function update_type_option_visibility(type_option_elts, purpose, allowed_types) {
const valid_types = allowed_types[purpose] || [];
for (const elt of type_option_elts) {
if (valid_types.indexOf(elt.value) === -1) {
elt.setAttribute('hidden', 'hidden');
} else {
elt.removeAttribute('hidden');
}
}
}
/* Update visibility of 'type' select so it is only shown when multiple options are available */
function update_widget_visibility(elt, purpose, allowed_types) {
const valid_types = allowed_types[purpose] || [];
if (valid_types.length > 1) {
elt.removeAttribute('hidden'); // make visible
} else {
elt.setAttribute('hidden', 'hidden'); // make invisible
}
}
/* Update the 'type' select to reflect a change in the selected purpose */
function update_type_element(type_elt, purpose, type_options, allowed_types) {
update_widget_visibility(type_elt, purpose, allowed_types);
update_type_option_visibility(type_options, purpose, allowed_types);
set_valid_type(type_elt, purpose, allowed_types);
}
/* Factory for event handler with a closure */
function purpose_change_handler(type_elt, type_options, allowed_types) {
return function(event) {
update_type_element(type_elt, event.target.value, type_options, allowed_types);
};
}
/* Initialization */
function on_load() {
for (const widget_id of get_widget_ids(widget_elements)) {
const purpose_elt = document.getElementById(widget_id + '_0');
const type_elt = document.getElementById(widget_id + '_1');
const type_options = type_elt.getElementsByTagName('option');
const allowed_types = JSON.parse(type_elt.dataset.allowedOptions);
purpose_elt.addEventListener(
'change',
purpose_change_handler(type_elt, type_options, allowed_types)
);
update_type_element(type_elt, purpose_elt.value, type_options, allowed_types);
}
}
window.addEventListener('load', on_load, false);
})();

View file

@ -1,59 +1,52 @@
// Copyright The IETF Trust 2015-2021, All Rights Reserved
/* global alert */
var ietf_sessions; // public interface
(function() {
'use strict';
function stat_ls (val){
if (val == 0) {
document.form_post.length_session1.disabled = true;
document.form_post.length_session2.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;
function get_formset_management_data(prefix) {
return {
total_forms: document.getElementById('id_' + prefix + '-TOTAL_FORMS').value,
};
}
if (val == 1) {
document.form_post.length_session1.disabled = false;
document.form_post.length_session2.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;
function update_session_form_visibility(session_num, is_visible) {
const elt = document.getElementById('session_row_' + session_num);
if (elt) {
elt.hidden = !is_visible;
elt.querySelector('[name$="DELETE"]').value = is_visible ? '' : 'on';
}
}
function check_num_session (val) {
if (document.form_post.num_session.value < val) {
alert("Please change the value in the Number of Sessions to use this field");
document.form_post.num_session.focused = true;
return true;
}
return false;
function have_additional_session() {
const elt = document.getElementById('id_third_session');
return elt && elt.checked;
}
function check_third_session () {
if (document.form_post.third_session.checked == false) {
return true;
function update_for_num_sessions(val) {
const total_forms = get_formset_management_data('session_set').total_forms;
val = Number(val);
if (have_additional_session()) {
val++;
}
for (let i=0; i < total_forms; i++) {
update_session_form_visibility(i, i < val);
}
const only_one_session = (val === 1);
if (document.form_post.session_time_relation) {
document.form_post.session_time_relation.disabled = only_one_session;
document.form_post.session_time_relation.closest('tr').hidden = only_one_session;
}
if (document.form_post.joint_for_session) {
document.form_post.joint_for_session.disabled = only_one_session;
}
const third_session_row = document.getElementById('third_session_row');
if (third_session_row) {
third_session_row.hidden = val < 2;
}
return false;
}
function delete_last_joint_with_groups () {
@ -114,7 +107,39 @@ var ietf_sessions; // public interface
delete_last_wg_constraint(slug);
}
/**
* Handler for the change event on the session count select or 'third session' checkbox
*/
function handle_num_session_change(event) {
const num_select_value = Number(event.target.value);
if (num_select_value !== 2) {
if (document.form_post.third_session) {
document.form_post.third_session.checked = false;
}
}
update_for_num_sessions(num_select_value);
}
function handle_third_session_change(event) {
const num_select_value = Number(document.getElementById('id_num_session').value);
if (num_select_value === 2) {
update_for_num_sessions(num_select_value);
} else {
event.target.checked = false;
}
}
/* Initialization */
function on_load() {
// Attach event handler to session count select
const num_session_select = document.getElementById('id_num_session');
num_session_select.addEventListener('change', handle_num_session_change);
const third_session_input = document.getElementById('id_third_session');
if (third_session_input) {
third_session_input.addEventListener('change', handle_third_session_change);
}
update_for_num_sessions(num_session_select.value);
// Attach event handlers to constraint selectors
let selectors = document.getElementsByClassName('wg_constraint_selector');
for (let index = 0; index < selectors.length; index++) {
@ -128,9 +153,6 @@ var ietf_sessions; // public interface
// expose public interface methods
ietf_sessions = {
stat_ls: stat_ls,
check_num_session: check_num_session,
check_third_session: check_third_session,
delete_last_joint_with_groups: delete_last_joint_with_groups,
delete_wg_constraint_clicked: delete_wg_constraint_clicked
}

View file

@ -1,20 +1,27 @@
<span class="required">*</span> Required Field
<form id="session-request-form" action="." method="post" name="form_post">{% csrf_token %}
{{ form.session_forms.management_form }}
{% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %}
<table id="sessions-new-table" cellspacing="1" cellpadding="1" border="0">
<col width="150">
<tr class="bg1"><td>Working Group Name:</td><td>{{ group.name }} ({{ group.acronym }})</td></tr>
<tr class="bg2"><td>Area Name:</td><td>{% if group.parent %}{{ group.parent.name }} ({{ group.parent.acronym }}){% endif %}</td></tr>
<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" id="session_row_0"><td>Session 1:<span class="required">*</span></td><td>{% include 'meeting/session_details_form.html' with form=form.session_forms.0 only %}</td></tr>
<tr class="bg2" id="session_row_1"><td>Session 2:<span class="required">*</span></td><td>{% include 'meeting/session_details_form.html' with form=form.session_forms.1 only %}</td></tr>
{% if not is_virtual %}
<tr class="bg2"><td>Time between two sessions:</td><td>{{ form.session_time_relation.errors }}{{ form.session_time_relation }}</td></tr>
{% endif %}
{% 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>
<tr class="bg2" id="third_session_row"><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>
Length of Third Session: {{ form.length_session3.errors }}{{ form.length_session3 }}</td></tr>
<div id="session_row_2">
Third Session:
{% include 'meeting/session_details_form.html' with form=form.session_forms.2 only %}
</div>
</td></tr>
{% else %}{# else group.type.slug != "wg" #}
{% include 'meeting/session_details_form.html' with form=form.session_forms.2 hidden=True only %}
{% endif %}
<tr class="bg1"><td>Number of Attendees:{% if not is_virtual %}<span class="required">*</span>{% endif %}</td><td>{{ form.attendees.errors }}{{ form.attendees }}</td></tr>
<tr class="bg2"><td>People who must be present:</td><td>{{ form.bethere.errors }}{{ form.bethere }}</td></tr>

View file

@ -4,16 +4,24 @@
<tr class="row1"><td>Working Group Name:</td><td>{{ group.name }} ({{ group.acronym }})</td></tr>
<tr class="row2"><td>Area Name:</td><td>{{ group.parent }}</td></tr>
<tr class="row1"><td>Number of Sessions Requested:</td><td>{% if session.length_session3 %}3{% else %}{{ session.num_session }}{% endif %}</td></tr>
<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>
{% if not is_virtual %}
{% for sess_form in form.session_forms %}{% if sess_form.cleaned_data and not sess_form.cleaned_data.DELETE %}
<tr class="row2"><td>Session {{ forloop.counter }}:</td><td>
<dl>
<dt>Length</dt><dd>{{ sess_form.cleaned_data.requested_duration.total_seconds|display_duration }}</dd>
{% if sess_form.cleaned_data.name %}<dt>Name</dt><dd>{{ sess_form.cleaned_data.name }}</dd>{% endif %}
{% if sess_form.cleaned_data.purpose.slug != 'session' %}
<dt>Purpose</dt>
<dd>
{{ sess_form.cleaned_data.purpose }}
{% if sess_form.cleaned_data.purpose.timeslot_types|length > 1 %}({{ sess_form.cleaned_data.type }}{% endif %}
</dd>
{% endif %}
</dl>
</td></tr>
{% if forloop.counter == 2 and not is_virtual %}
<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 %}
{% endif %}
{% if session.length_session3 %}
<tr class="row2"><td>Length of Session 3:</td><td>{{ session.length_session3|display_duration }}</td></tr>
{% endif %}
{% endif %}{% endfor %}
<tr class="row1"><td>Number of Attendees:</td><td>{{ session.attendees }}</td></tr>
<tr class="row2">
<td>Conflicts to Avoid:</td>

View file

@ -21,3 +21,11 @@
</div> <!-- module -->
{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ form.media.js }}
{% endblock %}
{% block extrastyle %}
{{ form.media.css }}
{% endblock %}

View file

@ -1,5 +1,5 @@
{% extends "meetings/base_rooms_times.html" %}
{% load agenda_custom_tags %}
{% block subsection %}
<div class="module">
@ -27,12 +27,12 @@
<tr class="{% cycle 'row1' 'row2' %}{% ifchanged assignment.session.type %} break{% endifchanged %}{% if assignment.current_session_status == "canceled" %} cancelled{% endif %}">
<td>{{ assignment.timeslot.time|date:"D" }}</td>
<td>{{ assignment.timeslot.time|date:"H:i" }}-{{ assignment.timeslot.end_time|date:"H:i" }}</td>
<td>{{ assignment.timeslot.name }}</td>
<td>{% assignment_display_name assignment %}</td>
<td>{{ assignment.session.short }}</td>
<td>{{ assignment.session.group.acronym }}</td>
<td>{{ assignment.timeslot.location }}</td>
<td>{{ assignment.timeslot.show_location }}</td>
<td>{{ assignment.timeslot.type }}</td>
<td>{% with purpose=assignment.session.purpose %}{{ purpose }}{% if purpose.timeslot_types|length > 1 %} ({{ assignment.slot_type }}){% endif %}{% endwith %}</td>
{% if assignment.schedule_id == schedule.pk %}
<td><a href="{% url "ietf.secr.meetings.views.misc_session_edit" meeting_id=meeting.number schedule_name=schedule.name slot_id=assignment.timeslot.id %}">Edit</a></td>
<td>
@ -49,7 +49,7 @@
</tbody>
</table>
{% else %}
<h3>No timeslots exist for this meeting. First add the rooms and then the app will create timeslots based on the schedule from the last meeting.</h3>
<h3>No timeslots exist for this meeting. Add rooms with the "duplicate timeslots" option enabled to copy timeslots from the last meeting.</h3>
{% endif %}
<br /><hr />
@ -74,3 +74,11 @@
{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ form.media.js }}
{% endblock %}
{% block extrastyle %}
{{ form.media.css }}
{% endblock %}

View file

@ -11,6 +11,7 @@
<form id="meetings-meta-rooms" action="" method="post">{% csrf_token %}
{{ formset.management_form }}
{{ formset.non_form_errors }}
{% if options_form %}{{ options_form.errors }}{% endif %}
<table id="id_rooms_table" class="full-width">
<thead>
@ -43,6 +44,7 @@
</div> <!-- iniline-related -->
</div> <!-- inline-group -->
{% if options_form %}{{ options_form }}{% endif %}
{% include "includes/buttons_save.html" %}
</form>

View file

@ -29,7 +29,7 @@
</tbody>
</table>
{% else %}
<h3>No timeslots exist for this meeting. First add the rooms and then the app will create timeslots based on the schedule from the last meeting.</h3>
<h3>No timeslots exist for this meeting. Add rooms with the "duplicate timeslots" option enabled to copy timeslots from the last meeting.</h3>
{% endif %}
<br /><hr />

View file

@ -3,8 +3,18 @@
{% block title %}Sessions - Confirm{% endblock %}
{% block extrastyle %}
<style>
dl {width: 100%;}
dt {float: left; width: 15%; margin: 0.1em 0 0.1em 0; }
dt::after {content: ":";}
dd {float: left; width: 85%; margin: 0.1em 0 0.1em 0;}
</style>
{% endblock %}
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
{{ form.media }}
{% endblock %}
{% block breadcrumbs %}{{ block.super }}
@ -20,7 +30,7 @@
{% include "includes/sessions_request_view.html" %}
{% if session.length_session3 %}
{% if form.session_forms.forms_to_keep|length > 2 %}
<br>
<span class="alert"><p><b>Note: Your request for a third session must be approved by an area director before
being submitted to agenda@ietf.org. Click "Submit" below to email an approval
@ -30,6 +40,8 @@
<form action="{% url "ietf.secr.sreq.views.confirm" acronym=group.acronym %}" method="post">{% csrf_token %}
{{ form }}
{{ form.session_forms.management_form }}
{% for sf in form.session_forms %}{% include 'meeting/session_details_form.html' with form=sf hidden=True only %}{% endfor %}
{% include "includes/buttons_submit_cancel.html" %}
</form>

View file

@ -959,8 +959,11 @@ INTERNET_DRAFT_DAYS_TO_EXPIRE = 185
FLOORPLAN_MEDIA_DIR = 'floor'
FLOORPLAN_DIR = os.path.join(MEDIA_ROOT, FLOORPLAN_MEDIA_DIR)
FLOORPLAN_LEGACY_BASE_URL = 'https://tools.ietf.org/agenda/{meeting.number}/venue/'
FLOORPLAN_LAST_LEGACY_MEETING = 95 # last meeting to use FLOORPLAN_LEGACY_BASE_URL
MEETING_USES_CODIMD_DATE = datetime.date(2020,7,6)
MEETING_LEGACY_OFFICE_HOURS_END = 111 # last meeting to use legacy office hours representation
# Maximum dimensions to accept at all
MEETINGHOST_LOGO_MAX_UPLOAD_WIDTH = 400

View file

@ -1545,6 +1545,43 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
width: 10em;
}
.timeslot-edit .tstable div.timeslot {
border: #000000 solid 1px;
border-radius: 0.5em;
padding: 0.5em;
}
.timeslot-edit .tstable .timeslot .ts-name {
overflow: hidden;
}
.timeslot-edit .tstable .timeslot .ts-type {
font-size: smaller;
}
.timeslot-edit .tstable .timeslot .timeslot-buttons {
float: right;
}
.timeslot-edit .tstable .timeslot.in-official-use {
background-color: #d9edf7;
}
.timeslot-edit .tstable .timeslot.in-unofficial-use {
background-color: #f8f8e0;
}
.timeslot-edit .tstable td.timeslot-collision {
background-color: #ffa0a0;
}
.timeslot-edit .tstable .tstype_unavail {
background-color:#666;
}
.timeslot-edit .official-use-warning {
color: #ff0000;
}
.rightmarker, .leftmarker {
width: 3px;
padding-right: 0px !important;

View file

@ -0,0 +1,109 @@
/* Copyright The IETF Trust 2021, All Rights Reserved
*
* JS support for the SessionDetailsForm
* */
(function() {
'use strict';
/* Find the id prefix for each widget. Individual elements have a _<number> suffix. */
function get_widget_ids(elements) {
const ids = new Set();
for (let ii=0; ii < elements.length; ii++) {
const parts = elements[ii].id.split('_');
parts.pop();
ids.add(parts.join('_'));
}
return ids;
}
/* Set the 'type' element to a type valid for the currently selected purpose, if possible */
function set_valid_type(type_elt, purpose, allowed_types) {
const valid_types = allowed_types[purpose] || [];
if (valid_types.indexOf(type_elt.value) === -1) {
type_elt.value = (valid_types.length > 0) ? valid_types[0] : '';
}
}
/* Hide any type options not allowed for the selected purpose */
function update_type_option_visibility(type_option_elts, purpose, allowed_types) {
const valid_types = allowed_types[purpose] || [];
for (const elt of type_option_elts) {
if (valid_types.indexOf(elt.value) === -1) {
elt.setAttribute('hidden', 'hidden');
} else {
elt.removeAttribute('hidden');
}
}
}
/* Update visibility of 'type' select so it is only shown when multiple options are available */
function update_widget_visibility(elt, purpose, allowed_types) {
const valid_types = allowed_types[purpose] || [];
if (valid_types.length > 1) {
elt.removeAttribute('hidden'); // make visible
} else {
elt.setAttribute('hidden', 'hidden'); // make invisible
}
}
/* Update the 'type' select to reflect a change in the selected purpose */
function update_type_element(type_elt, purpose, type_options, allowed_types) {
update_widget_visibility(type_elt, purpose, allowed_types);
update_type_option_visibility(type_options, purpose, allowed_types);
set_valid_type(type_elt, purpose, allowed_types);
}
function update_name_field_visibility(name_elt, purpose) {
const row = name_elt.closest('tr');
if (purpose === 'session') {
row.setAttribute('hidden', 'hidden');
} else {
row.removeAttribute('hidden');
}
}
/* Factory for event handler with a closure */
function purpose_change_handler(name_elt, type_elt, type_options, allowed_types) {
return function(event) {
const purpose = event.target.value;
update_name_field_visibility(name_elt, purpose);
update_type_element(type_elt, purpose, type_options, allowed_types);
};
}
function add_purpose_change_handler(form) {
const id_prefix = 'id_' + form.dataset.prefix;
const name_elt = document.getElementById(id_prefix + '-name');
const purpose_elt = document.getElementById(id_prefix + '-purpose');
const type_elt = document.getElementById(id_prefix + '-type');
const type_options = type_elt.getElementsByTagName('option');
const allowed_types = JSON.parse(type_elt.dataset.allowedOptions);
// update on future changes
purpose_elt.addEventListener(
'change',
purpose_change_handler(name_elt, type_elt, type_options, allowed_types)
);
// update immediately
update_type_element(type_elt, purpose_elt.value, type_options, allowed_types);
update_name_field_visibility(name_elt, purpose_elt.value);
// hide the purpose selector if only one option
const purpose_options = purpose_elt.querySelectorAll('option:not([value=""])');
if (purpose_options.length < 2) {
purpose_elt.closest('tr').setAttribute('hidden', 'hidden');
}
}
/* Initialization */
function on_load() {
/* Find elements that are parts of the session details forms. This is an
* HTMLCollection that will update if the DOM changes, so ok to evaluate immediately. */
const forms = document.getElementsByClassName('session-details-form');
for (const form of forms) {
add_purpose_change_handler(form);
}
}
window.addEventListener('load', on_load, false);
})();

View file

@ -4,7 +4,7 @@
{% load static %}
{% load ietf_filters %}
{% load textfilters %}
{% load htmlfilters %}
{% load htmlfilters agenda_custom_tags%}
{% block title %}
IETF {{ schedule.meeting.number }} meeting agenda
@ -143,7 +143,62 @@
</tr>
{% endifchanged %}
{% if item.timeslot.type_id == 'regular' %}
{% if item|is_special_agenda_item %}
<tr id="row-{{ item.slug }}" data-filter-keywords="{{ item.filter_keywords|join:',' }}"
data-slot-start-ts="{{item.start_timestamp}}"
data-slot-end-ts="{{item.end_timestamp}}">
<td class="leftmarker"></td>
<td class="text-nowrap text-right">
<span class="hidden-xs">
{% include "meeting/timeslot_start_end.html" %}
</span>
</td>
<td colspan="3">
<span class="hidden-sm hidden-md hidden-lg">
{% include "meeting/timeslot_start_end.html" %}
</span>
{% location_anchor item.timeslot %}
{{ item.timeslot.get_html_location }}
{% end_location_anchor %}
{% if item.timeslot.show_location and item.timeslot.get_html_location %}
{% with item.timeslot.location.floorplan as floor %}
{% if item.timeslot.location.floorplan %}
<span class="hidden-xs">
<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}#{{floor.name|xslugify}}"
class="pull-right" title="{{floor.name}}"><span class="label label-blank label-wide">{{floor.short}}</span></a>
</span>
{% endif %}
{% endwith %}
{% endif %}
</td>
<td>
{% agenda_anchor item.session %}
{% assignment_display_name item %}
{% end_agenda_anchor %}
{% if item.session.current_status == 'canceled' %}
<span class="label label-danger pull-right">CANCELLED</span>
{% else %}
<div class="pull-right padded-left">
{% if item.slot_type.slug == 'other' %}
{% if item.session.agenda or item.session.remote_instructions or item.session.agenda_note %}
{% include "meeting/session_buttons_include.html" with show_agenda=True item=item schedule=schedule %}
{% else %}
{% for slide in item.session.slides %}
<a href="{{slide.get_href}}">{{ slide.title|clean_whitespace }}</a>
<br>
{% endfor %}
{% endif %}
{% endif %}
</div>
{% endif %}
</td>
<td class="rightmarker"></td>
</tr>
{% elif item|is_regular_agenda_item or item|is_plenary_agenda_item %}
{% if item|is_regular_agenda_item %}
{% ifchanged %}
<tr class="info session-label-row"
data-slot-start-ts="{{item.start_timestamp}}"
@ -166,77 +221,14 @@
{% endifchanged %}
{% endif %}
{% if item.timeslot.type.slug == 'break' or item.timeslot.type.slug == 'reg' or item.timeslot.type.slug == 'other' %}
<tr id="row-{{ item.slug }}" data-filter-keywords="{{ item.filter_keywords|join:',' }}"
data-slot-start-ts="{{item.start_timestamp}}"
data-slot-end-ts="{{item.end_timestamp}}">
<td class="leftmarker"></td>
<td class="text-nowrap text-right">
<span class="hidden-xs">
{% include "meeting/timeslot_start_end.html" %}
</span>
</td>
<td colspan="3">
<span class="hidden-sm hidden-md hidden-lg">
{% include "meeting/timeslot_start_end.html" %}
</span>
{% if item.timeslot.show_location and item.timeslot.get_html_location %}
{% if schedule.meeting.number|add:"0" < 96 %}
<a href="https://tools.ietf.org/agenda/{{schedule.meeting.number}}/venue/?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
{% elif item.timeslot.location.floorplan %}
<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
{% else %}
{{item.timeslot.get_html_location}}
{% endif %}
{% with item.timeslot.location.floorplan as floor %}
{% if item.timeslot.location.floorplan %}
<span class="hidden-xs">
<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}#{{floor.name|xslugify}}"
class="pull-right" title="{{floor.name}}"><span class="label label-blank label-wide">{{floor.short}}</span></a>
</span>
{% endif %}
{% endwith %}
{% endif %}
</td>
<td>
{% if item.session.agenda %}
<a href="{{ item.session.agenda.get_href }}">
{{item.timeslot.name}}
</a>
{% else %}
{{item.timeslot.name}}
{% endif %}
{% if item.session.current_status == 'canceled' %}
<span class="label label-danger pull-right">CANCELLED</span>
{% else %}
<div class="pull-right padded-left">
{% if item.timeslot.type.slug == 'other' %}
{% if item.session.agenda or item.session.remote_instructions or item.session.agenda_note %}
{% include "meeting/session_buttons_include.html" with show_agenda=True item=item schedule=schedule %}
{% else %}
{% for slide in item.session.slides %}
<a href="{{slide.get_href}}">{{ slide.title|clean_whitespace }}</a>
<br>
{% endfor %}
{% endif %}
{% endif %}
</div>
{% endif %}
</td>
<td class="rightmarker"></td>
</tr>
{% endif %}
{% if item.timeslot.type_id == 'regular' or item.timeslot.type.slug == 'plenary' %}
{% if item.session.historic_group %}
<tr id="row-{{item.slug}}"
{% if item.timeslot.type.slug == 'plenary' %}class="{{item.timeslot.type.slug}}danger"{% endif %}
{% if item.slot_type.slug == 'plenary' %}class="{{item.slot_type.slug}}danger"{% endif %}
data-filter-keywords="{{ item.filter_keywords|join:',' }}"
data-slot-start-ts="{{item.start_timestamp}}"
data-slot-end-ts="{{item.end_timestamp}}">
<td class="leftmarker"></td>
{% if item.timeslot.type.slug == 'plenary' %}
{% if item.slot_type.slug == 'plenary' %}
<th class="text-nowrap text-right">
<span class="hidden-xs">
{% include "meeting/timeslot_start_end.html" %}
@ -246,15 +238,9 @@
<span class="hidden-sm hidden-md hidden-lg">
{% include "meeting/timeslot_start_end.html" %}
</span>
{% if item.timeslot.show_location and item.timeslot.get_html_location %}
{% if schedule.meeting.number|add:"0" < 96 %}
<a href="https://tools.ietf.org/agenda/{{schedule.meeting.number}}/venue/?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
{% elif item.timeslot.location.floorplan %}
<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
{% else %}
{{item.timeslot.get_html_location}}
{% endif %}
{% endif %}
{% location_anchor item.timeslot %}
{{ item.timeslot.get_html_location }}
{% end_location_anchor %}
</td>
{% else %}
@ -269,15 +255,9 @@
{% endwith %}
</td>
<td>
{% if item.timeslot.show_location and item.timeslot.get_html_location %}
{% if schedule.meeting.number|add:"0" < 96 %}
<a href="https://tools.ietf.org/agenda/{{schedule.meeting.number}}/venue/?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
{% elif item.timeslot.location.floorplan %}
<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
{% else %}
{{item.timeslot.get_html_location}}
{% endif %}
{% endif %}
{% location_anchor item.timeslot %}
{{ item.timeslot.get_html_location }}
{% end_location_anchor %}
</td>
<td><span class="hidden-xs">{{item.session.historic_group.historic_parent.acronym}}</span></td>
@ -292,18 +272,9 @@
{% endif %}
<td>
{% if item.session.agenda %}
<a href="{{ item.session.agenda.get_href }}">
{% endif %}
{% if item.timeslot.type.slug == 'plenary' %}
{{item.timeslot.name}}
{% else %}
{{item.session.historic_group.name}}
{% endif %}
{% if item.session.agenda %}
</a>
{% endif %}
{% agenda_anchor item.session %}
{% assignment_display_name item %}
{% end_agenda_anchor %}
{% if item.session.current_status == 'canceled' %}
<span class="label label-danger pull-right">CANCELLED</span>
{% else %}

View file

@ -14,17 +14,17 @@
{{ item.timeslot.time|date:"l"|upper }}, {{ item.timeslot.time|date:"F j, Y" }}
{% endifchanged %}{% if item.timeslot.type.slug == "reg" %}
{{ item.timeslot.time_desc }} {{ item.timeslot.name }}{% if schedule.meeting.reg_area %} - {{ schedule.meeting.reg_area }}{% endif %}{% endif %}{% if item.timeslot.type.slug == "plenary" %}
{% endifchanged %}{% if item.slot_type.slug == "reg" %}
{{ item.timeslot.time_desc }} {{ item.timeslot.name }}{% if schedule.meeting.reg_area %} - {{ schedule.meeting.reg_area }}{% endif %}{% endif %}{% if item.slot_type.slug == "plenary" %}
{{ item.timeslot.time_desc }} {{ item.session.name }} - {{ item.timeslot.location.name }}
{{ item.session.agenda_text.strip|indent:"3" }}
{% endif %}{% if item.timeslot.type_id == 'regular' %}{% if item.session.historic_group %}{% ifchanged %}
{% endif %}{% if item.slot_type.slug == 'regular' %}{% if item.session.historic_group %}{% ifchanged %}
{{ item.timeslot.time_desc }} {{ item.timeslot.name }}
{% endifchanged %}{{ item.timeslot.location.name|ljust:14 }} {{ item.session.historic_group.historic_parent.acronym|upper|ljust:4 }} {{ item.session.historic_group.acronym|ljust:10 }} {{ item.session.historic_group.name }} {% if item.session.historic_group.state_id == "bof" %}BOF{% elif item.session.historic_group.type_id == "wg" %}WG{% endif %}{% if item.session.agenda_note %} - {{ item.session.agenda_note }}{% endif %}{% if item.session.current_status == 'canceled' %} *** CANCELLED ***{% elif item.session.current_status == 'resched' %} *** RESCHEDULED{% if item.session.rescheduled_to %} TO {{ item.session.rescheduled_to.time|date:"l G:i"|upper }}-{{ item.session.rescheduled_to.end_time|date:"G:i" }}{% endif %} ***{% endif %}
{% endif %}{% endif %}{% if item.timeslot.type.slug == "break" %}
{{ item.timeslot.time_desc }} {{ item.timeslot.name }}{% if schedule.meeting.break_area and item.timeslot.show_location %} - {{ schedule.meeting.break_area }}{% endif %}{% endif %}{% if item.timeslot.type.slug == "other" %}
{% endif %}{% endif %}{% if item.slot_type.slug == "break" %}
{{ item.timeslot.time_desc }} {{ item.timeslot.name }}{% if schedule.meeting.break_area and item.timeslot.show_location %} - {{ schedule.meeting.break_area }}{% endif %}{% endif %}{% if item.slot_type.slug == "other" %}
{{ item.timeslot.time_desc }} {{ item.timeslot.name }} - {{ item.timeslot.location.name }}{% endif %}{% endfor %}
====================================================================

View file

@ -29,7 +29,7 @@ ul.sessionlist { list-style:none; padding-left:2em; margin-bottom:10px;}
<li class="roomlistentry"><h3>{{room.grouper|default:"Location Unavailable"}}</h3>
<ul class="sessionlist">
{% for ss in room.list %}
<li class="sessionlistentry type-{{ss.timeslot.type_id}} {% if ss.schedule_id != meeting.schedule_id %}from-base-schedule{% endif %}">{{ss.timeslot.time|date:"H:i"}}-{{ss.timeslot.end_time|date:"H:i"}} {{ss.session.short_name}}</li>
<li class="sessionlistentry type-{{ss.slot_type.slug}} {% if ss.schedule_id != meeting.schedule_id %}from-base-schedule{% endif %}">{{ss.timeslot.time|date:"H:i"}}-{{ss.timeslot.end_time|date:"H:i"}} {{ss.session.short_name}}</li>
{% endfor %}
</ul>
</li>

View file

@ -61,12 +61,12 @@ Optional parameters:
{% for fc in filter_categories %}
{% if not forloop.first %} <td></td> {% endif %}
{% for p in fc %}
<td class="view {{ p.keyword }}">
<td class="view{% if p.keyword %} {{ p.keyword }}{% endif %}">
<div class="btn-group-vertical btn-block">
{% for button in p.children|dictsort:"label" %}
{% for button in p.children %}
<div class="btn-group btn-group-xs btn-group-justified">
<button class="btn btn-default pickview {{ button.keyword }}"
{% if p.keyword or button.is_bof %}data-filter-keywords="{% if p.keyword %}{{ p.keyword }}{% if button.is_bof %},{% endif %}{% endif %}{% if button.is_bof %}bof{% endif %}"{% endif %}
{% if button.toggled_by %}data-filter-keywords="{{ button.toggled_by|join:"," }}"{% endif %}
data-filter-item="{{ button.keyword }}">
{% if button.is_bof %}
<i>{{ button.label }}</i>

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 %}
{% block pagehead %}
{{ form.media.css }}
{% endblock %}
{% block title %}Create timeslot for {{meeting}}{% endblock %}
{% block content %}
{% origin %}
<h1>Create timeslot for {{meeting}}</h1>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">Save</button>
<a class="btn btn-default" href="{% url 'ietf.meeting.views.edit_timeslots' num=meeting.number %}">Cancel</a>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
{{ form.media.js }}
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 %}
{% block pagehead %}
{{ form.media.css }}
{% endblock %}
{% block title %}Edit timeslot "{{ timeslot.name }}" for {{ timeslot.meeting }}{% endblock %}
{% block content %}
{% origin %}
<h1>Edit timeslot "{{ timeslot.name }}" for {{ timeslot.meeting }}</h1>
{% if sessions %}
<div class="alert alert-warning">
This timeslot currently has the following sessions assigned to it:
{% for s in sessions %}
<div>{{s}}</div>
{% endfor %}
</div>
{% endif %}
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
{% buttons %}
<button type="submit" class="btn btn-primary">Save</button>
<a class="btn btn-default" href="{% url 'ietf.meeting.views.edit_timeslots' num=timeslot.meeting.number %}">Cancel</a>
{% endbuttons %}
</form>
{% endblock %}
{% block js %}
{{ form.media.js }}
{% endblock %}

View file

@ -16,13 +16,13 @@
{% endif %}
<!-- etherpad -->
{% if use_codimd %}
{% if item.timeslot.type.slug == 'plenary' %}
{% if item.slot_type.slug == 'plenary' %}
<a class="" href="https://codimd.ietf.org/notes-ietf-{{ meeting.number }}-plenary title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>
{% else %}
<a class="" href="https://codimd.ietf.org/notes-ietf-{{ meeting.number }}-{{acronym}}" title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>
{% endif %}
{% else %}
{% if item.timeslot.type.slug == 'plenary' %}
{% if item.slot_type.slug == 'plenary' %}
<a class="" href="https://etherpad.ietf.org:9009/p/notes-ietf-{{ meeting.number }}-plenary?useMonospaceFont=true" title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>
{% else %}
<a class="" href="https://etherpad.ietf.org:9009/p/notes-ietf-{{ meeting.number }}-{{acronym}}?useMonospaceFont=true" title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>

View file

@ -24,7 +24,7 @@
{% for ss in assignments %}
if (room_names.indexOf("{{ss.timeslot.get_hidden_location}}") >= 0 )
{
items.push({room_index:room_names.indexOf("{{ss.timeslot.get_hidden_location}}"),day:{{ss.day}}, delta_from_beginning:{{ss.delta_from_beginning}},time:"{{ss.timeslot.time|date:"Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}", verbose_time:"{{ss.timeslot.time|date:"D M d Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}",duration:{{ss.timeslot.duration.total_seconds}}, type:"{{ss.timeslot.type}}", {% if ss.session.name %}name:"{{ss.session.name|escapejs}}",{% if ss.session.group.acronym %} wg:"{{ss.session.group.acronym}}",{%endif%}{% else %}{% if ss.timeslot.type.name == "Break" %}name:"{{ss.timeslot.name|escapejs}}", area:"break", wg:"break",{% elif ss.timeslot.type_id == "unavail" %}name:"Unavailable",{% else %}name:"{{ss.session.group.name|escapejs}}{%if ss.session.group.state.name == "BOF"%} BOF{%endif%}",wg:"{{ss.session.group.acronym}}",state:"{{ss.session.group.state}}",area:"{{ss.session.group.parent.acronym}}",{% endif %}{% endif %} dayname:"{{ ss.timeslot.time|date:"l"|upper }}, {{ ss.timeslot.time|date:"F j, Y" }}"{% if ss.session.agenda %}, agenda:"{{ss.session.agenda.get_href}}"{% endif %}, from_base_schedule: {% if ss.schedule_id != meeting.schedule_id %}true{% else %}false{% endif %} });
items.push({room_index:room_names.indexOf("{{ss.timeslot.get_hidden_location}}"),day:{{ss.day}}, delta_from_beginning:{{ss.delta_from_beginning}},time:"{{ss.timeslot.time|date:"Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}", verbose_time:"{{ss.timeslot.time|date:"D M d Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}",duration:{{ss.timeslot.duration.total_seconds}}, type:"{{ss.slot_type}}", {% if ss.session.name %}name:"{{ss.session.name|escapejs}}",{% if ss.session.group.acronym %} wg:"{{ss.session.group.acronym}}",{%endif%}{% else %}{% if ss.slot_type.name == "Break" %}name:"{{ss.timeslot.name|escapejs}}", area:"break", wg:"break",{% elif ss.slot_type.slug == "unavail" %}name:"Unavailable",{% else %}name:"{{ss.session.group.name|escapejs}}{%if ss.session.group.state.name == "BOF"%} BOF{%endif%}",wg:"{{ss.session.group.acronym}}",state:"{{ss.session.group.state}}",area:"{{ss.session.group.parent.acronym}}",{% endif %}{% endif %} dayname:"{{ ss.timeslot.time|date:"l"|upper }}, {{ ss.timeslot.time|date:"F j, Y" }}"{% if ss.session.agenda %}, agenda:"{{ss.session.agenda.get_href}}"{% endif %}, from_base_schedule: {% if ss.schedule_id != meeting.schedule_id %}true{% else %}false{% endif %} });
}
{% endfor %}
{% endautoescape %}

View file

@ -10,6 +10,9 @@
<h1>{% block title %}Possible Meeting Agendas for IETF {{ meeting.number }}{% endblock %}</h1>
<div>
{% if can_edit_timeslots %}
<p><a href="{% url "ietf.meeting.views.edit_timeslots" num=meeting.number %}">Edit timeslots and room availability</a></p>
{% endif %}
{% for schedules, own, label in schedule_groups %}
<div class="panel panel-default">
<div class="panel-heading">

View file

@ -2,8 +2,10 @@
{% load origin %}
{% load static %}
{% load textfilters %}
{% load ietf_filters %}
{% origin %}
{% if item|should_show_agenda_session_buttons %}
{% with slug=item.slug %}{% with session=item.session %}{% with timeslot=item.timeslot %}{% with meeting=schedule.meeting %}
<span id="session-buttons-{{session.pk}}" class="text-nowrap">
{% with acronym=session.historic_group.acronym %}
@ -118,3 +120,4 @@
{% endwith %}
</span>
{% endwith %}{% endwith %}{% endwith %}{% endwith %}
{% endif %}

View file

@ -0,0 +1,24 @@
{# Copyright The IETF Trust 2007-2020, All Rights Reserved #}
{% if hidden %}{{ form.name.as_hidden }}{{ form.purpose.as_hidden }}{{ form.type.as_hidden }}{{ form.requested_duration.as_hidden }}
{% else %}<div class="session-details-form" data-prefix="{{ form.prefix }}">
{{ form.id.as_hidden }}
{{ form.DELETE.as_hidden }}
<table>
<tr>
<th>{{ form.name.label_tag }}</th>
<td>{{ form.name }}{{ form.purpose.errors }}</td>
</tr>
<tr>
<th>{{ form.purpose.label_tag }}</th>
<td>
{{ form.purpose }} {{ form.type }}
{{ form.purpose.errors }}{{ form.type.errors }}
</td>
<tr>
<th>{{ form.requested_duration.label_tag }}</th>
<td>{{ form.requested_duration }}{{ form.requested_duration.errors }}</td>
</tr>
</tr>
</table>
</div>
{% endif %}

View file

@ -3,55 +3,410 @@
{% load origin %}
{% load agenda_custom_tags %}
{% block title %}IETF {{ meeting.number }} Meeting Agenda: Timeslot/Room Availability{% endblock %}
{% block title %}IETF {{ meeting.number }} Meeting Agenda: Timeslots and Room Availability{% endblock %}
{% block morecss %}
.tstable { width: 100%;}
.tstable th { white-space: nowrap;}
.tstable td { white-space: nowrap;}
.capacity { font-size:80%; font-weight: normal;}
.tstable .tstype_unavail {background-color:#666;}
.tstable { width: 100%;}
.tstable th { white-space: nowrap;}
.tstable td { white-space: nowrap;}
.capacity { font-size:80%; font-weight: normal;}
{% endblock %}
{% block content %}
{% origin %}
<p class="pull-right">
<a href="{% url "ietf.meeting.views.create_timeslot" num=meeting.number %}">New timeslot</a>
&middot;
{% if meeting.schedule %}
<a href="{% url "ietf.secr.meetings.views.rooms" meeting_id=meeting.number schedule_name=meeting.schedule.name %}">Edit rooms</a>
{% else %} {# secr app room view requires a schedule - show something for now (should try to avoid this possibility) #}
<span title="Must create meeting schedule to edit rooms">Edit rooms</span>
{% endif %}
</p>
<table class="tstable table table-striped table-compact table-bordered">
<p> IETF {{ meeting.number }} Meeting Agenda: Timeslots and Room Availability</p>
<div class="timeslot-edit">
{% if rooms|length == 0 %}
<p>No rooms exist for this meeting yet.</p>
{% if meeting.schedule %}
<a href="{% url "ietf.secr.meetings.views.rooms" meeting_id=meeting.number schedule_name=meeting.schedule.name %}">Create rooms</a>
{% else %}{# provide as helpful a link we can if we don't have an official schedule #}
<a href="{% url "ietf.secr.meetings.views.view" meeting_id=meeting.number %}">Create rooms through the secr app</a>
{% endif %}
{% else %}
<table id="timeslot-table" class="tstable table table-striped table-compact table-bordered">
{% with have_no_timeslots=time_slices|length_is:0 %}
<thead>
<tr>
{% if have_no_timeslots %}
<th></th>
<th></th>
{% else %}
<th></th>
{% for day in time_slices %}
<th colspan="{{date_slices|colWidth:day}}">
<th class="day-label"
colspan="{{date_slices|colWidth:day}}">
{{day|date:'D'}}&nbsp;({{day}})
<span class="fa fa-trash delete-button"
title="Delete all on this day"
data-delete-scope="day"
data-date-id="{{ day.isoformat }}">
</span>
</th>
{% endfor %}
{% endif %}
</tr>
<tr>
{% if have_no_timeslots %}
<th></th>
<th></th>
{% else %}
<th></th>
{% for day in time_slices %}
{% for slot in slot_slices|lookup:day %}
<th>
{{slot.time|date:'Hi'}}-{{slot.end_time|date:'Hi'}}
<th class="time-label">
{{slot.time|date:'H:i'}}-{{slot.end_time|date:'H:i'}}
<span class="fa fa-trash delete-button"
data-delete-scope="column"
data-date-id="{{ day.isoformat }}"
data-col-id="{{ day.isoformat }}T{{slot.time|date:'H:i'}}-{{slot.end_time|date:'H:i'}}"
title="Delete all in this column">
</span>
</th>
{% endfor %}
{% endfor %}
{% endif %}
</tr>
</thead>
<tbody>
{% for room in rooms %}
<tr>
<th>{{room.name}}<span class='capacity'>{% if room.capacity %} ({{room.capacity}}){% endif %}</th>
<th><span class="room-heading">{{room.name}}{% if room.capacity %} <span class='capacity'>({{room.capacity}})</span>{% endif %}</span></th>
{% if have_no_timeslots and forloop.first %}
<td rowspan="{{ rooms|length }}">
<p>No timeslots exist for this meeting yet.</p>
<a href="{% url "ietf.meeting.views.create_timeslot" num=meeting.number %}">Create a timeslot.</a>
</td>
{% else %}
{% for day in time_slices %}
{% for slice in date_slices|lookup:day %}
{% with ts=ts_list.popleft %}
<td{% if ts %} class="tstype_{{ts.type.slug}}"{% endif %}>{% if ts %}<a href="{% url 'ietf.meeting.views.edit_timeslot_type' num=meeting.number slot_id=ts.id %}">{{ts.type.slug}}</a>{% endif %}</td>
{% endwith %}
{% for slice in date_slices|lookup:day %}{% with cell_ts=ts_list.popleft %}
<td class="tscell {% if cell_ts|length > 1 %}timeslot-collision {% endif %}{% for ts in cell_ts %}tstype_{{ ts.type.slug }} {% endfor %}">
{% for ts in cell_ts %}
{% include 'meeting/timeslot_edit_timeslot.html' with ts=ts in_use=ts_with_any_assignments in_official_use=ts_with_official_assignments only %}
{% endfor %}
{% endwith %}{% endfor %}
</td>
{% endfor %}
{% endif %}
</tr>
{% endfor %}
</table>
</tbody>
{% endwith %}
</table>
{% endif %}
{# Modal to confirm timeslot deletion #}
<div id="delete-modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">
<span aria-hidden="true">&times;</span>
<span class="sr-only">Close</span>
</button>
<h4 class="modal-title">Confirm Delete</h4>
</div>
<div class="modal-body">
<p>
<span class="ts-singular">The following timeslot</span>
<span class="ts-plural"><span class="ts-count"></span> timeslots</span>
will be deleted:
</p>
<dl class="dl-horizontal">
<dt>Name</dt><dd><span class="ts-name"></span></dd>
<dt>Date</dt><dd><span class="ts-date"></span></dd>
<dt>Time</dt><dd><span class="ts-time"></span></dd>
<dt>Location</dt><dd><span class="ts-location"></span></dd>
</dl>
<p class="unofficial-use-warning">
The official schedule will not be affected, but sessions in
unofficial schedules currently assigned to
<span class="ts-singular">this timeslot</span>
<span class="ts-plural">any of these timeslots</span>
will become unassigned.
</p>
<p class="official-use-warning">
The official schedule will be affected.
Sessions currently assigned to
<span class="ts-singular">this timeslot</span>
<span class="ts-plural">any of these timeslots</span>
will become unassigned.
</p>
<p>
<span class="ts-singular">Are you sure you want to delete this timeslot?</span>
<span class="ts-plural">Are you sure you want to delete these <span class="ts-count"></span> timeslots?</span>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" id="confirm-delete-button" class="btn btn-primary">Delete</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block js %}
<script type="text/javascript">
// create a namespace for local JS
timeslotEdit = (function() {
let deleteModal;
let timeslotTableBody = document.querySelector('#timeslot-table tbody');
function initializeDeleteModal() {
deleteModal = jQuery('#delete-modal');
deleteModal.eltsToDelete = null; // PK of TimeSlot that modal 'Delete' button should delete
let spans = deleteModal.find('span');
deleteModal.elts = {
unofficialUseWarning: deleteModal.find('.unofficial-use-warning'),
officialUseWarning: deleteModal.find('.official-use-warning'),
timeslotNameSpans: spans.filter('.ts-name'),
timeslotDateSpans: spans.filter('.ts-date'),
timeslotTimeSpans: spans.filter('.ts-time'),
timeslotLocSpans: spans.filter('.ts-location'),
timeslotCountSpans: spans.filter('.ts-count'),
pluralSpans: spans.filter('.ts-plural'),
singularSpans: spans.filter('.ts-singular')
};
document.getElementById('confirm-delete-button').addEventListener(
'click',
() => timeslotEdit.handleDeleteButtonClick()
);
function uniqueArray(a) {
let s = new Set();
a.forEach(item => s.add(item));
return Array.from(s);
}
deleteModal.openModal = function (eltsToDelete) {
let eltArray = Array.from(eltsToDelete); // make sure this is an array
if (eltArray.length > 1) {
deleteModal.elts.pluralSpans.show();
deleteModal.elts.singularSpans.hide();
} else {
deleteModal.elts.pluralSpans.hide();
deleteModal.elts.singularSpans.show();
}
deleteModal.elts.timeslotCountSpans.text(String(eltArray.length));
let names = uniqueArray(eltArray.map(elt => elt.dataset.timeslotName));
if (names.length === 1) {
names = names[0];
} else {
names.sort();
names = names.join(', ');
}
deleteModal.elts.timeslotNameSpans.text(names);
let dates = uniqueArray(eltArray.map(elt => elt.dataset.timeslotDate));
if (dates.length === 1) {
dates = dates[0];
} else {
dates = 'Multiple';
}
deleteModal.elts.timeslotDateSpans.text(dates);
let times = uniqueArray(eltArray.map(elt => elt.dataset.timeslotTime));
if (times.length === 1) {
times = times[0];
} else {
times = 'Multiple';
}
deleteModal.elts.timeslotTimeSpans.text(times);
let locs = uniqueArray(eltArray.map(elt => elt.dataset.timeslotLocation));
if (locs.length === 1) {
locs = locs[0];
} else {
locs = 'Multiple';
}
deleteModal.elts.timeslotLocSpans.text(locs);
// Check whether any of the elts are used in official / unofficial schedules
let unofficialUse = eltArray.some(elt => elt.dataset.unofficialUse === 'true');
let officialUse = eltArray.some(elt => elt.dataset.officialUse === 'true');
deleteModal.elts.unofficialUseWarning.hide();
deleteModal.elts.officialUseWarning.hide();
if (officialUse) {
deleteModal.elts.officialUseWarning.show();
} else if (unofficialUse) {
deleteModal.elts.unofficialUseWarning.show();
}
deleteModal.eltsToDelete = eltsToDelete;
deleteModal.modal('show');
}
/**
* Handle deleting a single timeslot
*
* clicked arg is the clicked element, which must be a child of the timeslot element
*/
function deleteSingleTimeSlot(clicked) {
deleteModal.openModal([clicked.closest('.timeslot')]);
}
/**
* Handle deleting an entire day worth of timeslots
*
* clicked arg is the clicked element, which must be a child of the day header element
*/
function deleteDay(clicked) {
// Find all timeslots for this day
let dateId = clicked.dataset.dateId;
let timeslots = timeslotTableBody.querySelectorAll(
':scope .timeslot[data-date-id="' + dateId + '"]' // :scope prevents picking up results outside table body
);
if (timeslots.length > 0) {
deleteModal.openModal(timeslots);
}
}
/**
* Handle deleting an entire column worth of timeslots
*
* clicked arg is the clicked element, which must be a child of the column header element
*/
function deleteColumn(clicked) {
let colId = clicked.dataset.colId;
let timeslots = timeslotTableBody.querySelectorAll(
':scope .timeslot[data-col-id="' + colId + '"]' // :scope prevents picking up results outside table body
);
if (timeslots.length > 0) {
deleteModal.openModal(timeslots);
}
}
/**
* Event handler for clicks on the timeslot table
*
* Handles clicks on all the delete buttons to avoid large numbers of event handlers.
*/
document.getElementById('timeslot-table').addEventListener('click', function(event) {
let clicked = event.target; // find out what was clicked
if (clicked.dataset.deleteScope) {
switch (clicked.dataset.deleteScope) {
case 'timeslot':
deleteSingleTimeSlot(clicked)
break
case 'column':
deleteColumn(clicked)
break
case 'day':
deleteDay(clicked)
break
default:
throw new Error('Unexpected deleteScope "' + clicked.dataset.deleteScope + '"')
}
}
});
}
// Update timeslot classes when DOM changes
function tstableObserveCallback(mutationList) {
mutationList.forEach(mutation => {
if (mutation.type === 'childList' && mutation.target.classList.contains('tscell')) {
const tscell = mutation.target;
// mark collisions
if (tscell.getElementsByClassName('timeslot').length > 1) {
tscell.classList.add('timeslot-collision');
} else {
tscell.classList.remove('timeslot-collision');
}
// remove timeslot type classes for any removed timeslots
mutation.removedNodes.forEach(node => {
if (node.classList.contains('timeslot') && node.dataset.timeslotType) {
tscell.classList.remove('tstype_' + node.dataset.timeslotType);
}
});
// now add timeslot type classes for any remaining timeslots
Array.from(tscell.getElementsByClassName('timeslot')).forEach(elt => {
if (elt.dataset.timeslotType) {
tscell.classList.add('tstype_' + elt.dataset.timeslotType);
}
});
}
});
}
function initializeTsTableObserver() {
const observer = new MutationObserver(tstableObserveCallback);
observer.observe(timeslotTableBody, { childList: true, subtree: true });
}
window.addEventListener('load', function (event) {
initializeTsTableObserver();
initializeDeleteModal();
});
function removeTimeslotElement(elt) {
if (elt.parentNode) {
elt.parentNode.removeChild(elt);
}
}
function handleDeleteButtonClick() {
if (!deleteModal || !deleteModal.eltsToDelete) {
return; // do nothing if not yet initialized
}
let timeslotElts = Array.from(deleteModal.eltsToDelete); // make own copy as Array so we have .map()
ajaxDeleteTimeSlot(timeslotElts.map(elt => elt.dataset.timeslotPk))
.error(function(jqXHR, textStatus) {
displayError('Error deleting timeslot: ' + jqXHR.responseText)
})
.done(function () {timeslotElts.forEach(tse => tse.parentNode.removeChild(tse))})
.always(function () {deleteModal.modal('hide')});
}
/**
* Make an AJAX request to delete a TimeSlot
*
* @param pkArray array of PKs of timeslots to delete
* @returns jqXHR object corresponding to jQuery ajax request
*/
function ajaxDeleteTimeSlot(pkArray) {
return jQuery.ajax({
method: 'post',
timeout: 5 * 1000,
data: {
action: 'delete',
slot_id: pkArray.join(',')
}
});
}
function displayError(msg) {
window.alert(msg);
}
// export callable methods
return {
handleDeleteButtonClick: handleDeleteButtonClick,
}
})();
</script>
{% endblock %}

View file

@ -0,0 +1,26 @@
<div id="timeslot{{ ts.pk }}"
class="timeslot {% if ts in in_official_use %}in-official-use{% elif ts in in_use %}in-unofficial-use{% endif %}"
data-timeslot-pk="{{ ts.pk }}"
data-date-id="{{ ts.time.date.isoformat }}"{# used for identifying day/col contents #}
data-col-id="{{ ts.time.date.isoformat }}T{{ ts.time|date:"H:i" }}-{{ ts.end_time|date:"H:i" }}" {# used for identifying column contents #}
data-timeslot-name="{{ ts.name }}"
data-timeslot-type="{{ ts.type.slug }}"
data-timeslot-location="{{ ts.location.name }}"
data-timeslot-date="{{ ts.time|date:"l (Y-m-d)" }}"
data-timeslot-time="{{ ts.time|date:"H:i" }}-{{ ts.end_time|date:"H:i" }}"
data-unofficial-use="{% if ts in in_use and ts not in in_official_use %}true{% else %}false{% endif %}"
data-official-use="{% if ts in in_official_use %}true{% else %}false{% endif %}">
<div class="ts-name">
<span
title="{% if ts in in_official_use %}Used in official schedule{% elif ts in in_use %}Used in unofficial schedule{% else %}Unused{% endif %}">
{{ ts.name }}
</span>
</div>
<div class="timeslot-buttons">
<a href="{% url 'ietf.meeting.views.edit_timeslot' num=ts.meeting.number slot_id=ts.id %}">
<span class="fa fa-pencil-square-o"></span>
</a>
<span class="fa fa-trash delete-button" data-delete-scope="timeslot" title="Delete this timeslot"></span>
</div>
<div class="ts-type">{{ ts.type }}</div>
</div>

View file

@ -63,22 +63,67 @@ def yyyymmdd_to_strftime_format(fmt):
remaining = remaining[1:]
return res
class DatepickerDateField(forms.DateField):
"""DateField with some glue for triggering JS Bootstrap datepicker."""
def __init__(self, date_format, picker_settings={}, *args, **kwargs):
class DatepickerMedia:
"""Media definitions needed for Datepicker widgets"""
css = dict(all=('bootstrap-datepicker/css/bootstrap-datepicker3.min.css',))
js = ('bootstrap-datepicker/js/bootstrap-datepicker.min.js',)
class DatepickerDateInput(forms.DateInput):
"""DateInput that uses the Bootstrap datepicker
The format must be in the Bootstrap datepicker format (yyyy-mm-dd, e.g.), not the
strftime format. The picker_settings argument is a dict of parameters for the datepicker,
converting their camelCase names to dash-separated lowercase names and omitting the
'data-date' prefix to the key.
"""
Media = DatepickerMedia
def __init__(self, attrs=None, date_format=None, picker_settings=None):
super().__init__(
attrs,
yyyymmdd_to_strftime_format(date_format),
)
self.attrs.setdefault('data-provide', 'datepicker')
self.attrs.setdefault('data-date-format', date_format)
self.attrs.setdefault("data-date-autoclose", "1")
self.attrs.setdefault('placeholder', date_format)
if picker_settings is not None:
for k, v in picker_settings.items():
self.attrs['data-date-{}'.format(k)] = v
class DatepickerSplitDateTimeWidget(forms.SplitDateTimeWidget):
"""Split datetime widget using Bootstrap datepicker
The format must be in the Bootstrap datepicker format (yyyy-mm-dd, e.g.), not the
strftime format. The picker_settings argument is a dict of parameters for the datepicker,
converting their camelCase names to dash-separated lowercase names and omitting the
'data-date' prefix to the key.
"""
Media = DatepickerMedia
def __init__(self, *, date_format='yyyy-mm-dd', picker_settings=None, **kwargs):
date_attrs = kwargs.setdefault('date_attrs', dict())
date_attrs.setdefault("data-provide", "datepicker")
date_attrs.setdefault("data-date-format", date_format)
date_attrs.setdefault("data-date-autoclose", "1")
date_attrs.setdefault("placeholder", date_format)
if picker_settings is not None:
for k, v in picker_settings.items():
date_attrs['data-date-{}'.format(k)] = v
super().__init__(date_format=yyyymmdd_to_strftime_format(date_format), **kwargs)
class DatepickerDateField(forms.DateField):
"""DateField with some glue for triggering JS Bootstrap datepicker"""
def __init__(self, date_format, picker_settings=None, *args, **kwargs):
strftime_format = yyyymmdd_to_strftime_format(date_format)
kwargs["input_formats"] = [strftime_format]
kwargs["widget"] = forms.DateInput(format=strftime_format)
kwargs["widget"] = DatepickerDateInput(dict(placeholder=date_format), date_format, picker_settings)
super(DatepickerDateField, self).__init__(*args, **kwargs)
self.widget.attrs["data-provide"] = "datepicker"
self.widget.attrs["data-date-format"] = date_format
if "placeholder" not in self.widget.attrs:
self.widget.attrs["placeholder"] = date_format
for k, v in picker_settings.items():
self.widget.attrs["data-date-%s" % k] = v
# This accepts any ordered combination of labelled days, hours, minutes, seconds
ext_duration_re = re.compile(

View file

@ -8,8 +8,9 @@ from pyquery import PyQuery
from urllib.parse import urlparse, urlsplit, urlunsplit
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, URLValidator, EmailValidator, _lazy_re_compile, BaseValidator
from django.template.defaultfilters import filesizeformat
from django.utils.deconstruct import deconstructible
@ -248,3 +249,38 @@ class MaxImageSizeValidator(BaseValidator):
return x.width, x.height
except FileNotFoundError:
return 0, 0 # don't fail if the image is missing
@deconstructible
class JSONForeignKeyListValidator:
"""Validate that a JSONField is a list of valid foreign key references"""
def __init__(self, model_class, field_name='pk'):
# model_class can be a class or a string like "app_name.ModelName"
self._model = model_class
self.field_name = field_name
@property
def model(self):
# Lazy model lookup is needed because the validator's __init__() is usually
# called while Django is still setting up. It's likely that the model is not
# registered at that point, otherwise the caller would not have used the
# string form to refer to it.
if isinstance(self._model, str):
self._model = apps.get_model(self._model)
return self._model
def __call__(self, value):
for val in value:
try:
self.model.objects.get(**{self.field_name: val})
except ObjectDoesNotExist:
raise ValidationError(
f'No {self.model.__name__} with {self.field_name} == "{val}" exists.'
)
def __eq__(self, other):
return (
isinstance(other, self.__class__)
and (self.model == other.model)
and (self.field_name == other.field_name)
)