Snapshot of dev work to add session purpose annotation
- Legacy-Id: 19415
This commit is contained in:
parent
3386e59a61
commit
1054f90873
|
@ -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):
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
24
ietf/group/migrations/0051_groupfeatures_session_purposes.py
Normal file
24
ietf/group/migrations/0051_groupfeatures_session_purposes.py
Normal 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)]),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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
130
ietf/meeting/fields.py
Normal 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',
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
|
@ -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()
|
||||
|
|
21
ietf/meeting/migrations/0049_session_purpose.py
Normal file
21
ietf/meeting/migrations/0049_session_purpose.py
Normal 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'),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
20
ietf/meeting/templatetags/tests.py
Normal file
20
ietf/meeting/templatetags/tests.py
Normal 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
|
|
@ -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)
|
|
@ -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
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
|
@ -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": "",
|
||||
|
|
27
ietf/name/migrations/0032_agendafiltertypename.py
Normal file
27
ietf/name/migrations/0032_agendafiltertypename.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
33
ietf/name/migrations/0033_populate_agendafiltertypename.py
Normal file
33
ietf/name/migrations/0033_populate_agendafiltertypename.py
Normal 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),
|
||||
]
|
|
@ -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),
|
||||
]
|
32
ietf/name/migrations/0035_sessionpurposename.py
Normal file
32
ietf/name/migrations/0035_sessionpurposename.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
48
ietf/name/migrations/0036_populate_sessionpurposename.py
Normal file
48
ietf/name/migrations/0036_populate_sessionpurposename.py
Normal 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)
|
||||
]
|
|
@ -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")
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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'}
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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(
|
||||
|
|
83
ietf/secr/static/secr/js/session_purpose_and_type_widget.js
Normal file
83
ietf/secr/static/secr/js/session_purpose_and_type_widget.js
Normal 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);
|
||||
})();
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -21,3 +21,11 @@
|
|||
</div> <!-- module -->
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
{{ form.media.js }}
|
||||
{% endblock %}
|
||||
{% block extrastyle %}
|
||||
{{ form.media.css }}
|
||||
{% endblock %}
|
|
@ -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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
109
ietf/static/ietf/js/meeting/session_details_form.js
Normal file
109
ietf/static/ietf/js/meeting/session_details_form.js
Normal 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);
|
||||
})();
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
====================================================================
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
27
ietf/templates/meeting/create_timeslot.html
Executable file
27
ietf/templates/meeting/create_timeslot.html
Executable 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 %}
|
35
ietf/templates/meeting/edit_timeslot.html
Normal file
35
ietf/templates/meeting/edit_timeslot.html
Normal 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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 %}
|
24
ietf/templates/meeting/session_details_form.html
Normal file
24
ietf/templates/meeting/session_details_form.html
Normal 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 %}
|
|
@ -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>
|
||||
·
|
||||
{% 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'}} ({{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">×</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 %}
|
26
ietf/templates/meeting/timeslot_edit_timeslot.html
Normal file
26
ietf/templates/meeting/timeslot_edit_timeslot.html
Normal 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>
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue