From 1054f90873fae2e706831326b5a36b756b1ef460 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 12 Oct 2021 17:08:58 +0000 Subject: [PATCH 01/16] Snapshot of dev work to add session purpose annotation - Legacy-Id: 19415 --- ietf/doc/templatetags/ietf_filters.py | 87 ++ ietf/group/admin.py | 1 + .../0049_groupfeatures_agenda_filter_type.py | 20 + ...pulate_groupfeatures_agenda_filter_type.py | 35 + .../0051_groupfeatures_session_purposes.py | 24 + ...populate_groupfeatures_session_purposes.py | 48 + ietf/group/models.py | 13 +- ietf/meeting/fields.py | 130 ++ ietf/meeting/forms.py | 217 +++- ietf/meeting/helpers.py | 484 +++++++- .../migrations/0049_session_purpose.py | 21 + ietf/meeting/models.py | 46 +- .../templatetags/agenda_custom_tags.py | 86 +- ietf/meeting/templatetags/tests.py | 20 + ietf/meeting/tests_helpers.py | 272 ++++- ietf/meeting/tests_js.py | 211 +++- ietf/meeting/tests_views.py | 1070 ++++++++++++++++- ietf/meeting/urls.py | 2 + ietf/meeting/utils.py | 8 + ietf/meeting/views.py | 326 +++-- ietf/name/admin.py | 5 +- ietf/name/fixtures/names.json | 73 ++ .../migrations/0032_agendafiltertypename.py | 27 + .../0033_populate_agendafiltertypename.py | 33 + .../0034_add_officehours_timeslottypename.py | 29 + .../migrations/0035_sessionpurposename.py | 32 + .../0036_populate_sessionpurposename.py | 48 + ietf/name/models.py | 12 + ietf/name/resources.py | 19 +- ietf/secr/meetings/forms.py | 26 +- ietf/secr/meetings/views.py | 39 +- ietf/secr/sreq/forms.py | 71 +- ietf/secr/sreq/templatetags/ams_filters.py | 20 +- ietf/secr/sreq/views.py | 196 ++- .../js/session_purpose_and_type_widget.js | 83 ++ ietf/secr/static/secr/js/sessions.js | 116 +- .../includes/sessions_request_form.html | 15 +- .../includes/sessions_request_view.html | 24 +- .../templates/meetings/misc_session_edit.html | 8 + .../templates/meetings/misc_sessions.html | 16 +- ietf/secr/templates/meetings/rooms.html | 8 +- ietf/secr/templates/meetings/times.html | 2 +- ietf/secr/templates/sreq/confirm.html | 14 +- ietf/settings.py | 3 + ietf/static/ietf/css/ietf.css | 37 + .../ietf/js/meeting/session_details_form.js | 109 ++ ietf/templates/meeting/agenda.html | 209 ++-- ietf/templates/meeting/agenda.txt | 10 +- ietf/templates/meeting/agenda_by_room.html | 2 +- ietf/templates/meeting/agenda_filter.html | 6 +- ietf/templates/meeting/create_timeslot.html | 27 + ietf/templates/meeting/edit_timeslot.html | 35 + .../meeting/interim_session_buttons.html | 4 +- ietf/templates/meeting/room-view.html | 2 +- ietf/templates/meeting/schedule_list.html | 3 + .../meeting/session_buttons_include.html | 5 +- .../meeting/session_details_form.html | 24 + ietf/templates/meeting/timeslot_edit.html | 433 ++++++- .../meeting/timeslot_edit_timeslot.html | 26 + ietf/utils/fields.py | 67 +- ietf/utils/validators.py | 38 +- 61 files changed, 4342 insertions(+), 735 deletions(-) create mode 100644 ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py create mode 100644 ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py create mode 100644 ietf/group/migrations/0051_groupfeatures_session_purposes.py create mode 100644 ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py create mode 100644 ietf/meeting/fields.py create mode 100644 ietf/meeting/migrations/0049_session_purpose.py create mode 100644 ietf/meeting/templatetags/tests.py create mode 100644 ietf/name/migrations/0032_agendafiltertypename.py create mode 100644 ietf/name/migrations/0033_populate_agendafiltertypename.py create mode 100644 ietf/name/migrations/0034_add_officehours_timeslottypename.py create mode 100644 ietf/name/migrations/0035_sessionpurposename.py create mode 100644 ietf/name/migrations/0036_populate_sessionpurposename.py create mode 100644 ietf/secr/static/secr/js/session_purpose_and_type_widget.js create mode 100644 ietf/static/ietf/js/meeting/session_details_form.js create mode 100755 ietf/templates/meeting/create_timeslot.html create mode 100644 ietf/templates/meeting/edit_timeslot.html create mode 100644 ietf/templates/meeting/session_details_form.html create mode 100644 ietf/templates/meeting/timeslot_edit_timeslot.html 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) + ) From 5318081608050a639f756e790b7d1fb33633bced Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 14 Oct 2021 13:21:56 +0000 Subject: [PATCH 02/16] Allow non-WG-like groups to request additional sessions/durations and bypass approval - Legacy-Id: 19424 --- ietf/meeting/forms.py | 73 ++++++++++----- ietf/secr/sreq/forms.py | 7 +- ietf/secr/sreq/views.py | 90 +++++-------------- .../includes/sessions_request_form.html | 9 +- .../includes/sessions_request_view.html | 2 +- ietf/secr/templates/sreq/confirm.html | 2 +- 6 files changed, 83 insertions(+), 100 deletions(-) diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py index e63256e67..68bd8f853 100644 --- a/ietf/meeting/forms.py +++ b/ietf/meeting/forms.py @@ -531,27 +531,49 @@ class TimeSlotCreateForm(forms.Form): 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): + def __init__(self, durations=None, *args, **kwargs): + if durations is None: + durations = (3600, 7200) super().__init__( - choices=(('','--Please select'), *self.duration_choices), + choices=self._make_choices(durations), *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 str(int(value.total_seconds())) if isinstance(value, datetime.timedelta) 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)) + def to_python(self, value): + return datetime.timedelta(seconds=round(float(value))) if value not in self.empty_values else None + + def valid_value(self, value): + return super().valid_value(self.prepare_value(value)) + + def _format_duration_choice(self, dur): + seconds = int(dur.total_seconds()) if isinstance(dur, datetime.timedelta) else int(dur) + hours = int(seconds / 3600) + minutes = round((seconds - 3600 * hours) / 60) + hr_str = '{} hour{}'.format(hours, '' if hours == 1 else 's') + min_str = '{} minute{}'.format(minutes, '' if minutes == 1 else 's') + if hours > 0 and minutes > 0: + time_str = ' '.join((hr_str, min_str)) + elif hours > 0: + time_str = hr_str + else: + time_str = min_str + return (str(seconds), time_str) + + def _make_choices(self, durations): + return ( + ('','--Please select'), + *[self._format_duration_choice(dur) for dur in durations]) + + def _set_durations(self, durations): + self.choices = self._make_choices(durations) + + durations = property(None, _set_durations) class SessionDetailsForm(forms.ModelForm): @@ -573,6 +595,8 @@ class SessionDetailsForm(forms.ModelForm): }), }) self.fields['purpose'].queryset = SessionPurposeName.objects.filter(pk__in=session_purposes) + if not group.features.acts_like_wg: + self.fields['requested_duration'].durations = [datetime.timedelta(minutes=m) for m in range(30, 241, 30)] class Meta: model = Session @@ -606,7 +630,7 @@ class SessionDetailsInlineFormset(forms.BaseInlineFormSet): def save_new(self, form, commit=True): form.instance.meeting = self._meeting - super().save_new(form, commit) + return super().save_new(form, commit) def save(self, commit=True): existing_instances = set(form.instance for form in self.forms if form.instance.pk) @@ -619,14 +643,15 @@ class SessionDetailsInlineFormset(forms.BaseInlineFormSet): """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 +def sessiondetailsformset_factory(min_num=1, max_num=3): + return forms.inlineformset_factory( + Group, + Session, + formset=SessionDetailsInlineFormset, + form=SessionDetailsForm, + can_delete=True, + can_order=False, + min_num=min_num, + max_num=max_num, + extra=max_num, # only creates up to max_num total + ) \ No newline at end of file diff --git a/ietf/secr/sreq/forms.py b/ietf/secr/sreq/forms.py index a0b25c4e1..f64e1320c 100644 --- a/ietf/secr/sreq/forms.py +++ b/ietf/secr/sreq/forms.py @@ -8,7 +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.forms import sessiondetailsformset_factory from ietf.meeting.models import ResourceAssociation, Constraint from ietf.person.fields import SearchablePersonsField from ietf.utils.html import clean_text_field @@ -90,9 +90,12 @@ class SessionForm(forms.Form): self.hidden = False self.group = group - self.session_forms = SessionDetailsFormSet(group=self.group, meeting=meeting, data=data) + formset_class = sessiondetailsformset_factory(max_num=3 if group.features.acts_like_wg else 12) + self.session_forms = formset_class(group=self.group, meeting=meeting, data=data) super(SessionForm, self).__init__(data=data, *args, **kwargs) + if not self.group.features.acts_like_wg: + self.fields['num_session'].choices = ((n, str(n)) for n in range(1, 13)) 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')) diff --git a/ietf/secr/sreq/views.py b/ietf/secr/sreq/views.py index 0c912b73f..f3a806acd 100644 --- a/ietf/secr/sreq/views.py +++ b/ietf/secr/sreq/views.py @@ -61,7 +61,7 @@ def get_initial_session(sessions, prune_conflicts=False): conflicts = constraints.filter(name__is_group_conflict=True) # only the group conflict constraints # even if there are three sessions requested, the old form has 2 in this field - initial['num_session'] = min(sessions.count(), 2) + initial['num_session'] = min(sessions.count(), 2) if group.features.acts_like_wg else sessions.count() initial['attendees'] = sessions[0].attendees def valid_conflict(conflict): @@ -259,6 +259,13 @@ def cancel(request, acronym): messages.success(request, 'The %s Session Request has been cancelled' % group.acronym) return redirect('ietf.secr.sreq.views.main') + +def status_slug_for_new_session(session, session_number): + if session.group.features.acts_like_wg and session_number == 2: + return 'apprw' + return 'schedw' + + @role_required(*AUTHORIZED_ROLES) def confirm(request, acronym): ''' @@ -311,7 +318,6 @@ def confirm(request, acronym): # 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, @@ -324,7 +330,7 @@ def confirm(request, acronym): ) SchedulingEvent.objects.create( session=new_session, - status=SessionStatusName.objects.get(slug=slug), + status=SessionStatusName.objects.get(slug=status_slug_for_new_session(new_session, count)), by=login, ) if 'resources' in form.data: @@ -412,7 +418,6 @@ def edit(request, acronym, num=None): ).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() @@ -442,68 +447,16 @@ 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) + changed_session_forms = [sf for sf in form.session_forms.forms_to_keep if sf.has_changed()] + form.session_forms.save() + for n, new_session in enumerate(form.session_forms.created_instances): + SchedulingEvent.objects.create( + session=new_session, + status_id=status_slug_for_new_session(new_session, n), + by=request.user.person, + ) + for sf in changed_session_forms: + session_changed(sf.instance) # New sessions may have been created, refresh the sessions list sessions = add_event_info_to_session_qs( @@ -528,7 +481,8 @@ def edit(request, acronym, num=None): session_changed(sessions[current_joint_for_session_idx]) sessions[new_joint_for_session_idx].joint_with_groups.set(new_joint_with_groups) session_changed(sessions[new_joint_for_session_idx]) - + + # Update sessions to match changes to shared form fields if 'attendees' in form.changed_data: sessions.update(attendees=form.cleaned_data['attendees']) if 'comments' in form.changed_data: @@ -660,7 +614,7 @@ def main(request): # add session status messages for use in template for group in scheduled_groups: - if len(group.meeting_sessions) < 3: + if not group.features.acts_like_wg or (len(group.meeting_sessions) < 3): group.status_message = group.meeting_sessions[0].current_status else: group.status_message = 'First two sessions: %s, Third session: %s' % (group.meeting_sessions[0].current_status, group.meeting_sessions[2].current_status) diff --git a/ietf/secr/templates/includes/sessions_request_form.html b/ietf/secr/templates/includes/sessions_request_form.html index 256c91670..9ed17a84c 100755 --- a/ietf/secr/templates/includes/sessions_request_form.html +++ b/ietf/secr/templates/includes/sessions_request_form.html @@ -7,12 +7,11 @@ 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 }} - Session 1:*{% include 'meeting/session_details_form.html' with form=form.session_forms.0 only %} + {% if group.features.acts_like_wg %}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 %} {% if not is_virtual %} Time between two sessions:{{ form.session_time_relation.errors }}{{ form.session_time_relation }} {% endif %} - {% if group.type.slug == "wg" %} 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.
    @@ -20,8 +19,10 @@ {% 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 %} + {% else %}{# else not group.features.acts_like_wg #} + {% for session_form in form.session_forms %} + Session {{ forloop.counter }}:*{% include 'meeting/session_details_form.html' with form=session_form only %} + {% endfor %} {% endif %} Number of Attendees:{% if not is_virtual %}*{% endif %}{{ form.attendees.errors }}{{ form.attendees }} People who must be present:{{ form.bethere.errors }}{{ form.bethere }} diff --git a/ietf/secr/templates/includes/sessions_request_view.html b/ietf/secr/templates/includes/sessions_request_view.html index 7d842eb7a..3f85986fb 100644 --- a/ietf/secr/templates/includes/sessions_request_view.html +++ b/ietf/secr/templates/includes/sessions_request_view.html @@ -18,7 +18,7 @@ {% endif %} - {% if forloop.counter == 2 and not is_virtual %} + {% if group.features.acts_like_wg and forloop.counter == 2 and not is_virtual %} Time between sessions:{% if session.session_time_relation_display %}{{ session.session_time_relation_display }}{% else %}No preference{% endif %} {% endif %} {% endif %}{% endfor %} diff --git a/ietf/secr/templates/sreq/confirm.html b/ietf/secr/templates/sreq/confirm.html index 141b5cbce..c458459f7 100755 --- a/ietf/secr/templates/sreq/confirm.html +++ b/ietf/secr/templates/sreq/confirm.html @@ -30,7 +30,7 @@ {% include "includes/sessions_request_view.html" %} - {% if form.session_forms.forms_to_keep|length > 2 %} + {% if group.features.acts_like_wg and 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 From 173e438aeed89e99e5c62b66dafe43e6b1199017 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Fri, 15 Oct 2021 19:33:33 +0000 Subject: [PATCH 03/16] Add 'closed' session purpose, assign purposes for nomcom groups, and update schedule editor to enforce timeslot type and allow blurring sessions by purpose - Legacy-Id: 19427 --- ...populate_groupfeatures_session_purposes.py | 1 + ietf/meeting/models.py | 5 + ietf/meeting/views.py | 36 ++++--- .../0036_populate_sessionpurposename.py | 3 +- ietf/static/ietf/js/edit-meeting-schedule.js | 102 +++++++++++++----- .../meeting/edit_meeting_schedule.html | 17 +++ .../edit_meeting_schedule_session.html | 18 ++-- 7 files changed, 131 insertions(+), 51 deletions(-) diff --git a/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py index 5ad6bdbd9..5e2353ed7 100644 --- a/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py +++ b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py @@ -8,6 +8,7 @@ from django.db import migrations default_purposes = dict( dir=['presentation', 'social', 'tutorial'], ietf=['admin', 'presentation', 'social'], + nomcom=['closed', 'officehours'], rg=['session'], team=['coding', 'presentation', 'social', 'tutorial'], wg=['session'], diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 445ea49e2..964a02979 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -1146,6 +1146,11 @@ class SessionQuerySet(models.QuerySet): type__slug='regular' ) + def requests(self): + """Queryset containing sessions that may be handled as requests""" + return self.exclude( + type__in=('offagenda', 'reserved', 'unavail') + ) class Session(models.Model): """Session records that a group should have a session on the diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 5d06084cd..b2fb786a0 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -500,6 +500,9 @@ def new_meeting_schedule(request, num, owner=None, name=None): @ensure_csrf_cookie def edit_meeting_schedule(request, num=None, owner=None, name=None): + # Need to coordinate this list with types of session requests + # that can be created (see, e.g., SessionQuerySet.requests()) + IGNORE_TIMESLOT_TYPES = ('offagenda', 'reserved', 'unavail') meeting = get_meeting(num) if name is None: schedule = meeting.schedule @@ -544,7 +547,8 @@ 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', + ).exclude( + type__in=IGNORE_TIMESLOT_TYPES, ).order_by('pk'), requested_time=True, requested_by=True, @@ -552,12 +556,13 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): Q(current_status__in=['appr', 'schedw', 'scheda', 'sched']) | Q(current_status__in=tombstone_states, pk__in={a.session_id for a in assignments}) ).prefetch_related( - 'resources', 'group', 'group__parent', 'group__type', 'joint_with_groups', + 'resources', 'group', 'group__parent', 'group__type', 'joint_with_groups', 'purpose', ) timeslots_qs = TimeSlot.objects.filter( meeting=meeting, - # type='regular', + ).exclude( + type__in=IGNORE_TIMESLOT_TYPES, ).prefetch_related('type').order_by('location', 'time', 'name') min_duration = min(t.duration for t in timeslots_qs) @@ -591,10 +596,14 @@ 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.purpose is None or s.purpose.slug == 'regular') and s.group: + s.purpose_label = None + if (s.purpose is None or s.purpose.slug == 'session') and s.group: s.scheduling_label = s.group.acronym - elif s.name: - s.scheduling_label = s.name + s.purpose_label = 'BoF' if s.group.is_bof() else s.group.type.name + else: + s.purpose_label = s.purpose.name + if s.name: + s.scheduling_label = s.name s.requested_duration_in_hours = round(s.requested_duration.seconds / 60.0 / 60.0, 1) @@ -981,6 +990,8 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): p.scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round(x * 255)) for x in rgb_color)) p.light_scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round((0.9 + 0.1 * x) * 255)) for x in rgb_color)) + session_purposes = sorted(set(s.purpose for s in sessions if s.purpose), key=lambda p: p.name) + return render(request, "meeting/edit_meeting_schedule.html", { 'meeting': meeting, 'schedule': schedule, @@ -991,6 +1002,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): 'timeslot_groups': sorted((d, list(sorted(t_groups))) for d, t_groups in timeslot_groups.items()), 'unassigned_sessions': unassigned_sessions, 'session_parents': session_parents, + 'session_purposes': session_purposes, 'hide_menu': True, 'lock_time': lock_time, }) @@ -2261,14 +2273,10 @@ def agenda_json(request, num=None): def meeting_requests(request, num=None): meeting = get_meeting(num) - sessions = add_event_info_to_session_qs( - Session.objects.filter( - meeting__number=meeting.number, - # type__slug='regular', - group__parent__isnull=False - ), - requested_by=True, - ).exclude( + sessions = Session.objects.requests().filter( + meeting__number=meeting.number, + group__parent__isnull=False + ).with_current_status().with_requested_by().exclude( requested_by=0 ).order_by( "group__parent__acronym", "current_status", "group__acronym" diff --git a/ietf/name/migrations/0036_populate_sessionpurposename.py b/ietf/name/migrations/0036_populate_sessionpurposename.py index 50231ce63..b36036059 100644 --- a/ietf/name/migrations/0036_populate_sessionpurposename.py +++ b/ietf/name/migrations/0036_populate_sessionpurposename.py @@ -16,7 +16,8 @@ def forward(apps, schema_editor): ('coding', 'Coding', 'Coding session', ['other']), ('admin', 'Administrative', 'Meeting administration', ['other', 'reg']), ('social', 'Social', 'Social event or activity', ['other']), - ('presentation', 'Presentation', 'Presentation session', ['other', 'regular']) + ('presentation', 'Presentation', 'Presentation session', ['other', 'regular']), + ('closed', 'Closed meeting', 'Closed meeting', ['other',]), )): # verify that we're not about to use an invalid purpose for ts_type in tstypes: diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index b4aa3e365..dc8b35565 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -82,6 +82,7 @@ jQuery(document).ready(function () { jQuery(element).addClass("selected"); showConstraintHints(element); + showTimeSlotTypeIndicators(element.dataset.type); let sessionInfoContainer = content.find(".scheduling-panel .session-info-container"); sessionInfoContainer.html(jQuery(element).find(".session-info").html()); @@ -105,6 +106,7 @@ jQuery(document).ready(function () { else { sessions.removeClass("selected"); showConstraintHints(); + resetTimeSlotTypeIndicators(); content.find(".scheduling-panel .session-info-container").html(""); } } @@ -203,6 +205,23 @@ jQuery(document).ready(function () { } } + /** + * Remove timeslot classes indicating timeslot type disagreement + */ + function resetTimeSlotTypeIndicators() { + timeslots.removeClass('wrong-timeslot-type'); + } + + /** + * Add timeslot classes indicating timeslot type disagreement + * + * @param timeslot_type + */ + function showTimeSlotTypeIndicators(timeslot_type) { + timeslots.removeClass('wrong-timeslot-type'); + timeslots.filter('[data-type!="' + timeslot_type + '"]').addClass('wrong-timeslot-type'); + } + /** * Should this timeslot be treated as a future timeslot? * @@ -277,19 +296,42 @@ jQuery(document).ready(function () { return Boolean(event.originalEvent.dataTransfer.getData(dnd_mime_type)); } + /** + * Get the session element being dragged + * + * @param event drag-related event + */ + function getDraggedSession(event) { + if (!isSessionDragEvent(event)) { + return null; + } + const sessionId = event.originalEvent.dataTransfer.getData(dnd_mime_type); + const sessionElements = sessions.filter("#" + sessionId); + if (sessionElements.length > 0) { + return sessionElements[0]; + } + return null; + } + /** * Can a session be dropped in this element? * * Drop is allowed in drop-zones that are in unassigned-session or timeslot containers * not marked as 'past'. */ - function sessionDropAllowed(elt) { - if (!officialSchedule) { - return true; + function sessionDropAllowed(dropElement, sessionElement) { + const relevant_parent = dropElement.closest('.timeslot, .unassigned-sessions'); + if (!relevant_parent || !sessionElement) { + return false; } - const relevant_parent = elt.closest('.timeslot, .unassigned-sessions'); - return relevant_parent && !(relevant_parent.classList.contains('past')); + if (officialSchedule && relevant_parent.classList.contains('past')) { + return false; + } + + return !relevant_parent.dataset.type || ( + relevant_parent.dataset.type === sessionElement.dataset.type + ); } if (!content.find(".edit-grid").hasClass("read-only")) { @@ -314,7 +356,7 @@ jQuery(document).ready(function () { // dropping let dropElements = content.find(".timeslot .drop-target,.unassigned-sessions .drop-target"); dropElements.on('dragenter', function (event) { - if (sessionDropAllowed(this)) { + if (sessionDropAllowed(this, getDraggedSession(event))) { event.preventDefault(); // default action is signalling that this is not a valid target jQuery(this).parent().addClass("dropping"); } @@ -324,7 +366,7 @@ jQuery(document).ready(function () { // we don't actually need this event, except we need to signal // that this is a valid drop target, by cancelling the default // action - if (sessionDropAllowed(this)) { + if (sessionDropAllowed(this, getDraggedSession(event))) { event.preventDefault(); } }); @@ -332,7 +374,7 @@ jQuery(document).ready(function () { dropElements.on('dragleave', function (event) { // skip dragleave events if they are to children const leaving_child = event.originalEvent.currentTarget.contains(event.originalEvent.relatedTarget); - if (!leaving_child && sessionDropAllowed(this)) { + if (!leaving_child && sessionDropAllowed(this, getDraggedSession(event))) { jQuery(this).parent().removeClass('dropping'); } }); @@ -340,30 +382,21 @@ jQuery(document).ready(function () { dropElements.on('drop', function (event) { let dropElement = jQuery(this); - if (!isSessionDragEvent(event)) { - // event is result of something other than a session drag + const sessionElement = getDraggedSession(event); + if (!sessionElement) { + // not drag event or not from a session we recognize dropElement.parent().removeClass("dropping"); return; } - const sessionId = event.originalEvent.dataTransfer.getData(dnd_mime_type); - let sessionElement = sessions.filter("#" + sessionId); - if (sessionElement.length === 0) { - // drag event is not from a session we recognize - dropElement.parent().removeClass("dropping"); - return; - } - - // We now know this is a drop of a recognized session - - if (!sessionDropAllowed(this)) { + if (!sessionDropAllowed(this, sessionElement)) { dropElement.parent().removeClass("dropping"); // just in case return; // drop not allowed } event.preventDefault(); // prevent opening as link - let dragParent = sessionElement.parent(); + let dragParent = jQuery(sessionElement).parent(); if (dragParent.is(this)) { dropElement.parent().removeClass("dropping"); return; @@ -400,7 +433,7 @@ jQuery(document).ready(function () { timeout: 5 * 1000, data: { action: "unassign", - session: sessionId.slice("session".length) + session: sessionElement.id.slice("session".length) } }).fail(failHandler).done(done); } @@ -410,7 +443,7 @@ jQuery(document).ready(function () { method: "post", data: { action: "assign", - session: sessionId.slice("session".length), + session: sessionElement.id.slice("session".length), timeslot: dropParent.attr("id").slice("timeslot".length) }, timeout: 5 * 1000 @@ -673,7 +706,7 @@ jQuery(document).ready(function () { // toggling visible sessions by session parents let sessionParentInputs = content.find(".session-parent-toggles input"); - function setSessionHidden(sess, hide) { + function setSessionHiddenParent(sess, hide) { sess.toggleClass('hidden-parent', hide); sess.prop('draggable', !hide); } @@ -684,13 +717,28 @@ jQuery(document).ready(function () { checked.push(".parent-" + this.value); }); - setSessionHidden(sessions.not(".untoggleable").filter(checked.join(",")), false); - setSessionHidden(sessions.not(".untoggleable").not(checked.join(",")), true); + setSessionHiddenParent(sessions.not(".untoggleable-by-parent").filter(checked.join(",")), false); + setSessionHiddenParent(sessions.not(".untoggleable-by-parent").not(checked.join(",")), true); } sessionParentInputs.on("click", updateSessionParentToggling); updateSessionParentToggling(); + // Toggling session purposes + let sessionPurposeInputs = content.find('.session-purpose-toggles input'); + function updateSessionPurposeToggling() { + let checked = []; + sessionPurposeInputs.filter(":checked").each(function () { + checked.push(".purpose-" + this.value); + }); + + sessions.filter(checked.join(",")).removeClass('hidden-purpose'); + sessions.not(checked.join(",")).addClass('hidden-purpose'); + } + + sessionPurposeInputs.on("click", updateSessionPurposeToggling); + updateSessionPurposeToggling(); + // toggling visible timeslots let timeslotGroupInputs = content.find("#timeslot-group-toggles-modal .modal-body input"); function updateTimeslotGroupToggling() { diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 6718fd673..584836aa9 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -16,6 +16,10 @@ .edit-meeting-schedule .edit-grid .timeslot.past-hint { filter: brightness(0.9); } .edit-meeting-schedule .past-flag { visibility: hidden; font-size: smaller; } .edit-meeting-schedule .edit-grid .timeslot.past .past-flag { visibility: visible; color: #aaaaaa; } + {# type and purpose styling #} + .edit-meeting-schedule .edit-grid .timeslot.wrong-timeslot-type { background-color: transparent; ); } + .edit-meeting-schedule .edit-grid .timeslot.wrong-timeslot-type .time-label { color: transparent; ); } + .edit-meeting-schedule .session.hidden-purpose { filter: blur(3px); } {% endblock morecss %} {% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting agenda{% endblock %} @@ -133,6 +137,7 @@ data-end="{{ t.utc_end_time.isoformat }}" data-duration="{{ t.duration.total_seconds }}" data-scheduledatlabel="{{ t.time|date:"l G:i" }}-{{ t.end_time|date:"G:i" }}" + data-type="{{ t.type.slug }}" style="width: {{ t.layout_width }}rem;">

     {# blank div keeps time centered vertically #}
    @@ -184,6 +189,18 @@ {% endfor %} + + {% for purpose in session_purposes %} + + {% endfor %} + + + + {% for purpose in session_purposes %} + + {% endfor %} + + diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html index 064279d2f..29cdd18df 100644 --- a/ietf/templates/meeting/edit_meeting_schedule_session.html +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -1,4 +1,9 @@ -
    +
    {{ session.scheduling_label }} {% if session.group and session.group.is_bof %}BOF{% endif %} @@ -30,14 +35,9 @@
    - {{ session.scheduling_label }} - · {{ session.requested_duration_in_hours }}h - {% if session.group %} - · {% if session.group.is_bof %}BOF{% else %}{{ session.group.type.name }}{% endif %} - {% endif %} - {% if session.attendees != None %} - · {{ session.attendees }} - {% endif %} + {{ session.scheduling_label }} · {{ session.requested_duration_in_hours }}h + {% if session.purpose_label %} · {{ session.purpose_label }} {% endif %} + {% if session.attendees != None %} · {{ session.attendees }} {% endif %}
    From 7a2530a0a6c7024b1df36f351ac749f8be986382 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Sat, 16 Oct 2021 20:09:00 +0000 Subject: [PATCH 04/16] Add management command to set up timeslots/sessions for testing/demoing 'purpose' field - Legacy-Id: 19430 --- .../commands/session_purpose_demo.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 ietf/meeting/management/commands/session_purpose_demo.py diff --git a/ietf/meeting/management/commands/session_purpose_demo.py b/ietf/meeting/management/commands/session_purpose_demo.py new file mode 100644 index 000000000..624e89794 --- /dev/null +++ b/ietf/meeting/management/commands/session_purpose_demo.py @@ -0,0 +1,91 @@ +import datetime +import random + +from django.core.management.base import BaseCommand, CommandError + +from ietf.group.models import Group +from ietf.meeting.factories import RoomFactory, TimeSlotFactory, SessionFactory +from ietf.meeting.helpers import get_meeting +from ietf.meeting.models import Room, Session +from ietf.name.models import SessionPurposeName + + +class Command(BaseCommand): + help = 'Set up a demo of the session purpose updates' + + DEMO_PREFIX='PDemo' # used to identify things added by this command + + def add_arguments(self, parser): + parser.add_argument('--remove', action='store_true') + + def handle(self, *args, **options): + if options['remove']: + self.remove_demo() + else: + self.install_demo() + + def remove_demo(self): + self.stdout.write(f'Removing rooms with "{self.DEMO_PREFIX}" name prefix...\n') + Room.objects.filter(name__startswith=self.DEMO_PREFIX).delete() + self.stdout.write(f'Removing sessions with "{self.DEMO_PREFIX}" name prefix...\n') + Session.objects.filter(name__startswith=self.DEMO_PREFIX).delete() + + def install_demo(self): + # get meeting + try: + meeting = get_meeting(days=14) # matches how secr app finds meetings + except: + raise CommandError('No upcoming meeting to modify') + + # create rooms + self.stdout.write('Creating rooms...\n') + rooms = [ + RoomFactory(meeting=meeting, name=f'{self.DEMO_PREFIX} 1'), + RoomFactory(meeting=meeting, name=f'{self.DEMO_PREFIX} 2'), + RoomFactory(meeting=meeting, name=f'{self.DEMO_PREFIX} 3'), + ] + + # get all the timeslot types used by a session purpose + type_ids = set() + for purpose in SessionPurposeName.objects.filter(used=True): + type_ids.update(purpose.timeslot_types) + + # set up timeslots + self.stdout.write('Creating timeslots...\n') + for room in rooms: + for day in range(meeting.days): + date = meeting.get_meeting_date(day) + for n, type_id in enumerate(type_ids): + TimeSlotFactory( + type_id=type_id, + meeting=meeting, + location=room, + time=datetime.datetime.combine(date, datetime.time(10, 0, 0)) + datetime.timedelta(hours=n), + duration=datetime.timedelta(hours=1), + ) + + # set up sessions + self.stdout.write('Creating sessions...') + groups_for_session_purpose = { + purpose.slug: list( + Group.objects.filter( + type__features__session_purposes__contains=f'"{purpose.slug}"', + state_id='active', + ) + ) + for purpose in SessionPurposeName.objects.filter(used=True) + } + for purpose in SessionPurposeName.objects.filter(used=True): + for type_id in purpose.timeslot_types: + group=random.choice(groups_for_session_purpose[purpose.slug]) + SessionFactory( + meeting=meeting, + purpose=purpose, + type_id=type_id, + group=group, + name=f'{self.DEMO_PREFIX} for {group.acronym}', + status_id='schedw', + add_to_schedule=False, + ) + + self.stdout.write(f'\nRooms and sessions created with "{self.DEMO_PREFIX}" as name prefix\n') \ No newline at end of file From 3be50d6e3943cf96d68eb7ba97dd2e19697ec18a Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 18 Oct 2021 14:48:36 +0000 Subject: [PATCH 05/16] Update session purposes and group type -> purpose map to match notes page, change 'session' purpose to 'regular' - Legacy-Id: 19433 --- ...populate_groupfeatures_session_purposes.py | 19 ++++++++++++++----- ietf/meeting/views.py | 2 +- .../0036_populate_sessionpurposename.py | 8 +++++--- .../includes/sessions_request_view.html | 2 +- .../meeting/edit_meeting_schedule.html | 6 ------ .../edit_meeting_schedule_session.html | 2 +- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py index 5e2353ed7..ba7a93ea4 100644 --- a/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py +++ b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py @@ -6,12 +6,21 @@ from django.db import migrations default_purposes = dict( - dir=['presentation', 'social', 'tutorial'], - ietf=['admin', 'presentation', 'social'], - nomcom=['closed', 'officehours'], - rg=['session'], + adhoc=['presentation'], + adm=['closed_meeting', 'officehours'], + ag=['regular'], + area=['regular'], + dir=['presentation', 'social', 'tutorial', 'regular'], + iab=['closed_meeting', 'regular'], + iabasg=['open_meeting', 'closed_meeting'], + ietf=['admin', 'plenary', 'presentation', 'social'], + nomcom=['closed_meeting', 'officehours'], + program=['regular', 'tutorial'], + rag=['regular'], + review=['open_meeting', 'social'], + rg=['regular'], team=['coding', 'presentation', 'social', 'tutorial'], - wg=['session'], + wg=['regular'], ) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index b2fb786a0..396c26e34 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -597,7 +597,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): s.scheduling_label = "???" s.purpose_label = None - if (s.purpose is None or s.purpose.slug == 'session') and s.group: + if (s.purpose is None or s.purpose.slug == 'regular') and s.group: s.scheduling_label = s.group.acronym s.purpose_label = 'BoF' if s.group.is_bof() else s.group.type.name else: diff --git a/ietf/name/migrations/0036_populate_sessionpurposename.py b/ietf/name/migrations/0036_populate_sessionpurposename.py index b36036059..a8304b86d 100644 --- a/ietf/name/migrations/0036_populate_sessionpurposename.py +++ b/ietf/name/migrations/0036_populate_sessionpurposename.py @@ -10,14 +10,16 @@ def forward(apps, schema_editor): TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName') for order, (slug, name, desc, tstypes) in enumerate(( - ('session', 'Session', 'Group session', ['regular']), + ('regular', 'Regular', 'Regular 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']), + ('social', 'Social', 'Social event or activity', ['break', 'other']), + ('plenary', 'Plenary', 'Plenary session', ['plenary']), ('presentation', 'Presentation', 'Presentation session', ['other', 'regular']), - ('closed', 'Closed meeting', 'Closed meeting', ['other',]), + ('open_meeting', 'Open meeting', 'Open meeting', ['other']), + ('closed_meeting', 'Closed meeting', 'Closed meeting', ['other', 'regular']), )): # verify that we're not about to use an invalid purpose for ts_type in tstypes: diff --git a/ietf/secr/templates/includes/sessions_request_view.html b/ietf/secr/templates/includes/sessions_request_view.html index 3f85986fb..0f5bfca8f 100644 --- a/ietf/secr/templates/includes/sessions_request_view.html +++ b/ietf/secr/templates/includes/sessions_request_view.html @@ -9,7 +9,7 @@
    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' %} + {% if sess_form.cleaned_data.purpose.slug != 'regular' %}
    Purpose
    {{ sess_form.cleaned_data.purpose }} diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 584836aa9..ab5dd8ed2 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -195,12 +195,6 @@ {% endfor %} - - {% for purpose in session_purposes %} - - {% endfor %} - - diff --git a/ietf/templates/meeting/edit_meeting_schedule_session.html b/ietf/templates/meeting/edit_meeting_schedule_session.html index 29cdd18df..472138e26 100644 --- a/ietf/templates/meeting/edit_meeting_schedule_session.html +++ b/ietf/templates/meeting/edit_meeting_schedule_session.html @@ -1,5 +1,5 @@
    Date: Mon, 18 Oct 2021 15:02:39 +0000 Subject: [PATCH 06/16] Redirect edit_schedule urls to edit_meeting_schedule view - Legacy-Id: 19434 --- ietf/meeting/urls.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 1fb1ebd50..e4d58d25d 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -26,7 +26,9 @@ safe_for_all_meeting_types = [ type_ietf_only_patterns = [ - url(r'^agenda/%(owner)s/%(schedule_name)s/edit$' % settings.URL_REGEXPS, views.edit_schedule), + url(r'^agenda/%(owner)s/%(schedule_name)s/edit$' % settings.URL_REGEXPS, + RedirectView.as_view(pattern_name='ietf.meeting.views.edit_meeting_schedule', permanent=True), + name='ietf.meeting.views.edit_schedule'), url(r'^agenda/%(owner)s/%(schedule_name)s/edit/$' % settings.URL_REGEXPS, views.edit_meeting_schedule), url(r'^agenda/%(owner)s/%(schedule_name)s/timeslots/$' % settings.URL_REGEXPS, views.edit_meeting_timeslots_and_misc_sessions), url(r'^agenda/%(owner)s/%(schedule_name)s/details$' % settings.URL_REGEXPS, views.edit_schedule_properties), @@ -84,7 +86,9 @@ type_ietf_only_patterns_id_optional = [ url(r'^agenda(?P-utc)?(?P.html)?/?$', views.agenda), url(r'^agenda(?P.txt)$', views.agenda), url(r'^agenda(?P.csv)$', views.agenda), - url(r'^agenda/edit$', views.edit_schedule), + url(r'^agenda/edit$', + RedirectView.as_view(pattern_name='ietf.meeting.views.edit_meeting_schedule', permanent=True), + name='ietf.meetingviews.edit_schedule'), url(r'^agenda/edit/$', views.edit_meeting_schedule), url(r'^requests$', views.meeting_requests), url(r'^agenda/agenda\.ics$', views.agenda_ical), From b6ac3d4b1d2bbb6c10d7665b16d09058190f652f Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 19 Oct 2021 01:07:56 +0000 Subject: [PATCH 07/16] Allow hiding/blurring sessions and timeslots based on TimeSlotType in the schedule editor - Legacy-Id: 19438 --- ietf/meeting/helpers.py | 2 +- ietf/meeting/views.py | 9 +++ ietf/secr/sreq/forms.py | 2 + ietf/static/ietf/css/ietf.css | 14 +++- ietf/static/ietf/js/edit-meeting-schedule.js | 17 +++++ .../ietf/js/meeting/session_details_form.js | 2 +- .../meeting/edit_meeting_schedule.html | 67 ++++++++++++------- 7 files changed, 83 insertions(+), 30 deletions(-) diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py index 3fd4417a1..e750b3392 100644 --- a/ietf/meeting/helpers.py +++ b/ietf/meeting/helpers.py @@ -310,7 +310,7 @@ class AgendaKeywordTool: @property def filterable_purposes(self): - return SessionPurposeName.objects.exclude(slug='session').order_by('name') + return SessionPurposeName.objects.exclude(slug='regular').order_by('name') class AgendaFilterOrganizer(AgendaKeywordTool): diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 396c26e34..6e9fb0664 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -991,6 +991,14 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): p.light_scheduling_color = "rgb({}, {}, {})".format(*tuple(int(round((0.9 + 0.1 * x) * 255)) for x in rgb_color)) session_purposes = sorted(set(s.purpose for s in sessions if s.purpose), key=lambda p: p.name) + timeslot_types = sorted( + set( + s.type for s in sessions if s.type + ).union( + t.type for t in timeslots_qs.all() + ), + key=lambda tstype: tstype.name, + ) return render(request, "meeting/edit_meeting_schedule.html", { 'meeting': meeting, @@ -1003,6 +1011,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): 'unassigned_sessions': unassigned_sessions, 'session_parents': session_parents, 'session_purposes': session_purposes, + 'timeslot_types': timeslot_types, 'hide_menu': True, 'lock_time': lock_time, }) diff --git a/ietf/secr/sreq/forms.py b/ietf/secr/sreq/forms.py index f64e1320c..9bcf9ac8d 100644 --- a/ietf/secr/sreq/forms.py +++ b/ietf/secr/sreq/forms.py @@ -94,8 +94,10 @@ class SessionForm(forms.Form): self.session_forms = formset_class(group=self.group, meeting=meeting, data=data) super(SessionForm, self).__init__(data=data, *args, **kwargs) + # Allow additional sessions for non-wg-like groups if not self.group.features.acts_like_wg: self.fields['num_session'].choices = ((n, str(n)) for n in range(1, 13)) + 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')) diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 02f8614e2..1ab9e40b2 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1333,10 +1333,20 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { .edit-meeting-schedule .scheduling-panel .preferences { margin: 0.5em 0; + display: flex; + align-items: flex-start; } -.edit-meeting-schedule .scheduling-panel .preferences > span { +.edit-meeting-schedule .scheduling-panel .preferences > div { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.edit-meeting-schedule .scheduling-panel .preferences > div > span { + margin-top: 0; margin-right: 1em; + white-space: nowrap; } .edit-meeting-schedule .sort-unassigned select { @@ -1363,7 +1373,7 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { margin-top: 1em; } -.edit-meeting-schedule .session-parent-toggles label { +.edit-meeting-schedule .toggle-inputs label { font-weight: normal; margin-right: 1em; padding: 0 1em; diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index dc8b35565..1b92f2a4a 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -724,6 +724,23 @@ jQuery(document).ready(function () { sessionParentInputs.on("click", updateSessionParentToggling); updateSessionParentToggling(); + // Toggling timeslot types + let timeSlotTypeInputs = content.find('.timeslot-type-toggles input'); + function updateTimeSlotTypeToggling() { + let checked = []; + timeSlotTypeInputs.filter(":checked").each(function () { + checked.push("[data-type=" + this.value + "]"); + }); + + sessions.filter(checked.join(",")).removeClass('hidden-timeslot-type'); + sessions.not(checked.join(",")).addClass('hidden-timeslot-type'); + timeslots.filter(checked.join(",")).removeClass('hidden-timeslot-type'); + timeslots.not(checked.join(",")).addClass('hidden-timeslot-type'); + } + + timeSlotTypeInputs.on("click", updateTimeSlotTypeToggling); + updateTimeSlotTypeToggling(); + // Toggling session purposes let sessionPurposeInputs = content.find('.session-purpose-toggles input'); function updateSessionPurposeToggling() { diff --git a/ietf/static/ietf/js/meeting/session_details_form.js b/ietf/static/ietf/js/meeting/session_details_form.js index 28907bf92..8bce860b0 100644 --- a/ietf/static/ietf/js/meeting/session_details_form.js +++ b/ietf/static/ietf/js/meeting/session_details_form.js @@ -55,7 +55,7 @@ function update_name_field_visibility(name_elt, purpose) { const row = name_elt.closest('tr'); - if (purpose === 'session') { + if (purpose === 'regular') { row.setAttribute('hidden', 'hidden'); } else { row.removeAttribute('hidden'); diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index ab5dd8ed2..f354d4451 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -17,9 +17,12 @@ .edit-meeting-schedule .past-flag { visibility: hidden; font-size: smaller; } .edit-meeting-schedule .edit-grid .timeslot.past .past-flag { visibility: visible; color: #aaaaaa; } {# type and purpose styling #} - .edit-meeting-schedule .edit-grid .timeslot.wrong-timeslot-type { background-color: transparent; ); } - .edit-meeting-schedule .edit-grid .timeslot.wrong-timeslot-type .time-label { color: transparent; ); } - .edit-meeting-schedule .session.hidden-purpose { filter: blur(3px); } + .edit-meeting-schedule .edit-grid .timeslot.wrong-timeslot-type, + .edit-meeting-schedule .edit-grid .timeslot.hidden-timeslot-type { background-color: transparent; ); } + .edit-meeting-schedule .edit-grid .timeslot.wrong-timeslot-type .time-label, + .edit-meeting-schedule .edit-grid .timeslot.hidden-timeslot-type .time-label { color: transparent; ); } + .edit-meeting-schedule .session.hidden-purpose, + .edit-meeting-schedule .session.hidden-timeslot-type { filter: blur(3px); } {% endblock morecss %} {% block title %}{{ schedule.name }}: IETF {{ meeting.number }} meeting agenda{% endblock %} @@ -172,32 +175,44 @@
    - - Sort unassigned: - - +
    + + Sort unassigned: + + - - Show: - {% for p in session_parents %} - - {% endfor %} - + + + +
    - - {% for purpose in session_purposes %} - - {% endfor %} - +
    + + Show: + {% for p in session_parents %} + + {% endfor %} + - - - + + Purpose: + {% for purpose in session_purposes %} + + {% endfor %} + + + + Type: + {% for type in timeslot_types %} + + {% endfor %} + +
    From 446ac7a47ead8e0c01cfc8426a7149aa9aa15f33 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Tue, 19 Oct 2021 14:39:17 +0000 Subject: [PATCH 08/16] Disable session purpose/timeslot type hiding on schedule editor when only 0 or 1 options - Legacy-Id: 19439 --- ietf/static/ietf/js/edit-meeting-schedule.js | 14 ++++++---- .../meeting/edit_meeting_schedule.html | 28 +++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index 1b92f2a4a..add0957b1 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -737,9 +737,10 @@ jQuery(document).ready(function () { timeslots.filter(checked.join(",")).removeClass('hidden-timeslot-type'); timeslots.not(checked.join(",")).addClass('hidden-timeslot-type'); } - - timeSlotTypeInputs.on("click", updateTimeSlotTypeToggling); - updateTimeSlotTypeToggling(); + if (timeSlotTypeInputs.length > 0) { + timeSlotTypeInputs.on("click", updateTimeSlotTypeToggling); + updateTimeSlotTypeToggling(); + } // Toggling session purposes let sessionPurposeInputs = content.find('.session-purpose-toggles input'); @@ -752,9 +753,10 @@ jQuery(document).ready(function () { sessions.filter(checked.join(",")).removeClass('hidden-purpose'); sessions.not(checked.join(",")).addClass('hidden-purpose'); } - - sessionPurposeInputs.on("click", updateSessionPurposeToggling); - updateSessionPurposeToggling(); + if (sessionPurposeInputs.length > 0) { + sessionPurposeInputs.on("click", updateSessionPurposeToggling); + updateSessionPurposeToggling(); + } // toggling visible timeslots let timeslotGroupInputs = content.find("#timeslot-group-toggles-modal .modal-body input"); diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index f354d4451..3bea2dba5 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -199,19 +199,23 @@ {% endfor %} - - Purpose: - {% for purpose in session_purposes %} - - {% endfor %} - + {% if session_purposes|length > 1 %} + + Purpose: + {% for purpose in session_purposes %} + + {% endfor %} + + {% endif %} - - Type: - {% for type in timeslot_types %} - - {% endfor %} - + {% if timeslot_types|length > 1 %} + + Type: + {% for type in timeslot_types %} + + {% endfor %} + + {% endif %}
    From 5cbe402036e41795de7f798fa5321aff26a5ee31 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Thu, 21 Oct 2021 14:59:02 +0000 Subject: [PATCH 09/16] Improvements to the timeslot and schedule editors (move new toggles to modals, handle overflowing session names, fix timeslot editor scrolling, add buttons to quickly create single timeslot, accept trailing slash on edit URL) - Legacy-Id: 19449 --- ietf/meeting/urls.py | 2 +- ietf/static/ietf/css/ietf.css | 23 ++-- ietf/static/ietf/js/edit-meeting-schedule.js | 20 +++- .../static/ietf/js/meeting/create_timeslot.js | 43 +++++++ ietf/templates/meeting/create_timeslot.html | 5 +- .../meeting/edit_meeting_schedule.html | 111 ++++++++++-------- ietf/templates/meeting/timeslot_edit.html | 47 ++++++-- ietf/utils/templatetags/misc_filters.py | 10 ++ 8 files changed, 185 insertions(+), 76 deletions(-) create mode 100644 ietf/static/ietf/js/meeting/create_timeslot.js diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index e4d58d25d..c9c388c0e 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -54,7 +54,7 @@ type_ietf_only_patterns = [ url(r'^agendas/edit$', RedirectView.as_view(pattern_name='ietf.meeting.views.list_schedules', permanent=True)), url(r'^agendas/diff/$', views.diff_schedules), url(r'^agenda/new/$', views.new_meeting_schedule), - url(r'^timeslots/edit$', views.edit_timeslots), + 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), diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css index 1ab9e40b2..448827f12 100644 --- a/ietf/static/ietf/css/ietf.css +++ b/ietf/static/ietf/css/ietf.css @@ -1239,6 +1239,9 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { .edit-meeting-schedule .session .session-label { flex-grow: 1; margin-left: 0.1em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .edit-meeting-schedule .session .session-label .bof-tag { @@ -1333,20 +1336,11 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { .edit-meeting-schedule .scheduling-panel .preferences { margin: 0.5em 0; - display: flex; - align-items: flex-start; } -.edit-meeting-schedule .scheduling-panel .preferences > div { - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.edit-meeting-schedule .scheduling-panel .preferences > div > span { +.edit-meeting-schedule .scheduling-panel .preferences > span { margin-top: 0; margin-right: 1em; - white-space: nowrap; } .edit-meeting-schedule .sort-unassigned select { @@ -1354,17 +1348,20 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { display: inline-block; } -.edit-meeting-schedule #timeslot-group-toggles-modal .modal-body { +.edit-meeting-schedule #timeslot-group-toggles-modal .modal-body > div { + margin-bottom: 1.5em; +} +.edit-meeting-schedule #timeslot-group-toggles-modal .modal-body .individual-timeslots { /*column-count: 3;*/ display: flex; flex-flow: row wrap; } -.edit-meeting-schedule #timeslot-group-toggles-modal .modal-body > * { +.edit-meeting-schedule #timeslot-group-toggles-modal .modal-body .individual-timeslots > * { margin-right: 1.5em; } -.edit-meeting-schedule #timeslot-group-toggles-modal .modal-body label { +.edit-meeting-schedule #timeslot-group-toggles-modal .modal-body .individual-timeslots label { display: block; font-weight: normal; } diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index add0957b1..191a435ba 100644 --- a/ietf/static/ietf/js/edit-meeting-schedule.js +++ b/ietf/static/ietf/js/edit-meeting-schedule.js @@ -738,13 +738,13 @@ jQuery(document).ready(function () { timeslots.not(checked.join(",")).addClass('hidden-timeslot-type'); } if (timeSlotTypeInputs.length > 0) { - timeSlotTypeInputs.on("click", updateTimeSlotTypeToggling); + timeSlotTypeInputs.on("change", updateTimeSlotTypeToggling); updateTimeSlotTypeToggling(); } // Toggling session purposes let sessionPurposeInputs = content.find('.session-purpose-toggles input'); - function updateSessionPurposeToggling() { + function updateSessionPurposeToggling(evt) { let checked = []; sessionPurposeInputs.filter(":checked").each(function () { checked.push(".purpose-" + this.value); @@ -754,12 +754,24 @@ jQuery(document).ready(function () { sessions.not(checked.join(",")).addClass('hidden-purpose'); } if (sessionPurposeInputs.length > 0) { - sessionPurposeInputs.on("click", updateSessionPurposeToggling); + sessionPurposeInputs.on("change", updateSessionPurposeToggling); updateSessionPurposeToggling(); + content.find('#session-toggles-modal .select-all').get(0).addEventListener( + 'click', + function() { + sessionPurposeInputs.prop('checked', true); + updateSessionPurposeToggling(); + }); + content.find('#session-toggles-modal .clear-all').get(0).addEventListener( + 'click', + function() { + sessionPurposeInputs.prop('checked', false); + updateSessionPurposeToggling(); + }); } // toggling visible timeslots - let timeslotGroupInputs = content.find("#timeslot-group-toggles-modal .modal-body input"); + let timeslotGroupInputs = content.find("#timeslot-group-toggles-modal .modal-body .individual-timeslots input"); function updateTimeslotGroupToggling() { let checked = []; timeslotGroupInputs.filter(":checked").each(function () { diff --git a/ietf/static/ietf/js/meeting/create_timeslot.js b/ietf/static/ietf/js/meeting/create_timeslot.js new file mode 100644 index 000000000..7d0cbd271 --- /dev/null +++ b/ietf/static/ietf/js/meeting/create_timeslot.js @@ -0,0 +1,43 @@ +// Copyright The IETF Trust 2021, All Rights Reserved +/* global URLSearchParams */ +(function() { + 'use strict'; + + function initialize() { + const form = document.getElementById('timeslot-form'); + if (!form) { + return; + } + + const params = new URLSearchParams(document.location.search); + const day = params.get('day'); + const date = params.get('date'); + const location = params.get('location'); + const time = params.get('time'); + const duration = params.get('duration'); + + if (day) { + const inp = form.querySelector('#id_days input[value="' + day +'"]'); + if (inp) { + inp.checked = true; + } else if (date) { + const date_field = form.querySelector('#id_other_date'); + date_field.value = date; + } + } + if (location) { + const inp = form.querySelector('#id_locations input[value="' + location + '"]'); + inp.checked=true; + } + if (time) { + const inp = form.querySelector('input#id_time'); + inp.value = time; + } + if (duration) { + const inp = form.querySelector('input#id_duration'); + inp.value = duration; + } + } + + window.addEventListener('load', initialize); +})(); \ No newline at end of file diff --git a/ietf/templates/meeting/create_timeslot.html b/ietf/templates/meeting/create_timeslot.html index c3ff73058..75a7af0a8 100755 --- a/ietf/templates/meeting/create_timeslot.html +++ b/ietf/templates/meeting/create_timeslot.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {# Copyright The IETF Trust 2021, All Rights Reserved #} -{% load origin %} +{% load origin static %} {% load bootstrap3 %} {% block pagehead %} @@ -12,7 +12,7 @@ {% block content %} {% origin %}

    Create timeslot for {{meeting}}

    -
    + {% csrf_token %} {% bootstrap_form form %} {% buttons %} @@ -23,5 +23,6 @@ {% endblock %} {% block js %} + {{ form.media.js }} {% endblock %} \ No newline at end of file diff --git a/ietf/templates/meeting/edit_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 3bea2dba5..6f6fd6c0b 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -175,48 +175,27 @@
    -
    - - Sort unassigned: - - + + Sort unassigned: + + - - - -
    + + Show: + {% for p in session_parents %} + + {% endfor %} + -
    - - Show: - {% for p in session_parents %} - - {% endfor %} - - - {% if session_purposes|length > 1 %} - - Purpose: - {% for purpose in session_purposes %} - - {% endfor %} - - {% endif %} - - {% if timeslot_types|length > 1 %} - - Type: - {% for type in timeslot_types %} - - {% endfor %} - - {% endif %} -
    + {% if session_purposes|length > 1 %} + + {% endif %} +
    @@ -235,14 +214,52 @@
    + + +
    + + + +