diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index d59a13ef6..fe7de0a6f 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -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): diff --git a/ietf/group/admin.py b/ietf/group/admin.py index bae546648..8bbee7a13 100644 --- a/ietf/group/admin.py +++ b/ietf/group/admin.py @@ -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', diff --git a/ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py b/ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py new file mode 100644 index 000000000..e7272d571 --- /dev/null +++ b/ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py @@ -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'), + ), + ] diff --git a/ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py b/ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py new file mode 100644 index 000000000..5bed0235f --- /dev/null +++ b/ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py @@ -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), + ] diff --git a/ietf/group/migrations/0051_groupfeatures_session_purposes.py b/ietf/group/migrations/0051_groupfeatures_session_purposes.py new file mode 100644 index 000000000..d6adfca56 --- /dev/null +++ b/ietf/group/migrations/0051_groupfeatures_session_purposes.py @@ -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)]), + ), + ] diff --git a/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py new file mode 100644 index 000000000..5ad6bdbd9 --- /dev/null +++ b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py @@ -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), + ] diff --git a/ietf/group/models.py b/ietf/group/models.py index aae5c4807..8e2c55f9c 100644 --- a/ietf/group/models.py +++ b/ietf/group/models.py @@ -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) @@ -290,7 +294,10 @@ class GroupFeatures(models.Model): groupman_authroles = jsonfield.JSONField(max_length=128, blank=False, default=["Secretariat",]) 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.") + 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): diff --git a/ietf/meeting/fields.py b/ietf/meeting/fields.py new file mode 100644 index 000000000..06533f41c --- /dev/null +++ b/ietf/meeting/fields.py @@ -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', + ) + + + diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py index c082baebf..e63256e67 100644 --- a/ietf/meeting/forms.py +++ b/ietf/meeting/forms.py @@ -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, +) \ No newline at end of file diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py index 508caccea..3fd4417a1 100644 --- a/ietf/meeting/helpers.py +++ b/ietf/meeting/helpers.py @@ -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 = [] @@ -229,7 +232,7 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe l = sessions_for_groups.get((a.session.group, a.session.type_id), []) a.session.order_number = l.index(a) + 1 if a in l else 0 - + parents = Group.objects.filter(pk__in=parent_id_set) parent_replacements = find_history_replacements_active_at(parents, meeting_time) @@ -253,54 +256,459 @@ 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): - """Add keywords for agenda filtering - - Keywords are all lower case. +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. """ - for a in assignments: - a.filter_keywords = {a.timeslot.type.slug.lower()} - a.filter_keywords.update(filter_keywords_for_session(a.session)) - a.filter_keywords = sorted(list(a.filter_keywords)) + 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') -def filter_keywords_for_session(session): - keywords = {session.type.slug.lower()} - group = getattr(session, 'historic_group', session.group) - 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) + 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 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 _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(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): - """Get keyword that identifies a specific 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 = self._get_group(session) + if group is None: + return None + 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) - Returns None if the session cannot be selected individually. - """ - group = getattr(session, 'historic_group', session.group) - if group is None: - return None - kw = group.acronym.lower() # 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 diff --git a/ietf/meeting/migrations/0049_session_purpose.py b/ietf/meeting/migrations/0049_session_purpose.py new file mode 100644 index 000000000..be863905b --- /dev/null +++ b/ietf/meeting/migrations/0049_session_purpose.py @@ -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'), + ), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 24874817b..445ea49e2 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -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) diff --git a/ietf/meeting/templatetags/agenda_custom_tags.py b/ietf/meeting/templatetags/agenda_custom_tags.py index 30b18e0f3..1b64f9adf 100644 --- a/ietf/meeting/templatetags/agenda_custom_tags.py +++ b/ietf/meeting/templatetags/agenda_custom_tags.py @@ -5,6 +5,8 @@ from django import template from django.urls import reverse +from ietf.utils.text import xslugify + register = template.Library() @@ -67,4 +69,86 @@ def webcal_url(context, viewname, *args, **kwargs): return 'webcal://{}{}'.format( context.request.get_host(), reverse(viewname, args=args, kwargs=kwargs) - ) \ No newline at end of file + ) + +@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 + ... . If it returns None, the 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 '{}'.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) diff --git a/ietf/meeting/templatetags/tests.py b/ietf/meeting/templatetags/tests.py new file mode 100644 index 000000000..eb85fd47b --- /dev/null +++ b/ietf/meeting/templatetags/tests.py @@ -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 diff --git a/ietf/meeting/tests_helpers.py b/ietf/meeting/tests_helpers.py index 1e7705c5e..b37118a86 100644 --- a/ietf/meeting/tests_helpers.py +++ b/ietf/meeting/tests_helpers.py @@ -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') - 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 + # 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: - 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_group = GroupFactory(state_id=group_state_id) + if historic == '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) \ No newline at end of file diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index 228fa21f7..2341221ca 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -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 diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index eb7a61b1b..498b8298a 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -24,9 +24,10 @@ from django.urls import reverse as urlreverse from django.conf import settings from django.contrib.auth.models import User from django.test import Client, override_settings -from django.db.models import F +from django.db.models import F, Max from django.http import QueryDict, FileResponse from django.template import Context, Template +from django.utils.text import slugify from django.utils.timezone import now import debug # pyflakes:ignore @@ -39,7 +40,6 @@ from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_r from ietf.meeting.helpers import send_interim_approval_request from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates -from ietf.meeting.helpers import filter_keyword_for_specific_session from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room, Constraint, ConstraintName from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data from ietf.meeting.utils import finalize, condition_slide_order @@ -54,8 +54,8 @@ from ietf.utils.text import xslugify from ietf.person.factories import PersonFactory from ietf.group.factories import GroupFactory, GroupEventFactory, RoleFactory from ietf.meeting.factories import ( SessionFactory, ScheduleFactory, - SessionPresentationFactory, MeetingFactory, FloorPlanFactory, - TimeSlotFactory, SlideSubmissionFactory, RoomFactory, + SessionPresentationFactory, MeetingFactory, FloorPlanFactory, + TimeSlotFactory, SlideSubmissionFactory, RoomFactory, ConstraintFactory, MeetingHostFactory, ProceedingsMaterialFactory ) from ietf.doc.factories import DocumentFactory, WgDraftFactory from ietf.submit.tests import submission_file @@ -438,9 +438,12 @@ class MeetingTests(BaseMeetingTestCase): self.assertEqual(len(checkboxes), 1, 'Row for assignment {} does not have a checkbox input'.format(assignment)) checkbox = checkboxes.eq(0) + kw_token = assignment.session.docname_token_only_for_multiple() self.assertEqual( checkbox.attr('data-filter-item'), - filter_keyword_for_specific_session(assignment.session), + assignment.session.group.acronym.lower() + ( + '' if kw_token is None else f'-{kw_token}' + ) ) def test_agenda_personalize_updates_urls(self): @@ -721,10 +724,13 @@ class MeetingTests(BaseMeetingTestCase): if g.parent_id is not None: self.assertIn('%s?show=%s' % (ical_url, g.parent.acronym.lower()), content) - # Should be a 'non-area events' link showing appropriate types - non_area_labels = [ - 'BOF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools', - ] + # The 'non-area events' are those whose keywords are in the last column of buttons + na_col = q('#customize td.view:last-child') # find the column + non_area_labels = [e.attrib['data-filter-item'] + for e in na_col.find('button.pickview')] + assert len(non_area_labels) > 0 # test setup must produce at least one label for this test + + # Should be a 'non-area events' link showing appropriate types self.assertIn('%s?show=%s' % (ical_url, ','.join(non_area_labels).lower()), content) def test_parse_agenda_filter_params(self): @@ -1679,6 +1685,1048 @@ class EditMeetingScheduleTests(TestCase): +class EditTimeslotsTests(TestCase): + def login(self, username='secretary'): + """Log in with permission to edit timeslots""" + self.client.login(username=username, password='{}+password'.format(username)) + + @staticmethod + def edit_timeslots_url(meeting): + return urlreverse('ietf.meeting.views.edit_timeslots', kwargs={'num': meeting.number}) + + @staticmethod + def edit_timeslot_url(ts: TimeSlot): + return urlreverse('ietf.meeting.views.edit_timeslot', + kwargs={'num': ts.meeting.number, 'slot_id': ts.pk}) + + @staticmethod + def create_timeslots_url(meeting): + return urlreverse('ietf.meeting.views.create_timeslot', kwargs={'num': meeting.number}) + + @staticmethod + def create_bare_meeting(number=120) -> Meeting: + """Create a basic IETF meeting""" + return MeetingFactory( + type_id='ietf', + number=number, + date=datetime.datetime.today() + datetime.timedelta(days=10), + populate_schedule=False, + ) + + @staticmethod + def create_initial_schedule(meeting): + """Create initial / base schedule in the same manner as through the UI""" + owner = User.objects.get(username='secretary').person + base_schedule = Schedule.objects.create( + meeting=meeting, + name='base', + owner=owner, + visible=True, + public=True, + ) + + schedule = Schedule.objects.create(meeting = meeting, + name = "%s-1" % slugify(owner.plain_name()), + owner = owner, + visible = True, + public = True, + base = base_schedule, + ) + + meeting.schedule = schedule + meeting.save() + + def create_meeting(self, number=120): + """Create a meeting ready for adding timeslots in the usual workflow""" + meeting = self.create_bare_meeting(number=number) + RoomFactory.create_batch(8, meeting=meeting) + self.create_initial_schedule(meeting) + return meeting + + def test_view_permissions(self): + """Only the secretary should be able to edit timeslots""" + # test prep and helper method + usernames_to_reject = [ + 'plain', + RoleFactory(name_id='chair').person.user.username, + RoleFactory(name_id='ad', group__type_id='area').person.user.username, + ] + meeting = self.create_bare_meeting() + url = self.edit_timeslots_url(meeting) + + def _assert_permissions(comment): + self.client.logout() + logged_in_username = '' + try: + # loop through all the usernames that should be rejected + for username in usernames_to_reject: + login_testing_unauthorized(self, username, url) + logged_in_username = username + # test the last username to reject and log in as secretary + login_testing_unauthorized(self, 'secretary', url) + except AssertionError: + # give a better failure message + self.fail( + '{} should not be able to access the edit timeslots page {}'.format( + logged_in_username, + comment, + ) + ) + r = self.client.get(url) # confirm secretary can retrieve the page + self.assertEqual(r.status_code, 200, + 'secretary should be able to access the edit timeslots page {}'.format(comment)) + + # Actual tests here + _assert_permissions('without schedule') # first test without a meeting schedule + self.create_initial_schedule(meeting) + _assert_permissions('with schedule') # then test with a meeting schedule + + def test_linked_from_agenda_list(self): + """The edit timeslots view should be linked from the agenda list view""" + ad = RoleFactory(name_id='ad', group__type_id='area').person + + meeting = self.create_bare_meeting() + self.create_initial_schedule(meeting) + + url = urlreverse('ietf.meeting.views.list_schedules', kwargs={'num': meeting.number}) + + # Should have no link when logged in as area director + self.login(ad.user.username) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertEqual( + len(q('a[href="{}"]'.format(self.edit_timeslots_url(meeting)))), + 0, + 'User who cannot edit timeslots should not see a link to the edit timeslots page' + ) + + # Should have a link when logged in as secretary + self.login() + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + q = PyQuery(r.content) + self.assertGreaterEqual( + len(q('a[href="{}"]'.format(self.edit_timeslots_url(meeting)))), + 1, + 'Must be at least one link from the agenda list page to the edit timeslots page' + ) + + def assert_helpful_url(self, response, helpful_url, message): + q = PyQuery(response.content) + self.assertGreaterEqual( + len(q('.timeslot-edit a[href="{}"]'.format(helpful_url))), + 1, + message, + ) + + def test_with_no_rooms(self): + """Editor should be helpful when there are no rooms yet""" + meeting = self.create_bare_meeting() + self.login() + + # with no schedule, should get a link to the meeting page in the secr app until we can + # handle this situation in the meeting app + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + self.assert_helpful_url( + r, + urlreverse('ietf.secr.meetings.views.view', kwargs={'meeting_id': meeting.number}), + 'Must be a link to a helpful URL when there are no rooms and no schedule' + ) + + # with a schedule, should get a link to the create rooms page in the secr app + self.create_initial_schedule(meeting) + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + self.assert_helpful_url( + r, + urlreverse('ietf.secr.meetings.views.rooms', + kwargs={'meeting_id': meeting.number, 'schedule_name': meeting.schedule.name}), + 'Must be a link to a helpful URL when there are no rooms' + ) + + def test_with_no_timeslots(self): + """Editor should be helpful when there are rooms but no timeslots yet""" + meeting = self.create_bare_meeting() + RoomFactory(meeting=meeting) + self.login() + helpful_url = self.create_timeslots_url(meeting) + + # with no schedule, should get a link to the meeting page in the secr app until we can + # handle this situation in the meeting app + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + self.assert_helpful_url(r, helpful_url, + 'Must be a link to a helpful URL when there are no timeslots and no schedule') + + # with a schedule, should get a link to the create rooms page in the secr app + self.create_initial_schedule(meeting) + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + self.assert_helpful_url(r, helpful_url, + 'Must be a link to a helpful URL when there are no timeslots') + + def assert_required_links_present(self, response, meeting): + """Assert that required links on the editor page are present""" + q = PyQuery(response.content) + self.assertGreaterEqual( + len(q('a[href="{}"]'.format(self.create_timeslots_url(meeting)))), + 1, + 'Timeslot edit page should have a link to create timeslots' + ) + self.assertGreaterEqual( + len(q('a[href="{}"]'.format(urlreverse('ietf.secr.meetings.views.rooms', + kwargs={'meeting_id': meeting.number, + 'schedule_name': meeting.schedule.name})) + )), + 1, + 'Timeslot edit page should have a link to edit rooms' + ) + + def test_required_links_present(self): + """Editor should have links to create timeslots and edit rooms""" + meeting = self.create_meeting() + self.create_initial_schedule(meeting) + RoomFactory.create_batch(8, meeting=meeting) + + self.login() + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + self.assert_required_links_present(r, meeting) + + def test_shows_timeslots(self): + """Timeslots should be displayed properly""" + def _col_index(elt): + """Find the column index of an element in its table row + + First column is 1 + """ + selector = 'td, th' # accept both td and th elements + col_elt = elt.closest(selector) + tr = col_elt.parent('tr') + return 1 + tr.children(selector).index(col_elt[0]) # [0] gets bare element + + meeting = self.create_meeting() + # add some timeslots + times = [datetime.time(hour=h) for h in (11, 14)] + days = [meeting.get_meeting_date(ii).date() for ii in range(meeting.days)] + + timeslots = [] + duration = datetime.timedelta(minutes=90) + for room in meeting.room_set.all(): + for day in days: + timeslots.extend( + TimeSlotFactory( + meeting=meeting, + location=room, + time=datetime.datetime.combine(day, t), + duration=duration, + ) + for t in times + ) + + # get the page under test + self.login() + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + + q = PyQuery(r.content) + table = q('#timeslot-table') + self.assertEqual(len(table), 1, 'Exactly one timeslot-table required') + table = table.eq(0) + + # check the day super-column headings + day_headings = table.find('.day-label') + self.assertEqual(len(day_headings), len(days)) + day_columns = dict() # map datetime to iterable with table col indices for that day + next_col = _col_index(day_headings.eq(0)) # find column of the first day + for day, heading in zip(days, day_headings.items()): + self.assertIn(day.strftime('%a'), heading.text(), + 'Weekday abbrev for {} not found in heading'.format(day)) + self.assertIn(day.strftime('%Y-%m-%d'), heading.text(), + 'Numeric date for {} not found in heading'.format(day)) + cols = int(heading.attr('colspan')) # columns spanned by day header + day_columns[day] = range(next_col, next_col + cols) + next_col += cols + + # check the timeslot time headings + time_headings = table.find('.time-label') + self.assertEqual(len(time_headings), len(times) * len(days)) + + expected_columns = dict() # [date][time] element is expected column for a timeslot + for day, columns in day_columns.items(): + headings = time_headings.filter( + # selector for children in any of the day's columns + ','.join( + ':nth-child({})'.format(col) + for col in columns + ) + ) + expected_columns[day] = dict() + for time, heading in zip(times, headings.items()): + self.assertIn(time.strftime('%H:%M'), heading.text(), + 'Timeslot start {} not found for day {}'.format(time, day)) + expected_columns[day][time] = _col_index(heading) + + # check that the expected timeslots are shown with expected info / ui features + timeslot_elts = table.find('.timeslot') + self.assertEqual(len(timeslot_elts), len(timeslots), 'Unexpected or missing timeslot elements') + for ts in timeslots: + pk_elts = timeslot_elts.filter('#timeslot{}'.format(ts.pk)) + self.assertEqual(len(pk_elts), 1, 'Expect exactly one element for each timeslot') + elt = pk_elts.eq(0) + self.assertIn(ts.name, elt.text(), 'Timeslot name should appear in the element for {}'.format(ts)) + self.assertIn(str(ts.type), elt.text(), 'Timeslot type should appear in the element for {}'.format(ts)) + self.assertEqual(_col_index(elt), expected_columns[ts.time.date()][ts.time.time()], + 'Timeslot {} is in the wrong column'.format(ts)) + delete_btn = elt.find('.delete-button[data-delete-scope="timeslot"]') + self.assertEqual(len(delete_btn), 1, + 'Timeslot {} should have one delete button'.format(ts)) + edit_btn = elt.find('a[href="{}"]'.format( + urlreverse('ietf.meeting.views.edit_timeslot', + kwargs=dict(num=meeting.number, slot_id=ts.pk)) + )) + self.assertEqual(len(edit_btn), 1, + 'Timeslot {} should have one edit button'.format(ts)) + # find the room heading for the row + tr = elt.closest('tr') + self.assertIn(ts.location.name, tr.children('th').eq(0).text(), + 'Timeslot {} is not shown in the correct row'.format(ts)) + + def test_bulk_delete_buttons_exist(self): + """Delete buttons for days and columns should be shown""" + meeting = self.create_meeting() + for day in range(meeting.days): + TimeSlotFactory( + meeting=meeting, + location=meeting.room_set.first(), + time=datetime.datetime.combine( + meeting.get_meeting_date(day).date(), + datetime.time(hour=11) + ), + ) + TimeSlotFactory( + meeting=meeting, + location=meeting.room_set.first(), + time=datetime.datetime.combine( + meeting.get_meeting_date(day).date(), + datetime.time(hour=14) + ), + ) + + self.login() + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + + q = PyQuery(r.content) + table = q('#timeslot-table') + days = table.find('.day-label') + self.assertEqual(len(days), meeting.days, 'Wrong number of day labels') + for day_label in days.items(): + self.assertEqual(len(day_label.find('.delete-button[data-delete-scope="day"]')), 1, + 'No delete button for day {}'.format(day_label.text())) + + slots = table.find('.time-label') + self.assertEqual(len(slots), 2 * meeting.days, 'Wrong number of slot labels') + for slot_label in slots.items(): + self.assertEqual(len(slot_label.find('.delete-button[data-delete-scope="column"]')), 1, + 'No delete button for slot {}'.format(slot_label.text())) + + def test_timeslot_collision_flag(self): + """Overlapping timeslots in a room should be flagged + + Only checks exact overlap because that is all we currently handle. The display puts + overlapping but not exactly matching timeslots in separate columns which must be + manually checked. + """ + meeting = self.create_bare_meeting() + + t1 = TimeSlotFactory(meeting=meeting) + TimeSlotFactory(meeting=meeting, time=t1.time, duration=t1.duration, location=t1.location) + TimeSlotFactory(meeting=meeting, time=t1.time, duration=t1.duration) # other location + TimeSlotFactory(meeting=meeting, time=t1.time.replace(hour=t1.time.hour + 1), location=t1.location) # other time + + self.login() + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + + q = PyQuery(r.content) + slots = q('#timeslot-table .tscell') + self.assertEqual(len(slots), 4) # one per location per distinct time + collision = slots.filter('.timeslot-collision') + no_collision = slots.filter(':not(.timeslot-collision)') + self.assertEqual(len(collision), 1, 'Wrong number of timeslot collisions flagged') + self.assertEqual(len(no_collision), 3, 'Wrong number of non-colliding timeslots') + # check that the cell containing t1 is the one flagged as a conflict + self.assertEqual(len(collision.find('#timeslot{}'.format(t1.pk))), 1, + 'Wrong timeslot cell flagged as having a collision') + + def test_timeslot_in_use_flag(self): + """Timeslots that are in use should be flagged""" + meeting = self.create_meeting() + + # assign sessions to some timeslots + empty, has_official, has_other = TimeSlotFactory.create_batch(3, meeting=meeting, location=meeting.room_set.first()) + SchedTimeSessAssignment.objects.create( + timeslot=has_official, + session=SessionFactory(meeting=meeting, add_to_schedule=False), + schedule=meeting.schedule, # official schedule + ) + + SchedTimeSessAssignment.objects.create( + timeslot=has_other, + session=SessionFactory(meeting=meeting, add_to_schedule=False), + schedule=ScheduleFactory(meeting=meeting), # not the official schedule + ) + + # get the page + self.login() + r = self.client.get(self.edit_timeslots_url(meeting)) + self.assertEqual(r.status_code, 200) + + # now check that all timeslots appear, flagged appropriately + q = PyQuery(r.content) + empty_elt = q('#timeslot{}'.format(empty.pk)) + has_official_elt = q('#timeslot{}'.format(has_official.pk)) + has_other_elt = q('#timeslot{}'.format(has_other.pk)) + + self.assertEqual(empty_elt.attr('data-unofficial-use'), 'false', 'Unused timeslot should not be in use') + self.assertEqual(empty_elt.attr('data-official-use'), 'false', 'Unused timeslot should not be in use') + + self.assertEqual(has_other_elt.attr('data-unofficial-use'), 'true', + 'Unofficially used timeslot should be flagged') + self.assertEqual(has_other_elt.attr('data-official-use'), 'false', + 'Unofficially used timeslot is not in official use') + + self.assertEqual(has_official_elt.attr('data-unofficial-use'), 'false', + 'Officially used timeslot not in unofficial use') + self.assertEqual(has_official_elt.attr('data-official-use'), 'true', + 'Officially used timeslot should be flagged') + + def test_edit_timeslot(self): + """Edit page should work as expected""" + meeting = self.create_meeting() + + name_before = 'Name Classic (tm)' + type_before = 'regular' + time_before = datetime.datetime.combine( + meeting.date, + datetime.time(hour=10), + ) + duration_before = datetime.timedelta(minutes=60) + show_location_before = True + location_before = meeting.room_set.first() + ts = TimeSlotFactory( + meeting=meeting, + name=name_before, + type_id=type_before, + time=time_before, + duration=duration_before, + show_location=show_location_before, + location=location_before, + ) + + self.login() + name_after = 'New Name (tm)' + type_after = 'plenary' + time_after = time_before.replace(day=time_before.day + 1, hour=time_before.hour + 2) + duration_after = duration_before * 2 + show_location_after = False + location_after = meeting.room_set.last() + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name=name_after, + type=type_after, + time_0=time_after.strftime('%Y-%m-%d'), # date for SplitDateTimeField + time_1=time_after.strftime('%H:%M'), # time for SplitDateTimeField + duration=str(duration_after), + # show_location=show_location_after, # False values are omitted from form + location=location_after.pk, + ) + ) + self.assertEqual(r.status_code, 302) # expect redirect to timeslot edit url + self.assertEqual(r['Location'], self.edit_timeslots_url(meeting), + 'Expected to be redirected to meeting timeslots edit page') + + # check that we changed things + self.assertNotEqual(name_before, name_after) + self.assertNotEqual(type_before, type_after) + self.assertNotEqual(time_before, time_after) + self.assertNotEqual(duration_before, duration_after) + self.assertNotEqual(location_before, location_after) + + # and that we have the new values + ts = TimeSlot.objects.get(pk=ts.pk) + self.assertEqual(ts.name, name_after) + self.assertEqual(ts.type_id, type_after) + self.assertEqual(ts.time, time_after) + self.assertEqual(ts.duration, duration_after) + self.assertEqual(ts.show_location, show_location_after) + self.assertEqual(ts.location, location_after) + + def test_invalid_edit_timeslot(self): + meeting = self.create_bare_meeting() + ts: TimeSlot = TimeSlotFactory(meeting=meeting, name='slot') # n.b., colon indicates type hinting + self.login() + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name='', + type=ts.type.pk, + time_0=ts.time.strftime('%Y-%m-%d'), + time_1=ts.time.strftime('%H:%M'), + duration=str(ts.duration), + show_location=ts.show_location, + location=str(ts.location.pk), + ) + ) + self.assertContains(r, 'This field is required', status_code=400, + msg_prefix='Missing name not properly rejected') + + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name='different name', + type='this is not a type id', + time_0=ts.time.strftime('%Y-%m-%d'), + time_1=ts.time.strftime('%H:%M'), + duration=str(ts.duration), + show_location=ts.show_location, + location=str(ts.location.pk), + ) + ) + self.assertContains(r, 'Select a valid choice', status_code=400, + msg_prefix='Invalid type not properly rejected') + + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name='different name', + type=ts.type.pk, + time_0='this is not a date', + time_1=ts.time.strftime('%H:%M'), + duration=str(ts.duration), + show_location=ts.show_location, + location=str(ts.location.pk), + ) + ) + self.assertContains(r, 'Enter a valid date', status_code=400, + msg_prefix='Invalid date not properly rejected') + + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name='different name', + type=ts.type.pk, + time_0=ts.time.strftime('%Y-%m-%d'), + time_1='this is not a time', + duration=str(ts.duration), + show_location=ts.show_location, + location=str(ts.location.pk), + ) + ) + self.assertContains(r, 'Enter a valid time', status_code=400, + msg_prefix='Invalid time not properly rejected') + + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name='different name', + type=ts.type.pk, + time_0=ts.time.strftime('%Y-%m-%d'), + time_1=ts.time.strftime('%H:%M'), + duration='this is not a duration', + show_location=ts.show_location, + location=str(ts.location.pk), + ) + ) + self.assertContains(r, 'Enter a valid duration', status_code=400, + msg_prefix='Invalid duration not properly rejected') + + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name='different name', + type=ts.type.pk, + time_0=ts.time.strftime('%Y-%m-%d'), + time_1=ts.time.strftime('%H:%M'), + duration='26:00', # longer than 12 hours, + show_location=ts.show_location, + location=str(ts.location.pk), + ) + ) + self.assertContains(r, 'Ensure this value is less than or equal to', status_code=400, + msg_prefix='Overlong duration not properly rejected') + + r = self.client.post( + self.edit_timeslot_url(ts), + data=dict( + name='different name', + type=str(ts.type.pk), + time_0=ts.time.strftime('%Y-%m-%d'), + time_1=ts.time.strftime('%H:%M'), + duration=str(ts.duration), + show_location=ts.show_location, + location='this is not a location', + ) + ) + self.assertContains(r, 'Select a valid choice', status_code=400, + msg_prefix='Invalid location not properly rejected') + + ts_after = meeting.timeslot_set.get(pk=ts.pk) + self.assertEqual(ts.name, ts_after.name) + self.assertEqual(ts.type, ts_after.type) + self.assertEqual(ts.time, ts_after.time) + self.assertEqual(ts.duration, ts_after.duration) + self.assertEqual(ts.show_location, ts_after.show_location) + self.assertEqual(ts.location, ts_after.location) + + def test_create_single_timeslot(self): + """Creating a single timeslot should work""" + meeting = self.create_meeting() + timeslots_before = set(ts.pk for ts in meeting.timeslot_set.all()) + + post_data = dict( + name='some name', + type='regular', + days=str(meeting.date.toordinal()), + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=str(meeting.room_set.first().pk), + ) + self.login() + r = self.client.post( + self.create_timeslots_url(meeting), + data=post_data, + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(r['Location'], self.edit_timeslots_url(meeting), + 'Expected to be redirected to meeting timeslots edit page') + + self.assertEqual(meeting.timeslot_set.count(), len(timeslots_before) + 1) + ts = meeting.timeslot_set.exclude(pk__in=timeslots_before).first() # only 1 + self.assertEqual(ts.name, post_data['name']) + self.assertEqual(ts.type_id, post_data['type']) + self.assertEqual(str(ts.time.date().toordinal()), post_data['days']) + self.assertEqual(ts.time.strftime('%H:%M'), post_data['time']) + self.assertEqual(str(ts.duration), '{}:00'.format(post_data['duration'])) # add seconds + self.assertEqual(ts.show_location, post_data['show_location']) + self.assertEqual(str(ts.location.pk), post_data['locations']) + + def test_create_single_timeslot_outside_meeting_days(self): + """Creating a single timeslot outside the official meeting days should work""" + meeting = self.create_meeting() + timeslots_before = set(ts.pk for ts in meeting.timeslot_set.all()) + other_date = meeting.get_meeting_date(-7).date() + post_data = dict( + name='some name', + type='regular', + other_date=other_date.strftime('%Y-%m-%d'), + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=str(meeting.room_set.first().pk), + ) + self.login() + r = self.client.post( + self.create_timeslots_url(meeting), + data=post_data, + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(r['Location'], self.edit_timeslots_url(meeting), + 'Expected to be redirected to meeting timeslots edit page') + + self.assertEqual(meeting.timeslot_set.count(), len(timeslots_before) + 1) + ts = meeting.timeslot_set.exclude(pk__in=timeslots_before).first() # only 1 + self.assertEqual(ts.name, post_data['name']) + self.assertEqual(ts.type_id, post_data['type']) + self.assertEqual(ts.time.date(), other_date) + self.assertEqual(ts.time.strftime('%H:%M'), post_data['time']) + self.assertEqual(str(ts.duration), '{}:00'.format(post_data['duration'])) # add seconds + self.assertEqual(ts.show_location, post_data['show_location']) + self.assertEqual(str(ts.location.pk), post_data['locations']) + + + def test_invalid_create_timeslot(self): + meeting = self.create_bare_meeting() + room_pk = str(RoomFactory(meeting=meeting).pk) + timeslot_count = TimeSlot.objects.count() + + self.login() + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='', + type='regular', + days=str(meeting.date.toordinal()), + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'This field is required', status_code=400, + msg_prefix='Empty name not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='this is not a type', + days=str(meeting.date.toordinal()), + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Select a valid choice', status_code=400, + msg_prefix='Invalid type not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + # days='', + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Please select a day or specify a date', status_code=400, + msg_prefix='Missing date not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days='this is not an ordinal date', + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Select a valid choice', status_code=400, + msg_prefix='Invalid day not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days=[str(meeting.date.toordinal()), 'this is not an ordinal date'], + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Select a valid choice', status_code=400, + msg_prefix='Invalid day with valid day not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days=str(meeting.date.toordinal()), + other_date='this is not a date', + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Enter a valid date', status_code=400, + msg_prefix='Invalid other_date with valid days not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days='this is not an ordinal date', + other_date='2021-07-13', + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Select a valid choice', status_code=400, + msg_prefix='Invalid day with valid other_date not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + other_date='this is not a date', + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Enter a valid date', status_code=400, + msg_prefix='Invalid other_date not rejected properly') + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days=str(meeting.date.toordinal()), + time='14:37', + duration="ceci n'est pas une duree", + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Enter a valid duration', status_code=400, + msg_prefix='Invalid duration not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days=str(meeting.date.toordinal()), + time='14:37', + duration="26:00", + show_location=True, + locations=room_pk, + ) + ) + self.assertContains(r, 'Ensure this value is less than or equal to', status_code=400, + msg_prefix='Overlong duration not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days=str(meeting.date.toordinal()), + time='14:37', + duration="1:13", + show_location=True, + locations='this is not a room', + ) + ) + self.assertContains(r, 'is not a valid value', status_code=400, + msg_prefix='Invalid location not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days=str(meeting.date.toordinal()), + time='14:37', + duration="1:13", + show_location=True, + locations=[room_pk, 'this is not a room'], + ) + ) + self.assertContains(r, 'is not a valid value', status_code=400, + msg_prefix='Invalid location with valid location not rejected properly') + + r = self.client.post( + self.create_timeslots_url(meeting), + data=dict( + name='this is a name', + type='regular', + days=str(meeting.date.toordinal()), + time='14:37', + duration="1:13", + show_location=True, + ) + ) + self.assertContains(r, 'This field is required', status_code=400, + msg_prefix='Missing location not rejected properly') + + self.assertEqual(TimeSlot.objects.count(), timeslot_count, + 'TimeSlot unexpectedly created') + + def test_create_bulk_timeslots(self): + """Creating multiple timeslots should work""" + meeting = self.create_meeting() + timeslots_before = set(ts.pk for ts in meeting.timeslot_set.all()) + days = [meeting.get_meeting_date(n).date() for n in range(meeting.days)] + other_date = meeting.get_meeting_date(-1).date() # date before start of meeting + self.assertNotIn(other_date, days) + locations = meeting.room_set.all() + post_data = dict( + name='some name', + type='regular', + days=[str(d.toordinal()) for d in days], + other_date=other_date.strftime('%Y-%m-%d'), + time='14:37', + duration='1:13', # does not include seconds + show_location=True, + locations=[str(loc.pk) for loc in locations], + ) + self.login() + r = self.client.post( + self.create_timeslots_url(meeting), + data=post_data, + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(r['Location'], self.edit_timeslots_url(meeting), + 'Expected to be redirected to meeting timeslots edit page') + + days.append(other_date) + new_slot_count = len(days) * len(locations) + self.assertEqual(meeting.timeslot_set.count(), len(timeslots_before) + new_slot_count) + + day_locs = set((day, loc) for day in days for loc in locations) # cartesian product + for ts in meeting.timeslot_set.exclude(pk__in=timeslots_before): + self.assertEqual(ts.name, post_data['name']) + self.assertEqual(ts.type_id, post_data['type']) + self.assertEqual(ts.time.strftime('%H:%M'), post_data['time']) + self.assertEqual(str(ts.duration), '{}:00'.format(post_data['duration'])) # add seconds + self.assertEqual(ts.show_location, post_data['show_location']) + self.assertIn(ts.time.date(), days) + self.assertIn(ts.location, locations) + self.assertIn((ts.time.date(), ts.location), day_locs, + 'Duplicated day / location found') + day_locs.discard((ts.time.date(), ts.location)) + self.assertEqual(day_locs, set(), 'Not all day/location combinations created') + + def test_ajax_delete_timeslot(self): + """AJAX call to delete timeslot should work""" + meeting = self.create_bare_meeting() + ts_to_del, ts_to_keep = TimeSlotFactory.create_batch(2, meeting=meeting) + + self.login() + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='delete', + slot_id=str(ts_to_del.pk), + ) + ) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Deleted TimeSlot {}'.format(ts_to_del.pk)) + self.assertNotContains(r, 'Deleted TimeSlot {}'.format(ts_to_keep.pk)) + self.assertEqual(meeting.timeslot_set.filter(pk=ts_to_del.pk).count(), 0, + 'Timeslot not deleted') + self.assertEqual(meeting.timeslot_set.filter(pk=ts_to_keep.pk).count(), 1, + 'Extra timeslot deleted') + + def test_ajax_delete_timeslots(self): + """AJAX call to delete several timeslots should work""" + meeting = self.create_bare_meeting() + ts_to_del = TimeSlotFactory.create_batch(5, meeting=meeting) + ts_to_keep = TimeSlotFactory(meeting=meeting) + + self.login() + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='delete', + slot_id=','.join(str(ts.pk) for ts in ts_to_del), + ) + ) + self.assertEqual(r.status_code, 200) + for ts in ts_to_del: + self.assertContains(r, 'Deleted TimeSlot {}'.format(ts.pk)) + self.assertNotContains(r, 'Deleted TimeSlot {}'.format(ts_to_keep.pk)) + self.assertEqual( + meeting.timeslot_set.filter(pk__in=(ts.pk for ts in ts_to_del)).count(), + 0, + 'Timeslots not deleted', + ) + self.assertEqual(meeting.timeslot_set.filter(pk=ts_to_keep.pk).count(), 1, + 'Extra timeslot deleted') + + def test_ajax_delete_timeslots_invalid(self): + meeting = self.create_bare_meeting() + ts = TimeSlotFactory(meeting=meeting) + self.login() + r = self.client.post( + self.edit_timeslots_url(meeting), + ) + self.assertEqual(r.status_code, 400, 'Missing POST data not handled') + + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict() + ) + self.assertEqual(r.status_code, 400, 'Empty POST data not handled') + + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + slot_id=str(ts.pk), + ) + ) + self.assertEqual(r.status_code, 400, 'Missing action not handled') + + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='deletify', + slot_id=str(ts.pk), + ) + ) + self.assertEqual(r.status_code, 400, 'Invalid action not handled') + + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='delete', + ) + ) + self.assertEqual(r.status_code, 400, 'Missing slot_id not handled') + + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='delete', + slot_id='not an id', + ) + ) + self.assertEqual(r.status_code, 400, 'Invalid slot_id not handled') + + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='delete', + slot_id='{}, not an id'.format(ts.pk), + ) + ) + self.assertEqual(r.status_code, 400, 'Invalid slot_id not handled in bulk') + + nonexistent_id = TimeSlot.objects.all().aggregate(Max('id'))['id__max'] + 1 + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='delete', + slot_id=str(nonexistent_id), + ) + ) + self.assertEqual(r.status_code, 404, 'Nonexistent slot_id not handled in bulk') + + r = self.client.post( + self.edit_timeslots_url(meeting), + data=dict( + action='delete', + slot_id='{},{}'.format(nonexistent_id, ts.pk), + ) + ) + self.assertEqual(r.status_code, 404, 'Nonexistent slot_id not handled in bulk') + + self.assertEqual(meeting.timeslot_set.filter(pk=ts.pk).count(), 1, + 'TimeSlot unexpectedly deleted') + + class ReorderSlidesTests(TestCase): def test_add_slides_to_session(self): @@ -2024,7 +3072,7 @@ class EditTests(TestCase): meeting.date = datetime.date.today() + datetime.timedelta(days=days_offset) meeting.save() client.login(username="secretary", password="secretary+password") - url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number)) + url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number)) r = client.get(url) q = PyQuery(r.content) return(r, q) @@ -2419,7 +3467,7 @@ class EditTests(TestCase): 'time': assignment.timeslot.time.time().isoformat(), 'duration': assignment.timeslot.duration, 'location': assignment.timeslot.location_id, - 'type': assignment.timeslot.type_id, + 'type': assignment.slot_type().slug, 'name': assignment.timeslot.name, 'agenda_note': "New Test Note", 'action': 'edit-timeslot', diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 1bc18cac6..1fb1ebd50 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -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\d+)/edit$', views.edit_timeslot), url(r'^timeslot/(?P\d+)/edittype$', views.edit_timeslot_type), url(r'^rooms$', ajax.timeslot_roomsurl), url(r'^room/(?P\d+).json$', ajax.timeslot_roomurl), diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py index 64427a5db..6ac8e4c10 100644 --- a/ietf/meeting/utils.py +++ b/ietf/meeting/utils.py @@ -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() diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 4833b3216..5d06084cd 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -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,16 +366,49 @@ 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, "time_slices":time_slices, @@ -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' @@ -1639,7 +1578,7 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc="" # We do not have the appropriate data in the datatracker for IETF 64 and earlier. # So that we're not producing misleading pages... - + assert num is None or num.isdigit() meeting = get_ietf_meeting(num) @@ -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"] @@ -4115,11 +4021,65 @@ def edit_timeslot_type(request, num, slot_id): else: form = TimeSlotTypeForm(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_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): diff --git a/ietf/name/admin.py b/ietf/name/admin.py index d14778082..0cd6265e8 100644 --- a/ietf/name/admin.py +++ b/ietf/name/admin.py @@ -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) \ No newline at end of file diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 0b5d42f3c..213a3efab 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -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": "", diff --git a/ietf/name/migrations/0032_agendafiltertypename.py b/ietf/name/migrations/0032_agendafiltertypename.py new file mode 100644 index 000000000..6c6fa4eab --- /dev/null +++ b/ietf/name/migrations/0032_agendafiltertypename.py @@ -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, + }, + ), + ] diff --git a/ietf/name/migrations/0033_populate_agendafiltertypename.py b/ietf/name/migrations/0033_populate_agendafiltertypename.py new file mode 100644 index 000000000..9f450ca24 --- /dev/null +++ b/ietf/name/migrations/0033_populate_agendafiltertypename.py @@ -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), + ] diff --git a/ietf/name/migrations/0034_add_officehours_timeslottypename.py b/ietf/name/migrations/0034_add_officehours_timeslottypename.py new file mode 100644 index 000000000..db24c4cdd --- /dev/null +++ b/ietf/name/migrations/0034_add_officehours_timeslottypename.py @@ -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), + ] diff --git a/ietf/name/migrations/0035_sessionpurposename.py b/ietf/name/migrations/0035_sessionpurposename.py new file mode 100644 index 000000000..52d3ed82c --- /dev/null +++ b/ietf/name/migrations/0035_sessionpurposename.py @@ -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, + }, + ), + ] diff --git a/ietf/name/migrations/0036_populate_sessionpurposename.py b/ietf/name/migrations/0036_populate_sessionpurposename.py new file mode 100644 index 000000000..50231ce63 --- /dev/null +++ b/ietf/name/migrations/0036_populate_sessionpurposename.py @@ -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) + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 3c07c7afc..a06b78943 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -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") diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 37d49167a..2af01bb98 100644 --- a/ietf/name/resources.py +++ b/ietf/name/resources.py @@ -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()) diff --git a/ietf/secr/meetings/forms.py b/ietf/secr/meetings/forms.py index 0a24ee008..43cc790f8 100644 --- a/ietf/secr/meetings/forms.py +++ b/ietf/secr/meetings/forms.py @@ -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): diff --git a/ietf/secr/meetings/views.py b/ietf/secr/meetings/views.py index 622a2fed9..6582cb81e 100644 --- a/ietf/secr/meetings/views.py +++ b/ietf/secr/meetings/views.py @@ -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,29 +645,34 @@ 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() - # if we are creating rooms for the first time create full set of timeslots - if first_time: - build_timeslots(meeting) + # 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) - # otherwise if we're modifying rooms - else: - # add timeslots for new rooms, deleting rooms automatically deletes timeslots - for form in formset.forms[formset.initial_form_count():]: - if form.instance.pk: - build_timeslots(meeting,room=form.instance) + # otherwise if we're modifying rooms + else: + # add timeslots for new rooms, deleting rooms automatically deletes timeslots + for form in formset.forms[formset.initial_form_count():]: + if form.instance.pk: + build_timeslots(meeting,room=form.instance) messages.success(request, 'Meeting Rooms changed successfully') 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'} ) diff --git a/ietf/secr/sreq/forms.py b/ietf/secr/sreq/forms.py index 8cd2d91d9..a0b25c4e1 100644 --- a/ietf/secr/sreq/forms.py +++ b/ietf/secr/sreq/forms.py @@ -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 @@ -61,13 +62,11 @@ class NameModelMultipleChoiceField(forms.ModelMultipleChoiceField): def label_from_instance(self, name): return name.desc - + class SessionForm(forms.Form): num_session = forms.ChoiceField(choices=NUM_SESSION_CHOICES) - length_session1 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES) - length_session2 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES,required=False) + # session 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': + + 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 second session can not be the joint session, because you have not requested a second session.' + f'Session {joint_session} can not be the joint session, the session has not been requested.' ) ) - 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': - 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.' - ) - ) - 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) diff --git a/ietf/secr/sreq/templatetags/ams_filters.py b/ietf/secr/sreq/templatetags/ams_filters.py index 58db81aba..751517320 100644 --- a/ietf/secr/sreq/templatetags/ams_filters.py +++ b/ietf/secr/sreq/templatetags/ams_filters.py @@ -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'} - return map[value] + 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): diff --git a/ietf/secr/sreq/views.py b/ietf/secr/sreq/views.py index 121f15a00..0c912b73f 100644 --- a/ietf/secr/sreq/views.py +++ b/ietf/secr/sreq/views.py @@ -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): @@ -281,7 +272,7 @@ def confirm(request, acronym): form = FormClass(group, meeting, request.POST, hidden=True) form.is_valid() - + login = request.user.person # check if request already exists for this group @@ -316,38 +307,36 @@ 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: - 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)), - comments=form.cleaned_data['comments'], - type_id='regular', - ) - SchedulingEvent.objects.create( - session=new_session, - status=SessionStatusName.objects.get(slug=slug), - by=login, - ) - if 'resources' in form.data: - new_session.resources.set(session_data['resources']) - jfs = form.data.get('joint_for_session', '-1') - if not jfs: # jfs might be '' - jfs = '-1' - if int(jfs) == count: - groups_split = form.cleaned_data.get('joint_with_groups').replace(',',' ').split() - joint = Group.objects.filter(acronym__in=groups_split) - new_session.joint_with_groups.set(joint) - session_changed(new_session) + 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=sess_form.cleaned_data['requested_duration'], + name=sess_form.cleaned_data['name'], + comments=form.cleaned_data['comments'], + purpose=sess_form.cleaned_data['purpose'], + type=sess_form.cleaned_data['type'], + ) + SchedulingEvent.objects.create( + session=new_session, + status=SessionStatusName.objects.get(slug=slug), + by=login, + ) + if 'resources' in form.data: + new_session.resources.set(session_data['resources']) + jfs = form.data.get('joint_for_session', '-1') + if not jfs: # jfs might be '' + jfs = '-1' + if int(jfs) == count: + groups_split = form.cleaned_data.get('joint_with_groups').replace(',',' ').split() + joint = Group.objects.filter(acronym__in=groups_split) + new_session.joint_with_groups.set(joint) + session_changed(new_session) # write constraint records for conflictname, cfield_id in form.wg_constraint_field_ids(): @@ -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( diff --git a/ietf/secr/static/secr/js/session_purpose_and_type_widget.js b/ietf/secr/static/secr/js/session_purpose_and_type_widget.js new file mode 100644 index 000000000..692f45f00 --- /dev/null +++ b/ietf/secr/static/secr/js/session_purpose_and_type_widget.js @@ -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 _ 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); +})(); \ No newline at end of file diff --git a/ietf/secr/static/secr/js/sessions.js b/ietf/secr/static/secr/js/sessions.js index 4635c5603..a2770e626 100644 --- a/ietf/secr/static/secr/js/sessions.js +++ b/ietf/secr/static/secr/js/sessions.js @@ -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; - } - 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 get_formset_management_data(prefix) { + return { + total_forms: document.getElementById('id_' + prefix + '-TOTAL_FORMS').value, + }; + } + + 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 } diff --git a/ietf/secr/templates/includes/sessions_request_form.html b/ietf/secr/templates/includes/sessions_request_form.html index 5c4f3e91d..256c91670 100755 --- a/ietf/secr/templates/includes/sessions_request_form.html +++ b/ietf/secr/templates/includes/sessions_request_form.html @@ -1,20 +1,27 @@ * Required Field
{% csrf_token %} + {{ form.session_forms.management_form }} {% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %} - - + + {% if not is_virtual %} {% endif %} {% if group.type.slug == "wg" %} - +
+ Third Session: + {% include 'meeting/session_details_form.html' with form=form.session_forms.2 only %} +
+ + {% else %}{# else group.type.slug != "wg" #} + {% include 'meeting/session_details_form.html' with form=form.session_forms.2 hidden=True only %} {% endif %} diff --git a/ietf/secr/templates/includes/sessions_request_view.html b/ietf/secr/templates/includes/sessions_request_view.html index 595b69e3d..7d842eb7a 100644 --- a/ietf/secr/templates/includes/sessions_request_view.html +++ b/ietf/secr/templates/includes/sessions_request_view.html @@ -4,16 +4,24 @@ - - {% if session.length_session2 %} - - {% if not is_virtual %} + {% for sess_form in form.session_forms %}{% if sess_form.cleaned_data and not sess_form.cleaned_data.DELETE %} + + {% if forloop.counter == 2 and not is_virtual %} {% endif %} - {% endif %} - {% if session.length_session3 %} - - {% endif %} + {% endif %}{% endfor %} diff --git a/ietf/secr/templates/meetings/misc_session_edit.html b/ietf/secr/templates/meetings/misc_session_edit.html index 2bf666a0e..4544695cd 100755 --- a/ietf/secr/templates/meetings/misc_session_edit.html +++ b/ietf/secr/templates/meetings/misc_session_edit.html @@ -21,3 +21,11 @@ {% endblock %} + +{% block extrahead %} + {{ block.super }} + {{ form.media.js }} +{% endblock %} +{% block extrastyle %} + {{ form.media.css }} +{% endblock %} \ No newline at end of file diff --git a/ietf/secr/templates/meetings/misc_sessions.html b/ietf/secr/templates/meetings/misc_sessions.html index c946c338e..1c370b1ef 100644 --- a/ietf/secr/templates/meetings/misc_sessions.html +++ b/ietf/secr/templates/meetings/misc_sessions.html @@ -1,5 +1,5 @@ {% extends "meetings/base_rooms_times.html" %} - +{% load agenda_custom_tags %} {% block subsection %}
@@ -27,12 +27,12 @@
- + - + {% if assignment.schedule_id == schedule.pk %}
Working Group Name:{{ group.name }} ({{ group.acronym }})
Area Name:{% if group.parent %}{{ group.parent.name }} ({{ group.parent.acronym }}){% endif %}
Number of Sessions:*{{ form.num_session.errors }}{{ form.num_session }}
Length of Session 1:*{{ form.length_session1.errors }}{{ form.length_session1 }}
Length of Session 2:*{{ form.length_session2.errors }}{{ form.length_session2 }}
Session 1:*{% include 'meeting/session_details_form.html' with form=form.session_forms.0 only %}
Session 2:*{% include 'meeting/session_details_form.html' with form=form.session_forms.1 only %}
Time between two sessions:{{ form.session_time_relation.errors }}{{ form.session_time_relation }}
Additional Session Request:{{ form.third_session }} Check this box to request an additional session.
+
Additional Session Request:{{ form.third_session }} Check this box to request an additional session.
Additional slot may be available after agenda scheduling has closed and with the approval of an Area Director.
- Length of Third Session: {{ form.length_session3.errors }}{{ form.length_session3 }}
Number of Attendees:{% if not is_virtual %}*{% endif %}{{ form.attendees.errors }}{{ form.attendees }}
People who must be present:{{ form.bethere.errors }}{{ form.bethere }}
Working Group Name:{{ group.name }} ({{ group.acronym }})
Area Name:{{ group.parent }}
Number of Sessions Requested:{% if session.length_session3 %}3{% else %}{{ session.num_session }}{% endif %}
Length of Session 1:{{ session.length_session1|display_duration }}
Length of Session 2:{{ session.length_session2|display_duration }}
Session {{ forloop.counter }}: +
+
Length
{{ sess_form.cleaned_data.requested_duration.total_seconds|display_duration }}
+ {% if sess_form.cleaned_data.name %}
Name
{{ sess_form.cleaned_data.name }}
{% endif %} + {% if sess_form.cleaned_data.purpose.slug != 'session' %} +
Purpose
+
+ {{ sess_form.cleaned_data.purpose }} + {% if sess_form.cleaned_data.purpose.timeslot_types|length > 1 %}({{ sess_form.cleaned_data.type }}{% endif %} +
+ {% endif %} +
+
Time between sessions:{% if session.session_time_relation_display %}{{ session.session_time_relation_display }}{% else %}No preference{% endif %}
Length of Session 3:{{ session.length_session3|display_duration }}
Number of Attendees:{{ session.attendees }}
Conflicts to Avoid:
{{ assignment.timeslot.time|date:"D" }} {{ assignment.timeslot.time|date:"H:i" }}-{{ assignment.timeslot.end_time|date:"H:i" }}{{ assignment.timeslot.name }}{% assignment_display_name assignment %} {{ assignment.session.short }} {{ assignment.session.group.acronym }} {{ assignment.timeslot.location }} {{ assignment.timeslot.show_location }}{{ assignment.timeslot.type }}{% with purpose=assignment.session.purpose %}{{ purpose }}{% if purpose.timeslot_types|length > 1 %} ({{ assignment.slot_type }}){% endif %}{% endwith %}Edit @@ -49,7 +49,7 @@
{% else %} -

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.

+

No timeslots exist for this meeting. Add rooms with the "duplicate timeslots" option enabled to copy timeslots from the last meeting.

{% endif %}

@@ -74,3 +74,11 @@ {% endblock %} + +{% block extrahead %} + {{ block.super }} + {{ form.media.js }} +{% endblock %} +{% block extrastyle %} + {{ form.media.css }} +{% endblock %} \ No newline at end of file diff --git a/ietf/secr/templates/meetings/rooms.html b/ietf/secr/templates/meetings/rooms.html index 643be358c..ed1d31fb9 100644 --- a/ietf/secr/templates/meetings/rooms.html +++ b/ietf/secr/templates/meetings/rooms.html @@ -11,7 +11,8 @@ {% csrf_token %} {{ formset.management_form }} {{ formset.non_form_errors }} - + {% if options_form %}{{ options_form.errors }}{% endif %} + @@ -43,9 +44,10 @@ - {% include "includes/buttons_save.html" %} + {% if options_form %}{{ options_form }}{% endif %} + {% include "includes/buttons_save.html" %} - + {% endblock %} diff --git a/ietf/secr/templates/meetings/times.html b/ietf/secr/templates/meetings/times.html index 6d1a19736..56e6a4f8a 100644 --- a/ietf/secr/templates/meetings/times.html +++ b/ietf/secr/templates/meetings/times.html @@ -29,7 +29,7 @@
{% else %} -

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.

+

No timeslots exist for this meeting. Add rooms with the "duplicate timeslots" option enabled to copy timeslots from the last meeting.

{% endif %}

diff --git a/ietf/secr/templates/sreq/confirm.html b/ietf/secr/templates/sreq/confirm.html index 2bf472d31..141b5cbce 100755 --- a/ietf/secr/templates/sreq/confirm.html +++ b/ietf/secr/templates/sreq/confirm.html @@ -3,8 +3,18 @@ {% block title %}Sessions - Confirm{% endblock %} +{% block extrastyle %} + +{% endblock %} + {% block extrahead %}{{ block.super }} + {{ 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 %}

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 @@

{% 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" %} diff --git a/ietf/settings.py b/ietf/settings.py index 53614017c..75ec5bc89 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -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 diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 3f48b6d9b..02f8614e2 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -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; diff --git a/ietf/static/ietf/js/meeting/session_details_form.js b/ietf/static/ietf/js/meeting/session_details_form.js new file mode 100644 index 000000000..28907bf92 --- /dev/null +++ b/ietf/static/ietf/js/meeting/session_details_form.js @@ -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 _ 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); +})(); \ No newline at end of file diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html index 70f6fdbd8..8a74420c3 100644 --- a/ietf/templates/meeting/agenda.html +++ b/ietf/templates/meeting/agenda.html @@ -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,119 +143,105 @@ {% endifchanged %} - {% if item.timeslot.type_id == 'regular' %} - {% ifchanged %} - - - + {% if item|is_special_agenda_item %} + + + - - - - {{ item.timeslot.time|date:"l"}} - {{item.timeslot.name|capfirst_allcaps}} - - - - {% endifchanged %} - {% endif %} - - {% if item.timeslot.type.slug == 'break' or item.timeslot.type.slug == 'reg' or item.timeslot.type.slug == 'other' %} - - - - - - - - {% if item.timeslot.show_location and item.timeslot.get_html_location %} - {% if schedule.meeting.number|add:"0" < 96 %} - {{item.timeslot.get_html_location}} - {% elif item.timeslot.location.floorplan %} - {{item.timeslot.get_html_location}} - {% else %} - {{item.timeslot.get_html_location}} - {% endif %} - {% with item.timeslot.location.floorplan as floor %} - {% if item.timeslot.location.floorplan %} - - {% endif %} - {% endwith %} - {% endif %} - - - {% if item.session.agenda %} - - {{item.timeslot.name}} - - {% else %} - {{item.timeslot.name}} - {% endif %} - - {% if item.session.current_status == 'canceled' %} - CANCELLED - {% else %} -
- {% 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 %} - {{ slide.title|clean_whitespace }} -
- {% endfor %} - {% endif %} + + + + {% 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 %} + + {% endif %} + {% endwith %} {% endif %} -
- {% endif %} - - - - {% endif %} + + + {% agenda_anchor item.session %} + {% assignment_display_name item %} + {% end_agenda_anchor %} + + {% if item.session.current_status == 'canceled' %} + CANCELLED + {% else %} +
+ {% 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 %} + {{ slide.title|clean_whitespace }} +
+ {% endfor %} + {% endif %} + {% endif %} +
+ {% endif %} + + + + + {% elif item|is_regular_agenda_item or item|is_plenary_agenda_item %} + + {% if item|is_regular_agenda_item %} + {% ifchanged %} + + + + + + + + {{ item.timeslot.time|date:"l"}} + {{item.timeslot.name|capfirst_allcaps}} + + + + {% endifchanged %} + {% endif %} - {% if item.timeslot.type_id == 'regular' or item.timeslot.type.slug == 'plenary' %} {% if item.session.historic_group %} - - {% if item.timeslot.type.slug == 'plenary' %} + {% if item.slot_type.slug == 'plenary' %} - + - {% if item.timeslot.show_location and item.timeslot.get_html_location %} - {% if schedule.meeting.number|add:"0" < 96 %} - {{item.timeslot.get_html_location}} - {% elif item.timeslot.location.floorplan %} - {{item.timeslot.get_html_location}} - {% else %} - {{item.timeslot.get_html_location}} - {% endif %} - {% endif %} - + {% location_anchor item.timeslot %} + {{ item.timeslot.get_html_location }} + {% end_location_anchor %} + {% else %} @@ -269,15 +255,9 @@ {% endwith %} - {% if item.timeslot.show_location and item.timeslot.get_html_location %} - {% if schedule.meeting.number|add:"0" < 96 %} - {{item.timeslot.get_html_location}} - {% elif item.timeslot.location.floorplan %} - {{item.timeslot.get_html_location}} - {% else %} - {{item.timeslot.get_html_location}} - {% endif %} - {% endif %} + {% location_anchor item.timeslot %} + {{ item.timeslot.get_html_location }} + {% end_location_anchor %} @@ -292,18 +272,9 @@ {% endif %} - {% if item.session.agenda %} - - {% endif %} - {% if item.timeslot.type.slug == 'plenary' %} - {{item.timeslot.name}} - {% else %} - {{item.session.historic_group.name}} - {% endif %} - {% if item.session.agenda %} - - {% endif %} - + {% agenda_anchor item.session %} + {% assignment_display_name item %} + {% end_agenda_anchor %} {% if item.session.current_status == 'canceled' %} CANCELLED {% else %} @@ -453,9 +424,9 @@ // either have not yet loaded the iframe or we do not support history replacement wv_iframe.src = new_url; } - } + } } - + function update_view(filter_params) { update_agenda_display(filter_params); update_weekview(filter_params) diff --git a/ietf/templates/meeting/agenda.txt b/ietf/templates/meeting/agenda.txt index ce804b224..489ebbe6a 100644 --- a/ietf/templates/meeting/agenda.txt +++ b/ietf/templates/meeting/agenda.txt @@ -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 %} ==================================================================== diff --git a/ietf/templates/meeting/agenda_by_room.html b/ietf/templates/meeting/agenda_by_room.html index 2e0a68bd7..8f292fda0 100644 --- a/ietf/templates/meeting/agenda_by_room.html +++ b/ietf/templates/meeting/agenda_by_room.html @@ -29,7 +29,7 @@ ul.sessionlist { list-style:none; padding-left:2em; margin-bottom:10px;}
  • {{room.grouper|default:"Location Unavailable"}}

      {% for ss in room.list %} -
    • {{ss.timeslot.time|date:"H:i"}}-{{ss.timeslot.end_time|date:"H:i"}} {{ss.session.short_name}}
    • +
    • {{ss.timeslot.time|date:"H:i"}}-{{ss.timeslot.end_time|date:"H:i"}} {{ss.session.short_name}}
    • {% endfor %}
  • diff --git a/ietf/templates/meeting/agenda_filter.html b/ietf/templates/meeting/agenda_filter.html index cd91e7c1f..dff07a03a 100644 --- a/ietf/templates/meeting/agenda_filter.html +++ b/ietf/templates/meeting/agenda_filter.html @@ -61,12 +61,12 @@ Optional parameters: {% for fc in filter_categories %} {% if not forloop.first %} {% endif %} {% for p in fc %} - +
    - {% for button in p.children|dictsort:"label" %} + {% for button in p.children %}
    + Cancel + {% endbuttons %} + +{% endblock %} + +{% block js %} + {{ form.media.js }} +{% endblock %} \ No newline at end of file diff --git a/ietf/templates/meeting/edit_timeslot.html b/ietf/templates/meeting/edit_timeslot.html new file mode 100644 index 000000000..1bce120f7 --- /dev/null +++ b/ietf/templates/meeting/edit_timeslot.html @@ -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 %} +

    Edit timeslot "{{ timeslot.name }}" for {{ timeslot.meeting }}

    + {% if sessions %} +
    + This timeslot currently has the following sessions assigned to it: + {% for s in sessions %} +
    {{s}}
    + {% endfor %} +
    + {% endif %} +
    + {% csrf_token %} + {% bootstrap_form form %} + {% buttons %} + + Cancel + {% endbuttons %} +
    +{% endblock %} + +{% block js %} + {{ form.media.js }} +{% endblock %} \ No newline at end of file diff --git a/ietf/templates/meeting/interim_session_buttons.html b/ietf/templates/meeting/interim_session_buttons.html index 417c587b7..15ca3bafc 100644 --- a/ietf/templates/meeting/interim_session_buttons.html +++ b/ietf/templates/meeting/interim_session_buttons.html @@ -16,13 +16,13 @@ {% endif %} {% if use_codimd %} - {% if item.timeslot.type.slug == 'plenary' %} + {% if item.slot_type.slug == 'plenary' %} {% else %} {% endif %} {% else %} - {% if item.timeslot.type.slug == 'plenary' %} + {% if item.slot_type.slug == 'plenary' %} {% else %} diff --git a/ietf/templates/meeting/room-view.html b/ietf/templates/meeting/room-view.html index 74244e944..5baf3cf66 100644 --- a/ietf/templates/meeting/room-view.html +++ b/ietf/templates/meeting/room-view.html @@ -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 %} diff --git a/ietf/templates/meeting/schedule_list.html b/ietf/templates/meeting/schedule_list.html index 9ac4f0dd9..a51305431 100644 --- a/ietf/templates/meeting/schedule_list.html +++ b/ietf/templates/meeting/schedule_list.html @@ -10,6 +10,9 @@

    {% block title %}Possible Meeting Agendas for IETF {{ meeting.number }}{% endblock %}

    + {% if can_edit_timeslots %} +

    Edit timeslots and room availability

    + {% endif %} {% for schedules, own, label in schedule_groups %}
    diff --git a/ietf/templates/meeting/session_buttons_include.html b/ietf/templates/meeting/session_buttons_include.html index 7319ad1f1..15d7ef675 100644 --- a/ietf/templates/meeting/session_buttons_include.html +++ b/ietf/templates/meeting/session_buttons_include.html @@ -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 %} {% with acronym=session.historic_group.acronym %} @@ -117,4 +119,5 @@ {% endif %} {% endwith %} -{% endwith %}{% endwith %}{% endwith %}{% endwith %} \ No newline at end of file +{% endwith %}{% endwith %}{% endwith %}{% endwith %} +{% endif %} \ No newline at end of file diff --git a/ietf/templates/meeting/session_details_form.html b/ietf/templates/meeting/session_details_form.html new file mode 100644 index 000000000..cb69d8858 --- /dev/null +++ b/ietf/templates/meeting/session_details_form.html @@ -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 %}
    + {{ form.id.as_hidden }} + {{ form.DELETE.as_hidden }} + + + + + + + + + + + + + +
    {{ form.name.label_tag }}{{ form.name }}{{ form.purpose.errors }}
    {{ form.purpose.label_tag }} + {{ form.purpose }} {{ form.type }} + {{ form.purpose.errors }}{{ form.type.errors }} +
    {{ form.requested_duration.label_tag }}{{ form.requested_duration }}{{ form.requested_duration.errors }}
    +
    +{% endif %} \ No newline at end of file diff --git a/ietf/templates/meeting/timeslot_edit.html b/ietf/templates/meeting/timeslot_edit.html index d75ad70d3..dbdfb02a1 100644 --- a/ietf/templates/meeting/timeslot_edit.html +++ b/ietf/templates/meeting/timeslot_edit.html @@ -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 %} +

    + New timeslot + · + {% if meeting.schedule %} + Edit rooms + {% else %} {# secr app room view requires a schedule - show something for now (should try to avoid this possibility) #} + Edit rooms + {% endif %} +

    - - - - - {% for day in time_slices %} - - {% endfor %} - - - - {% for day in time_slices %} - {% for slot in slot_slices|lookup:day %} -
    - {{day|date:'D'}} ({{day}}) -
    - {{slot.time|date:'Hi'}}-{{slot.end_time|date:'Hi'}} +

    IETF {{ meeting.number }} Meeting Agenda: Timeslots and Room Availability

    +
    + {% if rooms|length == 0 %} +

    No rooms exist for this meeting yet.

    + {% if meeting.schedule %} + Create rooms + {% else %}{# provide as helpful a link we can if we don't have an official schedule #} + Create rooms through the secr app + {% endif %} + {% else %} + + {% with have_no_timeslots=time_slices|length_is:0 %} + + + {% if have_no_timeslots %} + + + {% else %} + + {% for day in time_slices %} + + {% endfor %} + {% endif %} + + + {% if have_no_timeslots %} + + + {% else %} + + {% for day in time_slices %} + {% for slot in slot_slices|lookup:day %} + - {% endfor %} - {% endfor %} - - + {% endfor %} + {% endfor %} + {% endif %} + + - {% for room in rooms %} - - - {% for day in time_slices %} - {% for slice in date_slices|lookup:day %} - {% with ts=ts_list.popleft %} - {% if ts %}{{ts.type.slug}}{% endif %} - {% endwith %} + + {% for room in rooms %} + + + {% if have_no_timeslots and forloop.first %} + + {% else %} + {% for day in time_slices %} + {% for slice in date_slices|lookup:day %}{% with cell_ts=ts_list.popleft %} + + {% endfor %} + {% endif %} + {% endfor %} - {% endfor %} - - {% endfor %} -
    + {{day|date:'D'}} ({{day}}) + + +
    + {{slot.time|date:'H:i'}}-{{slot.end_time|date:'H:i'}} + +
    {{room.name}}{% if room.capacity %} ({{room.capacity}}){% endif %}
    {{room.name}}{% if room.capacity %} ({{room.capacity}}){% endif %} +

    No timeslots exist for this meeting yet.

    + Create a timeslot. +
    + {% 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 %} +
    + + {% endwith %} +
    + {% endif %} + + {# Modal to confirm timeslot deletion #} + +
    {% endblock %} + +{% block js %} + +{% endblock %} \ No newline at end of file diff --git a/ietf/templates/meeting/timeslot_edit_timeslot.html b/ietf/templates/meeting/timeslot_edit_timeslot.html new file mode 100644 index 000000000..4d8afea4f --- /dev/null +++ b/ietf/templates/meeting/timeslot_edit_timeslot.html @@ -0,0 +1,26 @@ +
    +
    + + {{ ts.name }} + +
    +
    + + + + +
    +
    {{ ts.type }}
    +
    diff --git a/ietf/utils/fields.py b/ietf/utils/fields.py index 647ea0722..e5257e5ec 100644 --- a/ietf/utils/fields.py +++ b/ietf/utils/fields.py @@ -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( diff --git a/ietf/utils/validators.py b/ietf/utils/validators.py index 5adb29bc3..7b599b743 100644 --- a/ietf/utils/validators.py +++ b/ietf/utils/validators.py @@ -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) + )