diff --git a/hold-for-merge b/hold-for-merge index 96e5715ea..60b17852b 100644 --- a/hold-for-merge +++ b/hold-for-merge @@ -1,11 +1,13 @@ # -*- conf-mode -*- # + /personal/lars/7.39.1.dev0@19495 # Hold the modal 'give us your xml' poking until bibxml service is stable # and maybe until we have rendered previews. # Everyting below this line is OBE +/personal/rjs/7.39.1.dev1@19554 # Optimization wasn't measured correctly /personal/rjs/7.36.1.dev0@19318 # Folded this into r19336 /personal/rjs/7.36.1.dev0@19302 # Handled this in an earlier merge diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py index e10ca5002..f4b472c35 100644 --- a/ietf/doc/templatetags/ietf_filters.py +++ b/ietf/doc/templatetags/ietf_filters.py @@ -631,6 +631,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-112 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-112 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'), ('112', 'acme office hours')] + >>> any(should_show_agenda_session_buttons(factory(*tc)) for tc in test_cases) + False + >>> test_cases = [('interim-2020-acme-113', 'acme'), ('113', 'acme'), ('150', 'acme'), ('105', 'acme'),] + >>> test_cases.extend([('112', 'acme'), ('interim-2020-acme-113', 'acme office hours')]) + >>> test_cases.extend([('113', '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/doc/tests.py b/ietf/doc/tests.py index 310fe6de7..4559020e4 100644 --- a/ietf/doc/tests.py +++ b/ietf/doc/tests.py @@ -40,7 +40,7 @@ from ietf.doc.utils import create_ballot_if_not_open, uppercase_std_abbreviated_ from ietf.group.models import Group from ietf.group.factories import GroupFactory, RoleFactory from ietf.ipr.factories import HolderIprDisclosureFactory -from ietf.meeting.models import Meeting, Session, SessionPresentation, SchedulingEvent +from ietf.meeting.models import Meeting, SessionPresentation, SchedulingEvent from ietf.meeting.factories import ( MeetingFactory, SessionFactory, SessionPresentationFactory, ProceedingsMaterialFactory ) @@ -1465,12 +1465,12 @@ class DocTestCase(TestCase): ) doc.set_state(State.objects.get(type="slides", slug="active")) - session = Session.objects.create( + session = SessionFactory( name = "session-72-mars-1", meeting = Meeting.objects.get(number='72'), group = Group.objects.get(acronym='mars'), modified = datetime.datetime.now(), - type_id = 'regular', + add_to_schedule=False, ) SchedulingEvent.objects.create( session=session, diff --git a/ietf/doc/tests_material.py b/ietf/doc/tests_material.py index 7738dcc5d..b49689f0a 100644 --- a/ietf/doc/tests_material.py +++ b/ietf/doc/tests_material.py @@ -18,8 +18,8 @@ from django.urls import reverse as urlreverse from ietf.doc.models import Document, State, DocAlias, NewRevisionDocEvent from ietf.group.factories import RoleFactory from ietf.group.models import Group -from ietf.meeting.factories import MeetingFactory -from ietf.meeting.models import Meeting, Session, SessionPresentation, SchedulingEvent +from ietf.meeting.factories import MeetingFactory, SessionFactory +from ietf.meeting.models import Meeting, SessionPresentation, SchedulingEvent from ietf.name.models import SessionStatusName from ietf.person.models import Person from ietf.utils.test_utils import TestCase, login_testing_unauthorized @@ -151,12 +151,11 @@ class GroupMaterialTests(TestCase): def test_revise(self): doc = self.create_slides() - session = Session.objects.create( + session = SessionFactory( name = "session-42-mars-1", meeting = Meeting.objects.get(number='42'), group = Group.objects.get(acronym='mars'), modified = datetime.datetime.now(), - type_id='regular', ) SchedulingEvent.objects.create( session=session, 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/0050_groupfeatures_agenda_filter_type.py b/ietf/group/migrations/0050_groupfeatures_agenda_filter_type.py new file mode 100644 index 000000000..e7cdc64de --- /dev/null +++ b/ietf/group/migrations/0050_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', '0049_auto_20211019_1136'), + ] + + 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/0051_populate_groupfeatures_agenda_filter_type.py b/ietf/group/migrations/0051_populate_groupfeatures_agenda_filter_type.py new file mode 100644 index 000000000..fa5025902 --- /dev/null +++ b/ietf/group/migrations/0051_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', '0050_groupfeatures_agenda_filter_type'), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/group/migrations/0052_groupfeatures_session_purposes.py b/ietf/group/migrations/0052_groupfeatures_session_purposes.py new file mode 100644 index 000000000..f6f73799f --- /dev/null +++ b/ietf/group/migrations/0052_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', '0051_populate_groupfeatures_agenda_filter_type'), + ('name', '0034_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/0053_populate_groupfeatures_session_purposes.py b/ietf/group/migrations/0053_populate_groupfeatures_session_purposes.py new file mode 100644 index 000000000..642aa5f21 --- /dev/null +++ b/ietf/group/migrations/0053_populate_groupfeatures_session_purposes.py @@ -0,0 +1,64 @@ +# 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( + adhoc=['presentation'], + adm=['closed_meeting', 'officehours'], + ag=['regular'], + area=['regular'], + dir=['open_meeting', 'presentation', 'regular', 'social', 'tutorial'], + iab=['closed_meeting', 'regular'], + iabasg=['closed_meeting', 'officehours', 'open_meeting'], + iana=['officehours'], + iesg=['closed_meeting', 'open_meeting'], + ietf=['admin', 'plenary', 'presentation', 'social'], + irtf=[], + ise=['officehours'], + isoc=['officehours', 'open_meeting', 'presentation'], + nomcom=['closed_meeting', 'officehours'], + program=['regular', 'tutorial'], + rag=['regular'], + review=['open_meeting', 'social'], + rfcedtyp=['officehours'], + rg=['regular'], + team=['coding', 'presentation', 'social', 'tutorial'], + wg=['regular'], +) + + +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', '0052_groupfeatures_session_purposes'), + ('name', '0035_populate_sessionpurposename'), + + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/group/models.py b/ietf/group/models.py index a7335c2fa..7deb0b392 100644 --- a/ietf/group/models.py +++ b/ietf/group/models.py @@ -8,25 +8,23 @@ import jsonfield import os import re -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 - 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.db import IETFJSONField 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): @@ -167,30 +165,6 @@ class Group(GroupInfo): def bg_color(self): return bg_group_colors[self.upcase_acronym] - def json_url(self): - return "/group/%s.json" % (self.acronym,) - - def json_dict(self, host_scheme): - group1= dict() - group1['href'] = urljoin(host_scheme, self.json_url()) - group1['acronym'] = self.acronym - group1['name'] = self.name - group1['state'] = self.state.slug - group1['type'] = self.type.slug - if self.parent is not None: - group1['parent_href'] = urljoin(host_scheme, self.parent.json_url()) - # uncomment when people URL handle is created - try: - if self.ad_role() is not None: - group1['ad_href'] = urljoin(host_scheme, self.ad_role().person.json_url()) - except Person.DoesNotExist: - pass - group1['list_email'] = self.list_email - group1['list_subscribe'] = self.list_subscribe - group1['list_archive'] = self.list_archive - group1['comments'] = self.comments - return group1 - def liaison_approvers(self): '''Returns roles that have liaison statement approval authority for group''' @@ -248,6 +222,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() @@ -275,6 +250,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) @@ -289,6 +265,9 @@ class GroupFeatures(models.Model): matman_roles = IETFJSONField(max_length=128, accepted_empty_values=[[], {}], blank=False, default=["ad","chair","delegate","secr"]) role_order = IETFJSONField(max_length=128, accepted_empty_values=[[], {}], blank=False, default=["chair","secr","member"], help_text="The order in which roles are shown, for instance on photo pages. Enter valid JSON.") + session_purposes = IETFJSONField(max_length=256, accepted_empty_values=[[], {}], blank=False, default=[], + help_text="Allowed session purposes for this group type", + validators=[JSONForeignKeyListValidator(SessionPurposeName)]) class GroupHistory(GroupInfo): diff --git a/ietf/group/urls.py b/ietf/group/urls.py index a8d6a06e6..07eb2c4b6 100644 --- a/ietf/group/urls.py +++ b/ietf/group/urls.py @@ -54,7 +54,6 @@ info_detail_urls = [ group_urls = [ url(r'^$', views.active_groups), url(r'^groupmenu.json', views.group_menu_data, None, 'ietf.group.views.group_menu_data'), - url(r'^%(acronym)s.json$' % settings.URL_REGEXPS, views.group_json), url(r'^chartering/$', views.chartering_groups), url(r'^chartering/create/(?P(wg|rg))/$', views.edit, {'action': "charter"}), url(r'^concluded/$', views.concluded_groups), diff --git a/ietf/group/views.py b/ietf/group/views.py index d41a925f3..fcb3318ae 100644 --- a/ietf/group/views.py +++ b/ietf/group/views.py @@ -38,7 +38,6 @@ import copy import datetime import itertools import io -import json import markdown import math import os @@ -1299,13 +1298,6 @@ def stream_edit(request, acronym): ) -def group_json(request, acronym): - group = get_object_or_404(Group, acronym=acronym) - - return HttpResponse(json.dumps(group.json_dict(request.build_absolute_uri('/')), - sort_keys=True, indent=2), - content_type="application/json") - @cache_control(public=True, max_age=30*60) @cache_page(30 * 60) def group_menu_data(request): diff --git a/ietf/meeting/ajax.py b/ietf/meeting/ajax.py deleted file mode 100644 index d58d7a528..000000000 --- a/ietf/meeting/ajax.py +++ /dev/null @@ -1,629 +0,0 @@ -# Copyright The IETF Trust 2013-2019, All Rights Reserved -import json - -from django.shortcuts import get_object_or_404, redirect -from django.http import HttpResponse -from django.http import QueryDict -from django.http import Http404 -from django.views.decorators.http import require_POST - -from ietf.ietfauth.utils import role_required, has_role -from ietf.meeting.helpers import get_meeting, get_schedule, schedule_permissions, get_person_by_email, get_schedule_by_name -from ietf.meeting.models import TimeSlot, Session, Schedule, Room, Constraint, SchedTimeSessAssignment, ResourceAssociation -from ietf.meeting.views import edit_timeslots, edit_schedule - -import debug # pyflakes:ignore - -def is_truthy_enough(value): - return not (value == "0" or value == 0 or value=="false") - -# look up a schedule by number, owner and schedule name, returning an API error if it can not be found -def get_meeting_schedule(num, owner, name): - meeting = get_meeting(num) - person = get_person_by_email(owner) - schedule = get_schedule_by_name(meeting, person, name) - - if schedule is None or person is None or meeting is None: - meeting_pk = 0 - person_pk = 0 - schedule_pk =0 - # to make diagnostics more meaningful, log what we found - if meeting: - meeting_pk = meeting.pk - if person: - person_pk = person.pk - if schedule: - schedule_pk=schedule.pk - return HttpResponse(json.dumps({'error' : 'invalid meeting=%s/person=%s/schedule=%s' % (num,owner,name), - 'meeting': meeting_pk, - 'person': person_pk, - 'schedule': schedule_pk}), - content_type="application/json", - status=404); - return meeting, person, schedule - - - -# should asking if an schedule is read-only require any kind of permission? -def schedule_permission_api(request, num, owner, name): - meeting = get_meeting(num) - person = get_person_by_email(owner) - schedule = get_schedule_by_name(meeting, person, name) - - save_perm = False - secretariat = False - cansee = False - canedit = False - owner_href = "" - - if schedule is not None: - cansee,canedit,secretariat = schedule_permissions(meeting, schedule, request.user) - owner_href = request.build_absolute_uri(schedule.owner.json_url()) - - if has_role(request.user, "Area Director") or secretariat: - save_perm = True - - return HttpResponse(json.dumps({'secretariat': secretariat, - 'save_perm': save_perm, - 'read_only': canedit==False, - 'owner_href': owner_href}), - content_type="application/json") - -############################################################################# -## ROOM API -############################################################################# -from django.forms.models import modelform_factory -AddRoomForm = modelform_factory(Room, exclude=('meeting','time')) - -# no authorization required -def timeslot_roomlist(request, mtg): - rooms = mtg.room_set.all() - json_array=[] - for room in rooms: - json_array.append(room.json_dict(request.build_absolute_uri('/'))) - return HttpResponse(json.dumps(json_array), - content_type="application/json") - -@role_required('Secretariat') -def timeslot_addroom(request, meeting): - newroomform = AddRoomForm(request.POST) - if not newroomform.is_valid(): - return HttpResponse(status=404) - - newroom = newroomform.save(commit=False) - newroom.meeting = meeting - newroom.save() - newroom.create_timeslots() - - if "HTTP_ACCEPT" in request.META and "application/json" in request.META['HTTP_ACCEPT']: - return redirect(timeslot_roomurl, meeting.number, newroom.pk) - else: - return redirect(edit_timeslots, meeting.number) - -@role_required('Secretariat') -def timeslot_delroom(request, meeting, roomid): - room = get_object_or_404(meeting.room_set, pk=roomid) - - room.delete_timeslots() - room.delete() - return HttpResponse('{"error":"none"}', status = 200) - -@role_required('Secretariat') -def timeslot_updroom(request, meeting, roomid): - room = get_object_or_404(meeting.room_set, pk=roomid) - - if "name" in request.POST: - room.name = request.POST["name"] - - if "capacity" in request.POST: - room.capacity = request.POST["capacity"] - - if "resources" in request.POST: - new_resource_ids = request.POST["resources"] - new_resources = [ ResourceAssociation.objects.get(pk=a) - for a in new_resource_ids] - room.resources = new_resources - - room.save() - return HttpResponse('{"error":"none"}', status = 200) - -def timeslot_roomsurl(request, num=None): - meeting = get_meeting(num) - - if request.method == 'GET': - return timeslot_roomlist(request, meeting) - elif request.method == 'POST': - return timeslot_addroom(request, meeting) - - # unacceptable reply - return HttpResponse(status=406) - -def timeslot_roomurl(request, num=None, roomid=None): - meeting = get_meeting(num) - - if request.method == 'GET': - room = get_object_or_404(meeting.room_set, pk=roomid) - return HttpResponse(json.dumps(room.json_dict(request.build_absolute_uri('/'))), - content_type="application/json") - elif request.method == 'PUT': - return timeslot_updroom(request, meeting, roomid) - elif request.method == 'DELETE': - return timeslot_delroom(request, meeting, roomid) - -############################################################################# -## DAY/SLOT API -## -- this creates groups of timeslots, and associated schedtimesessassignments. -############################################################################# -AddSlotForm = modelform_factory(TimeSlot, exclude=('meeting','name','location','sessions', 'modified')) - -# no authorization required to list. -def timeslot_slotlist(request, mtg): - slots = mtg.timeslot_set.all() - # Restrict graphical editing to slots of type 'regular' for now - slots = slots.filter(type__slug='regular') - json_array=[] - for slot in slots: - json_array.append(slot.json_dict(request.build_absolute_uri('/'))) - return HttpResponse(json.dumps(json_array, sort_keys=True, indent=2), - content_type="application/json") - -@role_required('Secretariat') -def timeslot_addslot(request, meeting): - addslotform = AddSlotForm(request.POST) - #debug.log("newslot: %u" % ( addslotform.is_valid() )) - if not addslotform.is_valid(): - return HttpResponse(status=404) - - newslot = addslotform.save(commit=False) - newslot.meeting = meeting - newslot.save() - - # XXX FIXME: timeslot_dayurl is undefined. Placeholder: - # timeslot_dayurl = None - # XXX FIXME: newroom is undefined. Placeholder: - # newroom = None - values = newslot.json_dict(request.build_absolute_uri('/')) - response = HttpResponse(json.dumps(values), - content_type="application/json", - status=201) - response['Location'] = values['href'] - return response - -@role_required('Secretariat') -def timeslot_updslot(request, meeting, slotid): - slot = get_object_or_404(meeting.timeslot_set, pk=slotid) - - # at present, updates to the purpose only is supported. - # updates to time or duration would need likely need to be - # propogated to the entire vertical part of the grid, and nothing - # needs to do that yet. - if request.method == 'POST': - put_vars = request.POST - slot.type_id = put_vars["purpose"] - else: - put_vars = QueryDict(request.body) - slot.type_id = put_vars.get("purpose") - - slot.save() - - # WORKAROUND: Right now, if there are sessions scheduled in this timeslot - # when it is marked unavailable (or any other value besides 'regular') they - # become unreachable from the editing screen. The session is listed in the - # "unscheduled" block incorrectly, and drag-dropping it onto the a new - # timeslot produces erroneous results. To avoid this, we will silently - # unschedule any sessions in the timeslot that has just been made - # unavailable. - - if slot.type_id != 'regular': - slot.sessionassignments.all().delete() - - # ENDWORKAROUND - - # need to return the new object. - dict1 = slot.json_dict(request.build_absolute_uri('/')) - dict1['message'] = 'valid' - return HttpResponse(json.dumps(dict1), - content_type="application/json") - -@role_required('Secretariat') -def timeslot_delslot(request, meeting, slotid): - slot = get_object_or_404(meeting.timeslot_set, pk=slotid) - - # this will delete self as well. - slot.delete_concurrent_timeslots() - return HttpResponse('{"error":"none"}', status = 200) - -def timeslot_slotsurl(request, num=None): - meeting = get_meeting(num) - - if request.method == 'GET': - return timeslot_slotlist(request, meeting) - elif request.method == 'POST': - return timeslot_addslot(request, meeting) - - # unacceptable reply - return HttpResponse(status=406) - -def timeslot_sloturl(request, num=None, slotid=None): - meeting = get_meeting(num) - - if request.method == 'GET': - slot = get_object_or_404(meeting.timeslot_set, pk=slotid) - return HttpResponse(json.dumps(slot.json_dict(request.build_absolute_uri('/'))), - content_type="application/json") - elif request.method == 'POST' or request.method == 'PUT': - return timeslot_updslot(request, meeting, slotid) - elif request.method == 'DELETE': - return timeslot_delslot(request, meeting, slotid) - -############################################################################# -## Schedule List API -############################################################################# -ScheduleEntryForm = modelform_factory(Schedule, exclude=('meeting','owner')) -EditScheduleEntryForm = modelform_factory(Schedule, exclude=('meeting','owner', 'name')) - -@role_required('Area Director','Secretariat') -def schedule_list(request, mtg): - schedules = mtg.schedule_set.all() - json_array=[] - for schedule in schedules: - json_array.append(schedule.json_dict(request.build_absolute_uri('/'))) - return HttpResponse(json.dumps(json_array), - content_type="application/json") - -# duplicates save-as functionality below. -@role_required('Area Director','Secretariat') -def schedule_add(request, meeting): - newscheduleform = ScheduleEntryForm(request.POST) - if not newscheduleform.is_valid(): - return HttpResponse(status=404) - - newschedule = newscheduleform.save(commit=False) - newschedule.meeting = meeting - newschedule.owner = request.user.person - newschedule.save() - - if "HTTP_ACCEPT" in request.META and "application/json" in request.META['HTTP_ACCEPT']: - return redirect(schedule_infourl, meeting.number, newschedule.owner_email(), newschedule.name) - else: - return redirect(edit_schedule, meeting.number, newschedule.owner_email(), newschedule.name) - -@require_POST -def schedule_update(request, meeting, schedule): - # forms are completely useless for update actions that want to - # accept a subset of values. (huh? we could use required=False) - - user = request.user - - if not user.is_authenticated: - return HttpResponse({'error':'no permission'}, status=403) - - cansee,canedit,secretariat = schedule_permissions(meeting, schedule, request.user) - #read_only = not canedit ## not used - - # TODO: Secretariat should always get canedit - if not (canedit or secretariat): - return HttpResponse({'error':'no permission'}, status=403) - - if "public" in request.POST: - schedule.public = is_truthy_enough(request.POST["public"]) - - if "visible" in request.POST: - schedule.visible = is_truthy_enough(request.POST["visible"]) - - if "name" in request.POST: - schedule.name = request.POST["name"] - - schedule.save() - - # enforce that a non-public schedule can not be the public one. - if meeting.schedule == schedule and not schedule.public: - meeting.schedule = None - meeting.save() - - if "HTTP_ACCEPT" in request.META and "application/json" in request.META['HTTP_ACCEPT']: - return HttpResponse(json.dumps(schedule.json_dict(request.build_absolute_uri('/'))), - content_type="application/json") - else: - return redirect(edit_schedule, meeting.number, schedule.owner_email(), schedule.name) - -@role_required('Secretariat') -def schedule_del(request, meeting, schedule): - schedule.delete_assignments() - #debug.log("deleting meeting: %s schedule: %s" % (meeting, meeting.schedule)) - if meeting.schedule == schedule: - meeting.schedule = None - meeting.save() - schedule.delete() - return HttpResponse('{"error":"none"}', status = 200) - -def schedule_infosurl(request, num=None): - meeting = get_meeting(num) - - if request.method == 'GET': - return schedule_list(request, meeting) - elif request.method == 'POST': - return schedule_add(request, meeting) - - # unacceptable action - return HttpResponse(status=406) - -def schedule_infourl(request, num=None, owner=None, name=None): - meeting = get_meeting(num) - person = get_person_by_email(owner) - schedule = get_schedule_by_name(meeting, person, name) - if schedule is None: - raise Http404("No meeting information for meeting %s schedule %s available" % (num,name)) - - #debug.log("results in schedule: %u / %s" % (schedule.id, request.method)) - - if request.method == 'GET': - return HttpResponse(json.dumps(schedule.json_dict(request.build_absolute_uri('/'))), - content_type="application/json") - elif request.method == 'POST': - return schedule_update(request, meeting, schedule) - elif request.method == 'DELETE': - return schedule_del(request, meeting, schedule) - else: - return HttpResponse(status=406) - -############################################################################# -## Meeting API (very limited) -############################################################################# - -def meeting_get(request, meeting): - return HttpResponse(json.dumps(meeting.json_dict(request.build_absolute_uri('/')), - sort_keys=True, indent=2), - content_type="application/json") - -@role_required('Secretariat') -def meeting_update(request, meeting): - # at present, only the official schedule can be updated from this interface. - - #debug.log("1 meeting.schedule: %s / %s / %s" % (meeting.schedule, update_dict, request.body)) - if "schedule" in request.POST: - value = request.POST["schedule"] - #debug.log("4 meeting.schedule: %s" % (value)) - if not value or value == "None": # value == "None" is just weird, better with empty string - meeting.set_official_schedule(None) - else: - schedule = get_schedule(meeting, value) - if not schedule.public: - return HttpResponse(status = 406) - #debug.log("3 meeting.schedule: %s" % (schedule)) - meeting.set_official_schedule(schedule) - - #debug.log("2 meeting.schedule: %s" % (meeting.schedule)) - meeting.save() - return meeting_get(request, meeting) - -def meeting_json(request, num): - meeting = get_meeting(num) - - if request.method == 'GET': - return meeting_get(request, meeting) - elif request.method == 'POST': - return meeting_update(request, meeting) - else: - return HttpResponse(status=406) - - -############################################################################# -## Session details API functions -############################################################################# - -def session_json(request, num, sessionid): - meeting = get_meeting(num) - - try: - session = meeting.session_set.get(pk=int(sessionid)) - except Session.DoesNotExist: -# return json.dumps({'error':"no such session %s" % sessionid}) - return HttpResponse(json.dumps({'error':"no such session %s" % sessionid}), - status = 404, - content_type="application/json") - - sess1 = session.json_dict(request.build_absolute_uri('/')) - return HttpResponse(json.dumps(sess1, sort_keys=True, indent=2), - content_type="application/json") - -# get group of all sessions. -def sessions_json(request, num): - meeting = get_meeting(num) - - sessions = meeting.session_set.that_can_meet().with_requested_time().with_requested_by() - - sess1_dict = [ x.json_dict(request.build_absolute_uri('/')) for x in sessions ] - return HttpResponse(json.dumps(sess1_dict, sort_keys=True, indent=2), - content_type="application/json") - -############################################################################# -## Scheduledsesion -############################################################################# - -# this creates an entirely *NEW* schedtimesessassignment -def assignments_post(request, meeting, schedule): - cansee,canedit,secretariat = schedule_permissions(meeting, schedule, request.user) - if not canedit: - return HttpResponse(json.dumps({'error':'no permission to modify this schedule'}), - status = 403, - content_type="application/json") - - # get JSON out of raw body. XXX should check Content-Type! - newvalues = json.loads(request.body) - if not ("session_id" in newvalues) or not ("timeslot_id" in newvalues): - return HttpResponse(json.dumps({'error':'missing values, timeslot_id and session_id required'}), - status = 406, - content_type="application/json") - - try: - Session.objects.get(pk=newvalues["session_id"]) - except Session.DoesNotExist: - return HttpResponse(json.dumps({'error':'session has been deleted'}), - status = 406, - content_type="application/json") - - ss1 = SchedTimeSessAssignment(schedule = schedule, - session_id = newvalues["session_id"], - timeslot_id = newvalues["timeslot_id"]) - if("extendedfrom_id" in newvalues): - val = int(newvalues["extendedfrom_id"]) - try: - ss2 = schedule.assignments.get(pk = val) - ss1.extendedfrom = ss2 - except SchedTimeSessAssignment.DoesNotExist: - return HttpResponse(json.dumps({'error':'invalid extendedfrom value: %u' % val}), - status = 406, - content_type="application/json") - ss1.save() - ss1_dict = ss1.json_dict(request.build_absolute_uri('/')) - response = HttpResponse(json.dumps(ss1_dict), - status = 201, - content_type="application/json") - # 201 code needs a Location: header. - response['Location'] = ss1_dict["href"], - return response - -def assignments_get(request, num, schedule): - assignments = schedule.assignments.all() - - absolute_url = request.build_absolute_uri('/') - sess1_dict = [ x.json_dict(absolute_url) for x in assignments ] - return HttpResponse(json.dumps(sess1_dict, sort_keys=True, indent=2), - content_type="application/json") - -# this returns the list of scheduled sessions for the given named schedule -def assignments_json(request, num, owner, name): - info = get_meeting_schedule(num, owner, name) - # The return values from get_meeting_schedule() are silly, in that it - # is a tuple for non-error return, but a HTTPResponse when error, but - # work around that for the moment - if isinstance(info, HttpResponse): - return info - meeting, person, schedule = info - - if request.method == 'GET': - return assignments_get(request, meeting, schedule) - elif request.method == 'POST': - return assignments_post(request, meeting, schedule) - else: - return HttpResponse(json.dumps({'error':'inappropriate action: %s' % (request.method)}), - status = 406, - content_type="application/json") - -# accepts both POST and PUT in order to implement Postel Doctrine. -def assignment_update(request, meeting, schedule, ss): - cansee,canedit,secretariat = schedule_permissions(meeting, schedule, request.user) - if not canedit: - return HttpResponse(json.dumps({'error':'no permission to update this schedule'}), - status = 403, - content_type="application/json") - - if request.method == 'POST': - put_vars = request.POST - ss.pinned = is_truthy_enough(put_vars["pinned"]) - else: - put_vars = QueryDict(request.body) - ss.pinned = is_truthy_enough(put_vars.get("pinned")) - - ss.save() - return HttpResponse(json.dumps({'message':'valid'}), - content_type="application/json") - -def assignment_delete(request, meeting, schedule, ss): - cansee,canedit,secretariat = schedule_permissions(meeting, schedule, request.user) - if not canedit: - return HttpResponse(json.dumps({'error':'no permission to update this schedule'}), - status = 403, - content_type="application/json") - - # in case there is, somehow, more than one item with the same pk.. XXX - assignments = schedule.assignments.filter(pk = ss.pk) - if len(assignments) == 0: - return HttpResponse(json.dumps({'error':'no such object'}), - status = 404, - content_type="application/json") - count=0 - for ss in assignments: - ss.delete() - count += 1 - - return HttpResponse(json.dumps({'result':"%u objects deleted"%(count)}), - status = 200, - content_type="application/json") - -def assignment_get(request, meeting, schedule, ss): - cansee,canedit,secretariat = schedule_permissions(meeting, schedule, request.user) - - if not cansee: - return HttpResponse(json.dumps({'error':'no permission to see this schedule'}), - status = 403, - content_type="application/json") - - sess1_dict = ss.json_dict(request.build_absolute_uri('/')) - return HttpResponse(json.dumps(sess1_dict, sort_keys=True, indent=2), - content_type="application/json") - -# this return a specific session, updates a session or deletes a SPECIFIC scheduled session -def assignment_json(request, num, owner, name, assignment_id): - meeting, person, schedule = get_meeting_schedule(num, owner, name) - - assignments = schedule.assignments.filter(pk = assignment_id) - if len(assignments) == 0: - return HttpResponse(json.dumps({'error' : 'invalid assignment'}), - content_type="application/json", - status=404); - ss = assignments[0] - - if request.method == 'GET': - return assignment_get(request, meeting, schedule, ss) - elif request.method == 'PUT' or request.method=='POST': - return assignment_update(request, meeting, schedule, ss) - elif request.method == 'DELETE': - return assignment_delete(request, meeting, schedule, ss) - -############################################################################# -## Constraints API -############################################################################# - - -# Would like to cache for 1 day, but there are invalidation issues. -#@cache_page(86400) -def constraint_json(request, num, constraintid): - meeting = get_meeting(num) - - try: - constraint = meeting.constraint_set.get(pk=int(constraintid)) - except Constraint.DoesNotExist: - return HttpResponse(json.dumps({'error':"no such constraint %s" % constraintid}), - status = 404, - content_type="application/json") - - json1 = constraint.json_dict(request.build_absolute_uri('/')) - return HttpResponse(json.dumps(json1, sort_keys=True, indent=2), - content_type="application/json") - - -# Cache for 2 hour2 -#@cache_page(7200) -# caching is a problem if there Host: header changes. -# -def session_constraints(request, num, sessionid): - meeting = get_meeting(num) - - #print "Getting meeting=%s session contraints for %s" % (num, sessionid) - try: - session = meeting.session_set.get(pk=int(sessionid)) - except Session.DoesNotExist: - return json.dumps({"error":"no such session"}) - - constraint_list = session.constraints_dict(request.build_absolute_uri('/')) - - json_str = json.dumps(constraint_list, - sort_keys=True, indent=2), - #print " gives: %s" % (json_str) - - return HttpResponse(json_str, content_type="application/json") - - - diff --git a/ietf/meeting/factories.py b/ietf/meeting/factories.py index 729525745..a227b405a 100644 --- a/ietf/meeting/factories.py +++ b/ietf/meeting/factories.py @@ -12,7 +12,8 @@ from django.db.models import Q from ietf.meeting.models import (Meeting, Session, SchedulingEvent, Schedule, TimeSlot, SessionPresentation, FloorPlan, Room, SlideSubmission, Constraint, MeetingHost, ProceedingsMaterial) -from ietf.name.models import ConstraintName, SessionStatusName, ProceedingsMaterialTypeName, TimerangeName +from ietf.name.models import (ConstraintName, SessionStatusName, ProceedingsMaterialTypeName, + TimerangeName, SessionPurposeName) from ietf.doc.factories import ProceedingsMaterialDocFactory from ietf.group.factories import GroupFactory from ietf.person.factories import PersonFactory @@ -104,9 +105,11 @@ class SessionFactory(factory.django.DjangoModelFactory): model = Session meeting = factory.SubFactory(MeetingFactory) - type_id='regular' + purpose_id = 'regular' + type_id = 'regular' group = factory.SubFactory(GroupFactory) requested_duration = datetime.timedelta(hours=1) + on_agenda = factory.lazy_attribute(lambda obj: SessionPurposeName.objects.get(pk=obj.purpose_id).on_agenda) @factory.post_generation def status_id(obj, create, extracted, **kwargs): @@ -128,7 +131,7 @@ class SessionFactory(factory.django.DjangoModelFactory): status=SessionStatusName.objects.get(slug=extracted), by=PersonFactory(), ) - + @factory.post_generation def add_to_schedule(obj, create, extracted, **kwargs): # pylint: disable=no-self-argument ''' diff --git a/ietf/meeting/fields.py b/ietf/meeting/fields.py new file mode 100644 index 000000000..8f126d55e --- /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 # pyflakes: ignore + +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..9305e7c0b 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) @@ -253,6 +257,7 @@ class InterimSessionModelForm(forms.ModelForm): session = super(InterimSessionModelForm, self).save(commit=False) session.group = self.group session.type_id = 'regular' + session.purpose_id = 'regular' if kwargs.get('commit', True) is True: super(InterimSessionModelForm, self).save(commit=True) return session @@ -417,3 +422,258 @@ 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): + def __init__(self, durations=None, *args, **kwargs): + if durations is None: + durations = (3600, 7200) + super().__init__( + 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 isinstance(value, datetime.timedelta) else str(value) + return '' + + 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): + 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) + 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 + fields = ( + 'name', 'short', 'purpose', 'type', 'requested_duration', + 'on_agenda', 'remote_instructions', 'attendees', 'comments', + ) + labels = {'requested_duration': 'Length'} + + def clean(self): + super().clean() + if 'purpose' in self.cleaned_data and ( + 'purpose' in self.changed_data or self.instance.pk is None + ): + self.cleaned_data['on_agenda'] = self.cleaned_data['purpose'].on_agenda + + return self.cleaned_data + + class Media: + js = ('ietf/js/meeting/session_details_form.js',) + + +class SessionEditForm(SessionDetailsForm): + """Form to edit an existing session""" + def __init__(self, instance, *args, **kwargs): + kw_group = kwargs.pop('group', None) + if kw_group is not None and kw_group != instance.group: + raise ValueError('Session group does not match group keyword') + super().__init__(instance=instance, group=instance.group, *args, **kwargs) + + +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 + 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) + 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] + +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 + ) diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py index 508caccea..1a7a3d5c2 100644 --- a/ietf/meeting/helpers.py +++ b/ietf/meeting/helpers.py @@ -9,13 +9,11 @@ import os import re from tempfile import mkstemp -from django.http import HttpRequest, Http404 -from django.db.models import F, Max, Q, Prefetch +from django.http import Http404 +from django.db.models import F, Prefetch from django.conf import settings from django.contrib.auth.models import AnonymousUser -from django.core.cache import cache from django.urls import reverse -from django.utils.cache import get_cache_key from django.shortcuts import get_object_or_404 from django.template.loader import render_to_string @@ -30,93 +28,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.utils.history import find_history_active_at, find_history_replacements_active_at +from ietf.name.models import ImportantDateName, SessionPurposeName +from ietf.utils import log +from ietf.utils.history import 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 = [] - meeting_time = datetime.datetime.combine(meeting.date, datetime.time(0, 0, 0)) - - num = 0 - # get list of ADs which are/were active at the time of the meeting. - # (previous [x for x in y] syntax changed to aid debugging) - for g in Group.objects.filter(type="area").order_by("acronym"): - history = find_history_active_at(g, meeting_time) - num = num +1 - if history and history != g: - #print " history[%u]: %s" % (num, history) - if history.state_id == "active": - for x in history.rolehistory_set.filter(name="ad",group__type='area').select_related('group', 'person', 'email'): - #print "xh[%u]: %s" % (num, x) - ads.append(x) - else: - #print " group[%u]: %s" % (num, g) - if g.state_id == "active": - for x in g.role_set.filter(name="ad",group__type='area').select_related('group', 'person', 'email'): - #print "xg[%u]: %s (#%u)" % (num, x, x.pk) - ads.append(x) - return ads - - -# get list of all areas, + IRTF + IETF (plenaries). -def get_pseudo_areas(): - return Group.objects.filter(Q(state="active", name="IRTF")| - Q(state="active", name="IETF")| - Q(state="active", type="area")).order_by('acronym') - -# get list of all areas, + IRTF. -def get_areas(): - return Group.objects.filter(Q(state="active", - name="IRTF")| - Q(state="active", type="area")).order_by('acronym') - -# get list of areas that are referenced. -def get_area_list_from_sessions(assignments, num): - return assignments.filter(timeslot__type = 'regular', - session__group__parent__isnull = False).order_by( - 'session__group__parent__acronym').distinct().values_list( - 'session__group__parent__acronym',flat=True) - -def build_all_agenda_slices(meeting): - time_slices = [] - date_slices = {} - - for ts in meeting.timeslot_set.filter(type__in=['regular',]).order_by('time','name'): - ymd = ts.time.date() - - if ymd not in date_slices and ts.location != None: - date_slices[ymd] = [] - time_slices.append(ymd) - - if ymd in date_slices: - if [ts.time, ts.time+ts.duration] not in date_slices[ymd]: # only keep unique entries - date_slices[ymd].append([ts.time, ts.time+ts.duration]) - - time_slices.sort() - return time_slices,date_slices - -def get_all_assignments_from_schedule(schedule): - ss = schedule.assignments.filter(timeslot__location__isnull = False) - ss = ss.filter(session__type__slug='regular') - ss = ss.order_by('timeslot__time','timeslot__name') - - return ss - -def get_modified_from_assignments(assignments): - return assignments.aggregate(Max('timeslot__modified'))['timeslot__modified__max'] - -def get_wg_name_list(assignments): - return assignments.filter(timeslot__type = 'regular', - session__group__isnull = False, - session__group__parent__isnull = False).order_by( - 'session__group__acronym').distinct().values_list( - 'session__group__acronym',flat=True) - -def get_wg_list(assignments): - wg_name_list = get_wg_name_list(assignments) - return Group.objects.filter(acronym__in = set(wg_name_list)).order_by('parent__acronym','acronym') def get_meeting(num=None,type_in=['ietf',],days=28): meetings = Meeting.objects @@ -229,7 +147,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 +171,463 @@ 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='none').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',) + # 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 + if self._use_legacy_keywords(): + self.extra_labels += ('Plenary',) # need this when not using session purpose + + 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(sessions) + + # Not using legacy version + filter_cols = [] + for purpose in self.filterable_purposes: + if purpose.slug == 'regular': + continue + + # 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, sessions): + """Get list of non-group filters for older meetings + + Returns a list of filter columns + """ + office_hours_items = set() + suffix = ' office hours' + for s in sessions: + if s.name.lower().endswith(suffix): + office_hours_items.add((s.name[:-len(suffix)].strip(), s.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 = 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""" + extra = [] + if session.type_id == 'plenary': + extra.append('plenary') + office_hours_match = re.match(r'^ *\w+(?: +\w+)* +office hours *$', session.name, re.IGNORECASE) + if office_hours_match is not None: + suffix = 'officehours' + extra.extend([ + 'officehours', + session.name.lower().replace(' ', '')[:-len(suffix)] + '-officehours', + ]) + return extra + + 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 @@ -387,15 +714,6 @@ def schedule_permissions(meeting, schedule, user): return cansee, canedit, secretariat -def session_constraint_expire(request,session): - from .ajax import session_constraints - path = reverse(session_constraints, args=[session.meeting.number, session.pk]) - temp_request = HttpRequest() - temp_request.path = path - temp_request.META['HTTP_HOST'] = request.META['HTTP_HOST'] - key = get_cache_key(temp_request) - if key is not None and key in cache: - cache.delete(key) # ------------------------------------------------- # Interim Meeting Helpers 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 diff --git a/ietf/meeting/migrations/0049_session_purpose.py b/ietf/meeting/migrations/0049_session_purpose.py new file mode 100644 index 000000000..50e8305bf --- /dev/null +++ b/ietf/meeting/migrations/0049_session_purpose.py @@ -0,0 +1,22 @@ +# 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', '0035_populate_sessionpurposename'), + ('meeting', '0048_auto_20211008_0907'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='purpose', + field=ietf.utils.models.ForeignKey(default='none', help_text='Purpose of the session', on_delete=django.db.models.deletion.CASCADE, to='name.SessionPurposeName'), + preserve_default=False, + ), + ] diff --git a/ietf/meeting/migrations/0050_session_on_agenda.py b/ietf/meeting/migrations/0050_session_on_agenda.py new file mode 100644 index 000000000..15f1885ae --- /dev/null +++ b/ietf/meeting/migrations/0050_session_on_agenda.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-10-22 06:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('meeting', '0049_session_purpose'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='on_agenda', + field=models.BooleanField(default=True, help_text='Is this session visible on the meeting agenda?'), + ), + ] diff --git a/ietf/meeting/migrations/0051_populate_session_on_agenda.py b/ietf/meeting/migrations/0051_populate_session_on_agenda.py new file mode 100644 index 000000000..b63942072 --- /dev/null +++ b/ietf/meeting/migrations/0051_populate_session_on_agenda.py @@ -0,0 +1,39 @@ +# Generated by Django 2.2.24 on 2021-10-22 06:58 + +from django.db import migrations, models + + +def forward(apps, schema_editor): + Session = apps.get_model('meeting', 'Session') + SchedTimeSessAssignment = apps.get_model('meeting', 'SchedTimeSessAssignment') + # find official assignments that are to private timeslots and fill in session.on_agenda + private_assignments = SchedTimeSessAssignment.objects.filter( + models.Q( + schedule=models.F('session__meeting__schedule') + ) | models.Q( + schedule=models.F('session__meeting__schedule__base') + ), + timeslot__type__private=True, + ) + for pa in private_assignments: + pa.session.on_agenda = False + pa.session.save() + # Also update any sessions to match their purpose's default setting (this intentionally + # overrides the timeslot settings above, but that is unlikely to matter because the + # purposes will roll out at the same time as the on_agenda field) + Session.objects.filter(purpose__on_agenda=False).update(on_agenda=False) + Session.objects.filter(purpose__on_agenda=True).update(on_agenda=True) + +def reverse(apps, schema_editor): + Session = apps.get_model('meeting', 'Session') + Session.objects.update(on_agenda=True) # restore all to default on_agenda=True state + +class Migration(migrations.Migration): + + dependencies = [ + ('meeting', '0050_session_on_agenda'), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index 24874817b..7abe1b1c6 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -14,7 +14,6 @@ import string from collections import namedtuple from pathlib import Path -from urllib.parse import urljoin import debug # pyflakes:ignore @@ -23,9 +22,7 @@ from django.db import models from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q from django.db.models.functions import Coalesce 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 +33,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 @@ -308,36 +306,17 @@ class Meeting(models.Model): slugs = ('conflict', 'conflic2', 'conflic3') return ConstraintName.objects.filter(slug__in=slugs) - def json_url(self): - return "/meeting/%s/json" % (self.number, ) - def base_url(self): return "/meeting/%s" % (self.number, ) - def json_dict(self, host_scheme): - # unfortunately, using the datetime aware json encoder seems impossible, - # so the dates are formatted as strings here. - agenda_url = "" - if self.schedule: - agenda_url = urljoin(host_scheme, self.schedule.base_url()) - return { - 'href': urljoin(host_scheme, self.json_url()), - 'name': self.number, - 'submission_start_date': fmt_date(self.get_submission_start_date()), - 'submission_cut_off_date': fmt_date(self.get_submission_cut_off_date()), - 'submission_correction_date': fmt_date(self.get_submission_correction_date()), - 'date': fmt_date(self.date), - 'agenda_href': agenda_url, - 'city': self.city, - 'country': self.country, - 'time_zone': self.time_zone, - 'venue_name': self.venue_name, - 'venue_addr': self.venue_addr, - 'break_area': self.break_area, - 'reg_area': self.reg_area - } - 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 +338,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 @@ -428,13 +408,6 @@ class ResourceAssociation(models.Model): def __str__(self): return self.desc - def json_dict(self, host_scheme): - res1 = dict() - res1['name'] = self.name.slug - res1['icon'] = "/images/%s" % (self.icon) - res1['desc'] = self.desc - res1['resource_id'] = self.pk - return res1 class Room(models.Model): meeting = ForeignKey(Meeting) @@ -477,16 +450,19 @@ class Room(models.Model): def dom_id(self): return "room%u" % (self.pk) - def json_url(self): - return "/meeting/%s/room/%s.json" % (self.meeting.number, self.id) - - def json_dict(self, host_scheme): - return { - 'href': urljoin(host_scheme, self.json_url()), - 'name': self.name, - '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): @@ -678,29 +654,8 @@ class TimeSlot(models.Model): dom_id = self.location.dom_id() return "%s_%s_%s" % (dom_id, self.time.strftime('%Y-%m-%d'), self.time.strftime('%H%M')) - def json_dict(self, host_scheme): - ts = dict() - ts['timeslot_id'] = self.id - ts['href'] = urljoin(host_scheme, self.json_url()) - ts['room'] = self.get_location() - ts['roomtype'] = self.type.slug - if self.location is not None: - ts['capacity'] = self.location.capacity - ts["time"] = date_format(self.time, 'Hi') - ts["date"] = fmt_date(self.time) - ts["domid"] = self.js_identifier - following = self.slot_to_the_right - if following is not None: - ts["following_timeslot_id"] = following.id - return ts - - def json_url(self): - return "/meeting/%s/timeslot/%s.json" % (self.meeting.number, self.id) - - """ - This routine deletes all timeslots which are in the same time as this slot. - """ def delete_concurrent_timeslots(self): + """Delete all timeslots which are in the same time as this slot""" # can not include duration in filter, because there is no support # for having it a WHERE clause. # below will delete self as well. @@ -804,25 +759,6 @@ class Schedule(models.Model): def delete_assignments(self): self.assignments.all().delete() - def json_url(self): - return "%s.json" % self.base_url() - - def json_dict(self, host_scheme): - sch = dict() - sch['schedule_id'] = self.id - sch['href'] = urljoin(host_scheme, self.json_url()) - if self.visible: - sch['visible'] = "visible" - else: - sch['visible'] = "hidden" - if self.public: - sch['public'] = "public" - else: - sch['public'] = "private" - sch['owner'] = urljoin(host_scheme, self.owner.json_url()) - # should include href to list of assignments, but they have no direct API yet. - return sch - @property def qs_assignments_with_sessions(self): return self.assignments.filter(session__isnull=False) @@ -878,39 +814,13 @@ class SchedTimeSessAssignment(models.Model): else: return None - def json_url(self): - if not hasattr(self, '_cached_json_url'): - self._cached_json_url = "/meeting/%s/agenda/%s/%s/session/%u.json" % ( - self.schedule.meeting.number, - self.schedule.owner_email(), - self.schedule.name, self.id ) - return self._cached_json_url + def meeting(self): + """Get the meeting to which this assignment belongs""" + return self.session.meeting - def json_dict(self, host_scheme): - if not hasattr(self, '_cached_json_dict'): - ss = dict() - ss['assignment_id'] = self.id - ss['href'] = urljoin(host_scheme, self.json_url()) - ss['timeslot_id'] = self.timeslot.id - - efset = self.session.timeslotassignments.filter(schedule=self.schedule).order_by("timeslot__time") - if efset.count() > 1: - # now we know that there is some work to do finding the extendedfrom_id. - # loop through the list of items - previous = None - for efss in efset: - if efss.pk == self.pk: - extendedfrom = previous - break - previous = efss - if extendedfrom is not None: - ss['extendedfrom_id'] = extendedfrom.id - - if self.session: - ss['session_id'] = self.session.id - ss["pinned"] = self.pinned - self._cached_json_dict = ss - return self._cached_json_dict + def slot_type(self): + """Get the TimeSlotTypeName that applies to this assignment""" + return self.timeslot.type def slug(self): """Return sensible id string for session, e.g. suitable for use as HTML anchor.""" @@ -928,12 +838,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 @@ -1003,30 +913,6 @@ class Constraint(models.Model): elif not self.target and self.person: return "%s " % (self.person) - def json_url(self): - return "/meeting/%s/constraint/%s.json" % (self.meeting.number, self.id) - - def json_dict(self, host_scheme): - ct1 = dict() - ct1['constraint_id'] = self.id - ct1['href'] = urljoin(host_scheme, self.json_url()) - ct1['name'] = self.name.slug - if self.person is not None: - ct1['person_href'] = urljoin(host_scheme, self.person.json_url()) - if self.source is not None: - ct1['source_href'] = urljoin(host_scheme, self.source.json_url()) - if self.target is not None: - ct1['target_href'] = urljoin(host_scheme, self.target.json_url()) - ct1['meeting_href'] = urljoin(host_scheme, self.meeting.json_url()) - if self.time_relation: - ct1['time_relation'] = self.time_relation - ct1['time_relation_display'] = self.get_time_relation_display() - if self.timeranges.count(): - ct1['timeranges_cant_meet'] = [t.slug for t in self.timeranges.all()] - timeranges_str = ", ".join([t.desc for t in self.timeranges.all()]) - ct1['timeranges_display'] = "Can't meet %s" % timeranges_str - return ct1 - class SessionPresentation(models.Model): session = ForeignKey('Session') @@ -1098,6 +984,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 @@ -1109,6 +1002,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 @@ -1120,6 +1018,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=False, 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) @@ -1130,6 +1029,7 @@ class Session(models.Model): scheduled = models.DateTimeField(null=True, blank=True) modified = models.DateTimeField(auto_now=True) remote_instructions = models.CharField(blank=True,max_length=1024) + on_agenda = models.BooleanField(default=True, help_text='Is this session visible on the meeting agenda?') tombstone_for = models.ForeignKey('Session', blank=True, null=True, help_text="This session is the tombstone for a session that was rescheduled", on_delete=models.CASCADE) @@ -1323,92 +1223,10 @@ class Session(models.Model): def official_timeslotassignment(self): return self.timeslotassignments.filter(schedule__in=[self.meeting.schedule, self.meeting.schedule.base if self.meeting.schedule else None]).first() - def constraints_dict(self, host_scheme): - constraint_list = [] - for constraint in self.constraints(): - ct1 = constraint.json_dict(host_scheme) - constraint_list.append(ct1) - - for constraint in self.reverse_constraints(): - ct1 = constraint.json_dict(host_scheme) - constraint_list.append(ct1) - return constraint_list - @property def people_constraints(self): return self.group.constraint_source_set.filter(meeting=self.meeting, name='bethere') - def json_url(self): - return "/meeting/%s/session/%s.json" % (self.meeting.number, self.id) - - def json_dict(self, host_scheme): - sess1 = dict() - sess1['href'] = urljoin(host_scheme, self.json_url()) - if self.group is not None: - sess1['group'] = self.group.json_dict(host_scheme) - sess1['group_href'] = urljoin(host_scheme, self.group.json_url()) - if self.group.parent is not None: - sess1['area'] = self.group.parent.acronym.upper() - sess1['description'] = self.group.name - sess1['group_id'] = str(self.group.pk) - reslist = [] - for r in self.resources.all(): - reslist.append(r.json_dict(host_scheme)) - sess1['resources'] = reslist - sess1['session_id'] = str(self.pk) - sess1['name'] = self.name - sess1['title'] = self.short_name - sess1['short_name'] = self.short_name - sess1['bof'] = str(self.group.is_bof()) - sess1['agenda_note'] = self.agenda_note - sess1['attendees'] = str(self.attendees) - sess1['joint_with_groups'] = self.joint_with_groups_acronyms() - - # fish out scheduling information - eventually, we should pick - # this out in the caller instead - latest_event = None - first_event = None - - if self.pk is not None: - if not hasattr(self, 'current_status') or not hasattr(self, 'requested_time'): - events = list(SchedulingEvent.objects.filter(session=self.pk).order_by('time', 'id')) - if events: - first_event = events[0] - latest_event = events[-1] - - status_id = None - if hasattr(self, 'current_status'): - status_id = self.current_status - elif latest_event: - status_id = latest_event.status_id - - sess1['status'] = SessionStatusName.objects.get(slug=status_id).name if status_id else None - if self.comments is not None: - sess1['comments'] = self.comments - - requested_time = None - if hasattr(self, 'requested_time'): - requested_time = self.requested_time - elif first_event: - requested_time = first_event.time - sess1['requested_time'] = requested_time.strftime("%Y-%m-%d") if requested_time else None - - - requested_by = None - if hasattr(self, 'requested_by'): - requested_by = self.requested_by - elif first_event: - requested_by = first_event.by_id - - if requested_by is not None: - requested_by_person = Person.objects.filter(pk=requested_by).first() - if requested_by_person: - sess1['requested_by'] = str(requested_by_person) - - sess1['requested_duration']= "%.1f" % (float(self.requested_duration.seconds) / 3600) - sess1['special_request'] = str(self.special_request_token) - return sess1 - def agenda_text(self): doc = self.agenda() if doc: diff --git a/ietf/meeting/templatetags/agenda_custom_tags.py b/ietf/meeting/templatetags/agenda_custom_tags.py index 30b18e0f3..0bdd9d222 100644 --- a/ietf/meeting/templatetags/agenda_custom_tags.py +++ b/ietf/meeting/templatetags/agenda_custom_tags.py @@ -67,4 +67,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/test_data.py b/ietf/meeting/test_data.py index d7d21bd27..e5fdd71c5 100644 --- a/ietf/meeting/test_data.py +++ b/ietf/meeting/test_data.py @@ -11,8 +11,9 @@ import debug # pyflakes:ignore from ietf.doc.factories import DocumentFactory from ietf.group.factories import GroupFactory, RoleFactory -from ietf.group.models import Group -from ietf.meeting.models import (Meeting, Room, TimeSlot, Session, Schedule, SchedTimeSessAssignment, +from ietf.group.models import Group +from ietf.meeting.factories import SessionFactory +from ietf.meeting.models import (Meeting, Room, TimeSlot, Schedule, SchedTimeSessAssignment, ResourceAssociation, SessionPresentation, UrlResource, SchedulingEvent) from ietf.meeting.helpers import create_interim_meeting from ietf.name.models import RoomResourceName @@ -24,11 +25,11 @@ def make_interim_meeting(group,date,status='sched'): system_person = Person.objects.get(name="(System)") time = datetime.datetime.combine(date, datetime.time(9)) meeting = create_interim_meeting(group=group,date=date) - session = Session.objects.create(meeting=meeting, group=group, - attendees=10, - requested_duration=datetime.timedelta(minutes=20), - remote_instructions='http://webex.com', - type_id='regular') + session = SessionFactory(meeting=meeting, group=group, + attendees=10, + requested_duration=datetime.timedelta(minutes=20), + remote_instructions='http://webex.com', + add_to_schedule=False) SchedulingEvent.objects.create(session=session, status_id=status, by=system_person) slot = TimeSlot.objects.create( meeting=meeting, @@ -121,52 +122,52 @@ def make_meeting_test_data(meeting=None, create_interims=False): time=datetime.datetime.combine(session_date, datetime.time(11,0))) # mars WG mars = Group.objects.get(acronym='mars') - mars_session = Session.objects.create(meeting=meeting, group=mars, - attendees=10, requested_duration=datetime.timedelta(minutes=50), - type_id='regular') + mars_session = SessionFactory(meeting=meeting, group=mars, + attendees=10, requested_duration=datetime.timedelta(minutes=50), + add_to_schedule=False) SchedulingEvent.objects.create(session=mars_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=slot1, session=mars_session, schedule=schedule) SchedTimeSessAssignment.objects.create(timeslot=slot2, session=mars_session, schedule=unofficial_schedule) # ames WG - ames_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="ames"), - attendees=10, - requested_duration=datetime.timedelta(minutes=60), - type_id='regular') + ames_session = SessionFactory(meeting=meeting, group=Group.objects.get(acronym="ames"), + attendees=10, + requested_duration=datetime.timedelta(minutes=60), + add_to_schedule=False) SchedulingEvent.objects.create(session=ames_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=slot2, session=ames_session, schedule=schedule) SchedTimeSessAssignment.objects.create(timeslot=slot1, session=ames_session, schedule=unofficial_schedule) # IESG breakfast - iesg_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="iesg"), - name="IESG Breakfast", attendees=25, - requested_duration=datetime.timedelta(minutes=60), - type_id="lead") + iesg_session = SessionFactory(meeting=meeting, group=Group.objects.get(acronym="iesg"), + name="IESG Breakfast", attendees=25, + requested_duration=datetime.timedelta(minutes=60), + type_id="lead", purpose_id='closed_meeting', add_to_schedule=False) SchedulingEvent.objects.create(session=iesg_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=breakfast_slot, session=iesg_session, schedule=schedule) # No breakfast on unofficial schedule # Registration - reg_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="secretariat"), - name="Registration", attendees=250, - requested_duration=datetime.timedelta(minutes=480), - type_id="reg") + reg_session = SessionFactory(meeting=meeting, group=Group.objects.get(acronym="secretariat"), + name="Registration", attendees=250, + requested_duration=datetime.timedelta(minutes=480), + type_id="reg", purpose_id='admin', add_to_schedule=False) SchedulingEvent.objects.create(session=reg_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=reg_slot, session=reg_session, schedule=base_schedule) # Break - break_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="secretariat"), - name="Morning Break", attendees=250, - requested_duration=datetime.timedelta(minutes=30), - type_id="break") + break_session = SessionFactory(meeting=meeting, group=Group.objects.get(acronym="secretariat"), + name="Morning Break", attendees=250, + requested_duration=datetime.timedelta(minutes=30), + type_id="break", purpose_id='social', add_to_schedule=False) SchedulingEvent.objects.create(session=break_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=break_slot, session=break_session, schedule=base_schedule) # IETF Plenary - plenary_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="ietf"), - name="IETF Plenary", attendees=250, - requested_duration=datetime.timedelta(minutes=60), - type_id="plenary") + plenary_session = SessionFactory(meeting=meeting, group=Group.objects.get(acronym="ietf"), + name="IETF Plenary", attendees=250, + requested_duration=datetime.timedelta(minutes=60), + type_id="plenary", purpose_id='plenary', add_to_schedule=False) SchedulingEvent.objects.create(session=plenary_session, status_id='schedw', by=system_person) SchedTimeSessAssignment.objects.create(timeslot=plenary_slot, session=plenary_session, schedule=schedule) diff --git a/ietf/meeting/tests_api.py b/ietf/meeting/tests_api.py deleted file mode 100644 index c7d010dc5..000000000 --- a/ietf/meeting/tests_api.py +++ /dev/null @@ -1,501 +0,0 @@ -# Copyright The IETF Trust 2013-2020, All Rights Reserved -# -*- coding: utf-8 -*- - - -import datetime - -from urllib.parse import urlsplit - -from django.urls import reverse as urlreverse - -import debug # pyflakes:ignore - -from ietf.name.models import TimerangeName -from ietf.group.models import Group -from ietf.meeting.models import Schedule, TimeSlot, Session, SchedTimeSessAssignment, Meeting, Constraint -from ietf.meeting.test_data import make_meeting_test_data -from ietf.person.models import Person -from ietf.utils.test_utils import TestCase -from ietf.utils.mail import outbox - - -class ApiTests(TestCase): - def test_update_schedule(self): - meeting = make_meeting_test_data() - schedule = Schedule.objects.get(meeting__number=72,name="test-schedule") - mars_session = Session.objects.filter(meeting=meeting, group__acronym="mars").first() - ames_session = Session.objects.filter(meeting=meeting, group__acronym="ames").first() - - mars_scheduled = SchedTimeSessAssignment.objects.get(session=mars_session,schedule__name='test-schedule') - mars_slot = mars_scheduled.timeslot - - ames_scheduled = SchedTimeSessAssignment.objects.get(session=ames_session,schedule__name='test-schedule') - ames_slot = ames_scheduled.timeslot - - def do_unschedule(assignment): - url = urlreverse("ietf.meeting.ajax.assignment_json", - kwargs=dict(num=assignment.session.meeting.number, - owner=assignment.schedule.owner_email(), - name=assignment.schedule.name, - assignment_id=assignment.pk,)) - return self.client.delete(url) - - def do_schedule(schedule,session,timeslot): - url = urlreverse("ietf.meeting.ajax.assignments_json", - kwargs=dict(num=session.meeting.number, - owner=schedule.owner_email(), - name=schedule.name,)) - post_data = '{ "session_id": "%s", "timeslot_id": "%s" }'%(session.pk,timeslot.pk) - return self.client.post(url,post_data,content_type='application/x-www-form-urlencoded') - - def do_extend(schedule, assignment): - session = assignment.session - url = urlreverse("ietf.meeting.ajax.assignments_json", - kwargs=dict(num=session.meeting.number, - owner=schedule.owner_email(), - name=schedule.name,)) - post_data = '{ "session_id": "%s", "timeslot_id": "%s", "extendedfrom_id": "%s" }'%(session.pk,assignment.timeslot.slot_to_the_right.pk,assignment.pk) - - - return self.client.post(url,post_data,content_type='application/x-www-form-urlencoded') - - # not logged in - # faulty delete - r = do_unschedule(mars_scheduled) - self.assertEqual(r.status_code, 403) - self.assertEqual(SchedTimeSessAssignment.objects.get(pk=mars_scheduled.pk).session, mars_session) - # faulty post - r = do_schedule(schedule,ames_session,mars_slot) - self.assertEqual(r.status_code, 403) - - # logged in as non-owner - # faulty delete - self.client.login(username="ad", password="ad+password") - r = do_unschedule(mars_scheduled) - self.assertEqual(r.status_code, 403) - self.assertTrue("error" in r.json()) - # faulty post - r = do_schedule(schedule,ames_session,mars_slot) - self.assertEqual(r.status_code, 403) - - # Put ames in the same timeslot as mars - self.client.login(username="plain", password='plain+password') - r = do_unschedule(ames_scheduled) - self.assertEqual(r.status_code, 200) - self.assertNotIn("error", r.json()) - - r = do_schedule(schedule,ames_session,mars_slot) - self.assertEqual(r.status_code, 201) - - # Move the two timeslots close enough together for extension to work - ames_slot_qs=TimeSlot.objects.filter(id=ames_slot.id) - ames_slot_qs.update(time=mars_slot.time+mars_slot.duration+datetime.timedelta(minutes=10)) - - # Extend the mars session - r = do_extend(schedule,mars_scheduled) - self.assertEqual(r.status_code, 201) - self.assertTrue("error" not in r.json()) - self.assertEqual(mars_session.timeslotassignments.filter(schedule__name='test-schedule').count(),2) - - # Unschedule mars - r = do_unschedule(mars_scheduled) - self.assertEqual(r.status_code, 200) - self.assertNotIn("error", r.json()) - # Make sure it got both the original and extended session - self.assertEqual(mars_session.timeslotassignments.filter(schedule__name='test-schedule').count(),0) - - self.assertEqual(SchedTimeSessAssignment.objects.get(session=ames_session,schedule__name='test-schedule').timeslot, mars_slot) - - - def test_constraints_json(self): - meeting = make_meeting_test_data() - session = Session.objects.filter(meeting=meeting, group__acronym="mars").select_related("group").first() - c_ames = Constraint.objects.create(meeting=meeting, source=session.group, - target=Group.objects.get(acronym="ames"), - name_id="conflict") - - c_person = Constraint.objects.create(meeting=meeting, source=session.group, - person=Person.objects.get(user__username="ad"), - name_id="bethere") - - c_adjacent = Constraint.objects.create(meeting=meeting, source=session.group, - target=Group.objects.get(acronym="irg"), - name_id="wg_adjacent") - - c_time_relation = Constraint.objects.create(meeting=meeting, source=session.group, - time_relation='subsequent-days', - name_id="time_relation") - - c_timerange = Constraint.objects.create(meeting=meeting, source=session.group, - name_id="timerange") - c_timerange.timeranges.set(TimerangeName.objects.filter(slug__startswith='monday')) - - r = self.client.get(urlreverse("ietf.meeting.ajax.session_constraints", kwargs=dict(num=meeting.number, sessionid=session.pk))) - self.assertEqual(r.status_code, 200) - constraints = r.json() - expected_keys = set([c_ames.pk, c_person.pk, c_adjacent.pk, c_time_relation.pk, c_timerange.pk]) - self.assertEqual(expected_keys, set(c["constraint_id"] for c in constraints)) - - def test_meeting_json(self): - meeting = make_meeting_test_data() - - r = self.client.get(urlreverse("ietf.meeting.ajax.meeting_json", kwargs=dict(num=meeting.number))) - self.assertEqual(r.status_code, 200) - info = r.json() - self.assertEqual(info["name"], meeting.number) - - def test_get_room_json(self): - meeting = make_meeting_test_data() - room = meeting.room_set.first() - - r = self.client.get(urlreverse("ietf.meeting.ajax.timeslot_roomurl", kwargs=dict(num=meeting.number, roomid=room.pk))) - self.assertEqual(r.status_code, 200) - info = r.json() - self.assertEqual(info["name"], room.name) - - def test_create_new_room(self): - meeting = make_meeting_test_data() - timeslots_before = meeting.timeslot_set.filter(type='regular').count() - url = urlreverse("ietf.meeting.ajax.timeslot_roomsurl", kwargs=dict(num=meeting.number)) - - post_data = { "name": "new room", "capacity": "50" , "resources": [], "session_types":['regular']} - - # unauthorized post - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 302) - self.assertTrue(not meeting.room_set.filter(name="new room")) - - # create room - self.client.login(username="secretary", password="secretary+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 302) - self.assertTrue(meeting.room_set.filter(name="new room")) - - timeslots_after = meeting.timeslot_set.filter(type='regular').count() - # It's not clear that what that ajax function is doing is the right thing to do, - # but it currently makes a new timeslot for any existing timeslot. - # The condition tested below relies on the timeslots before this test all having different start and end times - self.assertEqual( timeslots_after, 2 * timeslots_before) - - def test_delete_room(self): - meeting = make_meeting_test_data() - room = meeting.room_set.first() - timeslots_before = list(room.timeslot_set.values_list("pk", flat=True)) - - url = urlreverse("ietf.meeting.ajax.timeslot_roomurl", kwargs=dict(num=meeting.number, roomid=room.pk)) - - # unauthorized delete - r = self.client.delete(url) - self.assertEqual(r.status_code, 302) - self.assertTrue(meeting.room_set.filter(pk=room.pk)) - - # delete - self.client.login(username="secretary", password="secretary+password") - r = self.client.delete(url) - self.assertTrue(not meeting.room_set.filter(pk=room.pk)) - self.assertTrue(not TimeSlot.objects.filter(pk__in=timeslots_before)) - - # This really belongs in group tests - def test_group_json(self): - make_meeting_test_data() - group = Group.objects.get(acronym="mars") - - url = urlreverse("ietf.group.views.group_json", kwargs=dict(acronym=group.acronym)) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - info = r.json() - self.assertEqual(info["name"], group.name) - - # This really belongs in person tests - def test_person_json(self): - make_meeting_test_data() - person = Person.objects.get(user__username="ad") - - url = urlreverse("ietf.person.ajax.person_json", kwargs=dict(personid=person.pk)) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - info = r.json() - self.assertEqual(info["name"], person.name) - - def test_sessions_json(self): - meeting = make_meeting_test_data() - - url = urlreverse("ietf.meeting.ajax.sessions_json",kwargs=dict(num=meeting.number)) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - info = r.json() - self.assertEqual(set([x['short_name'] for x in info]),set([s.session.short_name for s in meeting.schedule.assignments.filter(session__type_id='regular')])) - - schedule = meeting.schedule - url = urlreverse("ietf.meeting.ajax.assignments_json", - kwargs=dict(num=meeting.number,owner=schedule.owner_email(),name=schedule.name)) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - info = r.json() - self.assertEqual(len(info),schedule.assignments.count()) - - - def test_slot_json(self): - meeting = make_meeting_test_data() - slot = meeting.timeslot_set.all()[0] - - url = urlreverse("ietf.meeting.ajax.timeslot_sloturl", - kwargs=dict(num=meeting.number, slotid=slot.pk)) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - info = r.json() - self.assertEqual(info["timeslot_id"], slot.pk) - - def test_create_new_slot(self): - meeting = make_meeting_test_data() - - slot_time = datetime.date.today() - - url = urlreverse("ietf.meeting.ajax.timeslot_slotsurl", - kwargs=dict(num=meeting.number)) - post_data = { - 'type' : 'plenary', - 'time' : slot_time.strftime("%Y-%m-%d"), - 'duration': '08:00:00', - } - - # unauthorized post - prior_slotcount = meeting.timeslot_set.count() - self.client.login(username="ad", password="ad+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 403) - self.assertEqual(meeting.timeslot_set.count(),prior_slotcount) - - # create slot - self.client.login(username="secretary", password="secretary+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 201) - self.assertTrue(meeting.timeslot_set.filter(time=slot_time)) - self.assertEqual(meeting.timeslot_set.count(),prior_slotcount+1) - - def test_delete_slot(self): - meeting = make_meeting_test_data() - slot = meeting.timeslot_set.all()[0] - - url = urlreverse("ietf.meeting.ajax.timeslot_sloturl", - kwargs=dict(num=meeting.number, slotid=slot.pk)) - - # unauthorized delete - self.client.login(username="ad", password="ad+password") - r = self.client.delete(url) - self.assertEqual(r.status_code, 403) - - # delete - self.client.login(username="secretary", password="secretary+password") - self.client.delete(url) - self.assertTrue(not meeting.timeslot_set.filter(pk=slot.pk)) - - def test_schedule_json(self): - meeting = make_meeting_test_data() - - url = urlreverse("ietf.meeting.ajax.schedule_infourl", - kwargs=dict(num=meeting.number, - owner=meeting.schedule.owner_email(), - name=meeting.schedule.name)) - - r = self.client.get(url) - info = r.json() - self.assertEqual(info["schedule_id"], meeting.schedule.pk) - - def test_create_new_schedule(self): - meeting = make_meeting_test_data() - - url = urlreverse("ietf.meeting.ajax.schedule_infosurl", - kwargs=dict(num=meeting.number)) - post_data = { - 'name': 'new-schedule', - } - - # unauthorized post - self.client.login(username="plain", password="plain+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 403) - self.assertTrue(not meeting.schedule_set.filter(name='new-schedule')) - - # create new schedule - self.client.login(username="secretary", password="secretary+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 302) - self.assertTrue(meeting.schedule_set.filter(name='new-schedule')) - - def test_update_meeting_schedule(self): - meeting = make_meeting_test_data() - - self.assertTrue(meeting.schedule.visible) - - url = urlreverse("ietf.meeting.ajax.schedule_infourl", - kwargs=dict(num=meeting.number, - owner=meeting.schedule.owner_email(), - name=meeting.schedule.name)) - - post_data = { - 'visible': 'false', - 'name': 'new-test-name', - } - - # unauthorized posts - self.client.logout() - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 403) - self.client.login(username="ad", password="ad+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 403) - - # change schedule - self.client.login(username="secretary", password="secretary+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 302) - changed_schedule = Schedule.objects.get(pk=meeting.schedule.pk) - self.assertTrue(not changed_schedule.visible) - self.assertEqual(changed_schedule.name, "new-test-name") - - def test_delete_schedule(self): - meeting = make_meeting_test_data() - - url = urlreverse("ietf.meeting.ajax.schedule_infourl", - kwargs=dict(num=meeting.number, - owner=meeting.schedule.owner_email(), - name=meeting.schedule.name)) - # unauthorized delete - self.client.login(username="plain", password="plain+password") - r = self.client.delete(url) - self.assertEqual(r.status_code, 403) - - # delete - self.client.login(username="secretary", password="secretary+password") - r = self.client.delete(url) - self.assertEqual(r.status_code, 200) - self.assertTrue(not Schedule.objects.filter(pk=meeting.schedule.pk)) - - def test_set_meeting_schedule(self): - meeting = make_meeting_test_data() - schedule = meeting.schedule - - url = urlreverse("ietf.meeting.ajax.meeting_json", - kwargs=dict(num=meeting.number)) - post_data = { - "schedule": "", - } - # unauthorized post - self.client.login(username="ad", password="ad+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 403) - - # clear - self.client.login(username="secretary", password="secretary+password") - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 200) - self.assertTrue(not Meeting.objects.get(pk=meeting.pk).schedule) - - # set schedule - first fail with non-public - post_data = { - "schedule": schedule.name, - } - schedule.public = False - schedule.save() - - r = self.client.post(url, post_data) - self.assertTrue(r.status_code != 200) - self.assertTrue(not Meeting.objects.get(pk=meeting.pk).schedule) - - # then go through with public - schedule.public = True - schedule.save() - - # Setting a meeting as official no longer sends mail immediately - prior_length= len(outbox) - r = self.client.post(url, post_data) - self.assertEqual(r.status_code, 200) - self.assertEqual(Meeting.objects.get(pk=meeting.pk).schedule, schedule) - self.assertEqual(len(outbox),prior_length) - - def test_read_only(self): - meeting = make_meeting_test_data() - - # Secretariat - self.client.login(username="secretary", password="secretary+password") - url = '/meeting/%s/agenda/%s/%s/permissions' % (meeting.number, meeting.schedule.owner.email_address(), meeting.schedule.name); - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - - info = r.json() - self.assertEqual(info['secretariat'], True) - self.assertEqual(urlsplit(info['owner_href'])[2], "/person/%s.json" % meeting.schedule.owner_id) - self.assertEqual(info['read_only'], True) - self.assertEqual(info['save_perm'], True) - - # owner - self.client.login(username=meeting.schedule.owner.user.username, - password=meeting.schedule.owner.user.username+"+password") - url = '/meeting/%s/agenda/%s/%s/permissions' % (meeting.number, meeting.schedule.owner.email_address(), meeting.schedule.name); - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - - info = r.json() - self.assertEqual(info['secretariat'], False) - self.assertEqual(info['read_only'], False) - self.assertEqual(info['save_perm'], False) - - def test_update_timeslot_pinned(self): - meeting = make_meeting_test_data() - scheduled = SchedTimeSessAssignment.objects.filter( - session__meeting=meeting, session__group__acronym="mars").first() - - url = '/meeting/%s/agenda/%s/%s/session/%u.json' % (meeting.number, meeting.schedule.owner_email(), meeting.schedule.name, scheduled.pk) - - post_data = { - "pinned": True - } - - # unauthorized post gets failure (no redirect) - r = self.client.put(url, post_data) - self.assertEqual(r.status_code, 403, - "post to %s should have failed, no permission, got: %u/%s" % - (url, r.status_code, r.content)) - self.assertTrue(not SchedTimeSessAssignment.objects.get(pk=scheduled.pk).pinned) - - # set pinned - meeting.schedule.owner = Person.objects.get(user__username="secretary") - meeting.schedule.save() - - # need to rebuild URL, since the schedule owner has changed. - url = '/meeting/%s/agenda/%s/%s/session/%u.json' % (meeting.number, meeting.schedule.owner_email(), meeting.schedule.name, scheduled.pk) - - self.client.login(username="secretary", password="secretary+password") - r = self.client.put(url, post_data) - self.assertEqual(r.status_code, 200, - "post to %s should have worked, but got: %u/%s" % - (url, r.status_code, r.content)) - self.assertTrue(SchedTimeSessAssignment.objects.get(pk=scheduled.pk).pinned) - -class TimeSlotEditingApiTests(TestCase): - - def test_manipulate_timeslot(self): - meeting = make_meeting_test_data() - slot = meeting.timeslot_set.filter(type_id='regular')[0] - - url = urlreverse("ietf.meeting.ajax.timeslot_sloturl", - kwargs=dict(num=meeting.number, slotid=slot.pk)) - - modify_post_data = { - "purpose" : "plenary" - } - - # Fail as non-secretariat - self.client.login(username="plain", password="plain+password") - r = self.client.post(url, modify_post_data) - self.assertEqual(r.status_code, 403) - slot.refresh_from_db() - self.assertEqual(slot.type_id, 'regular') - - # Successful change of purpose - self.client.login(username="secretary", password="secretary+password") - r = self.client.post(url, modify_post_data) - self.assertEqual(r.status_code, 200) - slot.refresh_from_db() - self.assertEqual(slot.type_id, 'plenary') diff --git a/ietf/meeting/tests_helpers.py b/ietf/meeting/tests_helpers.py index 1e7705c5e..269d785fb 100644 --- a/ietf/meeting/tests_helpers.py +++ b/ietf/meeting/tests_helpers.py @@ -1,100 +1,335 @@ # Copyright The IETF Trust 2020, All Rights Reserved # -*- coding: utf-8 -*- + +from django.conf import settings +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.models import SchedTimeSessAssignment +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'] + # 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 - for assignment in assignments: - expected_filter_keywords = {assignment.timeslot.type.slug, assignment.session.type.slug} - - if assignment.session == office_hours: - expected_filter_keywords.update([ - group.parent.acronym, - 'officehours', - 'someofficehours', - ]) - else: - expected_filter_keywords.update([ + # create sessions, etc + session_data = [ + { + 'description': 'regular wg session', + 'session': SessionFactory( + group=group, meeting=meeting, add_to_schedule=False, + purpose_id='none' if legacy_keywords else 'regular', + type_id='regular', + ), + 'expected_keywords': { expected_group.acronym, - expected_area.acronym - ]) - if bof: - expected_filter_keywords.add('bof') - token = assignment.session.docname_token_only_for_multiple() - if token is not None: - expected_filter_keywords.update([expected_group.acronym + "-" + token]) + expected_area.acronym, + # if legacy_keywords, next line repeats a previous entry to avoid adding anything to the set + expected_group.acronym if legacy_keywords else 'regular', + f'{expected_group.acronym}-sessa', + }, + }, + { + 'description': 'plenary session', + 'session': SessionFactory( + group=group, meeting=meeting, add_to_schedule=False, + name=f'{group.acronym} plenary', + purpose_id='none' if legacy_keywords else 'plenary', + type_id='plenary', + ), + 'expected_keywords': { + expected_group.acronym, + expected_area.acronym, + f'{expected_group.acronym}-sessb', + 'plenary', + f'{group.acronym}-plenary', + }, + }, + { + 'description': 'office hours session', + 'session': SessionFactory( + group=group, meeting=meeting, add_to_schedule=False, + name=f'{group.acronym} office hours', + purpose_id='none' if legacy_keywords else 'officehours', + type_id='other', + ), + 'expected_keywords': { + expected_group.acronym, + expected_area.acronym, + f'{expected_group.acronym}-sessc', + 'officehours', + f'{group.acronym}-officehours' if legacy_keywords else 'officehours', + # officehours in prev line is a repeated value - since this is a set, it will be ignored + f'{group.acronym}-office-hours', + }, + } + ] + for sd in session_data: + sd['session'].timeslotassignments.create( + timeslot=TimeSlotFactory(meeting=meeting, type=sd['session'].type), + schedule=meeting.schedule, + ) + assignments = meeting.schedule.assignments.all() + + # Set up historic groups if needed. + if historic: + for a in assignments: + a.session.historic_group = expected_group + + # Execute the method under test + AgendaKeywordTagger(assignments=assignments).apply() + + # Assert expected results + + # check the assignment count - paranoid, but the method mutates its input so let's be careful + self.assertEqual(len(assignments), len(session_data), 'Should not change number of assignments') + + assignment_by_session_pk = {a.session.pk: a for a in assignments} + for sd in session_data: + assignment = assignment_by_session_pk[sd['session'].pk] + expected_filter_keywords = sd['expected_keywords'] + if bof: + expected_filter_keywords.add('bof') self.assertCountEqual( assignment.filter_keywords, expected_filter_keywords, - 'Assignment has incorrect filter keywords' + f'Assignment for "{sd["description"]}" has incorrect filter keywords' ) + @override_settings(MEETING_LEGACY_OFFICE_HOURS_END=111) 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') + + + @override_settings(MEETING_LEGACY_OFFICE_HOURS_END=111) + 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): + self.do_get_filter_categories_test(False) + + def test_get_legacy_filter_categories(self): + self.do_get_filter_categories_test(True) + + def do_get_filter_categories_test(self, legacy): + # set up + meeting = make_meeting_test_data() + if legacy: + meeting.session_set.all().update(purpose_id='none') # legacy meetings did not have purposes + else: + meeting.number = str(settings.MEETING_LEGACY_OFFICE_HOURS_END + 1) + meeting.save() + + # 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'), + purpose_id='officehours' if not legacy else 'none', + type_id='other', + name='FARFUT office hours', + meeting=meeting + ) + + if legacy: + expected = [ + [ + # area category + {'label': 'FARFUT', 'keyword': 'farfut', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'ames', 'keyword': 'ames', 'is_bof': False, 'toggled_by': ['farfut']}, + {'label': 'mars', 'keyword': 'mars', 'is_bof': False, 'toggled_by': ['farfut']}, + ]}, + ], + [ + # non-area category + {'label': 'IAB', 'keyword': 'iab', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': iab_child.acronym, 'keyword': iab_child.acronym, 'is_bof': False, 'toggled_by': ['iab']}, + ]}, + {'label': 'IRTF', 'keyword': 'irtf', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': irtf_child.acronym, 'keyword': irtf_child.acronym, 'is_bof': True, 'toggled_by': ['bof', 'irtf']}, + ]}, + ], + [ + # non-group category + {'label': 'Office Hours', 'keyword': 'officehours', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'FARFUT', 'keyword': 'farfut-officehours', 'is_bof': False, 'toggled_by': ['officehours', 'farfut']} + ]}, + {'label': None, 'keyword': None,'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'BoF', 'keyword': 'bof', 'is_bof': False, 'toggled_by': []}, + {'label': 'Plenary', 'keyword': 'plenary', 'is_bof': False, 'toggled_by': []}, + ]}, + ], + ] + else: + expected = [ + [ + # area category + {'label': 'FARFUT', 'keyword': 'farfut', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'ames', 'keyword': 'ames', 'is_bof': False, 'toggled_by': ['farfut']}, + {'label': 'mars', 'keyword': 'mars', 'is_bof': False, 'toggled_by': ['farfut']}, + ]}, + ], + [ + # non-area category + {'label': 'IAB', 'keyword': 'iab', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': iab_child.acronym, 'keyword': iab_child.acronym, 'is_bof': False, 'toggled_by': ['iab']}, + ]}, + {'label': 'IRTF', 'keyword': 'irtf', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': irtf_child.acronym, 'keyword': irtf_child.acronym, 'is_bof': True, 'toggled_by': ['bof', 'irtf']}, + ]}, + ], + [ + # non-group category + {'label': 'Administrative', 'keyword': 'admin', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'Registration', 'keyword': 'registration', 'is_bof': False, 'toggled_by': ['admin', 'secretariat']}, + ]}, + {'label': 'Closed meeting', 'keyword': 'closed_meeting', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'IESG Breakfast', 'keyword': 'iesg-breakfast', 'is_bof': False, 'toggled_by': ['closed_meeting', 'iesg']}, + ]}, + {'label': 'Office hours', 'keyword': 'officehours', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'FARFUT office hours', 'keyword': 'farfut-office-hours', 'is_bof': False, 'toggled_by': ['officehours', 'farfut']} + ]}, + {'label': 'Plenary', 'keyword': 'plenary', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'IETF Plenary', 'keyword': 'ietf-plenary', 'is_bof': False, 'toggled_by': ['plenary', 'ietf']}, + ]}, + {'label': 'Social', 'keyword': 'social', 'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'Morning Break', 'keyword': 'morning-break', 'is_bof': False, 'toggled_by': ['social', 'secretariat']}, + ]}, + {'label': None, 'keyword': None,'is_bof': False, 'toggled_by': [], + 'children': [ + {'label': 'BoF', 'keyword': 'bof', 'is_bof': False, 'toggled_by': []}, + ]}, + ], + ] + # put all the above together for single-column tests + expected_single_category = [sum(expected, [])] + + ### + # 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) + + # single-column + filter_organizer = AgendaFilterOrganizer(sessions=sessions, single_category=True) + self.assertEqual(filter_organizer.get_filter_categories(), expected_single_category) + + ### + # test again using assignments + assignments = SchedTimeSessAssignment.objects.filter( + schedule__in=(meeting.schedule, meeting.schedule.base) + ) + 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 39c2a3c56..b7b038988 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -89,7 +89,13 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): s2.save() SchedTimeSessAssignment.objects.filter(session=s1).delete() - s2b = Session.objects.create(meeting=meeting, group=s2.group, attendees=10, requested_duration=datetime.timedelta(minutes=60), type_id='regular') + s2b = SessionFactory( + meeting=meeting, + group=s2.group, + attendees=10, + requested_duration=datetime.timedelta(minutes=60), + add_to_schedule=False, + ) SchedulingEvent.objects.create( session=s2b, @@ -110,7 +116,7 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): WebDriverWait(self.driver, 2).until(expected_conditions.presence_of_element_located((By.CSS_SELECTOR, '.edit-meeting-schedule'))) - self.assertEqual(len(self.driver.find_elements(By.CSS_SELECTOR, '.session')), 3) + self.assertEqual(len(self.driver.find_elements(By.CSS_SELECTOR, '.session.purpose-regular')), 3) # select - show session info s2_element = self.driver.find_element(By.CSS_SELECTOR, '#session{}'.format(s2.pk)) @@ -167,7 +173,7 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): sorted_pks = [s.pk for s in sorted([s1, s2, s2b], key=lambda s: (s.requested_duration, s.group.parent.acronym, s.group.acronym, s.pk))] self.driver.find_element(By.CSS_SELECTOR, '[name=sort_unassigned] option[value=duration]').click() - self.assertTrue(self.driver.find_element(By.CSS_SELECTOR, '.unassigned-sessions .drop-target #session{} + #session{}'.format(*sorted_pks))) + self.assertTrue(self.driver.find_element(By.CSS_SELECTOR, '.unassigned-sessions .drop-target #session{} ~ #session{}'.format(*sorted_pks))) sorted_pks = [s.pk for s in sorted([s1, s2, s2b], key=lambda s: (int(bool(s.comments)), s.group.parent.acronym, s.group.acronym, s.requested_duration, s.pk))] self.driver.find_element(By.CSS_SELECTOR, '[name=sort_unassigned] option[value=comments]').click() @@ -258,7 +264,7 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): 'Session should be selectable when parent enabled') # hide timeslots - self.driver.find_element(By.CSS_SELECTOR, ".timeslot-group-toggles button").click() + self.driver.find_element(By.CSS_SELECTOR, "#timeslot-toggle-modal-open").click() self.assertTrue(self.driver.find_element(By.CSS_SELECTOR, "#timeslot-group-toggles-modal").is_displayed()) self.driver.find_element(By.CSS_SELECTOR, "#timeslot-group-toggles-modal [value=\"{}\"]".format("ts-group-{}-{}".format(slot2.time.strftime("%Y%m%d-%H%M"), int(slot2.duration.total_seconds() / 60)))).click() self.driver.find_element(By.CSS_SELECTOR, "#timeslot-group-toggles-modal [data-dismiss=\"modal\"]").click() @@ -760,18 +766,8 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): all of the events needed by the editor. """ # Set up a meeting and a schedule a plain user can edit - meeting = make_meeting_test_data() - schedule = Schedule.objects.filter(meeting=meeting, owner__user__username="plain").first() - sessions = meeting.session_set.filter(type_id='regular') - timeslots = meeting.timeslot_set.filter(type_id='regular') - self.assertGreaterEqual(timeslots.count(), sessions.count(), - 'Need a timeslot for each session') - for index, session in enumerate(sessions): - SchedTimeSessAssignment.objects.create( - schedule=schedule, - timeslot=timeslots[index], - session=session, - ) + schedule = ScheduleFactory(meeting__type_id='ietf', owner__user__username="plain") + meeting = schedule.meeting # Open the editor self.login() @@ -780,7 +776,6 @@ class EditMeetingScheduleTests(IetfSeleniumTestCase): kwargs=dict(num=meeting.number, name=schedule.name, owner=schedule.owner_email()) ) self.driver.get(url) - # Check that the drop target for unassigned sessions is actually empty drop_target = self.driver.find_element(By.CSS_SELECTOR, '.unassigned-sessions .drop-target' @@ -859,7 +854,7 @@ class ScheduleEditTests(IetfSeleniumTestCase): ss = list(SchedTimeSessAssignment.objects.filter(session__meeting__number=72,session__group__acronym='mars',schedule__name='test-schedule')) # pyflakes:ignore self.login() - url = self.absreverse('ietf.meeting.views.edit_schedule',kwargs=dict(num='72',name='test-schedule',owner='plain@example.com')) + url = self.absreverse('ietf.meeting.views.edit_meeting_schedule',kwargs=dict(num='72',name='test-schedule',owner='plain@example.com')) self.driver.get(url) # driver.get() will wait for scripts to finish, but not ajax @@ -1197,7 +1192,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 +1303,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 +1475,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 +1732,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 +1758,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 +1825,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 +1850,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 @@ -2005,6 +2016,7 @@ class InterimTests(IetfSeleniumTestCase): sg_interim = make_interim_meeting(somegroup, datetime.date.today() + datetime.timedelta(days=20)) sg_sess = sg_interim.session_set.first() sg_slot = sg_sess.timeslotassignments.first().timeslot + sg_sess.purpose_id = 'plenary' sg_sess.type_id = 'plenary' sg_slot.type_id = 'plenary' sg_sess.save() @@ -2580,6 +2592,166 @@ class ProceedingsMaterialTests(IetfSeleniumTestCase): 'URL field should be shown by default') +@ifSeleniumEnabled +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 @@ -2599,5 +2771,5 @@ class ProceedingsMaterialTests(IetfSeleniumTestCase): # make_meeting_test_data() # # def testOpenSchedule(self): -# url = urlreverse('ietf.meeting.views.edit_schedule', kwargs=dict(num='72',name='test-schedule')) +# url = urlreverse('ietf.meeting.views.edit_meeting_schedule', kwargs=dict(num='72',name='test-schedule')) # r = self.client.get(url) \ No newline at end of file diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index 0a0137484..450c7e1e4 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 @@ -315,12 +315,12 @@ class MeetingTests(BaseMeetingTestCase): parent=iab, list_email="venus@ietf.org", ) - venus_session = Session.objects.create( + venus_session = SessionFactory( meeting=meeting, group=venus, attendees=10, requested_duration=datetime.timedelta(minutes=60), - type_id='regular', + add_to_schedule=False, ) system_person = Person.objects.get(name="(System)") SchedulingEvent.objects.create(session=venus_session, status_id='schedw', by=system_person) @@ -423,7 +423,7 @@ class MeetingTests(BaseMeetingTestCase): q = PyQuery(r.content) for assignment in SchedTimeSessAssignment.objects.filter( schedule__in=[meeting.schedule, meeting.schedule.base], - timeslot__type__private=False, + session__on_agenda=True, ): row = q('#row-{}'.format(assignment.slug())) self.assertIsNotNone(row, 'No row for assignment {}'.format(assignment)) @@ -431,9 +431,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): @@ -714,10 +717,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): @@ -771,7 +777,7 @@ class MeetingTests(BaseMeetingTestCase): ) self.do_ical_filter_test( meeting, - querystring='?show=plenary,secretariat,ames&hide=reg', + querystring='?show=plenary,secretariat,ames&hide=admin', expected_session_summaries=[ 'Morning Break', 'IETF Plenary', @@ -1672,6 +1678,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): @@ -1998,6 +3046,7 @@ class ReorderSlidesTests(TestCase): class EditTests(TestCase): + """Test schedule edit operations""" def setUp(self): super().setUp() # make sure we have the colors of the area @@ -2006,19 +3055,12 @@ class EditTests(TestCase): fg_group_colors[area_upper] = "#333" bg_group_colors[area_upper] = "#aaa" - def test_edit_schedule(self): - meeting = make_meeting_test_data() - - self.client.login(username="secretary", password="secretary+password") - r = self.client.get(urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number))) - self.assertContains(r, "load_assignments") - def test_official_record_schedule_is_read_only(self): def _set_date_offset_and_retrieve_page(meeting, days_offset, client): 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) @@ -2102,9 +3144,9 @@ class EditTests(TestCase): timeslots = list(TimeSlot.objects.filter(meeting=meeting, type='regular').order_by('time')) - base_session = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym="irg"), - attendees=20, requested_duration=datetime.timedelta(minutes=30), - type_id='regular') + base_session = SessionFactory(meeting=meeting, group=Group.objects.get(acronym="irg"), + attendees=20, requested_duration=datetime.timedelta(minutes=30), + add_to_schedule=False) SchedulingEvent.objects.create(session=base_session, status_id='schedw', by=Person.objects.get(user__username='secretary')) SchedTimeSessAssignment.objects.create(timeslot=base_timeslot, session=base_session, schedule=meeting.schedule.base) @@ -2413,7 +3455,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', @@ -2540,8 +3582,8 @@ class EditTests(TestCase): self.assertEqual(tostring(s2_constraints[1][0]), conf_label) # [0][0] is the innermost def test_new_meeting_schedule(self): + """Can create a meeting schedule from scratch""" meeting = make_meeting_test_data() - self.client.login(username="secretary", password="secretary+password") # new from scratch @@ -2565,7 +3607,11 @@ class EditTests(TestCase): self.assertEqual(new_schedule.origin, None) self.assertEqual(new_schedule.base_id, meeting.schedule.base_id) - # copy + def test_copy_meeting_schedule(self): + """Can create a copy of an existing meeting schedule""" + meeting = make_meeting_test_data() + self.client.login(username="secretary", password="secretary+password") + url = urlreverse("ietf.meeting.views.new_meeting_schedule", kwargs=dict(num=meeting.number, owner=meeting.schedule.owner_email(), name=meeting.schedule.name)) r = self.client.get(url) self.assertEqual(r.status_code, 200) @@ -2589,34 +3635,21 @@ class EditTests(TestCase): for a in SchedTimeSessAssignment.objects.filter(schedule=new_schedule): self.assertIn((a.session_id, a.timeslot_id), old_assignments) - def test_save_agenda_as_and_read_permissions(self): + def test_schedule_read_permissions(self): meeting = make_meeting_test_data() + schedule = meeting.schedule # try to get non-existing agenda - url = urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number, - owner=meeting.schedule.owner_email(), - name="foo")) + url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, + owner=schedule.owner_email(), + name="foo")) r = self.client.get(url) self.assertEqual(r.status_code, 404) - # save as new name (requires valid existing agenda) - url = urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number, - owner=meeting.schedule.owner_email(), - name=meeting.schedule.name)) - self.client.login(username="ad", password="ad+password") - r = self.client.post(url, { - 'savename': "foo", - 'saveas': "saveas", - }) - self.assertEqual(r.status_code, 302) - # Verify that we actually got redirected to a new place. - self.assertNotEqual(urlparse(r.url).path, url) - - # get - schedule = meeting.get_schedule_by_name("foo") - url = urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number, - owner=schedule.owner_email(), - name="foo")) + url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number, + owner=schedule.owner_email(), + name=schedule.name)) + self.client.login(username='ad', password='ad+password') r = self.client.get(url) self.assertEqual(r.status_code, 200) @@ -2643,39 +3676,73 @@ class EditTests(TestCase): r = self.client.get(url) self.assertEqual(r.status_code, 200) - def test_save_agenda_broken_names(self): + def test_new_meeting_schedule_rejects_invalid_names(self): meeting = make_meeting_test_data() - # save as new name (requires valid existing agenda) - url = urlreverse("ietf.meeting.views.edit_schedule", kwargs=dict(num=meeting.number, - owner=meeting.schedule.owner_email(), - name=meeting.schedule.name)) + orig_schedule_count = meeting.schedule_set.count() self.client.login(username="ad", password="ad+password") + url = urlreverse("ietf.meeting.views.new_meeting_schedule", kwargs=dict(num=meeting.number)) r = self.client.post(url, { - 'savename': "/no/this/should/not/work/it/is/too/long", - 'saveas': "saveas", - }) - self.assertEqual(r.status_code, 302) - self.assertEqual(urlparse(r.url).path, url) - # TODO: Verify that an error message was in fact returned. + 'name': "/no/this/should/not/work/it/is/too/long", + 'public': "on", + 'notes': "Name too long", + 'base': meeting.schedule.base_id, + }) + self.assertEqual(r.status_code, 200) + self.assertFormError(r, 'form', 'name', 'Enter a valid value.') + self.assertEqual(meeting.schedule_set.count(), orig_schedule_count, 'Schedule should not be created') r = self.client.post(url, { - 'savename': "/invalid/chars/", - 'saveas': "saveas", - }) - # TODO: Verify that an error message was in fact returned. - self.assertEqual(r.status_code, 302) - self.assertEqual(urlparse(r.url).path, url) + 'name': "/invalid/chars/", + 'public': "on", + 'notes': "Name too long", + 'base': meeting.schedule.base_id, + }) + self.assertEqual(r.status_code, 200) + self.assertFormError(r, 'form', 'name', 'Enter a valid value.') + self.assertEqual(meeting.schedule_set.count(), orig_schedule_count, 'Schedule should not be created') # Non-ASCII alphanumeric characters r = self.client.post(url, { - 'savename': "f\u00E9ling", - 'saveas': "saveas", - }) - # TODO: Verify that an error message was in fact returned. - self.assertEqual(r.status_code, 302) - self.assertEqual(urlparse(r.url).path, url) - + 'name': "f\u00E9ling", + 'public': "on", + 'notes': "Name too long", + 'base': meeting.schedule.base_id, + }) + self.assertEqual(r.status_code, 200) + self.assertFormError(r, 'form', 'name', 'Enter a valid value.') + self.assertEqual(meeting.schedule_set.count(), orig_schedule_count, 'Schedule should not be created') + + def test_edit_session(self): + session = SessionFactory(meeting__type_id='ietf', group__type_id='team') # type determines allowed session purposes + self.client.login(username='secretary', password='secretary+password') + url = urlreverse('ietf.meeting.views.edit_session', kwargs={'session_id': session.pk}) + r = self.client.get(url) + self.assertContains(r, 'Edit session', status_code=200) + r = self.client.post(url, { + 'name': 'this is a name', + 'short': 'tian', + 'purpose': 'coding', + 'type': 'other', + 'requested_duration': '3600', + 'on_agenda': True, + 'remote_instructions': 'Do this do that', + 'attendees': '103', + 'comments': 'So much to say', + }) + self.assertNoFormPostErrors(r) + self.assertRedirects(r, urlreverse('ietf.meeting.views.edit_meeting_schedule', + kwargs={'num': session.meeting.number})) + session = Session.objects.get(pk=session.pk) # refresh objects from DB + self.assertEqual(session.name, 'this is a name') + self.assertEqual(session.short, 'tian') + self.assertEqual(session.purpose_id, 'coding') + self.assertEqual(session.type_id, 'other') + self.assertEqual(session.requested_duration, datetime.timedelta(hours=1)) + self.assertEqual(session.on_agenda, True) + self.assertEqual(session.remote_instructions, 'Do this do that') + self.assertEqual(session.attendees, 103) + self.assertEqual(session.comments, 'So much to say') def test_edit_timeslots(self): meeting = make_meeting_test_data() @@ -2819,9 +3886,9 @@ class EditScheduleListTests(TestCase): session1 = Session.objects.filter(meeting=meeting, group__acronym='mars').first() session2 = Session.objects.filter(meeting=meeting, group__acronym='ames').first() - session3 = Session.objects.create(meeting=meeting, group=Group.objects.get(acronym='mars'), - attendees=10, requested_duration=datetime.timedelta(minutes=70), - type_id='regular') + session3 = SessionFactory(meeting=meeting, group=Group.objects.get(acronym='mars'), + attendees=10, requested_duration=datetime.timedelta(minutes=70), + add_to_schedule=False) SchedulingEvent.objects.create(session=session3, status_id='schedw', by=Person.objects.first()) slot2 = TimeSlot.objects.filter(meeting=meeting, type='regular').order_by('-time').first() @@ -5115,11 +6182,13 @@ class AgendaFilterTests(TestCase): dict( label='child00', keyword='keyword00', + toggled_by=['keyword0'], is_bof=False, ), dict( label='child01', keyword='keyword01', + toggled_by=['keyword0', 'bof'], is_bof=True, ), ]), @@ -5130,11 +6199,13 @@ class AgendaFilterTests(TestCase): dict( label='child10', keyword='keyword10', + toggled_by=['keyword1'], is_bof=False, ), dict( label='child11', keyword='keyword11', + toggled_by=['keyword1', 'bof'], is_bof=True, ), ]), @@ -5147,11 +6218,13 @@ class AgendaFilterTests(TestCase): dict( label='child20', keyword='keyword20', + toggled_by=['keyword2', 'bof'], is_bof=True, ), dict( label='child21', keyword='keyword21', + toggled_by=['keyword2'], is_bof=False, ), ]), @@ -5164,11 +6237,13 @@ class AgendaFilterTests(TestCase): dict( label='child30', keyword='keyword30', + toggled_by=[], is_bof=False, ), dict( label='child31', keyword='keyword31', + toggled_by=['bof'], is_bof=True, ), ]), @@ -5198,7 +6273,6 @@ class AgendaFilterTests(TestCase): _assert_button_ok(header_cells.eq(0)('button.keyword0'), expected_label='area0', expected_filter_item='keyword0') - buttons = button_cells.eq(0)('button.pickview') self.assertEqual(len(buttons), 2) # two children _assert_button_ok(buttons('.keyword00'), diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 1bc18cac6..a051bd953 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -4,7 +4,7 @@ from django.conf.urls import include from django.views.generic import RedirectView from django.conf import settings -from ietf.meeting import views, ajax, views_proceedings +from ietf.meeting import views, views_proceedings from ietf.utils.urls import url safe_for_all_meeting_types = [ @@ -26,8 +26,7 @@ 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, views.edit_meeting_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), url(r'^agenda/%(owner)s/%(schedule_name)s/delete$' % settings.URL_REGEXPS, views.delete_schedule), @@ -38,10 +37,6 @@ type_ietf_only_patterns = [ url(r'^agenda/%(owner)s/%(schedule_name)s/by-room/?$' % settings.URL_REGEXPS, views.agenda_by_room), url(r'^agenda/%(owner)s/%(schedule_name)s/by-type/?$' % settings.URL_REGEXPS, views.agenda_by_type), url(r'^agenda/%(owner)s/%(schedule_name)s/by-type/(?P[a-z]+)$' % settings.URL_REGEXPS, views.agenda_by_type), - url(r'^agenda/%(owner)s/%(schedule_name)s/permissions$' % settings.URL_REGEXPS, ajax.schedule_permission_api), - url(r'^agenda/%(owner)s/%(schedule_name)s/session/(?P\d+).json$' % settings.URL_REGEXPS, ajax.assignment_json), - url(r'^agenda/%(owner)s/%(schedule_name)s/sessions.json$' % settings.URL_REGEXPS, ajax.assignments_json), - url(r'^agenda/%(owner)s/%(schedule_name)s.json$' % settings.URL_REGEXPS, ajax.schedule_infourl), url(r'^agenda/%(owner)s/%(schedule_name)s/new/$' % settings.URL_REGEXPS, views.new_meeting_schedule), url(r'^agenda/by-room$', views.agenda_by_room), url(r'^agenda/by-type$', views.agenda_by_type), @@ -52,22 +47,12 @@ 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), - url(r'^rooms$', ajax.timeslot_roomsurl), - url(r'^room/(?P\d+).json$', ajax.timeslot_roomurl), - url(r'^timeslots$', ajax.timeslot_slotsurl), - url(r'^timeslots.json$', ajax.timeslot_slotsurl), - url(r'^timeslot/(?P\d+).json$', ajax.timeslot_sloturl), - url(r'^agendas$', ajax.schedule_infosurl), - url(r'^agendas.json$', ajax.schedule_infosurl), url(r'^agenda/(?P[-a-z0-9]+)-drafts.pdf$', views.session_draft_pdf), url(r'^agenda/(?P[-a-z0-9]+)-drafts.tgz$', views.session_draft_tarfile), - url(r'^sessions\.json$', ajax.sessions_json), - url(r'^session/(?P\d+).json', ajax.session_json), - url(r'^session/(?P\d+)/constraints.json', ajax.session_constraints), - url(r'^constraint/(?P\d+).json', ajax.constraint_json), - url(r'^json$', ajax.meeting_json), ] # This is a limited subset of the list above -- many of the views above won't work for interim meetings @@ -82,7 +67,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.meeting.views.edit_meeting_schedule'), url(r'^agenda/edit/$', views.edit_meeting_schedule), url(r'^requests$', views.meeting_requests), url(r'^agenda/agenda\.ics$', views.agenda_ical), @@ -140,6 +127,7 @@ urlpatterns = [ url(r'^upcoming\.ics/?$', views.upcoming_ical), url(r'^upcoming\.json/?$', views.upcoming_json), url(r'^session/(?P\d+)/agenda_materials$', views.session_materials), + url(r'^session/(?P\d+)/edit/?', views.edit_session), # Then patterns from more specific to less url(r'^(?Pinterim-[a-z0-9-]+)/', include(type_interim_patterns)), url(r'^(?P\d+)/requests.html$', RedirectView.as_view(url='/meeting/%(num)s/requests', permanent=True)), 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..72161a16b 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -57,16 +57,13 @@ 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.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.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm, + TimeSlotCreateForm, TimeSlotEditForm, SessionEditForm ) +from ietf.meeting.helpers import get_person_by_email, get_schedule_by_name 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,10 +81,10 @@ 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 +from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName from ietf.secr.proceedings.utils import handle_upload_file from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files, create_recording) @@ -279,102 +276,55 @@ def materials_editable_groups(request, num=None): return render(request, "meeting/materials_editable_groups.html", { 'meeting_num': meeting.number}) -def ascii_alphanumeric(string): - return re.match(r'^[a-zA-Z0-9]*$', string) - -class SaveAsForm(forms.Form): - savename = forms.CharField(max_length=16) - -@role_required('Area Director','Secretariat') -def schedule_create(request, num=None, owner=None, name=None): - meeting = get_meeting(num) - person = get_person_by_email(owner) - schedule = get_schedule_by_name(meeting, person, name) - - if schedule is None: - # here we have to return some ajax to display an error. - messages.error("Error: No meeting information for meeting %s owner %s schedule %s available" % (num, owner, name)) # pylint: disable=no-value-for-parameter - return redirect(edit_schedule, num=num, owner=owner, name=name) - - # authorization was enforced by the @group_require decorator above. - - saveasform = SaveAsForm(request.POST) - if not saveasform.is_valid(): - messages.info(request, "This name is not valid. Please choose another one.") - return redirect(edit_schedule, num=num, owner=owner, name=name) - - savedname = saveasform.cleaned_data['savename'] - - if not ascii_alphanumeric(savedname): - messages.info(request, "This name contains illegal characters. Please choose another one.") - return redirect(edit_schedule, num=num, owner=owner, name=name) - - # create the new schedule, and copy the assignments - try: - sched = meeting.schedule_set.get(name=savedname, owner=request.user.person) - if sched: - return redirect(edit_schedule, num=meeting.number, owner=sched.owner_email(), name=sched.name) - else: - messages.info(request, "Schedule creation failed. Please try again.") - return redirect(edit_schedule, num=num, owner=owner, name=name) - - except Schedule.DoesNotExist: - pass - - # must be done - newschedule = Schedule(name=savedname, - owner=request.user.person, - meeting=meeting, - base=schedule.base, - origin=schedule, - visible=False, - public=False) - - newschedule.save() - if newschedule is None: - return HttpResponse(status=500) - - # keep a mapping so that extendedfrom references can be chased. - mapping = {}; - for ss in schedule.assignments.all(): - # hack to copy the object, creating a new one - # just reset the key, and save it again. - oldid = ss.pk - ss.pk = None - ss.schedule=newschedule - ss.save() - mapping[oldid] = ss.pk - #print "Copying %u to %u" % (oldid, ss.pk) - - # now fix up any extendedfrom references to new set. - for ss in newschedule.assignments.all(): - if ss.extendedfrom is not None: - oldid = ss.extendedfrom.id - newid = mapping[oldid] - #print "Fixing %u to %u" % (oldid, newid) - ss.extendedfrom = newschedule.assignments.get(pk = newid) - ss.save() - - - # now redirect to this new schedule. - return redirect(edit_schedule, meeting.number, newschedule.owner_email(), newschedule.name) - @role_required('Secretariat') 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 +332,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): @@ -464,6 +416,19 @@ def new_meeting_schedule(request, num, owner=None, name=None): @ensure_csrf_cookie def edit_meeting_schedule(request, num=None, owner=None, name=None): + """Schedule editor + + In addition to the URL parameters, accepts a query string parameter 'type'. + If present, only sessions/timeslots with a TimeSlotTypeName with that slug + will be included in the editor. More than one type can be enabled by passing + multiple type parameters. + + ?type=regular - shows only regular sessions/timeslots (i.e., old editor behavior) + ?type=regular&type=other - shows both regular and other sessions/timeslots + """ + # 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 @@ -493,11 +458,19 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): "hide_menu": True }, status=403, content_type="text/html") + # See if we were given one or more 'type' query string parameters. If so, filter to that timeslot type. + if 'type' in request.GET: + include_timeslot_types = request.GET.getlist('type') + else: + include_timeslot_types = None # disables filtering by type (other than IGNORE_TIMESLOT_TYPES) + assignments = SchedTimeSessAssignment.objects.filter( schedule__in=[schedule, schedule.base], timeslot__location__isnull=False, - session__type='regular', - ).order_by('timeslot__time','timeslot__name') + ) + if include_timeslot_types is not None: + assignments = assignments.filter(session__type__in=include_timeslot_types) + assignments = assignments.order_by('timeslot__time','timeslot__name') assignments_by_session = defaultdict(list) for a in assignments: @@ -505,10 +478,12 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): tombstone_states = ['canceled', 'canceledpa', 'resched'] + sessions = Session.objects.filter(meeting=meeting) + if include_timeslot_types is not None: + sessions = sessions.filter(type__in=include_timeslot_types) sessions = add_event_info_to_session_qs( - Session.objects.filter( - meeting=meeting, - type='regular', + sessions.exclude( + type__in=IGNORE_TIMESLOT_TYPES, ).order_by('pk'), requested_time=True, requested_by=True, @@ -516,13 +491,22 @@ 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').prefetch_related('type').order_by('location', 'time', 'name') + timeslots_qs = TimeSlot.objects.filter(meeting=meeting) + if include_timeslot_types is not None: + timeslots_qs = timeslots_qs.filter(type__in=include_timeslot_types) + timeslots_qs = timeslots_qs.exclude( + type__in=IGNORE_TIMESLOT_TYPES, + ).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) + if timeslots_qs.count() > 0: + min_duration = min(t.duration for t in timeslots_qs) + max_duration = max(t.duration for t in timeslots_qs) + else: + min_duration = 1 + max_duration = 2 def timedelta_to_css_ems(timedelta): # we scale the session and slots a bit according to their @@ -552,10 +536,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.group: + s.purpose_label = None + if (s.purpose.slug in ('none', 'regular')) 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) @@ -659,7 +647,10 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): all_days = sorted(all_days) # changes set to a list # Note the maximum timeslot count for any room - max_timeslots = max(rd['timeslot_count'] for rd in room_data.values()) + if len(room_data) > 0: + max_timeslots = max(rd['timeslot_count'] for rd in room_data.values()) + else: + max_timeslots = 0 # Partition rooms into groups with identical timeslot arrangements. # Start by discarding any roos that have no timeslots. @@ -872,7 +863,10 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None): return _json_response(False, error="Invalid parameters") # Show only rooms that have regular sessions - rooms = meeting.room_set.filter(session_types__slug='regular') + if include_timeslot_types is None: + rooms = meeting.room_set.all() + else: + rooms = meeting.room_set.filter(session_types__slug__in=include_timeslot_types) # Construct timeslot data for the template to render days = prepare_timeslots_for_display(timeslots_qs, rooms) @@ -942,6 +936,16 @@ 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) + 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, 'schedule': schedule, @@ -952,6 +956,8 @@ 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, + 'timeslot_types': timeslot_types, 'hide_menu': True, 'lock_time': lock_time, }) @@ -968,6 +974,7 @@ class TimeSlotForm(forms.Form): location = RoomNameModelChoiceField(queryset=Room.objects.all(), required=False, empty_label="(No location)") show_location = forms.BooleanField(initial=True, required=False) type = forms.ModelChoiceField(queryset=TimeSlotTypeName.objects.filter(used=True), empty_label=None, required=False) + purpose = forms.ModelChoiceField(queryset=SessionPurposeName.objects.filter(used=True), required=False, widget=forms.HiddenInput) name = forms.CharField(help_text='Name that appears on the agenda', required=False) short = forms.CharField(max_length=32,label='Short name', help_text='Abbreviated session name used for material file names', required=False) group = forms.ModelChoiceField(queryset=Group.objects.filter(type__in=['ietf', 'team'], state='active'), @@ -993,6 +1000,12 @@ class TimeSlotForm(forms.Form): self.active_assignment = None + # only allow timeslots with at least one purpose + timeslot_types_with_purpose = set() + for spn in SessionPurposeName.objects.filter(used=True): + timeslot_types_with_purpose.update(spn.timeslot_types) + self.fields['type'].queryset = self.fields['type'].queryset.filter(pk__in=timeslot_types_with_purpose) + if timeslot: self.initial = { 'day': timeslot.time.date(), @@ -1025,7 +1038,10 @@ class TimeSlotForm(forms.Form): ts_type = self.cleaned_data.get('type') short = self.cleaned_data.get('short') - if ts_type: + if not ts_type: + # assign a generic purpose if no type has been set + self.cleaned_data['purpose'] = SessionPurposeName.objects.get(slug='open_meeting') + else: if ts_type.slug in ['break', 'reg', 'reserved', 'unavail', 'regular']: if ts_type.slug != 'regular': self.cleaned_data['group'] = self.fields['group'].queryset.get(acronym='secretariat') @@ -1035,10 +1051,22 @@ 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': + # find an allowed session purpose (guaranteed by TimeSlotForm) + for purpose in SessionPurposeName.objects.filter(used=True): + if ts_type.pk in purpose.timeslot_types: + self.cleaned_data['purpose'] = purpose + break + if self.cleaned_data['purpose'] is None: + self.add_error('type', f'{ts_type} has no allowed purposes') + + + 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") @@ -1118,6 +1146,7 @@ def edit_meeting_timeslots_and_misc_sessions(request, num=None, owner=None, name short=c['short'], group=c['group'], type=c['type'], + purpose=c['purpose'], agenda_note=c.get('agenda_note') or "", ) @@ -1284,80 +1313,6 @@ def edit_meeting_timeslots_and_misc_sessions(request, num=None, owner=None, name }) -############################################################################## -#@role_required('Area Director','Secretariat') -# disable the above security for now, check it below. -@ensure_csrf_cookie -def edit_schedule(request, num=None, owner=None, name=None): - - if request.method == 'POST': - return schedule_create(request, num, owner, name) - - user = request.user - meeting = get_meeting(num) - person = get_person_by_email(owner) - if name is None: - schedule = meeting.schedule - else: - schedule = get_schedule_by_name(meeting, person, name) - if schedule is None: - raise Http404("No meeting information for meeting %s owner %s schedule %s available" % (num, owner, name)) - - meeting_base_url = request.build_absolute_uri(meeting.base_url()) - site_base_url = request.build_absolute_uri('/')[:-1] # skip the trailing slash - - rooms = meeting.room_set.filter(session_types__slug='regular').distinct().order_by("capacity") - saveas = SaveAsForm() - saveasurl=reverse(edit_schedule, - args=[meeting.number, schedule.owner_email(), schedule.name]) - - can_see, can_edit,secretariat = schedule_permissions(meeting, schedule, user) - - if not can_see: - return render(request, "meeting/private_schedule.html", - {"schedule":schedule, - "meeting": meeting, - "meeting_base_url":meeting_base_url, - "hide_menu": True - }, status=403, content_type="text/html") - - assignments = get_all_assignments_from_schedule(schedule) - - # get_modified_from needs the query set, not the list - modified = get_modified_from_assignments(assignments) - - area_list = get_areas() - wg_name_list = get_wg_name_list(assignments) - wg_list = get_wg_list(wg_name_list) - ads = find_ads_for_meeting(meeting) - for ad in ads: - # set the default to avoid needing extra arguments in templates - # django 1.3+ - ad.default_hostscheme = site_base_url - - time_slices,date_slices = build_all_agenda_slices(meeting) - - return render(request, "meeting/landscape_edit.html", - {"schedule":schedule, - "saveas": saveas, - "saveasurl": saveasurl, - "meeting_base_url": meeting_base_url, - "site_base_url": site_base_url, - "rooms":rooms, - "time_slices":time_slices, - "date_slices":date_slices, - "modified": modified, - "meeting":meeting, - "area_list": area_list, - "area_directors" : ads, - "wg_list": wg_list , - "assignments": assignments, - "show_inline": set(["txt","htm","html"]), - "hide_menu": True, - "can_edit_properties": can_edit or secretariat, - }) - - class SchedulePropertiesForm(forms.ModelForm): class Meta: model = Schedule @@ -1391,7 +1346,7 @@ def edit_schedule_properties(request, num, owner, name): form.save() if request.GET.get('next'): return HttpResponseRedirect(request.GET.get('next')) - return redirect('ietf.meeting.views.edit_schedule', num=num, owner=owner, name=name) + return redirect('ietf.meeting.views.edit_meeting_schedule', num=num, owner=owner, name=name) else: form = SchedulePropertiesForm(meeting, instance=schedule) @@ -1450,6 +1405,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): @@ -1519,114 +1475,10 @@ def get_assignments_for_agenda(schedule): """Get queryset containing assignments to show on the agenda""" return SchedTimeSessAssignment.objects.filter( schedule__in=[schedule, schedule.base], - timeslot__type__private=False, + session__on_agenda=True, ) -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 +1491,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 +1519,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 +1533,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 +1576,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 +1600,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 +1610,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 +1690,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 +1706,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, @@ -1986,10 +1830,10 @@ def week_view(request, num=None, name=None, owner=None): filtered_assignments = SchedTimeSessAssignment.objects.filter( schedule__in=[schedule, schedule.base], - timeslot__type__private=False, + session__on_agenda=True, ) 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 +1842,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 +1852,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": @@ -2169,10 +2013,10 @@ def agenda_ical(request, num=None, name=None, acronym=None, session_id=None): assignments = SchedTimeSessAssignment.objects.filter( schedule__in=[schedule, schedule.base], - timeslot__type__private=False, + session__on_agenda=True, ) 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) @@ -2207,7 +2051,7 @@ def agenda_json(request, num=None): parent_acronyms = set() assignments = SchedTimeSessAssignment.objects.filter( schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None], - timeslot__type__private=False, + session__on_agenda=True, ).exclude( session__type__in=['break', 'reg'] ) @@ -2330,14 +2174,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" @@ -3625,37 +3465,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 +3509,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 +3543,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 +3635,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 +3930,83 @@ 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 edit_session(request, session_id): + session = get_object_or_404(Session, pk=session_id) + if request.method == 'POST': + form = SessionEditForm(instance=session, data=request.POST) + if form.is_valid(): + form.save() + return HttpResponseRedirect( + reverse('ietf.meeting.views.edit_meeting_schedule', + kwargs={'num': form.instance.meeting.number})) + else: + form = SessionEditForm(instance=session) + return render( + request, + 'meeting/edit_session.html', + {'session': session, 'form': form}, + ) @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..dd87e5a74 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, @@ -2619,6 +2620,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"lead\",\n \"delegate\",\n \"matman\"\n]", + "session_purposes": "[\n \"presentation\"\n]", "show_on_agenda": true }, "model": "group.groupfeatures", @@ -2629,6 +2631,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, @@ -2654,6 +2657,7 @@ "parent_types": [], "req_subm_approval": false, "role_order": "[\n \"chair\"\n]", + "session_purposes": "[\n \"closed_meeting\",\n \"officehours\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2664,6 +2668,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, @@ -2692,6 +2697,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"regular\"\n]", "show_on_agenda": true }, "model": "group.groupfeatures", @@ -2702,6 +2708,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, @@ -2729,6 +2736,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"regular\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2739,6 +2747,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, @@ -2766,6 +2775,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"open_meeting\",\n \"presentation\",\n \"regular\",\n \"social\",\n \"tutorial\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2776,6 +2786,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, @@ -2803,6 +2814,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"closed_meeting\",\n \"regular\"\n]", "show_on_agenda": true }, "model": "group.groupfeatures", @@ -2813,6 +2825,7 @@ "about_page": "ietf.group.views.group_about", "acts_like_wg": false, "admin_roles": "[\n \"lead\"\n]", + "agenda_filter_type": "none", "agenda_type": "ad", "create_wiki": false, "custom_group_roles": true, @@ -2840,6 +2853,7 @@ ], "req_subm_approval": false, "role_order": "[\n \"lead\",\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"closed_meeting\",\n \"officehours\",\n \"open_meeting\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2850,6 +2864,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, @@ -2875,6 +2890,7 @@ "parent_types": [], "req_subm_approval": false, "role_order": "[\n \"chair\"\n]", + "session_purposes": "[\n \"officehours\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2885,6 +2901,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, @@ -2910,6 +2927,7 @@ "parent_types": [], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"delegate\",\n \"member\"\n]", + "session_purposes": "[\n \"closed_meeting\",\n \"open_meeting\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2920,6 +2938,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, @@ -2947,6 +2966,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"admin\",\n \"plenary\",\n \"presentation\",\n \"social\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2957,6 +2977,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, @@ -2984,6 +3005,7 @@ ], "req_subm_approval": false, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -2994,6 +3016,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, @@ -3021,6 +3044,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3031,6 +3055,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, @@ -3056,6 +3081,7 @@ "parent_types": [], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"delegate\"\n]", + "session_purposes": "[\n \"officehours\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3066,6 +3092,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, @@ -3093,6 +3120,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"officehours\",\n \"open_meeting\",\n \"presentation\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3103,6 +3131,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, @@ -3130,6 +3159,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"member\",\n \"advisor\"\n]", + "session_purposes": "[\n \"closed_meeting\",\n \"officehours\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3140,6 +3170,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, @@ -3167,6 +3198,7 @@ ], "req_subm_approval": false, "role_order": "[\n \"lead\",\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"regular\",\n \"tutorial\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3177,6 +3209,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, @@ -3204,6 +3237,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"regular\"\n]", "show_on_agenda": true }, "model": "group.groupfeatures", @@ -3214,6 +3248,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, @@ -3241,6 +3276,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"open_meeting\",\n \"social\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3251,6 +3287,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, @@ -3276,6 +3313,7 @@ "parent_types": [], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\"\n]", + "session_purposes": "[\n \"officehours\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3286,6 +3324,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, @@ -3313,6 +3352,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"delegate\",\n \"secr\"\n]", + "session_purposes": "[\n \"regular\"\n]", "show_on_agenda": true }, "model": "group.groupfeatures", @@ -3323,6 +3363,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, @@ -3351,6 +3392,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"liaiman\"\n]", + "session_purposes": "[]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3361,6 +3403,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, @@ -3388,6 +3431,7 @@ ], "req_subm_approval": false, "role_order": "[\n \"chair\",\n \"member\",\n \"matman\"\n]", + "session_purposes": "[\n \"coding\",\n \"presentation\",\n \"social\",\n \"tutorial\"\n]", "show_on_agenda": false }, "model": "group.groupfeatures", @@ -3398,6 +3442,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, @@ -3425,6 +3470,7 @@ ], "req_subm_approval": true, "role_order": "[\n \"chair\",\n \"secr\",\n \"delegate\"\n]", + "session_purposes": "[\n \"regular\"\n]", "show_on_agenda": true }, "model": "group.groupfeatures", @@ -6095,6 +6141,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": "", @@ -12676,6 +12762,138 @@ "model": "name.roomresourcename", "pk": "webex" }, + { + "fields": { + "desc": "Meeting administration", + "name": "Administrative", + "on_agenda": true, + "order": 5, + "timeslot_types": "[\n \"other\",\n \"reg\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "admin" + }, + { + "fields": { + "desc": "Closed meeting", + "name": "Closed meeting", + "on_agenda": false, + "order": 10, + "timeslot_types": "[\n \"other\",\n \"regular\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "closed_meeting" + }, + { + "fields": { + "desc": "Coding session", + "name": "Coding", + "on_agenda": true, + "order": 4, + "timeslot_types": "[\n \"other\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "coding" + }, + { + "fields": { + "desc": "Value not set (do not use for new sessions)", + "name": "None", + "on_agenda": true, + "order": 0, + "timeslot_types": "[]", + "used": false + }, + "model": "name.sessionpurposename", + "pk": "none" + }, + { + "fields": { + "desc": "Office hours session", + "name": "Office hours", + "on_agenda": true, + "order": 3, + "timeslot_types": "[\n \"other\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "officehours" + }, + { + "fields": { + "desc": "Open meeting", + "name": "Open meeting", + "on_agenda": true, + "order": 9, + "timeslot_types": "[\n \"other\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "open_meeting" + }, + { + "fields": { + "desc": "Plenary session", + "name": "Plenary", + "on_agenda": true, + "order": 7, + "timeslot_types": "[\n \"plenary\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "plenary" + }, + { + "fields": { + "desc": "Presentation session", + "name": "Presentation", + "on_agenda": true, + "order": 8, + "timeslot_types": "[\n \"other\",\n \"regular\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "presentation" + }, + { + "fields": { + "desc": "Regular group session", + "name": "Regular", + "on_agenda": true, + "order": 1, + "timeslot_types": "[\n \"regular\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "regular" + }, + { + "fields": { + "desc": "Social event or activity", + "name": "Social", + "on_agenda": true, + "order": 6, + "timeslot_types": "[\n \"break\",\n \"other\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "social" + }, + { + "fields": { + "desc": "Tutorial or training session", + "name": "Tutorial", + "on_agenda": true, + "order": 2, + "timeslot_types": "[\n \"other\"\n]", + "used": true + }, + "model": "name.sessionpurposename", + "pk": "tutorial" + }, { "fields": { "desc": "", @@ -13101,7 +13319,6 @@ "desc": "", "name": "Break", "order": 0, - "private": false, "used": true }, "model": "name.timeslottypename", @@ -13112,7 +13329,6 @@ "desc": "Leadership Meetings", "name": "Leadership", "order": 0, - "private": true, "used": true }, "model": "name.timeslottypename", @@ -13123,8 +13339,7 @@ "desc": "Other Meetings Not Published on Agenda", "name": "Off Agenda", "order": 0, - "private": true, - "used": true + "used": false }, "model": "name.timeslottypename", "pk": "offagenda" @@ -13134,7 +13349,6 @@ "desc": "", "name": "Other", "order": 0, - "private": false, "used": true }, "model": "name.timeslottypename", @@ -13145,7 +13359,6 @@ "desc": "", "name": "Plenary", "order": 0, - "private": false, "used": true }, "model": "name.timeslottypename", @@ -13156,7 +13369,6 @@ "desc": "", "name": "Registration", "order": 0, - "private": false, "used": true }, "model": "name.timeslottypename", @@ -13167,7 +13379,6 @@ "desc": "", "name": "Regular", "order": 0, - "private": false, "used": true }, "model": "name.timeslottypename", @@ -13178,8 +13389,7 @@ "desc": "A room has been reserved for use by another body the timeslot indicated", "name": "Room Reserved", "order": 0, - "private": false, - "used": true + "used": false }, "model": "name.timeslottypename", "pk": "reserved" @@ -13189,7 +13399,6 @@ "desc": "A room was not booked for the timeslot indicated", "name": "Room Unavailable", "order": 0, - "private": false, "used": true }, "model": "name.timeslottypename", 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_sessionpurposename.py b/ietf/name/migrations/0034_sessionpurposename.py new file mode 100644 index 000000000..ca22e1a15 --- /dev/null +++ b/ietf/name/migrations/0034_sessionpurposename.py @@ -0,0 +1,33 @@ +# 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', '0033_populate_agendafiltertypename'), + ] + + 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')])), + ('on_agenda', models.BooleanField(default=True, help_text='Are sessions of this purpose visible on the agenda by default?')), + ], + options={ + 'ordering': ['order', 'name'], + 'abstract': False, + }, + ), + ] diff --git a/ietf/name/migrations/0035_populate_sessionpurposename.py b/ietf/name/migrations/0035_populate_sessionpurposename.py new file mode 100644 index 000000000..8af7d60d1 --- /dev/null +++ b/ietf/name/migrations/0035_populate_sessionpurposename.py @@ -0,0 +1,53 @@ +# 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, on_agenda, used) in enumerate(( + ('none', 'None', 'Value not set (do not use for new sessions)', [], True, False), + ('regular', 'Regular', 'Regular group session', ['regular'], True, True), + ('tutorial', 'Tutorial', 'Tutorial or training session', ['other'], True, True), + ('officehours', 'Office hours', 'Office hours session', ['other'], True, True), + ('coding', 'Coding', 'Coding session', ['other'], True, True), + ('admin', 'Administrative', 'Meeting administration', ['other', 'reg'], True, True), + ('social', 'Social', 'Social event or activity', ['break', 'other'], True, True), + ('plenary', 'Plenary', 'Plenary session', ['plenary'], True, True), + ('presentation', 'Presentation', 'Presentation session', ['other', 'regular'], True, True), + ('open_meeting', 'Open meeting', 'Open meeting', ['other'], True, True), + ('closed_meeting', 'Closed meeting', 'Closed meeting', ['other', 'regular'], False, True), + )): + # verify that we're not about to use an invalid type + 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=used, + order=order, + timeslot_types = tstypes, + on_agenda=on_agenda, + ) + + +def reverse(apps, schema_editor): + SessionPurposeName = apps.get_model('name', 'SessionPurposeName') + SessionPurposeName.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0034_sessionpurposename'), + ] + + operations = [ + migrations.RunPython(forward, reverse) + ] diff --git a/ietf/name/migrations/0036_depopulate_timeslottypename_private.py b/ietf/name/migrations/0036_depopulate_timeslottypename_private.py new file mode 100644 index 000000000..352ab8d58 --- /dev/null +++ b/ietf/name/migrations/0036_depopulate_timeslottypename_private.py @@ -0,0 +1,31 @@ +# Generated by Django 2.2.24 on 2021-10-25 16:58 + +from django.db import migrations + + +PRIVATE_TIMESLOT_SLUGS = {'lead', 'offagenda'} # from DB 2021 Oct + + +def forward(apps, schema_editor): + TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName') + slugs = TimeSlotTypeName.objects.filter(private=True).values_list('slug', flat=True) + if set(slugs) != PRIVATE_TIMESLOT_SLUGS: + # the reverse migration will not restore the database, refuse to migrate + raise ValueError('Disagreement between migration data and database') + + +def reverse(apps, schema_editor): + TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName') + TimeSlotTypeName.objects.filter(slug__in=PRIVATE_TIMESLOT_SLUGS).update(private=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0035_populate_sessionpurposename'), + ('meeting', '0051_populate_session_on_agenda'), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/name/migrations/0037_remove_timeslottypename_private.py b/ietf/name/migrations/0037_remove_timeslottypename_private.py new file mode 100644 index 000000000..2a8678056 --- /dev/null +++ b/ietf/name/migrations/0037_remove_timeslottypename_private.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.24 on 2021-10-25 17:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0036_depopulate_timeslottypename_private'), + ] + + operations = [ + migrations.RemoveField( + model_name='timeslottypename', + name='private', + ), + ] diff --git a/ietf/name/migrations/0038_disuse_offagenda_and_reserved.py b/ietf/name/migrations/0038_disuse_offagenda_and_reserved.py new file mode 100644 index 000000000..be0b507bd --- /dev/null +++ b/ietf/name/migrations/0038_disuse_offagenda_and_reserved.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.24 on 2021-10-29 06:44 + +from django.db import migrations + + +def forward(apps, schema_editor): + TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName') + TimeSlotTypeName.objects.filter(slug__in=('offagenda', 'reserved')).update(used=False) + + +def reverse(apps, schema_editor): + TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName') + TimeSlotTypeName.objects.filter(slug__in=('offagenda', 'reserved')).update(used=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('name', '0037_remove_timeslottypename_private'), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 3c07c7afc..e797c7782 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,11 +67,21 @@ 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')], + ) + on_agenda = models.BooleanField(default=True, help_text='Are sessions of this purpose visible on the agenda by default?') + 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") class ConstraintName(NameModel): """conflict, conflic2, conflic3, bethere, timerange, time_relation, wg_adjacent""" penalty = models.IntegerField(default=0, help_text="The penalty for violating this kind of constraint; for instance 10 (small penalty) or 10000 (large penalty)") diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 37d49167a..764c5e108 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, @@ -18,7 +18,7 @@ from ietf.name.models import ( AgendaTypeName, BallotPositionName, ConstraintNam ReviewAssignmentStateName, ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName, SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, TopicAudienceName, ReviewerQueuePolicyName, TimerangeName, ExtResourceTypeName, ExtResourceName, - SlideSubmissionStatusName, ProceedingsMaterialTypeName) + SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName ) class TimeSlotTypeNameResource(ModelResource): class Meta: @@ -684,3 +684,39 @@ 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()) + + +class SessionPurposeNameResource(ModelResource): + class Meta: + queryset = SessionPurposeName.objects.all() + serializer = api.Serializer() + cache = SimpleCache() + #resource_name = 'sessionpurposename' + ordering = ['slug', ] + filtering = { + "slug": ALL, + "name": ALL, + "desc": ALL, + "used": ALL, + "order": ALL, + "timeslot_types": ALL, + "on_agenda": ALL, + } +api.name.register(SessionPurposeNameResource()) diff --git a/ietf/person/ajax.py b/ietf/person/ajax.py index ee4cbb0ea..e8a4f1699 100644 --- a/ietf/person/ajax.py +++ b/ietf/person/ajax.py @@ -6,14 +6,6 @@ from django.http import HttpResponse from ietf.ietfauth.utils import role_required from ietf.person.models import Person -def person_json(request, personid): - person = get_object_or_404(Person, pk=personid) - - return HttpResponse(json.dumps(person.json_dict(request.build_absolute_uri("/")), - sort_keys=True, indent=2), - content_type="application/json") - - @role_required('Secretariat') def person_email_json(request, personid): person = get_object_or_404(Person, pk=personid) diff --git a/ietf/person/models.py b/ietf/person/models.py index 47eaeb659..381fee968 100644 --- a/ietf/person/models.py +++ b/ietf/person/models.py @@ -228,18 +228,6 @@ class Person(models.Model): def defurl(self): return urljoin(self.default_hostscheme,self.json_url()) - def json_url(self): - return "/person/%s.json" % (self.id, ) - - # return info about the person - def json_dict(self, hostscheme): - ct1 = dict() - ct1['person_id'] = self.id - ct1['href'] = urljoin(hostscheme, self.json_url()) - ct1['name'] = self.name - ct1['ascii'] = self.ascii - return ct1 - def available_api_endpoints(self): from ietf.ietfauth.utils import has_role return list(set([ (v, n) for (v, n, r) in PERSON_API_KEY_VALUES if r==None or has_role(self.user, r) ])) diff --git a/ietf/person/urls.py b/ietf/person/urls.py index 68b6acd8d..f37d8b46c 100644 --- a/ietf/person/urls.py +++ b/ietf/person/urls.py @@ -4,7 +4,6 @@ from ietf.utils.urls import url urlpatterns = [ url(r'^merge/$', views.merge), url(r'^search/(?P(person|email))/$', views.ajax_select2_search), - url(r'^(?P[0-9]+).json$', ajax.person_json), url(r'^(?P[0-9]+)/email.json$', ajax.person_email_json), url(r'^(?P[^/]+)$', views.profile), url(r'^(?P[^/]+)/photo/?$', views.photo), 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/tests.py b/ietf/secr/meetings/tests.py index 0d6573338..5c440802b 100644 --- a/ietf/secr/meetings/tests.py +++ b/ietf/secr/meetings/tests.py @@ -336,7 +336,8 @@ class SecrMeetingTestCase(TestCase): 'duration':'02:00', 'name':'Testing', 'short':'test', - 'type':'reg', + 'purpose_0': 'admin', # purpose + 'purpose_1':'reg', # type 'group':group.pk, 'location': room.pk, 'remote_instructions': 'http://webex.com/foobar', @@ -382,7 +383,8 @@ class SecrMeetingTestCase(TestCase): 'time':new_time.strftime('%H:%M'), 'duration':'01:00', 'day':'2', - 'type':'other', + 'purpose_0': 'coding', # purpose + 'purpose_1': 'other', # type 'remote_instructions': 'http://webex.com/foobar', }) self.assertRedirects(response, redirect_url) 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..1b61ac392 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_factory 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,21 @@ 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 + 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) + + # 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)) - 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 +149,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 +229,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 +253,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 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 + forms.Media(js=('secr/js/session_form.js',)) + 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..47109a081 100644 --- a/ietf/secr/sreq/templatetags/ams_filters.py +++ b/ietf/secr/sreq/templatetags/ams_filters.py @@ -25,15 +25,19 @@ 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] + if value in (None, ''): + return 'unspecified' + 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/tests.py b/ietf/secr/sreq/tests.py index b6165670c..c1907c680 100644 --- a/ietf/secr/sreq/tests.py +++ b/ietf/secr/sreq/tests.py @@ -4,6 +4,7 @@ import datetime +from django.test import override_settings from django.urls import reverse import debug # pyflakes:ignore @@ -76,6 +77,7 @@ class SessionRequestTestCase(TestCase): self.assertRedirects(r,reverse('ietf.secr.sreq.views.main')) self.assertEqual(SchedulingEvent.objects.filter(session=session).order_by('-id')[0].status_id, 'deleted') + @override_settings(SECR_VIRTUAL_MEETINGS=tuple()) # ensure not unexpectedly testing a virtual meeting session def test_edit(self): meeting = MeetingFactory(type_id='ietf', date=datetime.date.today()) mars = RoleFactory(name_id='chair', person__user__username='marschairman', group__acronym='mars').group @@ -90,17 +92,43 @@ class SessionRequestTestCase(TestCase): self.client.login(username="marschairman", password="marschairman+password") r = self.client.get(url) self.assertEqual(r.status_code, 200) + attendees = 10 + comments = 'need lights' + mars_sessions = meeting.session_set.filter(group__acronym='mars') post_data = {'num_session':'2', - 'length_session1':'3600', - 'length_session2':'3600', - 'attendees':'10', + 'attendees': attendees, 'constraint_chair_conflict':iabprog.acronym, - 'comments':'need lights', 'session_time_relation': 'subsequent-days', 'adjacent_with_wg': group2.acronym, 'joint_with_groups': group3.acronym + ' ' + group4.acronym, 'joint_for_session': '2', 'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'], + 'session_set-TOTAL_FORMS': '2', + 'session_set-INITIAL_FORMS': '1', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + 'session_set-0-id':mars_sessions[0].pk, + 'session_set-0-name': mars_sessions[0].name, + 'session_set-0-short': mars_sessions[0].short, + 'session_set-0-purpose': mars_sessions[0].purpose_id, + 'session_set-0-type': mars_sessions[0].type_id, + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': mars_sessions[0].on_agenda, + 'session_set-0-remote_instructions': mars_sessions[0].remote_instructions, + 'session_set-0-attendees': attendees, + 'session_set-0-comments': comments, + 'session_set-0-DELETE': '', + # no session_set-1-id because it's a new request + 'session_set-1-name': '', + 'session_set-1-short': '', + 'session_set-1-purpose': 'regular', + 'session_set-1-type': 'regular', + 'session_set-1-requested_duration': '3600', + 'session_set-1-on_agenda': True, + 'session_set-1-remote_instructions': mars_sessions[0].remote_instructions, + 'session_set-1-attendees': attendees, + 'session_set-1-comments': comments, + 'session_set-1-DELETE': '', 'submit': 'Continue'} r = self.client.post(url, post_data, HTTP_HOST='example.com') redirect_url = reverse('ietf.secr.sreq.views.view', kwargs={'acronym': 'mars'}) @@ -133,11 +161,37 @@ class SessionRequestTestCase(TestCase): post_data = {'num_session':'2', 'length_session1':'3600', 'length_session2':'3600', - 'attendees':'10', + 'attendees':attendees, 'constraint_chair_conflict':'', 'comments':'need lights', 'joint_with_groups': group2.acronym, 'joint_for_session': '1', + 'session_set-TOTAL_FORMS': '2', + 'session_set-INITIAL_FORMS': '2', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + 'session_set-0-id':sessions[0].pk, + 'session_set-0-name': sessions[0].name, + 'session_set-0-short': sessions[0].short, + 'session_set-0-purpose': sessions[0].purpose_id, + 'session_set-0-type': sessions[0].type_id, + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': sessions[0].on_agenda, + 'session_set-0-remote_instructions': sessions[0].remote_instructions, + 'session_set-0-attendees': sessions[0].attendees, + 'session_set-0-comments': sessions[1].comments, + 'session_set-0-DELETE': '', + 'session_set-1-id': sessions[1].pk, + 'session_set-1-name': sessions[1].name, + 'session_set-1-short': sessions[1].short, + 'session_set-1-purpose': sessions[1].purpose_id, + 'session_set-1-type': sessions[1].type_id, + 'session_set-1-requested_duration': '3600', + 'session_set-1-on_agenda': sessions[1].on_agenda, + 'session_set-1-remote_instructions': sessions[1].remote_instructions, + 'session_set-1-attendees': sessions[1].attendees, + 'session_set-1-comments': sessions[1].comments, + 'session_set-1-DELETE': '', 'submit': 'Continue'} r = self.client.post(url, post_data, HTTP_HOST='example.com') self.assertRedirects(r, redirect_url) @@ -160,7 +214,7 @@ class SessionRequestTestCase(TestCase): """Inactive conflicts should be displayed and removable""" meeting = MeetingFactory(type_id='ietf', date=datetime.date.today(), group_conflicts=['chair_conflict']) mars = RoleFactory(name_id='chair', person__user__username='marschairman', group__acronym='mars').group - SessionFactory(meeting=meeting, group=mars, status_id='sched') + session = SessionFactory(meeting=meeting, group=mars, status_id='sched') other_group = GroupFactory() Constraint.objects.create( meeting=meeting, @@ -184,16 +238,31 @@ class SessionRequestTestCase(TestCase): # check that the target is displayed correctly in the UI self.assertIn(other_group.acronym, delete_checkbox.find('../input[@type="text"]').value) + attendees = '10' post_data = { 'num_session': '1', - 'length_session1': '3600', - 'attendees': '10', + 'attendees': attendees, 'constraint_chair_conflict':'', 'comments':'', 'joint_with_groups': '', 'joint_for_session': '', - 'submit': 'Save', 'delete_conflict': 'on', + 'session_set-TOTAL_FORMS': '1', + 'session_set-INITIAL_FORMS': '1', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + 'session_set-0-id':session.pk, + 'session_set-0-name': session.name, + 'session_set-0-short': session.short, + 'session_set-0-purpose': session.purpose_id, + 'session_set-0-type': session.type_id, + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': session.on_agenda, + 'session_set-0-remote_instructions': session.remote_instructions, + 'session_set-0-attendees': attendees, + 'session_set-0-comments': '', + 'session_set-0-DELETE': '', + 'submit': 'Save', } r = self.client.post(url, post_data, HTTP_HOST='example.com') redirect_url = reverse('ietf.secr.sreq.views.view', kwargs={'acronym': 'mars'}) @@ -283,15 +352,31 @@ class SubmitRequestCase(TestCase): url = reverse('ietf.secr.sreq.views.new',kwargs={'acronym':group.acronym}) confirm_url = reverse('ietf.secr.sreq.views.confirm',kwargs={'acronym':group.acronym}) main_url = reverse('ietf.secr.sreq.views.main') + attendees = '10' + comments = 'need projector' post_data = {'num_session':'1', - 'length_session1':'3600', - 'attendees':'10', + 'attendees':attendees, 'constraint_chair_conflict':'', - 'comments':'need projector', + 'comments':comments, 'adjacent_with_wg': group2.acronym, 'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'], 'joint_with_groups': group3.acronym + ' ' + group4.acronym, 'joint_for_session': '1', + 'session_set-TOTAL_FORMS': '1', + 'session_set-INITIAL_FORMS': '0', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + # no 'session_set-0-id' to create a new session + 'session_set-0-name': '', + 'session_set-0-short': '', + 'session_set-0-purpose': 'regular', + 'session_set-0-type': 'regular', + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': True, + 'session_set-0-remote_instructions': '', + 'session_set-0-attendees': attendees, + 'session_set-0-comments': comments, + 'session_set-0-DELETE': '', 'submit': 'Continue'} self.client.login(username="secretary", password="secretary+password") r = self.client.post(url,post_data) @@ -313,7 +398,7 @@ class SubmitRequestCase(TestCase): self.assertRedirects(r, main_url) session_count_after = Session.objects.filter(meeting=meeting, group=group, type='regular').count() self.assertEqual(session_count_after, session_count_before + 1) - + # Verify database content session = Session.objects.get(meeting=meeting, group=group) self.assertEqual(session.constraints().get(name='wg_adjacent').target.acronym, group2.acronym) @@ -329,17 +414,35 @@ class SubmitRequestCase(TestCase): area = RoleFactory(name_id='ad', person=ad, group__type_id='area').group group = GroupFactory(parent=area) url = reverse('ietf.secr.sreq.views.new',kwargs={'acronym':group.acronym}) - post_data = {'num_session':'2', - 'length_session1':'3600', - 'attendees':'10', - 'constraint_chair_conflict':'', - 'comments':'need projector'} + attendees = '10' + comments = 'need projector' + post_data = { + 'num_session':'2', + 'attendees':attendees, + 'constraint_chair_conflict':'', + 'comments':comments, + 'session_set-TOTAL_FORMS': '1', + 'session_set-INITIAL_FORMS': '1', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + # no 'session_set-0-id' to create a new session + 'session_set-0-name': '', + 'session_set-0-short': '', + 'session_set-0-purpose': 'regular', + 'session_set-0-type': 'regular', + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': True, + 'session_set-0-remote_instructions': '', + 'session_set-0-attendees': attendees, + 'session_set-0-comments': comments, + 'session_set-0-DELETE': '', + } self.client.login(username="secretary", password="secretary+password") r = self.client.post(url,post_data) self.assertEqual(r.status_code, 200) q = PyQuery(r.content) self.assertEqual(len(q('#session-request-form')),1) - self.assertContains(r, 'You must enter a length for all sessions') + self.assertContains(r, 'Must provide data for all sessions') def test_submit_request_check_constraints(self): m1 = MeetingFactory(type_id='ietf', date=datetime.date.today() - datetime.timedelta(days=100)) @@ -363,7 +466,7 @@ class SubmitRequestCase(TestCase): target=inactive_group, name_id='chair_conflict', ) - SessionFactory(group=group, meeting=m1) + session = SessionFactory(group=group, meeting=m1) self.client.login(username="secretary", password="secretary+password") @@ -375,11 +478,27 @@ class SubmitRequestCase(TestCase): self.assertIn(still_active_group.acronym, conflict1) self.assertNotIn(inactive_group.acronym, conflict1) + attendees = '10' + comments = 'need projector' post_data = {'num_session':'1', - 'length_session1':'3600', - 'attendees':'10', + 'attendees':attendees, 'constraint_chair_conflict': group.acronym, - 'comments':'need projector', + 'comments':comments, + 'session_set-TOTAL_FORMS': '1', + 'session_set-INITIAL_FORMS': '1', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + # no 'session_set-0-id' to create a new session + 'session_set-0-name': '', + 'session_set-0-short': '', + 'session_set-0-purpose': session.purpose_id, + 'session_set-0-type': session.type_id, + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': session.on_agenda, + 'session_set-0-remote_instructions': session.remote_instructions, + 'session_set-0-attendees': attendees, + 'session_set-0-comments': comments, + 'session_set-0-DELETE': '', 'submit': 'Continue'} r = self.client.post(url,post_data) self.assertEqual(r.status_code, 200) @@ -405,10 +524,9 @@ class SubmitRequestCase(TestCase): url = reverse('ietf.secr.sreq.views.new',kwargs={'acronym':group.acronym}) confirm_url = reverse('ietf.secr.sreq.views.confirm',kwargs={'acronym':group.acronym}) len_before = len(outbox) + attendees = '10' post_data = {'num_session':'2', - 'length_session1':'3600', - 'length_session2':'3600', - 'attendees':'10', + 'attendees':attendees, 'bethere':str(ad.pk), 'constraint_chair_conflict':group4.acronym, 'comments':'', @@ -418,6 +536,32 @@ class SubmitRequestCase(TestCase): 'joint_with_groups': group3.acronym, 'joint_for_session': '2', 'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'], + 'session_set-TOTAL_FORMS': '2', + 'session_set-INITIAL_FORMS': '0', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + # no 'session_set-0-id' for new session + 'session_set-0-name': '', + 'session_set-0-short': '', + 'session_set-0-purpose': 'regular', + 'session_set-0-type': 'regular', + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': True, + 'session_set-0-remote_instructions': '', + 'session_set-0-attendees': attendees, + 'session_set-0-comments': '', + 'session_set-0-DELETE': '', + # no 'session_set-1-id' for new session + 'session_set-1-name': '', + 'session_set-1-short': '', + 'session_set-1-purpose': 'regular', + 'session_set-1-type': 'regular', + 'session_set-1-requested_duration': '3600', + 'session_set-1-on_agenda': True, + 'session_set-1-remote_instructions': '', + 'session_set-1-attendees': attendees, + 'session_set-1-comments': '', + 'session_set-1-DELETE': '', 'submit': 'Continue'} self.client.login(username="ameschairman", password="ameschairman+password") # submit @@ -541,23 +685,59 @@ class SessionFormTest(TestCase): self.group5 = GroupFactory() self.group6 = GroupFactory() + attendees = '10' + comments = 'need lights' self.valid_form_data = { 'num_session': '2', 'third_session': 'true', - 'length_session1': '3600', - 'length_session2': '3600', - 'length_session3': '3600', - 'attendees': '10', + 'attendees': attendees, 'constraint_chair_conflict': self.group2.acronym, 'constraint_tech_overlap': self.group3.acronym, 'constraint_key_participant': self.group4.acronym, - 'comments': 'need lights', + 'comments': comments, 'session_time_relation': 'subsequent-days', 'adjacent_with_wg': self.group5.acronym, 'joint_with_groups': self.group6.acronym, 'joint_for_session': '3', 'timeranges': ['thursday-afternoon-early', 'thursday-afternoon-late'], - 'submit': 'Continue' + 'submit': 'Continue', + 'session_set-TOTAL_FORMS': '3', + 'session_set-INITIAL_FORMS': '0', + 'session_set-MIN_NUM_FORMS': '1', + 'session_set-MAX_NUM_FORMS': '3', + # no 'session_set-0-id' for new session + 'session_set-0-name': '', + 'session_set-0-short': '', + 'session_set-0-purpose': 'regular', + 'session_set-0-type': 'regular', + 'session_set-0-requested_duration': '3600', + 'session_set-0-on_agenda': True, + 'session_set-0-remote_instructions': '', + 'session_set-0-attendees': attendees, + 'session_set-0-comments': '', + 'session_set-0-DELETE': '', + # no 'session_set-1-id' for new session + 'session_set-1-name': '', + 'session_set-1-short': '', + 'session_set-1-purpose': 'regular', + 'session_set-1-type': 'regular', + 'session_set-1-requested_duration': '3600', + 'session_set-1-on_agenda': True, + 'session_set-1-remote_instructions': '', + 'session_set-1-attendees': attendees, + 'session_set-1-comments': '', + 'session_set-1-DELETE': '', + # no 'session_set-2-id' for new session + 'session_set-2-name': '', + 'session_set-2-short': '', + 'session_set-2-purpose': 'regular', + 'session_set-2-type': 'regular', + 'session_set-2-requested_duration': '3600', + 'session_set-2-on_agenda': True, + 'session_set-2-remote_instructions': '', + 'session_set-2-attendees': attendees, + 'session_set-2-comments': '', + 'session_set-2-DELETE': '', } def test_valid(self): @@ -639,58 +819,65 @@ class SessionFormTest(TestCase): def test_invalid_joint_for_session(self): form = self._invalid_test_helper({ 'third_session': '', + 'session_set-TOTAL_FORMS': '2', 'num_session': 2, 'joint_for_session': '3', }) self.assertEqual(form.errors, { - 'joint_for_session': ['The third session can not be the joint session, ' - 'because you have not requested a third session.'] + 'joint_for_session': [ + 'Session 3 can not be the joint session, the session has not been requested.'] }) form = self._invalid_test_helper({ 'third_session': '', - 'length_session2': '', + 'session_set-TOTAL_FORMS': '1', 'num_session': 1, 'joint_for_session': '2', 'session_time_relation': '', }) self.assertEqual(form.errors, { - 'joint_for_session': ['The second session can not be the joint session, ' - 'because you have not requested a second session.'] + 'joint_for_session': [ + 'Session 2 can not be the joint session, the session has not been requested.'] }) def test_invalid_missing_session_length(self): form = self._invalid_test_helper({ - 'length_session2': '', + 'session_set-TOTAL_FORMS': '2', + 'session_set-1-requested_duration': '', 'third_session': 'false', 'joint_for_session': None, }) - self.assertEqual(form.errors, - { - 'length_session2': ['You must enter a length for all sessions'], - }) + self.assertEqual(form.session_forms.errors, + [ + {}, + {'requested_duration': ['This field is required.']}, + ]) form = self._invalid_test_helper({ - 'length_session2': '', - 'length_session3': '', + 'session_set-1-requested_duration': '', + 'session_set-2-requested_duration': '', 'joint_for_session': None, }) - self.assertEqual(form.errors, - { - 'length_session2': ['You must enter a length for all sessions'], - 'length_session3': ['You must enter a length for all sessions'], - }) + self.assertEqual( + form.session_forms.errors, + [ + {}, + {'requested_duration': ['This field is required.']}, + {'requested_duration': ['This field is required.']}, + ]) form = self._invalid_test_helper({ - 'length_session3': '', + 'session_set-2-requested_duration': '', 'joint_for_session': None, }) - self.assertEqual(form.errors, - { - 'length_session3': ['You must enter a length for all sessions'], - }) + self.assertEqual(form.session_forms.errors, + [ + {}, + {}, + {'requested_duration': ['This field is required.']}, + ]) def _invalid_test_helper(self, new_form_data): form_data = dict(self.valid_form_data, **new_form_data) diff --git a/ietf/secr/sreq/views.py b/ietf/secr/sreq/views.py index 121f15a00..7443e28f8 100644 --- a/ietf/secr/sreq/views.py +++ b/ietf/secr/sreq/views.py @@ -60,17 +60,13 @@ def get_initial_session(sessions, prune_conflicts=False): constraints = group.constraint_source_set.filter(meeting=meeting) # all constraints with this group as source 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) - - # 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 + if group.features.acts_like_wg: + # even if there are three sessions requested, the old form has 2 in this field + initial['num_session'] = min(sessions.count(), 2) + initial['third_session'] = sessions.count() > 2 + else: + initial['num_session'] = sessions.count() + initial['third_session'] = False initial['attendees'] = sessions[0].attendees def valid_conflict(conflict): @@ -268,6 +264,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): ''' @@ -276,12 +279,14 @@ def confirm(request, acronym): ''' # FIXME: this should be using form.is_valid/form.cleaned_data - invalid input will make it crash group = get_object_or_404(Group,acronym=acronym) + if len(group.features.session_purposes) == 0: + raise Http404(f'Cannot request sessions for group "{acronym}"') meeting = get_meeting(days=14) FormClass = get_session_form_class() 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 +321,27 @@ 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 + form.session_forms.save() + for count, sess_form in enumerate(form.session_forms[:num_sessions]): + new_session = sess_form.instance + SchedulingEvent.objects.create( + session=new_session, + status=SessionStatusName.objects.get(slug=status_slug_for_new_session(new_session, count)), + 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 + 1: # count is zero-indexed + 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) + new_session.save() + session_changed(new_session) # write constraint records for conflictname, cfield_id in form.wg_constraint_field_ids(): @@ -418,8 +412,13 @@ 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_count = sessions.count() + if len(group.features.session_purposes) == 0: + raise Http404(f'Cannot request sessions for group "{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', 'deleted']) + ).order_by('id') initial = get_initial_session(sessions) FormClass = get_session_form_class() @@ -449,67 +448,18 @@ def edit(request, acronym, num=None): form = FormClass(group, meeting, request.POST, initial=initial) if form.is_valid(): if form.has_changed(): - # 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', - ) + changed_session_forms = [sf for sf in form.session_forms.forms_to_keep if sf.has_changed()] + form.session_forms.save() + for n, subform in enumerate(form.session_forms): + session = subform.instance + if session in form.session_forms.created_instances: SchedulingEvent.objects.create( - session=new_session, - status=SessionStatusName.objects.get(slug='schedw'), + session=session, + status_id=status_slug_for_new_session(session, n), 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) + 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( @@ -534,7 +484,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: @@ -588,10 +539,6 @@ def edit(request, acronym, num=None): # send notification send_notification(group,meeting,login,form.cleaned_data,'update') - # nuke any cache that might be lingering around. - from ietf.meeting.helpers import session_constraint_expire - session_constraint_expire(request,session) - messages.success(request, 'Session Request updated') return redirect('ietf.secr.sreq.views.view', acronym=acronym) @@ -666,7 +613,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) @@ -690,9 +637,11 @@ def new(request, acronym): to create the request. ''' group = get_object_or_404(Group, acronym=acronym) + if len(group.features.session_purposes) == 0: + raise Http404(f'Cannot request sessions for group "{acronym}"') meeting = get_meeting(days=14) session_conflicts = dict(inbound=inbound_session_conflicts_as_string(group, meeting)) - is_virtual = meeting.number in settings.SECR_VIRTUAL_MEETINGS, + is_virtual = meeting.number in settings.SECR_VIRTUAL_MEETINGS FormClass = get_session_form_class() # check if app is locked @@ -770,6 +719,7 @@ def no_session(request, acronym): meeting=meeting, requested_duration=datetime.timedelta(0), type_id='regular', + purpose_id='regular', ) SchedulingEvent.objects.create( session=session, @@ -892,7 +842,8 @@ def view(request, acronym, num = None): return render(request, 'sreq/view.html', { 'is_locked': is_locked, 'is_virtual': meeting.number in settings.SECR_VIRTUAL_MEETINGS, - 'session': session, + 'session': session, # legacy processed data + 'sessions': sessions, # actual session instances 'activities': activities, 'meeting': meeting, 'group': group, diff --git a/ietf/secr/static/secr/js/session_form.js b/ietf/secr/static/secr/js/session_form.js new file mode 100644 index 000000000..6f28f16db --- /dev/null +++ b/ietf/secr/static/secr/js/session_form.js @@ -0,0 +1,28 @@ +/* Copyright The IETF Trust 2021, All Rights Reserved + * + * JS support for the SessionForm + * */ +(function() { + 'use strict'; + + function track_common_input(input, name_suffix) { + const handler = function() { + const hidden_inputs = document.querySelectorAll( + '.session-details-form input[name$="-' + name_suffix + '"]' + ); + for (let hi of hidden_inputs) { + hi.value = input.value; + } + }; + input.addEventListener('change', handler); + handler(); + } + + function initialize() { + // Keep all the hidden inputs in sync with the main form + track_common_input(document.getElementById('id_attendees'), 'attendees'); + track_common_input(document.getElementById('id_comments'), 'comments'); + } + + window.addEventListener('load', initialize); +})(); \ No newline at end of file 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/session_info.txt b/ietf/secr/templates/includes/session_info.txt index db22a520b..80910a593 100644 --- a/ietf/secr/templates/includes/session_info.txt +++ b/ietf/secr/templates/includes/session_info.txt @@ -6,7 +6,7 @@ Session Requester: {{ login }} {% if session.joint_with_groups %}{{ session.joint_for_session_display }} joint with: {{ session.joint_with_groups }}{% endif %} Number of Sessions: {{ session.num_session }} -Length of Session(s): {{ session.length_session1|display_duration }}{% if session.length_session2 %}, {{ session.length_session2|display_duration }}{% endif %}{% if session.length_session3 %}, {{ session.length_session3|display_duration }}{% endif %} +Length of Session(s): {{ session.length_session1|display_duration }}{% if session.length_session2 %}, {{ session.length_session2|display_duration }}{% endif %}{% if session.length_session3 %}, {{ session.length_session3|display_duration }}{% endif %} Number of Attendees: {{ session.attendees }} Conflicts to Avoid: {% for line in session.outbound_conflicts %} {{line}} diff --git a/ietf/secr/templates/includes/sessions_request_form.html b/ietf/secr/templates/includes/sessions_request_form.html index 5c4f3e91d..9ed17a84c 100755 --- a/ietf/secr/templates/includes/sessions_request_form.html +++ b/ietf/secr/templates/includes/sessions_request_form.html @@ -1,20 +1,28 @@ * Required Field
{% csrf_token %} + {{ form.session_forms.management_form }} {% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %} - - + {% if group.features.acts_like_wg %} + {% 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 not group.features.acts_like_wg #} + {% for session_form in form.session_forms %} + + {% endfor %} {% endif %} diff --git a/ietf/secr/templates/includes/sessions_request_view.html b/ietf/secr/templates/includes/sessions_request_view.html index 595b69e3d..b2350190f 100644 --- a/ietf/secr/templates/includes/sessions_request_view.html +++ b/ietf/secr/templates/includes/sessions_request_view.html @@ -3,16 +3,11 @@ - - - {% if session.length_session2 %} - - {% if not is_virtual %} - - {% endif %} - {% endif %} - {% if session.length_session3 %} - + + {% if form %} + {% include 'includes/sessions_request_view_formset.html' with formset=form.session_forms group=group session=session only %} + {% else %} + {% include 'includes/sessions_request_view_session_set.html' with session_set=sessions group=group session=session only %} {% endif %} diff --git a/ietf/secr/templates/includes/sessions_request_view_formset.html b/ietf/secr/templates/includes/sessions_request_view_formset.html new file mode 100644 index 000000000..ff502dea3 --- /dev/null +++ b/ietf/secr/templates/includes/sessions_request_view_formset.html @@ -0,0 +1,30 @@ +{% load ams_filters %}{# keep this in sync with sessions_request_view_session_set.html #} +{% for sess_form in formset %}{% if sess_form.cleaned_data and not sess_form.cleaned_data.DELETE %} + + + + + {% if group.features.acts_like_wg and forloop.counter == 2 and not is_virtual %} + + + + + {% endif %} +{% endif %}{% endfor %} \ No newline at end of file diff --git a/ietf/secr/templates/includes/sessions_request_view_session_set.html b/ietf/secr/templates/includes/sessions_request_view_session_set.html new file mode 100644 index 000000000..1f953ae3a --- /dev/null +++ b/ietf/secr/templates/includes/sessions_request_view_session_set.html @@ -0,0 +1,30 @@ +{% load ams_filters %}{# keep this in sync with sessions_request_view_formset.html #} +{% for sess in session_set %} + + + + + {% if group.features.acts_like_wg and forloop.counter == 2 and not is_virtual %} + + + + + {% endif %} +{% endfor %} \ No newline at end of file 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 }}
Session {{ forloop.counter }}:*{% include 'meeting/session_details_form.html' with form=session_form only %}
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 }}
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 Sessions Requested:{% if session.third_session %}3{% else %}{{ session.num_session }}{% endif %}
Number of Attendees:{{ session.attendees }}
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 != 'regular' %} +
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 %}
Session {{ forloop.counter }}: +
+
Length
+
{{ sess.requested_duration.total_seconds|display_duration }}
+ {% if sess.name %} +
Name
+
{{ sess.name }}
{% endif %} + {% if sess.purpose.slug != 'regular' %} +
Purpose
+
+ {{ sess.purpose }} + {% if sess.purpose.timeslot_types|length > 1 %}({{ sess.type }} + ){% endif %} +
+ {% endif %} +
+
Time between sessions:{% if session.session_time_relation_display %}{{ session.session_time_relation_display }}{% else %}No + preference{% endif %}
{{ 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..c458459f7 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 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 @@ -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..f836ceb59 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 = 112 # 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..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 { @@ -1335,7 +1338,8 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container { margin: 0.5em 0; } -.edit-meeting-schedule .scheduling-panel .preferences > span { +.edit-meeting-schedule .scheduling-panel .preferences > span { + margin-top: 0; margin-right: 1em; } @@ -1344,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; } @@ -1363,7 +1370,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; @@ -1545,6 +1552,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/agenda/agenda_edit.js b/ietf/static/ietf/js/agenda/agenda_edit.js deleted file mode 100644 index 81bc58692..000000000 --- a/ietf/static/ietf/js/agenda/agenda_edit.js +++ /dev/null @@ -1,184 +0,0 @@ -/* -* -* FILE: agenda_edit.js -* Copyright (c) 2013, The IETF Trust. See ../../../LICENSE. -* -* www.credil.org: Project Orlando 2013 -* Author: Justin Hornosty ( justin@credil.org ) -* Michael Richardson -* -* Description: -* This is the main file for the agenda editing page. -* It contains the document read function that starts everything -* off, and uses functions and objects from agenda_*.js -* -*/ - - - - -//////////////-GLOBALS----//////////////////////////////////////// - -// these need to be setup in landscape_edit's setup_slots() inline function: -//var meeting_number = 0; // is the meeting name. -//var schedule_id = 0; // what is the schedule we are editing. -//var schedule_name; // what is the schedule we are editing. -//var schedule_owner_href = ''; // who owns this schedule -//var assignments_post_href; -//var meeting_base_url; -//var site_base_url; -//var total_rooms = 0; // the number of rooms -//var total_days = 0; // the number of days - -var is_secretariat = false; - -var agenda_globals; - -var area_directors = {}; // list of promises of area directors, index by href. - -var read_only = true; // it is true until we learn otherwise. -var days = []; -var legend_status = {}; // agenda area colors. -var load_conflicts = true; -var duplicate_sessions = {}; -/********* colors ************************************/ - -var dragging_color = "blue"; // color when draging events. -var none_color = ''; // when we reset the color. I believe doing '' will force it back to the stylesheet value. -var color_droppable_empty_slot = 'rgb(0, 102, 153)'; - -// these are used for debugging only. -var last_json_txt = ""; // last txt from a json call. -var last_json_reply = []; // last parsed content - -var hidden_rooms = []; -var hidden_days = []; - -/****************************************************/ - -/////////////-END-GLOBALS-/////////////////////////////////////// - -/* refactor this out into the html */ -$(document).ready(function() { - initStuff(); - - $("#close_ietf_menubar").click(); - -}); - -/* initStuff() - This is ran at page load and sets up the entire page. -*/ -function initStuff(){ - agenda_globals = new AgendaGlobals(); - //agenda_globals.__debug_session_move = true; - - log("initstuff() running..."); - var directorpromises = []; - - /* define a slot for unscheduled items */ - var unassigned = new ScheduledSlot(); - unassigned.make_unassigned(); - - setup_slots(directorpromises); - mark_area_directors(directorpromises); - log("setup_slots() ran"); - droppable(); - log("droppable() ran"); - - $.when.apply($,directorpromises).done(function() { - /* can not load events until area director info, - timeslots, sessions, and assignments - have been loaded - */ - log("loading/linking objects"); - load_events(); - log("load_events() ran"); - find_meeting_no_room(); - calculate_name_select_box(); - calculate_room_select_box(); - listeners(); - droppable(); - duplicate_sessions = find_double_timeslots(); - empty_info_table(); - count_sessions(); - - if(load_conflicts) { - recalculate(null); - } - }); - - static_listeners(); - log("listeners() ran"); - - start_spin(); - - read_only = true; - log("do read only check"); - read_only_check(); - stop_spin(); - - meeting_objs_length = Object.keys(agenda_globals.meeting_objs).length; - - /* Comment this out for fast loading */ - //load_conflicts = false; -} - -var __READ_ONLY; -function read_only_result(msg) { - __READ_ONLY = msg; - is_secretariat = msg.secretariat; - - read_only = msg.read_only; - console.log("read only", read_only); - - if(!read_only) { - $("#read_only").css("display", "none"); - } - - if(msg.save_perm) { - $(".agenda_save_box").css("display", "block"); - if(read_only) { - $(".agenda_save_box").css("position", "fixed"); - $(".agenda_save_box").css("top", "20px"); - $(".agenda_save_box").css("right", "10px"); - $(".agenda_save_box").css("bottom", "auto"); - $(".agenda_save_box").css("border", "3px solid blue"); - $(".agenda_save_box").css("z-index", "2000"); - } - } else { - $(".agenda_save_box").html("please login to save"); - } - - schedule_owner_href = msg.owner_href; - // XX go fetch the owner and display it. - console.log("owner href:", schedule_owner_href); - - $("#pageloaded").show(); - - listeners(); - droppable(); -} - -function read_only_check() { - var read_only_url = meeting_base_url + "/agenda/" + schedule_owner_email + "/" + schedule_name + "/permissions"; - console.log("Loading readonly status from: ", read_only_url); - var read_only_load = $.ajax(read_only_url); - - read_only_load.success(function(newobj, status, jqXHR) { - last_json_reply = newobj; - read_only_result(newobj); - }); -} - -function print_all_ss(objs){ - console.log(objs) -} - - -/* - * Local Variables: - * c-basic-offset:4 - * End: - */ - diff --git a/ietf/static/ietf/js/agenda/agenda_helpers.js b/ietf/static/ietf/js/agenda/agenda_helpers.js deleted file mode 100644 index 8cb36a0db..000000000 --- a/ietf/static/ietf/js/agenda/agenda_helpers.js +++ /dev/null @@ -1,626 +0,0 @@ -/* -* agenda_helpers.js -* -* Copyright (c) 2013, The IETF Trust. See ../../../LICENSE. -* -* www.credil.org: Project Orlando 2013 -* Author: Justin Hornosty ( justin@credil.org ) -* Michael Richardson -* -* Should contain miscellaneous commonly used functions. -* -* -*/ - -/* do_work: - when we are waiting for something to either resolve to true, or another similar job - this function should achieve this. - - result will be a function that when returns true will stop the work and then the callback - will be triggered. - - ex: - global_x = 0 - do_work(function(){ global_x++; return global_x > 100 }, function(){ console.log("resolved") }) -*/ -function do_work(result,callback){ - setTimeout(function(){ - if(!result()){ - setTimeout(arguments.callee,1); - } - else{ - callback(); - } - }); -} - - -function log(text){ - console.log(text); -} - -function print_all(){ - console.log("all"); - console.log(agenda_globals.meeting_objs.length); - for(var i=0; ititledata -*/ -function gen_tr_td(title,data){ - return ""+title+""+data+""; -} - -/* Mainly for the case where we didn't get any data back from the server */ -function empty_info_table(){ - $("#info_grp").html(name_select_html); - $("#info_name").html(""); - $("#info_area").html(""); - $("#info_duration").html(""); - - $(".agenda_selected_buttons").attr('disabled',true); - $(".agenda_double_slot").addClass("button_disabled"); - $(".agenda_double_slot").removeClass("button_enabled"); - - if(!read_only) { - $("#info_location").html(generate_select_box()+""); - $("#info_location_select").val(""); - $("#info_location_select").val($("#info_location_select_option_"+current_timeslot_id).val()); - } - $("#info_responsible").html(""); - $("#info_requestedby").html(""); - $("#agenda_requested_features").html(""); - - /* need to reset listeners, because we just changed the HTML */ - listeners(); -} - - -var temp_1; -/* creates the 'info' table that is located on the right side. - takes in a json. -*/ - -function compare_timeslot(a,b) { - //console.log("day: a,b", a.day, b.day); - - // sometimes (a.day==b.say)==false and (a.day===b.day)==false, - // for days that appear identical, but built from different strings, - // yet (a.day-b.day)==0. - if((a.day - b.day) == 0) { - //console.log("time: a,b", a.starttime, b.starttime); - if(a.starttime == b.starttime) { - //console.log("room: a,b", a.room, b.room, a.room < b.room); - if(a.room > b.room) { - return 1; - } else { - return -1; - } - }; - if(a.starttime > b.starttime) { - return 1; - } else { - return -1; - } - } - if(a.day > b.day) { - return 1; - } else { - return -1; - } -} - -var room_select_html = ""; -function calculate_room_select_box() { - var html = ""; - room_select_html = html; - return room_select_html; -} - -var name_select_html = undefined; -var temp_sorted = null; -function calculate_name_select_box(){ - var html = ""; - name_select_html = html; - return html; -} - - - -function generate_select_box(){ - if(!room_select_html) { - calculate_name_select_box(); - } - return room_select_html; -} - - - - -function insert_cell(js_room_id, text, replace){ - slot_id = ("#"+js_room_id); - try{ - var found; - if(replace) { - found = $(slot_id).html(text); - } else { - found = $(slot_id).append($(text)); - - } - $(slot_id).css('background',''); - $(slot_id).removeClass('free_slot'); - if(found.length == 0){ - // do something here, if length was zero... then? - } - - } - catch(err){ - log("error"); - log(err); - } -} - - -function find_meeting_no_room(){ - $.each(agenda_globals.meeting_objs, function(key){ - if(agenda_globals.meeting_objs[key].slot_status_key == null) { - session = agenda_globals.meeting_objs[key] - session.slot_status_key = null; - session.populate_event(bucketlist_id); - } - }) -} - - -/* in some cases we have sessions that span over two timeslots. - so we end up with two slot_status pointing to the same meeting_obj. - this this occures when someone requests a session that is extra long - which will then fill up the next timeslot. - - this functions finds those cases. - - returns a json{ 'ts': arr[time_slot_ids] } - -*/ -function find_double_timeslots(){ - var duplicate = {}; - - $.each(agenda_globals.slot_status, function(key){ - for(var i =0; i 1){ - dup[key] = duplicate[key]['ts']; - - } - }); - return dup; -} - - -var child = null; -/* removes a duplicate timeslot. completely. it's gone. */ -function remove_duplicate(timeslot_id, ss_id){ - children = $("#"+timeslot_id).children(); - child = children; - for(var i = 0; i< children.length; i++){ // loop to - if($(children[i]).attr('session_id') == ss_id) { // make sure we only remove duplicate. - try{ - $(children[i]).remove(); - }catch(exception){ - console.log("exception from remove_duplicate",exception); - } - } - } - -} - - - -function auto_remove(){ - dup = find_double_timeslots(); - $.each(dup, function(key){ - remove_duplicate(dup[key][1], key); - }) -} - - - -/* for the spinnner */ - -/* spinner code from: - http://fgnass.github.com/spin.js/ - - ex: $("#spinner").spin() < start the spin - $("#spinner").spin(false) < stop the spin - - http://gist.github.com/itsflorida < jquery functionality. - - lines: 30, // The number of lines to draw - length: 7, // The length of each line - width: 1, // The line thickness - radius: 20, // The radius of the inner circle - corners: 1, // Corner roundness (0..1) - rotate: 0, // The rotation offset - color: '#000', // #rgb or #rrggbb - speed: 1, // Rounds per second - trail: 60, // Afterglow percentage - shadow: false, // Whether to render a shadow - hwaccel: true, // Whether to use hardware acceleration - className: 'spinner', // The CSS class to assign to the spinner - zIndex: 2e9, // The zindex (defaults to 2000000000) - top: 'auto', // Top position relative to parent in px - left: 'auto' // Left position relative to parent in px - -*/ - -(function($) { - $.fn.spin = function(opts, color) { - if (Spinner) { - return this.each(function() { - var $this = $(this), - data = $this.data(); - - if (data.spinner) { - data.spinner.stop(); - delete data.spinner; - } - if (opts !== false) { - if (typeof opts === "string") { - if (opts in presets) { - opts = presets[opts]; - } else { - opts = {}; - } - if (color) { - opts.color = color; - } - } - data.spinner = new Spinner($.extend({color: $this.css('color')}, opts)).spin(this); - } - }); - } else { - throw "Spinner class not available."; - } - }; -})(jQuery); - - -function start_spin(opts){ -//spinner - // $("#schedule_name").hide(); - $("#spinner").show(); - $("#spinner").spin({lines:16, radius:8, length:16, width:4}); - $("#pageloaded").hide(); -} -function stop_spin(){ -//spinner - $("#schedule_name").show(); - $("#spinner").hide(); - $("#spinner").spin(false); - $("#pageloaded").show(); -} - -/* - * Local Variables: - * c-basic-offset:4 - * End: - */ diff --git a/ietf/static/ietf/js/agenda/agenda_listeners.js b/ietf/static/ietf/js/agenda/agenda_listeners.js deleted file mode 100644 index 3ee70b5de..000000000 --- a/ietf/static/ietf/js/agenda/agenda_listeners.js +++ /dev/null @@ -1,1266 +0,0 @@ -/* -* agenda_listeners.js -* -* Copyright (c) 2013, The IETF Trust. See ../../../LICENSE. -* -* www.credil.org: Project Orlando 2013 -* Author: Justin Hornosty ( justin@credil.org ) -* Michael Richardson -* -* This file should contain functions relating to -* jquery ui droppable ( http://jqueryui.com/droppable/ ) -* and other interactions. -* -*/ - -var bucketlist_id = "sortable-list" // for if/when the id for bucket list changes. - -function resize_listeners() { - for(i = 0; i 0){ - for(var i = 0; i< free_slots.length; i++){ - if(free_slots[i].capacity >= target_capacity) { - perfect_slots.push(free_slots[i]); - } - } - if(perfect_slots.length > 0){ - return perfect_slots[0]; - } - else{ - return free_slots[0]; // just return the first one. - } - } - else{ - return null; - } -} - -function extend_slot(event) { - // event is just the button push, ignore it. - - session = last_session; - - console.log("session", session.title, "sslot:", current_scheduledslot.assignment_id); - - /* bind current_timeslot into this function and continuations */ - var slot = current_timeslot; - - // determine if this slot can be extended. - if(current_timeslot.can_extend_right()) { - $("#can-extend-dialog").html("Extend "+session.title+" to slot "+slot.following_timeslot.domid); - $("#can-extend-dialog").dialog({ - resizable: true, - modal: true, - dialogClass: "extend-dialog", - buttons: { - "Yes": { - click: function() { - // need to create new assignment - var new_ss = make_ss({ "session_id" : session.session_id, - "timeslot_id": slot.following_timeslot.timeslot_id, - "extendedfrom_id" : current_scheduledslot.assignment_id}); - // make_ss also adds to slot_status. - new_ss.saveit(); - - slot.extendedto = slot.following_timeslot; - slot.extendedto.extendedfrom = slot; - session.double_wide = true; - session.repopulate_event(slot.domid); - - droppable(); - listeners(); - $( this ).dialog( "close" ); - - // may have caused some new conflicts!!!! - recalculate_conflicts_for_session(session, - [slot.column_class], - [slot.column_class, slot.extendedto.column_class]); - }, - text: "Yes", - id: "extend-yes"}, - "Cancel": { - click: function() { - $( this ).dialog( "close" ); - result = "cancel" - }, - text: "Cancel", - id: "extend-cancel"}, - } - }); - } else { - $( "#can-not-extend-dialog" ).dialog(); - } -} - -function find_free(){ - if(last_session) { - var empty_slot = find_empty_slot(last_session); - if(empty_slot != null){ - var domthing = $("#"+empty_slot.domid); - domthing.effect("highlight", {},3000); - if(current_item != null){ - $(current_item).addClass('ui-effects-transfer'); - $(current_item).effect("transfer", {to: domthing }, 1000); - } - $(current_item).removeClass('ui-effects-transfer'); - } - } -} - - -function expand_spacer(target) { - var current_width = $(target).css('min-width'); - current_width = current_width.substr(0,current_width.search("px")); - current_width = parseInt(current_width) + 20; - $(target).css('min-width',current_width); - $(target).css('width',current_width); - -} - -function sort_by_alphaname(a,b) { - am = agenda_globals.meeting_objs[$(a).attr('session_id')] - bm = agenda_globals.meeting_objs[$(b).attr('session_id')] - if(am.title < bm.title) { - return -1; - } else { - return 1; - } -} -function sort_by_area(a,b) { - am = agenda_globals.meeting_objs[$(a).attr('session_id')] - bm = agenda_globals.meeting_objs[$(b).attr('session_id')] - if(am.area < bm.area) { - return -1; - } else { - return 1; - } -} -function sort_by_duration(a,b) { - am = agenda_globals.meeting_objs[$(a).attr('session_id')] - bm = agenda_globals.meeting_objs[$(b).attr('session_id')] - if(am.requested_duration < bm.requested_duration) { - // sort duration biggest to smallest. - return 1; - } else if(am.requested_duration == bm.requested_duration && - am.title < bm.title) { - return 1; - } else { - return -1; - } -} -function sort_by_specialrequest(a,b) { - am = agenda_globals.meeting_objs[$(a).attr('session_id')] - bm = agenda_globals.meeting_objs[$(b).attr('session_id')] - if(am.special_request == '*' && bm.special_request == '') { - return -1; - } else if(am.special_request == '' && bm.special_request == '*') { - return 1; - } else if(am.title < bm.title) { - return -1; - } else { - return 1; - } -} - -function sort_unassigned() { - $('#'+bucketlist_id+" div.meeting_box_container").sort(unassigned_sort_function).appendTo('#'+bucketlist_id); -} - -var unassigned_sort_function = sort_by_alphaname; -function unassigned_sort_change(){ - var last_sort_method = unassigned_sort_function; - var sort_method= $("#unassigned_sort_button").attr('value'); - - if(sort_method == "alphaname") { - unassigned_sort_function = sort_by_alphaname; - } else if(sort_method == "area") { - unassigned_sort_function = sort_by_area; - } else if(sort_method == "duration") { - unassigned_sort_function = sort_by_duration; - } else if(sort_method == "special") { - unassigned_sort_function = sort_by_specialrequest; - } else { - unassigned_sort_function = sort_by_alphaname; - } - - if(unassigned_sort_function != last_sort_method) { - sort_unassigned(); - } -} - - -/* the functionality of these listeners will never change so they do not need to be run twice */ -function static_listeners(){ - $('#close_ietf_menubar').click(hide_ietf_menu_bar); - - $("#show_hidden_days").unbind('click'); - $("#show_hidden_days").click(show_hidden_days); - $("#show_hidden_rooms").unbind('click'); - $("#show_hidden_rooms").click(show_hidden_rooms); - - $('#unassigned_sort_button').unbind('change'); - $('#unassigned_sort_button').change(unassigned_sort_change); - $('#unassigned_sort_button').css('display','block'); - $("#unassigned_alpha").attr('selected',true); - sort_unassigned(); -} - -// recalculate all conflicts from scratch -function recalculate(event) { - start_spin(); - console.log("loading all conflicts"); - - var promise = get_all_conflicts(); - promise.done(function() { - stop_spin(); - console.log("showing all conflicts"); - show_all_conflicts(); - }); -} - -function color_legend_click(event){ - var clicked = $(event.target).attr('id'); - if(legend_status[clicked]){ - legend_status[clicked] = false; - } - else{ - legend_status[clicked] = true; - } - set_transparent(); -} - -var conflict_status = {}; - -function conflict_click(event){ - var clicked = $(event.target).attr('id'); - var constraint = find_conflict(clicked); - //console.log("7 fill", clicked, conflict_status[clicked]); - if(conflict_status[clicked] == true){ - //console.log("8 fill", constraint.href); - conflict_status[clicked] = false; - constraint.checked = "checked"; - } - else{ - //console.log("9 fill", constraint.href); - conflict_status[clicked] = true; - constraint.show_conflict_view(); - } -} - -function set_transparent(){ - $.each(legend_status, function(k){ - if(legend_status[k] != true){ - $("."+k+"-scheme.meeting_obj").parent().parent().parent().draggable("option","disabled",true); - }else{ - $("."+k+"-scheme.meeting_obj").parent().parent().parent().draggable("option","disabled",false); - } - - }); -} - -var __debug_meeting_click = false; -var clicked_event; -var __DEBUG__SESSION_OBJ; -var __DEBUG__SLOT_OBJ; -var __debug_click_slot_id; -var __debug_click_container; - -// current_item is the domid that was clicked -// last_session is the session active. -var current_item = null; -var current_timeslot = null; -var current_scheduledslot = null; -var current_timeslot_id = null; // global used by empty_info_table to move picker. -var meeting_clicked = false; -function meeting_event_click(event){ - //hide_all_conflicts(); - try{ - clear_highlight(find_friends(current_item)); - }catch(err){ } - - if(__debug_meeting_click) { - console.log("1 meeting_click:", event); - } - - // keep event from going up the chain. - event.preventDefault(); - meeting_clicked = true; - - var slot_id = $(event.target).closest('.agenda_slot').attr('id'); - var container = $(event.target).closest('.meeting_box_container'); - __debug_click_slot_id = slot_id; - __debug_click_container = container; - - if(container == undefined) { - return; - } - - var session_id = container.attr('session_id'); - var session = agenda_globals.meeting_objs[session_id]; - - select_session(session); - __DEBUG__SS_OBJ = current_scheduledslot; - __DEBUG__SLOT_OBJ = current_timeslot; - __DEBUG__SESSION_OBJ = session; -} - -function select_session(session) { - if(last_session != null) { - last_session.unselectit(); - } - last_session = session; - - empty_info_table(); - current_item = session.element(); - - /* clear set ot conflict views */ - clear_conflict_classes(); - conflict_classes = {}; - - current_timeslot = session.slot; - if(current_timeslot) { - current_timeslot_id = current_timeslot.timeslot_id; - } - current_scheduledslot = session.assignment; - if(__debug_meeting_click) { - console.log("2 meeting_click:", current_timeslot, session); - } - - fill_in_session_info(session, true, session.slot); -} - -var last_item = null; // used during location change we make the background color -// of the timeslot highlight because it is being set into that slot. -function info_location_select_change(){ - if(last_item != null){ - $(last_item).removeClass("selected_slot"); - } - - last_item = '#'+$('#info_location_select').val(); - $(last_item).addClass("selected_slot"); -} - -// called when the "Set" button is called, needs to perform a move, as if -// it was dragged and dropped. -function info_location_select_set() { - // figure out where the item was, and where it is going to. - var session = last_session; - var from_slot = session.slot; - - var id = $('#info_location_select').val(); - var to_slot = agenda_globals.timeslot_byid[id]; - - console.log("moved by select box from", from_slot, "to", to_slot); - - move_thing({ "session": session, - "to_slot_id": to_slot.domid, - "to_slot": to_slot, - "dom_obj": "#" + to_slot.domid, - "from_slot_id": from_slot.domid, - "from_slot": from_slot}); -} - -function move_thing(parameters) { - // hasn't moved don't do anything - if(parameters.from_slot_id == parameters.to_slot_id){ - $(parameters.dom_obj).removeClass('highlight_free_slot'); - return; - } - - parameters.too_small = false; - parameters.slot_occupied = false; - - var room_capacity = parameters.to_slot.capacity; - - if(parameters.session.session_attendees > room_capacity){ - parameters.too_small = true; - } - - parameters.bucket_list = (parameters.to_slot_id == bucketlist_id); - if(!parameters.bucket_list && !parameters.to_slot.empty){ - parameters.slot_occupied = true - } - - if(parameters.too_small || parameters.slot_occupied){ - toggle_dialog(parameters); - return - } - - clear_conflict_classes(); - // clear double wide setting for now. - // (could return if necessary) - parameters.session.double_wide = false; - - move_slot(parameters); -} - - - -var last_session = null; -var last_name_item = null; -function info_name_select_change(){ - if(last_session != null) { - console.log("unselecting:",last_session.title); - /* clear set ot conflict views */ - clear_conflict_classes(); - conflict_classes = {}; - last_session.unselectit(); - } - $(".same_group").removeClass("same_group"); - $(".selected_group").removeClass("selected_group"); - $(".selected_slot").removeClass("selected_slot"); - - if(last_item != null) { - $(last_item).removeClass("selected_slot"); - } - - var slot_id = $('#info_name_select').val(); - last_name_item = '#'+slot_id; - console.log("selecting group", slot_id); - - var ssk = agenda_globals.meeting_objs[slot_id].slot_status_key; - // ssk is null when item is in bucket list. - - current_item = "#session_"+slot_id; //slot_status_obj[0].session_id; - if(current_item != null){ - $(current_item).addClass("selected_slot"); - $(current_item).get(0).scrollIntoView(true); - } - - if(ssk != null){ - var slot_status_obj = agenda_globals.slot_status[ssk]; - current_timeslot = slot_status_obj[0].timeslot; - current_timeslot_id = slot_status_obj[0].timeslot_id; - ss = slot_status_obj[0]; - session = ss.session; - last_session = session; - last_session.selectit(); - // now set up the call back that might have to retrieve info. - fill_in_session_info(session, true, ss); - } - else { - ss = agenda_globals.meeting_objs[slot_id]; - last_session = ss; - last_session.selectit(); - fill_in_session_info(ss, true, ss.slot); - } - - console.log("selecting new item:", last_session.title); -} - -function set_pin_session_button(assignment) { - $("#pin_slot").unbind('click'); - if(assignment == undefined) { - console.log("pin not set, assignment undefined"); - return; - } - state = assignment.pinned; - //console.log("button set to: ",state); - $("#pin_slot").attr('disabled',false); - if(state) { - $("#pin_slot").html("unPin"); - $("#agenda_pin_slot").addClass("button_down"); - $("#agenda_pin_slot").removeClass("button_up"); - $("#pin_slot").click(function(event) { - update_pin_session(assignment, false); - }); - } else { - $("#pin_slot").html(" Pin "); - $("#agenda_pin_slot").addClass("button_up"); - $("#agenda_pin_slot").removeClass("button_down"); - $("#pin_slot").click(function(event) { - update_pin_session(assignment, true); - }); - } -} - -function update_pin_session(assignment, state) { - start_spin(); - assignment.set_pinned(state, function(assignment) { - stop_spin(); - session.repopulate_event(assignment.domid()); - set_pin_session_button(assignment); - }); -} - -function enable_button(divid, buttonid, func) { - $(buttonid).unbind('click'); - $(buttonid).click(func); - $(buttonid).attr('disabled',false); - - $(divid).removeClass("button_disabled"); - $(divid).addClass("button_enabled"); -} - -function disable_button(divid, buttonid) { - $(buttonid).unbind('click'); - $(buttonid).attr('disabled',true); - - $(divid).addClass("button_disabled"); - $(divid).removeClass("button_enabled"); -} - -function highlight_session(session) { - var element = session.element()[0]; - element.scrollIntoView(true); - session.element().parent().parent().parent().effect("pulsate", {color:"lightcoral"}, 10000); -} - -function fill_in_session_info(session, success, extra) { - var prev_session = null; - var next_session = null; - - if(session == null || session == "None" || !success){ - empty_info_table(); - return; - } - session.generate_info_table(); - - prev_session = session.prev_session; - next_session = session.next_session; - - if(!read_only) { - enable_button("#agenda_double_slot", "#double_slot", extend_slot); - - $("#agenda_pin_slot").removeClass("button_disabled"); - $("#agenda_pin_slot").addClass("button_enabled"); - set_pin_session_button(session.assignment); - } else { - $("#pin_slot").unbind('click'); - } - - enable_button("#agenda_show", "#show_session", show_all_session); - - if(prev_session) { - enable_button("#agenda_prev_session", "#prev_session", function(event) { - select_session(prev_session); - highlight_session(prev_session); - }); - } else { - disable_button("#agenda_prev_session", "#prev_session"); - } - if(next_session) { - enable_button("#agenda_next_session", "#next_session", function(event) { - select_session(next_session); - highlight_session(next_session); - }); - } else { - disable_button("#agenda_next_session", "#next_session"); - } - - $("#agenda_requested_features").html(""); - // fill in list of resources into #agenda_requested_features - if(session.resources != undefined) { - $.each(session.resources, function(index) { - resource = this; - - $("#agenda_requested_features").html(function(inbox, oldhtml) { - return "
"+ - "\""+"+ - "
"+ - oldhtml - }); - - console.log(session.title, "asks for resource", resource.desc); - }); - } - - - // here is where we would set up the session request edit button. - // $(".agenda_sreq_button").html(session.session_req_link); - - session.retrieve_constraints_by_session().success(function(newobj,status,jq) { - draw_constraints(session); - }); -} - -function group_name_or_empty(constraint) { - if(constraint == undefined) { - return ""; - } else { - return constraint.conflict_view(); - } -} - -function draw_constraints(session) { - - if("conflicts" in session) { - var display = agenda_globals.group_conflict_labels; - var group_icons = ""; - var group_set = {}; - $.each(session.conflicts, function(index) { - conflict = session.conflicts[index]; - conflict.build_othername(); - if(conflict.conflict_groupP()) { - if ( ! (conflict.othergroup_name in group_set) ) { - group_set[conflict.othergroup_name] = {}; - } - group_set[conflict.othergroup_name][conflict.direction]=display[conflict.conflict_type]; - // This had been in build_group_conflict_view - conflict.populate_conflict_classes(); - highlight_conflict(conflict); - } - }); - - - $.each(group_set, function(index) { - group = group_set[index]; - group_view = "
  • "; - group_view += "
    " - if ('ours' in group_set[index]) { - group_view += group_set[index].ours+"->" - } - group_view += "
    " - group_view += index; - group_view += "
    " - if ('theirs' in group_set[index]) { - group_view += "->"+group_set[index].theirs - } - group_view += "
    " - group_view += "
  • "; - group_icons += group_view; - }); - - if(group_icons == "") { - $("#conflict_group_list").html("none"); - } else { - $("#conflict_group_list").html("
      "+group_icons+"
    "); - } - } else { - $("#conflict_group_list").html("none"); - } - if("bethere" in session.constraints) { - var people_icons = ""; - - $.each(session.constraints.bethere, function(index) { - conflict = session.constraints.bethere[index]; - if(conflict.conflict_peopleP()) { - people_icons += "
  • "+conflict.conflict_view(); - } - }); - if(people_icons == "") { - $("#conflict_people_list").html("none"); - } else { - $("#conflict_people_list").html("
      "+people_icons+"
    "); - } - } else { - $("#conflict_people_list").html("none"); - } - -} - -function highlight_conflict(constraint) { - if(constraint != undefined) { - var clicked = constraint.dom_id; - //console.log("91 fill", constraint.href, constraint.othergroup.href); - conflict_status[clicked] = true; - constraint.show_conflict_view(); - } -} - -var menu_bar_hidden = false; -function hide_ietf_menu_bar(){ - $('#ietf_menubar').toggle('slide',"",100); - if(menu_bar_hidden){ - menu_bar_hidden = false; - $('.wrapper').css('width','auto'); - $('.wrapper').css('margin-left','160px'); - $('#close_ietf_menubar').html("<"); - - } - else{ - menu_bar_hidden = true; - $('.wrapper').css('width','auto'); - $('.wrapper').css('margin-left','0px'); - $('#close_ietf_menubar').html(">"); - } -} - - - -/* create the droppable */ -function droppable(){ - if(read_only) { - return; - } - $(function() { - /* the thing that is draggable */ - $( ".meeting_event").draggable({ - appendTo: "body", - helper: "clone", - drag: drag_drag, - start: drag_start, - stop: drag_stop, - }); - - $( "#sortable-list").droppable({ - over : drop_over, - activate: drop_activate, - out : drop_out, - drop : drop_drop, - start: drop_start, - }) - - $("#meetings td.ui-droppable").droppable({ - over :drop_over, - activate:drop_activate, - out :drop_out, - drop : drop_drop, - create: drop_create, - start: drop_start, - - }); // end $(#meetings td).droppable - }); // end function() -} // end droppable() - - -function update_to_slot(session_id, to_slot_id, force){ - //console.log("update_to_slot meeting_id:",session_id, to_slot_id); - if(to_slot_id == "sortable-list") { - /* must be bucket list */ - return true; - } - - var to_timeslot = agenda_globals.timeslot_bydomid[to_slot_id]; - if(to_timeslot != undefined && (to_timeslot.empty == true || force)) { - // add a new assignment for this, save it. - var new_ss = make_ss({ "session_id" : session_id, - "timeslot_id": to_timeslot.timeslot_id}); - // make_ss also adds to slot_status. - var save_promise = new_ss.saveit(); - - if(to_slot_id != bucketlist_id) { - to_timeslot.mark_occupied(); - } - - // update meeting_obj - agenda_globals.meeting_objs[session_id].placed(to_timeslot, true, new_ss); - - return save_promise; - } else { - console.log("update_to_slot failed", to_timeslot, force, "empty", to_timeslot.empty); - return false; - } -} - -function update_from_slot(session_id, from_slot_id) -{ - var from_timeslot = agenda_globals.timeslot_bydomid[from_slot_id]; - - /* this is a list of schedule-timeslot-session-assignments */ - var from_scheduledslots = agenda_globals.slot_status[from_slot_id]; - var delete_promises = []; - - // it will be null if it's coming from a bucketlist - if(from_slot_id != null && from_scheduledslots != undefined){ - //console.log("1 from_slot_id", from_slot_id, from_scheduledslots); - var count = from_scheduledslots.length; - var found = []; - for(var k = 0; k 0) { - remove_duplicate(parameters.from_slot_id, parameters.session.session_id); - // do something else? - } - else { - console.log("WARNING -- nothing to delete when updating from_slot", parameters.from_slot_id, agenda_globals.slot_status[parameters.from_slot_id]); - //return; - } - parameters.session.slot_status_key = parameters.to_slot_id; - - var eTemplate = parameters.session.event_template() - $(parameters.dom_obj).append(eTemplate); - - if(parameters.ui) { - parameters.ui.draggable.remove(); - } - - /* recalculate all the conflict classes given new slot */ - parameters.session.update_column_classes([parameters.to_slot], - parameters.bucket_list); - - /* set colours */ - $(parameters.dom_obj).removeClass('highlight_free_slot'); - if(parameters.to_slot.empty) { - $(parameters.dom_obj).removeClass('free_slot') - } - else{ - $(parameters.dom_obj).addClass('free_slot') - } - - if(parameters.from_slot != undefined && parameters.from_slot.empty){ - $("#"+parameters.from_slot_id).removeClass('free_slot'); - } - else{ - $("#"+parameters.from_slot_id).addClass('free_slot'); - } - $("#"+bucketlist_id).removeClass('free_slot'); - /******************************************************/ - - droppable(); - listeners(); - sort_unassigned(); - - delete_promises.push(save_promise); - return $.when.apply($,delete_promises); -} - -/* first thing that happens when we grab a meeting_event */ -function drop_activate(event, ui){ - //$(event.draggable).css("background",dragging_color); - $(event.currentTarget).addClass('highlight_current_moving'); -} - - -/* what happens when moving a meeting event over something that is 'droppable' */ -function drop_over(event, ui){ - if(check_free(this)){ - $(this).addClass('highlight_free_slot'); - } - $(event.draggable).addClass('highlight_current_selected'); - $(ui.draggable).addClass('highlight_current_selected'); - -// $(ui.draggable).css("background",dragging_color); -// $(event.draggable).css("background",dragging_color); -} - -/* when we have actually dropped the meeting event */ -function drop_out(event, ui){ - if(check_free(this)){ - if($(this).attr('id') != bucketlist_id){ - $(this).addClass("free_slot"); - } - } - $(this).removeClass('highlight_free_slot'); - $(event.draggable).removeClass('highlight_current_selected'); - $(ui.draggable).removeClass('highlight_current_selected'); - -} - -function drag_stop(event,ui){ - $(event.target).removeClass('highlight_current_selected'); - $(event.target).removeClass('highlight_current_moving'); -} - - -/* functions here are not used at the moment */ -function drop_create(event,ui){ -} - -function drop_start(event,ui){ -} - -function drag_drag(event, ui){ -} - -function drag_start(event, ui){ - return; -} - -/* - * Local Variables: - * c-basic-offset:4 - * End: - */ diff --git a/ietf/static/ietf/js/agenda/agenda_objects.js b/ietf/static/ietf/js/agenda/agenda_objects.js deleted file mode 100644 index 441f27b81..000000000 --- a/ietf/static/ietf/js/agenda/agenda_objects.js +++ /dev/null @@ -1,1935 +0,0 @@ -/* -* -* FILE: agenda_objects.js -* Copyright (c) 2013, The IETF Trust. See ../../../LICENSE. -* -* www.credil.org: Project Orlando 2013 -* Author: Justin Hornosty ( justin@credil.org ) -* Michael Richardson -* -* Description: -* Contains the objects relating to django's models. -* As much business logic as possible should be here. -* This file should be resuable by other than agenda_edit.js -* -* Display logic should be contained in agenda_listeners.js -* -* Functions: -* - check_delimiter(inp) -* - upperCaseWords(inp) -* -*/ - -function AgendaGlobals() { - this.group_objs = {}; - this.slot_status = {}; - this.slot_objs = {}; - this.meeting_objs = {}; - this.sessions_objs = {}; - this.timeslot_bydomid = {}; - this.timeslot_byid = {}; - this.assignment_promise = undefined; - this.timeslot_promise = undefined; - this.__debug_session_move = false; - /* Group_conflict_labels defines constraint names that are group conflicts and - * the symbols used to represent them. Shadow the old 1-2-3 labels to maintain - * behavior from before the new conflict types were introduced. This may need - * to be changed if the old editor is not phased out. */ - this.group_conflict_labels = { - 'conflict':'1' , - 'conflic2':'2' , - 'conflic3':'3', - 'chair_conflict': '1', - 'tech_overlap': '2', - 'key_participant': '3' - }; - -} - -function createLine(x1,y1, x2,y2){ - var length = Math.sqrt((x1-x2)*(x1-x2) + (y1-y2)*(y1-y2)); - var angle = Math.atan2(y2 - y1, x2 - x1) * 180 / Math.PI; - var transform = 'rotate('+angle+'deg)'; - - var line = $('
    ') - .appendTo('#meetings') - .addClass('line') - .css({ - 'position': '', - 'transform': transform - }) - .width(length) - .offset({left: x1, top: y1}); - - return line; -} - - -function empty_callback(inp){ -// console.log('inp:', inp); -} - -function get_all_constraints(){ - for(s in agenda_globals.meeting_objs){ - show_non_conflicting_spots(s) - } - -} - -function show_all_conflicts(){ - console.log("showing all conflicts"); - for(sk in agenda_globals.meeting_objs) { - var s = agenda_globals.meeting_objs[sk]; - s.display_conflict(); - s.display_personconflict(); - } -} - -// not really used anymore -- just for debugging -function hide_all_conflicts(){ - for(sk in agenda_globals.meeting_objs) { - var s = agenda_globals.meeting_objs[sk]; - s.hide_conflict(); - } -} - -function get_all_conflicts(){ - var all_constraint_promises = []; - var one_constraint; - var sess1; - //console.log("get_all_conflicts()"); - for(var s in agenda_globals.meeting_objs){ - sess1 = agenda_globals.meeting_objs[s]; - sess1.clear_conflict(); - sess1.display_conflict(); - - one_constraint = sess1.retrieve_constraints_by_session(); - all_constraint_promises.push(one_constraint); - } - - /* now make a promise that ends when all the conflicts are loaded */ - var all_constraints = $.when.apply($,all_constraint_promises); - - all_constraints.done(function() { - for(var s in agenda_globals.meeting_objs) { - var sess2 = agenda_globals.meeting_objs[s]; - sess2.examine_people_conflicts(); - } - }); - return all_constraints; -} - -var __debug_conflict_calculate = false; -var __verbose_conflict_calculate = false; - -function calculate_real_conflict(conflict, vertical_location, room_tag, session_obj) { - if(__debug_conflict_calculate) { - console.log(" conflict check:", conflict.othergroup.acronym, "me:", vertical_location, room_tag); - } - - if(session_obj.group.href == conflict.othergroup.href) { - console.log(" session: ",session_obj.session_id, "lists conflict with self"); - return; - } - - var osessions = conflict.othergroup.all_sessions; - if(__debug_conflict_calculate) { - console.log(" ogroup: ", conflict.othergroup.href, "me: ", session_obj.group.href); - } - if(conflict.othergroup === session_obj.group) { - osessions = conflict.thisgroup.all_sessions; - } - if(osessions != null) { - $.each(osessions, function(index) { - osession = osessions[index]; - for(ccn in osession.column_class_list) { - var value = osession.column_class_list[ccn]; - if(value != undefined) { - if(__debug_conflict_calculate) { - console.log(" vs: ",index, "session_id:",osession.session_id," at: ",value.column_tag, value.room_tag); - } - if(value.column_tag == vertical_location && - value.room_tag != room_tag) { - if(__verbose_conflict_calculate || __debug_conflict_calculate) { - console.log("real conflict:",session_obj.title," with: ",conflict.othergroup.acronym, " #session_",session_obj.session_id, value.room_tag, room_tag, value.column_tag, vertical_location); - } - // there is a conflict! - __DEBUG_SHOW_CONSTRAINT = $("#"+value[0]).children()[0]; - session_obj.add_conflict(conflict); - } - } - } - }); - } -} - -var __DEBUG_SHOW_CONSTRAINT = null; -// can become a method now. -function find_and_populate_conflicts(session_obj) { - if(__debug_conflict_calculate) { - console.log("populating conflict:", session_obj.title, session_obj.column_class_list); - } - - var room_tag = null; - session_obj.reset_conflicts(); - - for(let ccn in session_obj.column_class_list) { - if (session_obj.column_class_list.hasOwnProperty(ccn)) { - var vertical_location = session_obj.column_class_list[ccn].column_tag; - var room_tag = session_obj.column_class_list[ccn].room_tag; - $.each(Object.keys(agenda_globals.group_conflict_labels), (i, conflict_name) => { - if (session_obj.constraints[conflict_name]) { - $.each(session_obj.constraints[conflict_name], (j, conflict) => { - calculate_real_conflict(conflict, vertical_location, room_tag, session_obj); - }); - } - if(session_obj.theirconstraints.conflict){ - $.each(session_obj.theirconstraints.conflict, (i, conflict) => { - calculate_real_conflict(conflict, vertical_location, room_tag, session_obj); - }); - } - } - ); - - /* bethere constraints are processed in another loop */ - } - } -} - -function show_non_conflicting_spots(ss_id){ - var conflict_spots = [] - $.each(conflict_classes, function(key){ - conflict_spots.push(conflict_classes[key].session.slot_status_key); - }); - var empty_slots = find_empty_slots(); - conflict_spots.forEach(function(val){ - empty_slots.forEach(function(s){ - if(val == s.key){ - } - }); - }); -} - -function find_empty_slots(){ - var empty_slots = []; - $.each(slot_status, function(key){ - for(var i =0; i 0 above. - session.group = json.group; - - if(session.requested_duration == undefined) { - session.requested_duration = session.duration; - } - - // make it a number. - session.attendees = parseInt(session.attendees); - - session.ogroup = session.group; - if(session.group != undefined) { - /* it has an inline group object, intern it, and redirect to interned object */ - //console.log(session.title, "using embedded group: ", - // session.group.acronym, session.group.href, session.group); - session.group = load_group_from_json(session.group); - session.group_href = session.group.href; - //console.log(session.title, "2 using embedded group: ", - // session.group.acronym, session.group.href, session.group); - } else if(session.group_href != undefined) { - console.log("session ",session.session_id, - "has no embedded group, load by href", session.group_href); - session.group = find_group_by_href(session.group_href, "session_load"); - } else { - // bogus - session.group_href = site_base_url+'/group/'+session.title+".json"; - } - - // keep a list of sessions by name - // this is mostly used for debug purposes only. - if(agenda_globals.sessions_objs[session.title] == undefined) { - agenda_globals.sessions_objs[session.title] = []; - } - agenda_globals.sessions_objs[session.title].push(session); // an array since there can be more than one session/wg - - agenda_globals.meeting_objs[session.session_id] = session; - - return session; -} - -/* feed this an array of sessions */ -function make_sessions(json, status, jqXHR) { - $.each(json, function(index) { - var thing = json[index]; - session_obj(thing); - }); -} - -function load_sessions(href) { - if(agenda_globals.session_promise == undefined) { - agenda_globals.session_promise = $.Deferred(); - - var ss = $.ajax(href); - ss.success(function(newobj, status, jqXHR) { - console.log("finished session promise"); - make_sessions(newobj); - agenda_globals.session_promise.resolve(newobj); - }); - } - return agenda_globals.session_promise; -} - -function count_sessions() { - $.each(agenda_globals.sessions_objs, function(title) { - //console.log("title", title, this); - var lastone = null; - var sessions = agenda_globals.sessions_objs[title]; - var num_sessions = sessions.length; - $.each(sessions, function(index) { - //console.log("session", index, this); - this.number = index; // a number - this.maxNum = num_sessions; - - this.prev_session = lastone; - this.next_session = null; - if(index < num_sessions) { - this.next_session = sessions[index+1]; - } - lastone = this; - }); - }); -} - - -// augument to jQuery.getJSON( url, [data], [callback] ) -Session.prototype.load_session_obj = function(andthen, arg) { - session = this; - if(this.loaded == undefined) { - start_spin(); - this.loaded = $.ajax(this.href); - } - - this.loaded.success(function(newobj, status, jqXHR) { - last_json_reply = newobj; - $.extend(session, newobj); - - if(andthen != undefined) { - andthen(session, true, arg); - } - stop_spin(); - }); - this.loaded.error(function(jqXHR, textStatus, errorThrown ) { - console.log("exception: ",textStatus,errorThrown); - if(andthen != undefined) { - andthen(session, false, arg); - } - stop_spin(); - }); -}; - -Session.prototype.find_responsible_ad = function() { - var session = this; - //console.log("session",this.title, this.session_id,"looking for ad",this.group.ad_href); - if(this.group && this.group.ad_href) { - find_person_by_href(session.group.ad_href).done(function(ad) { - //console.log("session",session.session_id,"found ", ad); - session.responsible_ad = ad; - }); - } -}; - -Session.prototype.element = function() { - return $("#session_"+this.session_id); -}; - -Session.prototype.personconflict_element = function() { - return this.element().parent().find(".personconflict"); -}; - -Session.prototype.selectit = function() { - clear_all_selections(); - // mark self as selected - if(this.group != undefined) { - $("." + this.group.acronym).addClass("same_group"); - } - this.element().removeClass("save_group"); - this.element().addClass("selected_group"); -}; -Session.prototype.unselectit = function() { - clear_all_selections(); -}; - -Session.prototype.on_bucket_list = function() { - this.is_placed = false; - this.column_class_list = []; - this.element().parent("div").addClass("meeting_box_bucket_list"); -}; -Session.prototype.placed = function(where, forceslot, assignment) { - this.is_placed = true; - - // forceslot is set on a move, but unset on initial placement, - // as placed might be called more than once for a double slot session. - if(forceslot || this.slot==undefined) { - this.slot = where; - this.assignment = assignment; - - /* we can not mark old slot as empty, because it - might have multiple sessions in it. - we can do the opposite: mark it was not empty when we fill it. - */ - - if(where != undefined) { - where.empty = false; - } - } - if(where != undefined) { - this.add_column_class(where.column_class); - } - //console.log("session:",session.title, "column_class", ssid.column_class()); - this.element().parent("div").removeClass("meeting_box_bucket_list"); - this.pinned = where.pinned; -}; - -Session.prototype.populate_event = function(js_room_id) { - var eTemplate = this.event_template() - insert_cell(js_room_id, eTemplate, false); -}; -Session.prototype.repopulate_event = function(js_room_id) { - var eTemplate = this.event_template() - insert_cell(js_room_id, eTemplate, true); - this.display_conflict(); -}; - -Session.prototype.visible_title = function() { - return this.special_request + this.title; -}; - -var _conflict_debug = false; -Session.prototype.mark_conflict = function(value) { - this.conflicted = value; -}; -Session.prototype.add_conflict = function(conflict) { - this.conflicted = true; - if (this.conflicted_direction==undefined) { - this.conflicted_direction={}; - } - this.conflicted_direction[conflict.direction] = true; - if (this.highest_conflict==undefined) { - this.highest_conflict={}; - } - if(this.highest_conflict[conflict.direction]==undefined) { - this.highest_conflict[conflict.direction] = conflict; - } else { - var oldhighest = this.highest_conflict[conflict.direction]; - this.highest_conflict[conflict.direction] = this.highest_conflict[conflict.direction].conflict_compare(conflict); - if(_conflict_debug) { - console.log("add conflict for", this.title, - oldhighest.conflict_type, ">?", conflict.conflict_type, - "=", this.highest_conflict[conflict.direction].conflict_type); - } - } - if (this.conflict_level==undefined){ - this.conflict_level={}; - } - this.conflict_level[conflict.direction] = this.highest_conflict[conflict.direction].conflict_type; -}; - -Session.prototype.clear_conflict = function() { - this.conflicted = false; -}; - -Session.prototype.clear_all_conflicts = function(old_column_classes) { - var session_obj = this; - this.clear_conflict(); - - if(old_column_classes != undefined) { - $.each(session_obj.constraints.bethere, function(i) { - var conflict = session_obj.constraints.bethere[i]; - var person = conflict.person; - - person.clear_session(session_obj, old_column_classes); - }); - } -}; - -Session.prototype.show_conflict = function() { - if(_conflict_debug) { - console.log("showing conflict for", this.title, this.conflict_level['ours'],this.conflict_level['theirs']); - } - var display = agenda_globals.group_conflict_labels; - if (this.conflicted) { - if ('ours' in this.conflict_level) { - this.element().find('.ourconflicts').text('->'+display[this.conflict_level.ours]); - } - if ('theirs' in this.conflict_level) { - this.element().find('.theirconflicts').text(display[this.conflict_level.theirs]+'->'); - } - } -}; - -Session.prototype.hide_conflict = function() { - if(_conflict_debug) { - console.log("removing conflict for", this.title); - } - this.element().find('.ourconflicts').text(''); - this.element().find('.theirconflicts').text(''); -}; - -Session.prototype.display_conflict = function() { - if(this.conflicted || this.theirconflicted) { - this.show_conflict(); - } else { - this.hide_conflict(); - } -}; - -Session.prototype.reset_conflicts = function() { - this.conflict_level = undefined; - this.highest_conflict = undefined; - this.conflicted = false; - this.conflicted_direction = undefined; - this.theirconflict_level = undefined; - this.highest_theirconflict = undefined; - this.theirconflicted = false; -}; - -Session.prototype.show_personconflict = function() { - if(_conflict_debug) { - console.log("showing person conflict for", this.title, this.conflict_level.ours); - } - this.personconflict_element().removeClass("hidepersonconflict"); - this.personconflict_element().addClass("showpersonconflict"); -}; -Session.prototype.hide_personconflict = function() { - if(_conflict_debug) { - console.log("removing person conflict for", this.title); - } - this.personconflict_element().addClass("hidepersonconflict"); - this.personconflict_element().removeClass("showpersonconflict"); -}; -Session.prototype.display_personconflict = function() { - if(this.person_conflicted) { - this.show_personconflict(); - } else { - this.hide_personconflict(); - } -}; -Session.prototype.add_personconflict = function(conflict) { - this.person_conflicted = true; -}; - -Session.prototype.examine_people_conflicts = function() { - /* - * the scan for people conflicts has to be done after the fill_in_constraints - * because we don't know which sessions the people will need to attend until - * all the constraints have been examined. - */ - var session_obj = this; - - // reset people conflicts. - session_obj.person_conflicted = false; - - for(ccn in session_obj.column_class_list) { - var vertical_location = session_obj.column_class_list[ccn].column_tag; - var room_tag = session_obj.column_class_list[ccn].room_tag; - - if(session_obj.constraints.bethere != null) { - if(_person_bethere_debug) { - console.log("examining bethere constraints for", session_obj.title); - } - $.each(session_obj.constraints.bethere, function(i) { - var conflict = session_obj.constraints.bethere[i]; - find_person_by_href(conflict.person_href).done(function(person) { - conflict.person = person; - if(_person_bethere_debug) { - console.log("examining", person.ascii," bethere constraints for", session_obj.title); - } - if(person.conflicted_time(vertical_location)) { - session_obj.add_personconflict(conflict); - } - }); - }); - } - } -} - - -Session.prototype.area_scheme = function() { - return this.area.toUpperCase() + "-scheme"; -}; - -Session.prototype.is_bof = function() { - return this.bof == "True"; -} -Session.prototype.wg_scheme = function() { - if(this.is_bof()) { - return "bof_style"; - } else { - return "wg_style"; - } -}; - -Session.prototype.add_column_class = function(column_class) { - if(__column_class_debug) { - console.log("adding:",column_class, "to ", this.title); - } - this.column_class_list.push(column_class); -}; - -var _LAST_MOVED_OLD; -var _LAST_MOVED_NEW; -// timeslot_list is a list of slots where the session has been located. -// bucket_list is a boolean. -Session.prototype.update_column_classes = function(timeslot_list, bucket_list) { - - // COLUMN CLASSES MUST BE A LIST because of multiple slot use - console.log("updating column_classes for ", this.title); - - var old_column_classes = this.column_class_list; - if(old_column_classes.length == 0) { - console.log("old column class was undefined for session:", session.title); - old_column_classes = [new ColumnClass()]; - } - - // zero out list. - this.column_class_list = []; - var new_column_tag = "none"; - if(bucket_list) { - this.on_bucket_list(); - - } else { - for(tsn in timeslot_list) { - var ts = timeslot_list[tsn]; - console.log("timeslot_list", tsn, ts); - this.add_column_class(ts.column_class); - } - new_column_tag = this.column_class_list[0].column_tag; - } - - var old_column_class_name = "none"; - if(old_column_classes != undefined && - old_column_classes[0] != undefined) { - old_colum_class_name = old_column_classes[0].column_tag; - } - - console.log("setting column_class for ",this.title," to ", - new_column_tag, "was: ", old_column_class_name); - - console.log("unset conflict for ",this.title," is ", this.conflicted); - - _LAST_MOVED_OLD = old_column_classes; - _LAST_MOVED_NEW = this.column_class_list; - - this.group.del_column_classes(old_column_classes); - this.group.add_column_classes(this.column_class_list); - recalculate_conflicts_for_session(this, old_column_classes, this.column_class_list); -}; - - -// utility/debug function, draws all events. -function update_all_templates() { - for(key in agenda_globals.meeting_objs) { - session = agenda_globals.meeting_objs[key]; - var slot = session.slot_status_key; - if(slot != null) { - session.repopulate_event(slot); - } - } -} - -Session.prototype.event_template = function() { - // the extra div is present so that the table can have a border which does not - // affect the total height of the box. The border otherwise screws up the height, - // causing things to the right to avoid this box. - var area_mark = ""; - if(this.responsible_ad != undefined) { - area_mark = this.responsible_ad.area_mark; - if(area_mark == undefined) { - // This doesn't seem to do the right thing - // area_mark = "ad:" + this.responsible_ad.href; - area_mark = ""; - } - } - - var bucket_list_style = "meeting_box_bucket_list" - if(this.is_placed) { - bucket_list_style = ""; - area_mark = ""; /* no mark for unplaced items: it goes to the wrong place */ - } - if(this.double_wide) { - bucket_list_style = bucket_list_style + " meeting_box_double"; - } - - var pinned = ""; - if(this.pinned) { - bucket_list_style = bucket_list_style + " meeting_box_pinned"; - pinned="P"; - } - - var groupacronym = "nogroup"; - if(this.group != undefined) { - groupacronym = this.group.acronym; - } - //console.log("acronym", groupacronym, this.group.acronym, this.visible_title()); - - var durationstring=""; - if (this.requested_duration!="0.0") { - durationstring = " ("+this.requested_duration+")" - } - // see comment in ietf.ccs, and - // http://stackoverflow.com/questions/5148041/does-firefox-support-position-relative-on-table-elements - return "
    "+pinned+"
    "+ - this.visible_title()+ - "" + durationstring + "" + - "
    "+ area_mark +"
    "; -}; - -function andthen_alert(object, result, arg) { - alert("result: "+result+" on obj: "+object); -}; - -Session.prototype.generate_info_table = function() { - $("#info_grp").html(name_select_html); - if(this.is_bof()) { - $("#grp_type").html("BOF"); - } else { - $("#grp_type").html("WG"); - } - - $("#info_name_select").val($("#info_name_select_option_"+this.session_id).val()); - if(this.description.length > 33) { - $("#info_name").html(""+this.description.substring(0,35)+"..."); - } else { - $("#info_name").html(this.description); - } - $("#info_area").html(""+this.area+""); - $("#info_duration").html(this.requested_duration); - if(this.attendees == "None") { - $("#info_capacity").text("size unknown"); - } else { - $("#info_capacity").text(this.attendees + " people"); - } - - if(!read_only) { - $("#info_location").html(generate_select_box()+""); - } - - var special_requests_text = ''; - if(this.joint_with_groups) { - special_requests_text += 'Joint session with ' + this.joint_with_groups.join(', ') + '. '; - } - if(this.constraints.wg_adjacent) { - for (var target_href in this.constraints.wg_adjacent) { - if (this.constraints.wg_adjacent.hasOwnProperty(target_href)) { - special_requests_text += 'Schedule adjacent with ' + this.constraints.wg_adjacent[target_href].othergroup.acronym + '. '; - } - } - } - if(this.constraints.time_relation) { - special_requests_text += this.constraints.time_relation.time_relation.time_relation_display + '. '; - } - if(this.constraints.timerange) { - special_requests_text += this.constraints.timerange.timerange.timeranges_display + '. '; - } - if("comments" in this && this.comments.length > 0 && this.comments != "None") { - special_requests_text += this.comments; - } else { - special_requests_text += "Special requests: None"; - } - $("#special_requests").text(special_requests_text); - - this.selectit(); - - if(this.slot != undefined) { - ss = this.slot; - if(ss.timeslot_id == null){ - $("#info_location_select").val(agenda_globals.meeting_objs[ss.assignment_id]); - }else{ - $("#info_location_select").val(ss.timeslot_id); // *** - } - $("#info_location_select").val($("#info_location_select_option_"+ss.timeslot_id).val()); - } - - //console.log("ad for session",this.session_id,"is",this.responsible_ad); - if(this.responsible_ad) { - this.responsible_ad.populate_responsible_ad(); - } - $("#info_requestedby").html(this.requested_by +" ("+this.requested_time+")"); - - listeners(); -}; - -function load_all_groups() { - for(key in agenda_globals.meeting_objs) { - session = agenda_globals.meeting_objs[key]; - session.group = find_group_by_href(session.group_href, "load all"); - } -} - -var __DEBUG_THIS_SLOT; -Session.prototype.retrieve_constraints_by_session = function() { - __DEBUG_THIS_SLOT = this; - //console.log("4 retrieve loaded:", this.title, this.constraints_loaded, "loading:", this.constraints_loading); - - if(this.constraints_promise != undefined) { - return this.constraints_promise; - } - - var session_obj = this; - var href = meeting_base_url+'/session/'+session_obj.session_id+"/constraints.json"; - - this.constraints_promise = $.ajax(href); - this.constraints_loading = true; - this.constraints_promise.success(function(newobj, status, jq) { - session_obj.fill_in_constraints(newobj); - session_obj.constraints_loaded = true; - session_obj.constraints_loading = false; - find_and_populate_conflicts(session_obj); - }); - - return this.constraints_promise; -}; - -var __verbose_person_conflicts = false; -Session.prototype.calculate_bethere = function() { - var session_obj = this; - - if("bethere" in this.constraints) { - $.each(this.constraints["bethere"], function(index) { - var bethere = session_obj.constraints["bethere"][index]; - find_person_by_href(bethere.person_href).done(function(person) { - if(__verbose_person_conflicts) { - console.log("person",person.ascii,"attends session",session_obj.group.acronym); - } - person.attend_session(session_obj); - }); - }); - } -}; - -Session.prototype.fill_in_constraints = function(constraint_list) { - var session_obj = this; - $.each(constraint_list, function(key){ - thing = constraint_list[key]; - session_obj.add_constraint_obj(thing); - }); - - // here we can sort the constraints by group name. - // make a single list. this.constraints is not an array, can not use concat. - this.conflicts = []; - $.each(Object.keys(agenda_globals.group_conflict_labels), (i, conflict_name) => { - if (conflict_name in this.constraints) { - $.each(this.constraints[conflict_name], (j, conflict) => { - session_obj.conflicts.push(conflict); - }); - } - if (conflict_name in this.theirconstraints) { - $.each(this.theirconstraints[conflict_name], (j, conflict) => { - session_obj.conflicts.push(conflict); - }); - } - }); - this.calculate_bethere(); - this.conflicts = sort_conflict_list(this.conflicts) -}; - -// ++++++++++++++++++ -// Group Objects -function Group() { - this.andthen_list = []; /* should be removed, or replaced with promise */ - this.all_sessions = []; -} - -Group.prototype.loaded_andthen = function() { - me = this; - $.each(this.andthen_list, function(index, andthen) { - andthen(me); - }); - this.andthen_list = []; -}; - -Group.prototype.load_group_obj = function(andthen) { - //console.log("group ",this.href); - var group_obj = this; - - if(!this.loaded && !this.loading) { - this.loading = true; - this.andthen_list.push(andthen); - $.ajax(this.href, - { - success: function(newobj, status, jqXHR) { - if(newobj) { - $.extend(group_obj, newobj); - group_obj.loaded = true; - } - group_obj.loading = false; - group_obj.loaded_andthen(); - }, - error: function(jqXHR, textStatus, errorThrown ) { - console.log("error loading ",group_obj.href," textStatus: ", textStatus,errorThrown); - group_obj.loading = false; - group_obj.loaded = true; // white lie - group_obj.load_error = true; - } - }); - } else { - if(!this.loaded) { - // queue this continuation for later. - this.andthen_list.push(andthen); - } else { - this.loading = false; - andthen(group_obj); - } - } -} - -Group.prototype.add_session = function(session) { - if(this.all_sessions == undefined) { - this.all_sessions = []; - } - this.all_sessions.push(session); -}; - -var __DEBUG_GROUP_COLUMN_CLASSES = false; -Group.prototype.add_column_class = function(column_class) { - if(this.column_class_list == undefined) { - this.column_class_list = []; - } - if(__DEBUG_GROUP_COLUMN_CLASSES) { - console.log("group",this.acronym,"adding column_class",column_class); - } - this.column_class_list.push(column_class); -}; -Group.prototype.del_column_class = function(column_class) { - if(__DEBUG_GROUP_COLUMN_CLASSES) { - console.log("group",this.acronym,"del column_class",column_class); - } - for(n in this.column_class_list) { - if(this.column_class_list[n] == column_class) { - this.column_class_list.splice(n,1); - } - } -}; - -Group.prototype.add_column_classes = function(column_class_list) { - for(ccn in column_class_list) { - cc = column_class_list[ccn]; - this.add_column_class(cc); - } -}; -Group.prototype.del_column_classes = function(column_class_list) { - for(ccn in column_class_list) { - cc = column_class_list[ccn]; - this.del_column_class(cc); - } -}; - -var __debug_group_load = false; -function create_group_by_href(href) { - if(agenda_globals.group_objs[href] == undefined) { - agenda_globals.group_objs[href]=new Group(); - var g = agenda_globals.group_objs[href]; - if(__debug_group_load) { - console.log("creating new group for", href); - } - g.loaded = false; - g.loading= false; - } - return agenda_globals.group_objs[href]; -} - -function load_group_by_href(href) { - var g = agenda_globals.group_objs[href]; - if(!g.loaded) { - g.href = href; - if(__debug_group_load) { - console.log("loading group href", href); - } - g.load_group_obj(function() {}); - } - return g; -} - -// takes a json that has at least a "href" member, -// and finds or creates the object. Any additional -// fields are added to the group object, and the group -// is marked loaded. The resulting group object is returned. -function load_group_from_json(json) { - var g = create_group_by_href(json.href); - for(var key in json) { - if(json[key].length > 0) { - g[key]=json[key]; - } - } - g.loaded = true; - g.loading= false; - return g; -} - -var group_references = 0; -var group_demand_loads = 0; -function find_group_by_href(href, msg) { - group_references++; - var g=agenda_globals.group_objs[href]; - if(g == undefined) { - group_demand_loads++; - if(__debug_group_load) { - console.log("loading",href,"because of",msg); - } - g = create_group_by_href(href); - load_group_by_href(href); - } - //console.log("finding group by ", href, "gives: ", g); - return g; -} - -// ++++++++++++++++++ -// Constraint Objects -function Constraint() { -// fields: (see ietf.meeting.models Constraint.json_dict) -// -// -constraint_id -// -href -// -name -- really the conflict_type, which will get filled in -// -person/_href -// -source/_href -// -target/_href -// -meeting/_href -// -} - -var conflict_classes = {}; - -function clear_conflict_classes() { - // remove all conflict boxes from before - $.each(Object.keys(agenda_globals.group_conflict_labels), (i, conflict_name) => { - $(".show_" + conflict_name + "_specific_box").removeClass("show_" + conflict_name + "_specific_box"); - }); - - // reset all column headings - $(".show_conflict_view_highlight").removeClass("show_conflict_view_highlight"); -} -function find_conflict(domid) { - return conflict_classes[domid]; -} - -Constraint.prototype.column_class_list = function() { - return this.othergroup.column_class_list; -}; - -/* N.B., handling new conflict types as equivalent to the originals for prioritization. - * If this editor is not replaced by the new one, it might be worth sorting out how to - * properly prioritize. Otherwise, this maintains the behavior seen prior to the introduction - * of the new conflicts. */ -Constraint.prototype.conflict1P = function() { - return ((this.conflict_type === "conflict") || (this.conflict_type === "chair_conflict")); -}; - -Constraint.prototype.conflict2P = function() { - return ((this.conflict_type === "conflic2") || (this.conflict_type === "tech_overlap")); -}; - -Constraint.prototype.conflict3P = function() { - return ((this.conflict_type === "conflic3") || (this.conflict_type === "key_participant")); -}; - -Constraint.prototype.conflict_groupP = function() { - return this.conflict_type in agenda_globals.group_conflict_labels; -}; - -Constraint.prototype.conflict_peopleP = function() { - return (this.conflict_type === "bethere") -}; - -Constraint.prototype.conflict_compare = function(oflict) { - if(this.conflict_peopleP()) { - return oflict; - } - if(this.conflict1P()) { - /* "conflict" is highest, return it. */ - return this; - } - if(this.conflict2P() && oflict.conflict3P()) { - /* "conflic2" > "conflic3" */ - return this; - } - /* self > 2, so otype would win */ - return oflict; -}; - -// red is arbitrary here... There should be multiple shades of red for -// multiple types of conflicts. - - - -var __CONSTRAINT_DEBUG = null; -var __column_class_debug = false; - -// one used to get here by having the conflict boxes enabled/disabled, but they were -// removed from the UI. -// when a session is selected, the conflict boxes are filled in, -// and then they are all clicked in order to highlight everything. -Constraint.prototype.show_conflict_view = function() { - classes=this.column_class_list(); - if(classes == undefined) { - classes = [] - } - //console.log("show_conflict_view", this); - __CONSTRAINT_DEBUG = this; - if(__column_class_debug) { - console.log("show conflict", this.href, "classes", classes.length, this); - } - - // this highlights the column headings of the sessions that conflict. - for(ccn in classes) { - var cc = classes[ccn]; // cc is a ColumnClass now - - if(cc != undefined) { - /* this extracts the day from this structure */ - var th_tag = cc.th_tag; - if(__column_class_debug) { - console.log("add conflict for column_class", this.session.title, th_tag); - } - $(th_tag).addClass("show_conflict_view_highlight"); - } else { - console.log("cc is undefined for ccn:",ccn); - } - } - - // this highlights the conflicts themselves - //console.log("make box", this.thisgroup.href); - sessions = this.othergroup.all_sessions - // set class to like: .show_conflict_specific_box - conflict_class = "show_"+this.conflict_type+"_specific_box"; - if(sessions) { - $.each(sessions, function(key) { - //console.log("2 make box", key); - this.element().addClass(conflict_class); - }); - } - //console.log("viewed", this.thisgroup.href); -}; - -Constraint.prototype.populate_conflict_classes = function() { - // this is used for the red square highlighting. - var checkbox_id = "conflict_"+this.dom_id; - conflict_classes[checkbox_id] = this; -}; - -// Made dead by change to how the group view is built out -//Constraint.prototype.build_group_conflict_view = function() { -// -// var display = { 'conflict':'1' , 'conflic2':'2' , 'conflic3':'3' }; -// -// // this is used for the red square highlighting. -// var checkbox_id = "conflict_"+this.dom_id; -// conflict_classes[checkbox_id] = this; -// -// build = "
    "; -// if (this.direction=='theirs') { -// build += display[this.conflict_type]+"->"; -// } -// build += this.othergroup_name; -// if (this.direction=='ours') { -// build += "->"+display[this.conflict_type]; -// } -// build += "
    "; -// -// return build -// -//}; - -Constraint.prototype.build_people_conflict_view = function() { - var area_mark = ""; - if(this.person != undefined && this.person.area_mark_basic != undefined) { - area_mark = this.person.area_mark_basic; - } - return "
    "+area_mark+"
    "; -}; - -Constraint.prototype.build_othername = function() { - if(this.othergroup.load_error) { - console.log("request for unloaded group: ",this.othergroup.href); - var patt = /.*\/group\//; // ugly assumption about href structure. - var base = this.othergroup.href.replace(patt,"") - this.othergroup_name = base.replace(".json","") - } else { - this.othergroup_name = this.othergroup.acronym; - } -}; - -// subclasses would make some sense here. -Constraint.prototype.conflict_view = function() { - this.dom_id = "constraint_"+this.constraint_id; - - if(this.conflict_peopleP()) { - return this.build_people_conflict_view(); - } - else { - // This function is currently never called for this case - console.log("!! unexpected conflict_view for", this.href); - //this.build_othername(); - //return this.build_group_conflict_view(); - } -}; - -var _constraint_load_debug = false; -// SESSION CONFLICT OBJECTS -// take an object and add attributes so that it becomes a session_conflict_obj. -// note that constraints are duplicated: each session has both incoming and outgoing constraints added. -Session.prototype.add_constraint_obj = function(obj) { - // turn this into a Constraint object - // can not print or JSONify these on ff as this has cyclic references. Chrome can. - //console.log("session: ", this); - //console.log("add_constraint: ",obj.constraint_id, obj.name); - - obj2 = new Constraint(); - $.extend(obj2, obj); - - obj = obj2; - obj.session = this; - - var listname = obj.name; - obj.conflict_type = listname; - if(this.constraints[listname]==undefined) { - this.constraints[listname]={}; - } - if(this.theirconstraints[listname]==undefined) { - this.theirconstraints[listname]={}; - } - - if(listname == "bethere") { - //console.log("bethere constraint: ", obj); - var person_href = obj.person_href; - var session = this; - this.constraints[listname][person_href]=obj; - find_person_by_href(person_href).done(function(person) { - if(_constraint_load_debug) { - console.log("recorded bethere constraint: ",person.ascii,"for group",session.group.acronym); - } - obj.person = person; - }); - } else { - // must be conflic*, timerange, time_relation or wg_adjacent - var ogroupname; - if(obj.source_href == this.group_href) { - obj.thisgroup = this.group; - obj.othergroup = find_group_by_href(obj.target_href, "constraint src"+obj.href); - obj.direction = 'ours'; - if (obj.target_href) { - ogroupname = obj.target_href; - } else { - ogroupname = obj.name; - } - if(this.constraints[listname][ogroupname]) { - console.log("Found multiple instances of",this.group_href,listname,ogroupname); - } - this.constraints[listname][ogroupname] = obj - } else { - obj.thisgroup = this.group; - obj.othergroup = find_group_by_href(obj.source_href, "constraint dst"+obj.href); - obj.direction = 'theirs'; - ogroupname = obj.source_href; - if(this.theirconstraints[listname][ogroupname]) { - console.log("Found multiple instances of",ogroupname,listname,this.group_href); - } - this.theirconstraints[listname][ogroupname] = obj - } - - } - -}; - -function constraint_compare(a, b) -{ - if(a==undefined || a.othergroup == undefined) { - return -1; - } - if(b==undefined || b.othergroup == undefined) { - return 1; - } - return (a.othergroup.href > b.othergroup.href ? 1 : -1); -} - -function sort_conflict_list(things) { - var keys = Object.keys(things); - var keys1 = keys.sort(function(a,b) { - return constraint_compare(things[a],things[b]); - }); - var newlist = []; - for(i=0; i"; - this.area_mark += "AD"; - this.area_mark += ""; - this.area_mark += adnum; - this.area_mark += ""; - - this.area_mark_basic = ""; - this.area_mark_basic += adnum; - this.area_mark_basic += ""; -}; - -Person.prototype.populate_responsible_ad = function() { - var area_mark = ""; - if(this.area_mark != undefined) { - area_mark = this.area_mark_basic; - } - $("#info_responsible").html(this.name + area_mark); -}; - -var _person_bethere_debug = false; -// this marks a person as needing to attend a session -// in a particular timeslot. -Person.prototype.clear_session = function(session, old_column_classes) { - for(ccn in old_column_class_list) { - var vertical_location = session.column_class_list[ccn].column_tag; - var room_tag = session.column_class_list[ccn].room_tag; - - if(_person_bethere_debug) { - console.log("person",this.ascii,"maybe no longer attending session", - session.session_id, "in room",room_tag); - } - - // probably should make it dict, to make removal easier - if(this.sessions == undefined) { - continue; - } - - if(this.sessions[vertical_location] == undefined) { - continue; - } - - if(room_tag in this.sessions[vertical_location]) { - delete this.sessions[vertical_location][room_tag]; - if(_person_bethere_debug) { - console.log("person: ",this.ascii,"removed from room", - room_tag, "at", vertical_location); - } - } - } -}; - -Person.prototype.attend_session = function(session) { - for(ccn in session.column_class_list) { - var vertical_location = session.column_class_list[ccn].column_tag; - var room_tag = session.column_class_list[ccn].room_tag; - - if(_person_bethere_debug) { - console.log("person",this.ascii,"maybe attending session", session.session_id, "in room",room_tag); - } - - // probably should make it dict, to make removal easier - if(this.sessions == undefined) { - this.sessions = []; - } - if(this.sessions[vertical_location] == undefined) { - this.sessions[vertical_location] = []; - } - - - if(!(room_tag in this.sessions[vertical_location])) { - this.sessions[vertical_location][room_tag]=true; - if(_person_bethere_debug) { - console.log("person: ",this.ascii,"needs to be in room", - room_tag, "at", vertical_location); - } - } - } -}; - -Person.prototype.conflicted_time = function(vertical_location) { - var yesno = this.conflicted_time1(vertical_location); - //console.log("person: ",this.ascii,"examining for", vertical_location, "gives",yesno); - return yesno; -} - -Person.prototype.conflicted_time1 = function(vertical_location) { - if(this.sessions == undefined) { - return false; - } - - if(this.sessions[vertical_location] == undefined) { - return false; - } - - var placestobe = Object.keys(this.sessions[vertical_location]); - if(placestobe.length > 1) { - return true; - } else { - return false; - } -}; - - -/* - * Local Variables: - * c-basic-offset:4 - * End: - */ diff --git a/ietf/static/ietf/js/edit-meeting-schedule.js b/ietf/static/ietf/js/edit-meeting-schedule.js index b4aa3e365..60bce13ea 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,18 +717,76 @@ 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 visible timeslots - let timeslotGroupInputs = content.find("#timeslot-group-toggles-modal .modal-body input"); - function updateTimeslotGroupToggling() { + // Toggling timeslot types + let timeSlotTypeInputs = content.find('.timeslot-type-toggles input'); + function updateTimeSlotTypeToggling() { let checked = []; - timeslotGroupInputs.filter(":checked").each(function () { + 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'); + } + if (timeSlotTypeInputs.length > 0) { + timeSlotTypeInputs.on("change", updateTimeSlotTypeToggling); + updateTimeSlotTypeToggling(); + content.find('#timeslot-group-toggles-modal .timeslot-type-toggles .select-all').get(0).addEventListener( + 'click', + function() { + timeSlotTypeInputs.prop('checked', true); + updateTimeSlotTypeToggling(); + }); + content.find('#timeslot-group-toggles-modal .timeslot-type-toggles .clear-all').get(0).addEventListener( + 'click', + function() { + timeSlotTypeInputs.prop('checked', false); + updateTimeSlotTypeToggling(); + }); + } + + // Toggling session purposes + let sessionPurposeInputs = content.find('.session-purpose-toggles input'); + function updateSessionPurposeToggling(evt) { + 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'); + } + if (sessionPurposeInputs.length > 0) { + 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 .individual-timeslots input"); + function updateTimeSlotGroupToggling() { + let checked = []; + timeSlotGroupInputs.filter(":checked").each(function () { checked.push("." + this.value); }); @@ -707,8 +798,21 @@ jQuery(document).ready(function () { }); } - timeslotGroupInputs.on("click change", updateTimeslotGroupToggling); - updateTimeslotGroupToggling(); + timeSlotGroupInputs.on("click change", updateTimeSlotGroupToggling); + content.find('#timeslot-group-toggles-modal .timeslot-group-buttons .select-all').get(0).addEventListener( + 'click', + function() { + timeSlotGroupInputs.prop('checked', true); + updateTimeSlotGroupToggling(); + }); + content.find('#timeslot-group-toggles-modal .timeslot-group-buttons .clear-all').get(0).addEventListener( + 'click', + function() { + timeSlotGroupInputs.prop('checked', false); + updateTimeSlotGroupToggling(); + }); + + updateTimeSlotGroupToggling(); updatePastTimeslots(); setInterval(updatePastTimeslots, 10 * 1000 /* ms */); 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/static/ietf/js/meeting/session_details_form.js b/ietf/static/ietf/js/meeting/session_details_form.js new file mode 100644 index 000000000..5016a0139 --- /dev/null +++ b/ietf/static/ietf/js/meeting/session_details_form.js @@ -0,0 +1,115 @@ +/* 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 (row) { + if (purpose === 'regular') { + 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 ? (form.dataset.prefix + '-') : ''); + const purpose_elt = document.getElementById(id_prefix + 'purpose'); + if (purpose_elt.type === 'hidden') { + return; // element is hidden, so nothing to do + } + const name_elt = document.getElementById(id_prefix + 'name'); + const type_elt = document.getElementById(id_prefix + 'type'); + const type_options = type_elt.getElementsByTagName('option'); + const allowed_types = (type_elt.dataset.allowedOptions) ? + 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 6adc80427..85890dd4d 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,124 +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 %} - {% comment %}{% endcomment %} - {{item.timeslot.get_html_location}} - } - {% comment %}{% endcomment %} - {% 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 %} -
  • {{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_meeting_schedule.html b/ietf/templates/meeting/edit_meeting_schedule.html index 6718fd673..5d3c60271 100644 --- a/ietf/templates/meeting/edit_meeting_schedule.html +++ b/ietf/templates/meeting/edit_meeting_schedule.html @@ -16,6 +16,15 @@ .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; } + {# style off-agenda sessions to indicate this #} + .edit-meeting-schedule .session.off-agenda { filter: brightness(0.9); } + {# type and purpose styling #} + .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 %} @@ -40,7 +49,7 @@ · {% endif %} - New agenda + Copy agenda · Other Agendas @@ -133,6 +142,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 #}
    @@ -177,16 +187,17 @@ - + Show: {% for p in session_parents %} {% endfor %} - - - + {% if session_purposes|length > 1 %} + + {% endif %} +
    @@ -205,14 +216,59 @@
    + + + + + + +