* feat: add session recordings * feat: add session recordings * feat: deleting recordings * feat: deleting recordings and initial form values * feat: use meeting date rather than today for initial title field. Fix delete recording * feat: confirm delete recordings modal. fix server utils delete recording * fix: removing debug console.log * feat: change button name from 'Ok' to 'Delete' for confirm deletion to be clearer * feat: UTC time in string and delete modal text * fix: django html validation tests * fix: django html validation tests * fix: django html validation tests * refactor: Work with SessionPresentations * fix: better ordering * chore: drop rev, hide table when empty * test: test delete_recordings method * fix: debug delete_recordings * test: test add_session_recordings view * fix: better permissions handling * fix: only delete recordings for selected session * refactor: inline script -> js module * chore: remove accidental import *shakes fist at pycharm* * fix: consistent timestamp format plus slight rephrase * style: Black * chore: remove comment * test: update test to match * fix: reversible url pattern for materials Tests were perturbed in a way that led to a test getting an interim instead of an IETF meeting. This exposed a bug reversing the URL for the materials_document() view. This splits it into two patterns that are equivalent to the original. --------- Co-authored-by: Jennifer Richards <jennifer@staff.ietf.org>
5245 lines
224 KiB
Python
5245 lines
224 KiB
Python
# Copyright The IETF Trust 2007-2024, All Rights Reserved
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
import csv
|
|
import datetime
|
|
import io
|
|
import itertools
|
|
import json
|
|
import math
|
|
import os
|
|
import pytz
|
|
import re
|
|
import tarfile
|
|
import tempfile
|
|
import shutil
|
|
|
|
from calendar import timegm
|
|
from collections import OrderedDict, Counter, deque, defaultdict, namedtuple
|
|
from functools import partialmethod
|
|
import jsonschema
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, unquote, urlencode, urlsplit, urlunsplit, urlparse
|
|
from tempfile import mkstemp
|
|
from wsgiref.handlers import format_date_time
|
|
|
|
from django import forms
|
|
from django.core.cache import caches
|
|
from django.shortcuts import render, redirect, get_object_or_404
|
|
from django.http import (HttpResponse, HttpResponseRedirect, HttpResponseForbidden,
|
|
HttpResponseNotFound, Http404, HttpResponseBadRequest,
|
|
JsonResponse, HttpResponseGone, HttpResponseNotAllowed)
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.core.validators import URLValidator
|
|
from django.urls import reverse,reverse_lazy
|
|
from django.db.models import F, Max, Q
|
|
from django.forms.models import modelform_factory, inlineformset_factory
|
|
from django.template import TemplateDoesNotExist
|
|
from django.template.loader import render_to_string
|
|
from django.utils import timezone
|
|
from django.utils.encoding import force_str
|
|
from django.utils.text import slugify
|
|
from django.views.decorators.cache import cache_page
|
|
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt
|
|
from django.views.generic import RedirectView
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from ietf.doc.fields import SearchableDocumentsField
|
|
from ietf.doc.models import Document, State, DocEvent, NewRevisionDocEvent
|
|
from ietf.group.models import Group
|
|
from ietf.group.utils import can_manage_session_materials, can_manage_some_groups, can_manage_group
|
|
from ietf.person.models import Person, User
|
|
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, Attended
|
|
from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName
|
|
from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm, ImportMinutesForm,
|
|
TimeSlotCreateForm, TimeSlotEditForm, SessionCancelForm, 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
|
|
from ietf.meeting.helpers import preprocess_assignments_for_agenda, read_agenda_file
|
|
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
|
|
from ietf.meeting.helpers import can_request_interim_meeting, get_announcement_initial
|
|
from ietf.meeting.helpers import sessions_post_save, is_interim_meeting_approved
|
|
from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice
|
|
from ietf.meeting.helpers import send_interim_approval
|
|
from ietf.meeting.helpers import send_interim_approval_request
|
|
from ietf.meeting.helpers import send_interim_announcement_request, sessions_post_cancel
|
|
from ietf.meeting.utils import finalize, sort_accept_tuple, condition_slide_order
|
|
from ietf.meeting.utils import add_event_info_to_session_qs
|
|
from ietf.meeting.utils import session_time_for_sorting
|
|
from ietf.meeting.utils import session_requested_by, SaveMaterialsError
|
|
from ietf.meeting.utils import current_session_status, get_meeting_sessions, SessionNotScheduledError
|
|
from ietf.meeting.utils import data_for_meetings_overview, handle_upload_file, save_session_minutes_revision
|
|
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, bulk_create_timeslots
|
|
from ietf.meeting.utils import preprocess_meeting_important_dates
|
|
from ietf.meeting.utils import new_doc_for_session, write_doc_for_session
|
|
from ietf.meeting.utils import get_activity_stats, post_process, create_recording, delete_recording
|
|
from ietf.meeting.utils import participants_for_meeting, generate_bluesheet, bluesheet_data, save_bluesheet
|
|
from ietf.message.utils import infer_message
|
|
from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName, SessionPurposeName
|
|
from ietf.stats.models import MeetingRegistration
|
|
from ietf.utils import markdown
|
|
from ietf.utils.decorators import require_api_key
|
|
from ietf.utils.hedgedoc import Note, NoteError
|
|
from ietf.utils.meetecho import MeetechoAPIError, SlidesManager
|
|
from ietf.utils.log import assertion, log
|
|
from ietf.utils.mail import send_mail_message, send_mail_text
|
|
from ietf.utils.mime import get_mime_type
|
|
from ietf.utils.pipe import pipe
|
|
from ietf.utils.pdf import pdf_pages
|
|
from ietf.utils.response import permission_denied
|
|
from ietf.utils.text import xslugify
|
|
from ietf.utils.timezone import datetime_today, date_today
|
|
from ietf.settings import YOUTUBE_DOMAINS
|
|
|
|
from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm,
|
|
InterimCancelForm, InterimSessionInlineFormSet, RequestMinutesForm,
|
|
UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm,
|
|
UploadNarrativeMinutesForm)
|
|
|
|
request_summary_exclude_group_types = ['team']
|
|
|
|
|
|
def get_interim_menu_entries(request):
|
|
'''Setup menu entries for interim meeting view tabs'''
|
|
entries = []
|
|
entries.append(("Upcoming", reverse("ietf.meeting.views.upcoming")))
|
|
entries.append(("Pending", reverse("ietf.meeting.views.interim_pending")))
|
|
entries.append(("Announce", reverse("ietf.meeting.views.interim_announce")))
|
|
return entries
|
|
|
|
def send_interim_change_notice(request, meeting):
|
|
"""Sends an email notifying changes to a previously scheduled / announced meeting"""
|
|
group = meeting.session_set.first().group
|
|
form = InterimAnnounceForm(get_announcement_initial(meeting, is_change=True))
|
|
message = form.save(user=request.user)
|
|
message.related_groups.add(group)
|
|
send_mail_message(request, message)
|
|
|
|
# -------------------------------------------------
|
|
# View Functions
|
|
# -------------------------------------------------
|
|
|
|
def materials(request, num=None):
|
|
meeting = get_meeting(num)
|
|
begin_date = meeting.get_submission_start_date()
|
|
cut_off_date = meeting.get_submission_cut_off_date()
|
|
cor_cut_off_date = meeting.get_submission_correction_date()
|
|
today_utc = date_today(datetime.timezone.utc)
|
|
old = timezone.now() - datetime.timedelta(days=1)
|
|
if settings.SERVER_MODE != 'production' and '_testoverride' in request.GET:
|
|
pass
|
|
elif today_utc > cor_cut_off_date:
|
|
if meeting.number.isdigit() and int(meeting.number) > 96:
|
|
return redirect('ietf.meeting.views.proceedings', num=meeting.number)
|
|
else:
|
|
with timezone.override(meeting.tz()):
|
|
return render(request, "meeting/materials_upload_closed.html", {
|
|
'meeting_num': meeting.number,
|
|
'begin_date': begin_date,
|
|
'cut_off_date': cut_off_date,
|
|
'cor_cut_off_date': cor_cut_off_date
|
|
})
|
|
|
|
past_cutoff_date = today_utc > meeting.get_submission_correction_date()
|
|
|
|
schedule = get_schedule(meeting, None)
|
|
|
|
sessions = add_event_info_to_session_qs(Session.objects.filter(
|
|
meeting__number=meeting.number,
|
|
timeslotassignments__schedule__in=[schedule, schedule.base if schedule else None]
|
|
).distinct().select_related('meeting__schedule', 'group__state', 'group__parent')).order_by('group__acronym')
|
|
|
|
plenaries = sessions.filter(name__icontains='plenary')
|
|
ietf = sessions.filter(group__parent__type__slug = 'area').exclude(group__acronym='edu').order_by('group__parent__acronym', 'group__acronym')
|
|
irtf = sessions.filter(group__parent__acronym = 'irtf')
|
|
training = sessions.filter(group__acronym__in=['edu','iaoc'], type_id__in=['regular', 'other', ])
|
|
iab = sessions.filter(group__parent__acronym = 'iab')
|
|
editorial = sessions.filter(group__acronym__in=['rsab','rswg'])
|
|
|
|
session_pks = [s.pk for ss in [plenaries, ietf, irtf, training, iab, editorial] for s in ss]
|
|
other = sessions.filter(type__in=['regular'], group__type__features__has_meetings=True).exclude(pk__in=session_pks)
|
|
|
|
for topic in [plenaries, ietf, training, irtf, iab, editorial]:
|
|
for event in topic:
|
|
date_list = []
|
|
for slide_event in event.all_meeting_slides(): date_list.append(slide_event.time)
|
|
for agenda_event in event.all_meeting_agendas(): date_list.append(agenda_event.time)
|
|
if date_list: setattr(event, 'last_update', sorted(date_list, reverse=True)[0])
|
|
|
|
for session_list in [plenaries, ietf, training, irtf, iab, editorial, other]:
|
|
for session in session_list:
|
|
session.past_cutoff_date = past_cutoff_date
|
|
|
|
proceedings_materials = [
|
|
(type_name, meeting.proceedings_materials.filter(type=type_name).first())
|
|
for type_name in ProceedingsMaterialTypeName.objects.all()
|
|
]
|
|
|
|
plenaries, _ = organize_proceedings_sessions(plenaries)
|
|
irtf, _ = organize_proceedings_sessions(irtf)
|
|
training, _ = organize_proceedings_sessions(training)
|
|
iab, _ = organize_proceedings_sessions(iab)
|
|
editorial, _ = organize_proceedings_sessions(editorial)
|
|
other, _ = organize_proceedings_sessions(other)
|
|
|
|
ietf_areas = []
|
|
for area, area_sessions in itertools.groupby(
|
|
ietf,
|
|
key=lambda s: s.group.parent
|
|
):
|
|
meeting_groups, not_meeting_groups = organize_proceedings_sessions(area_sessions)
|
|
ietf_areas.append((area, meeting_groups, not_meeting_groups))
|
|
|
|
with timezone.override(meeting.tz()):
|
|
return render(request, "meeting/materials.html", {
|
|
'meeting': meeting,
|
|
'proceedings_materials': proceedings_materials,
|
|
'plenaries': plenaries,
|
|
'ietf_areas': ietf_areas,
|
|
'training': training,
|
|
'irtf': irtf,
|
|
'iab': iab,
|
|
'editorial': editorial,
|
|
'other': other,
|
|
'cut_off_date': cut_off_date,
|
|
'cor_cut_off_date': cor_cut_off_date,
|
|
'submission_started': today_utc > begin_date,
|
|
'old': old,
|
|
})
|
|
|
|
def current_materials(request):
|
|
today = date_today()
|
|
meetings = Meeting.objects.exclude(number__startswith='interim-').filter(date__lte=today).order_by('-date')
|
|
if meetings:
|
|
return redirect(materials, meetings[0].number)
|
|
else:
|
|
raise Http404('No such meeting')
|
|
|
|
|
|
def _get_materials_doc(meeting, name):
|
|
"""Get meeting materials document named by name
|
|
|
|
Raises Document.DoesNotExist if a match cannot be found.
|
|
"""
|
|
# try an exact match first
|
|
doc = Document.objects.filter(name=name).first()
|
|
if doc is not None and doc.get_related_meeting() == meeting:
|
|
return doc, None
|
|
# try parsing a rev number
|
|
if "-" in name:
|
|
docname, rev = name.rsplit("-", 1)
|
|
if len(rev) == 2 and rev.isdigit():
|
|
doc = Document.objects.get(name=docname) # may raise Document.DoesNotExist
|
|
if doc.get_related_meeting() == meeting and rev in doc.revisions_by_newrevisionevent():
|
|
return doc, rev
|
|
# give up
|
|
raise Document.DoesNotExist
|
|
|
|
|
|
@cache_page(1 * 60)
|
|
def materials_document(request, document, num=None, ext=None):
|
|
"""Materials document view
|
|
|
|
:param request: Django request
|
|
:param document: Name of document without an extension
|
|
:param num: meeting number
|
|
:param ext: extension including preceding '.'
|
|
"""
|
|
meeting = get_meeting(num, type_in=["ietf", "interim"])
|
|
num = meeting.number
|
|
try:
|
|
doc, rev = _get_materials_doc(meeting=meeting, name=document)
|
|
except Document.DoesNotExist:
|
|
raise Http404("No such document for meeting %s" % num)
|
|
|
|
if not rev:
|
|
filename = Path(doc.get_file_name())
|
|
else:
|
|
filename = Path(doc.get_file_path()) / document
|
|
if ext:
|
|
filename = filename.with_suffix(ext)
|
|
elif filename.suffix == "":
|
|
# If we don't already have an extension, try to add one
|
|
ext_choices = {
|
|
# Construct a map from suffix to full filename
|
|
fn.suffix: fn
|
|
for fn in sorted(filename.parent.glob(filename.stem + ".*"))
|
|
}
|
|
if len(ext_choices) > 0:
|
|
if ".pdf" in ext_choices:
|
|
filename = ext_choices[".pdf"]
|
|
else:
|
|
filename = list(ext_choices.values())[0]
|
|
if not filename.exists():
|
|
raise Http404(f"File not found: {filename}")
|
|
|
|
old_proceedings_format = meeting.number.isdigit() and int(meeting.number) <= 96
|
|
if settings.MEETING_MATERIALS_SERVE_LOCALLY or old_proceedings_format:
|
|
bytes = filename.read_bytes()
|
|
mtype, chset = get_mime_type(bytes)
|
|
content_type = "%s; charset=%s" % (mtype, chset)
|
|
|
|
if filename.suffix == ".md" and mtype == "text/plain":
|
|
sorted_accept = sort_accept_tuple(request.META.get("HTTP_ACCEPT"))
|
|
for atype in sorted_accept:
|
|
if atype[0] == "text/markdown":
|
|
content_type = content_type.replace("plain", "markdown", 1)
|
|
break
|
|
elif atype[0] == "text/html":
|
|
bytes = render_to_string(
|
|
"minimal.html",
|
|
{
|
|
"content": markdown.markdown(bytes.decode(encoding=chset)),
|
|
"title": filename.name,
|
|
},
|
|
)
|
|
content_type = content_type.replace("plain", "html", 1)
|
|
break
|
|
elif atype[0] == "text/plain":
|
|
break
|
|
|
|
response = HttpResponse(bytes, content_type=content_type)
|
|
response["Content-Disposition"] = f'inline; filename="{filename.name}"'
|
|
return response
|
|
else:
|
|
return HttpResponseRedirect(redirect_to=doc.get_href(meeting=meeting))
|
|
|
|
|
|
@login_required
|
|
def materials_editable_groups(request, num=None):
|
|
meeting = get_meeting(num)
|
|
return render(request, "meeting/materials_editable_groups.html", {
|
|
'meeting_num': meeting.number})
|
|
|
|
|
|
@role_required('Secretariat')
|
|
def edit_timeslots(request, num=None):
|
|
meeting = get_meeting(num)
|
|
if 'sched' in request.GET:
|
|
schedule = Schedule.objects.filter(pk=request.GET.get('sched', None)).first()
|
|
schedule_edit_url = _schedule_edit_url(meeting, schedule)
|
|
else:
|
|
schedule_edit_url = None
|
|
|
|
with timezone.override(meeting.tz()):
|
|
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 timeslots
|
|
# (with only one timeslot 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])))
|
|
|
|
# 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,
|
|
"slot_slices": slots,
|
|
"date_slices":date_slices,
|
|
"meeting":meeting,
|
|
"schedule_edit_url": schedule_edit_url,
|
|
"ts_list":ts_list,
|
|
"ts_with_official_assignments": ts_with_official_assignments,
|
|
"ts_with_any_assignments": ts_with_any_assignments,
|
|
})
|
|
|
|
|
|
class NewScheduleForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Schedule
|
|
fields = ['name', 'visible', 'public', 'notes', 'base']
|
|
|
|
def __init__(self, meeting, schedule, new_owner, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.meeting = meeting
|
|
self.schedule = schedule
|
|
self.new_owner = new_owner
|
|
|
|
username = new_owner.user.username
|
|
|
|
name_suggestion = username
|
|
counter = 2
|
|
|
|
existing_names = set(Schedule.objects.filter(meeting=meeting, owner=new_owner).values_list('name', flat=True))
|
|
while name_suggestion in existing_names:
|
|
name_suggestion = username + str(counter)
|
|
counter += 1
|
|
|
|
self.fields['name'].initial = name_suggestion
|
|
self.fields['name'].label = "Name of new agenda"
|
|
|
|
self.fields['base'].queryset = self.fields['base'].queryset.filter(meeting=meeting)
|
|
|
|
if schedule:
|
|
self.fields['visible'].initial = schedule.visible
|
|
self.fields['public'].initial = schedule.public
|
|
self.fields['base'].queryset = self.fields['base'].queryset.exclude(pk=schedule.pk)
|
|
self.fields['base'].initial = schedule.base_id
|
|
else:
|
|
base = Schedule.objects.filter(meeting=meeting, name='base').first()
|
|
if base:
|
|
self.fields['base'].initial = base.pk
|
|
|
|
def clean_name(self):
|
|
name = self.cleaned_data.get('name')
|
|
if name and Schedule.objects.filter(meeting=self.meeting, owner=self.new_owner, name=name):
|
|
raise forms.ValidationError("Schedule with this name already exists.")
|
|
return name
|
|
|
|
@role_required('Area Director','Secretariat')
|
|
def new_meeting_schedule(request, num, owner=None, name=None):
|
|
meeting = get_meeting(num)
|
|
schedule = get_schedule_by_name(meeting, get_person_by_email(owner), name)
|
|
|
|
if request.method == 'POST':
|
|
form = NewScheduleForm(meeting, schedule, request.user.person, request.POST)
|
|
|
|
if form.is_valid():
|
|
new_schedule = form.save(commit=False)
|
|
new_schedule.meeting = meeting
|
|
new_schedule.owner = request.user.person
|
|
new_schedule.origin = schedule
|
|
new_schedule.save()
|
|
|
|
if schedule:
|
|
for assignment in schedule.assignments.all():
|
|
# clone by resetting primary key
|
|
assignment.pk = None
|
|
assignment.schedule = new_schedule
|
|
assignment.extendedfrom = None
|
|
assignment.save()
|
|
|
|
# now redirect to this new schedule
|
|
return redirect(edit_meeting_schedule, meeting.number, new_schedule.owner_email(), new_schedule.name)
|
|
|
|
else:
|
|
form = NewScheduleForm(meeting, schedule, request.user.person)
|
|
|
|
return render(request, "meeting/new_meeting_schedule.html", {
|
|
'meeting': meeting,
|
|
'schedule': schedule,
|
|
'form': form,
|
|
})
|
|
|
|
@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())
|
|
meeting = get_meeting(num)
|
|
if name is None:
|
|
schedule = meeting.schedule
|
|
else:
|
|
schedule = get_schedule_by_name(meeting, get_person_by_email(owner), name)
|
|
|
|
if schedule is None:
|
|
raise Http404("No meeting information for meeting %s owner %s schedule %s available" % (num, owner, name))
|
|
|
|
can_see, can_edit, secretariat = schedule_permissions(meeting, schedule, request.user)
|
|
|
|
lock_time = settings.MEETING_SESSION_LOCK_TIME
|
|
def timeslot_locked(ts):
|
|
meeting_now = timezone.now().astimezone(meeting.tz())
|
|
return schedule.is_official and (ts.time - meeting_now < lock_time)
|
|
|
|
if not can_see:
|
|
if request.method == 'POST':
|
|
permission_denied(request, "Can't view this schedule.")
|
|
|
|
return render(request, "meeting/private_schedule.html", {
|
|
"schedule":schedule,
|
|
"meeting": meeting,
|
|
"meeting_base_url": request.build_absolute_uri(meeting.base_url()),
|
|
"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,
|
|
)
|
|
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:
|
|
assignments_by_session[a.session_id].append(a)
|
|
|
|
tombstone_states = ['canceled', 'canceledpa', 'resched']
|
|
|
|
sessions = meeting.session_set.with_current_status()
|
|
if include_timeslot_types is not None:
|
|
sessions = sessions.filter(type__in=include_timeslot_types)
|
|
sessions_to_schedule = sessions.that_can_be_scheduled()
|
|
session_tombstones = sessions.filter(
|
|
current_status__in=tombstone_states, pk__in={a.session_id for a in assignments}
|
|
)
|
|
sessions = sessions_to_schedule | session_tombstones
|
|
sessions = add_event_info_to_session_qs(
|
|
sessions.order_by('pk'),
|
|
requested_time=True,
|
|
requested_by=True,
|
|
).prefetch_related(
|
|
'resources', 'group', 'group__parent', 'group__type', 'joint_with_groups', 'purpose',
|
|
)
|
|
|
|
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.that_can_be_scheduled().prefetch_related('type').order_by('location', 'time', 'name')
|
|
|
|
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 = datetime.timedelta(minutes=30)
|
|
max_duration = datetime.timedelta(minutes=120)
|
|
|
|
def timedelta_to_css_ems(timedelta):
|
|
# we scale the session and slots a bit according to their
|
|
# length for an added visual clue
|
|
capped_min_d = max(min_duration, datetime.timedelta(minutes=30))
|
|
capped_max_d = min(max_duration, datetime.timedelta(hours=4))
|
|
capped_timedelta = min(max(capped_min_d, timedelta), capped_max_d)
|
|
|
|
min_d_css_rems = 5
|
|
max_d_css_rems = 7
|
|
# interpolate
|
|
scale = (capped_timedelta - capped_min_d) / (capped_max_d - capped_min_d) if capped_min_d != capped_max_d else 1
|
|
return min_d_css_rems + (max_d_css_rems - min_d_css_rems) * scale
|
|
|
|
def prepare_sessions_for_display(sessions):
|
|
# requesters
|
|
requested_by_lookup = {p.pk: p for p in Person.objects.filter(pk__in=set(s.requested_by for s in sessions if s.requested_by))}
|
|
|
|
# constraints
|
|
constraints_for_sessions, formatted_constraints_for_sessions, constraint_names = preprocess_constraints_for_meeting_schedule_editor(meeting, sessions)
|
|
|
|
sessions_for_group = defaultdict(list)
|
|
for s in sessions:
|
|
sessions_for_group[s.group_id].append(s)
|
|
|
|
for s in sessions:
|
|
s.requested_by_person = requested_by_lookup.get(s.requested_by)
|
|
|
|
s.purpose_label = None
|
|
if s.group:
|
|
if (s.purpose.slug in ('none', 'regular')):
|
|
s.scheduling_label = s.group.acronym
|
|
s.purpose_label = 'BoF' if s.group.is_bof() else s.group.type.name
|
|
else:
|
|
s.scheduling_label = s.name if s.name else f'??? [{s.group.acronym}]'
|
|
s.purpose_label = s.purpose.name
|
|
else:
|
|
s.scheduling_label = s.name if s.name else '???'
|
|
s.purpose_label = s.purpose.name
|
|
|
|
s.requested_duration_in_hours = round(s.requested_duration.seconds / 60.0 / 60.0, 1)
|
|
|
|
session_layout_margin = 0.2
|
|
s.layout_width = timedelta_to_css_ems(s.requested_duration) - 2 * session_layout_margin
|
|
s.parent_acronym = s.group.parent.acronym if s.group and s.group.parent else ""
|
|
|
|
# compress the constraints, so similar constraint labels are
|
|
# shared between the conflicting sessions they cover - the JS
|
|
# then simply has to detect violations and show the
|
|
# preprocessed labels
|
|
ConstraintHint = namedtuple('ConstraintHint', 'constraint_name count')
|
|
constraint_hints = defaultdict(set)
|
|
for name_id, ts in itertools.groupby(sorted(constraints_for_sessions.get(s.pk, [])), key=lambda t: t[0]): # name_id same for each set of ts
|
|
ts = list(ts)
|
|
session_pks = (t[1] for t in ts)
|
|
for session_pk, grouped_session_pks in itertools.groupby(session_pks):
|
|
# count is the number of instances of session_pk - should only have multiple in the
|
|
# case of bethere constraints, where there will be one per person.pk
|
|
count = len(list(grouped_session_pks)) # list() needed because iterator has no len()
|
|
constraint_hints[ConstraintHint(constraint_names[name_id], count)].add(session_pk)
|
|
|
|
# The constraint hint key is a tuple (ConstraintName, count). Value is the set of sessions pks that
|
|
# should trigger that hint.
|
|
s.constraint_hints = list(constraint_hints.items())
|
|
s.formatted_constraints = formatted_constraints_for_sessions.get(s.pk, {})
|
|
|
|
s.other_sessions = [s_other for s_other in sessions_for_group.get(s.group_id) if s != s_other]
|
|
|
|
s.readonly = s.current_status in tombstone_states or any(a.schedule_id != schedule.pk for a in assignments_by_session.get(s.pk, []))
|
|
|
|
def prepare_timeslots_for_display(timeslots, rooms):
|
|
"""Prepare timeslot data for template
|
|
|
|
Prepares timeslots for display by sorting into groups in a structure
|
|
that can be rendered by the template and by adding some data to the timeslot
|
|
instances. Currently adds a 'layout_width' property to each timeslot instance.
|
|
The layout_width is the width, in em, that should be used to style the timeslot's
|
|
width.
|
|
|
|
Rooms are partitioned into groups that have identical sets of timeslots
|
|
for the entire meeting.
|
|
|
|
The result of this method is an OrderedDict, days, keyed by the Date
|
|
of each day that has at least one timeslot. The value of days[day] is a
|
|
list with one entry for each group of rooms. Each entry is a list of
|
|
dicts with keys 'room' and 'timeslots'. The 'room' value is the room
|
|
instance and 'timeslots' is a list of timeslot instances for that room.
|
|
|
|
The format is more easily illustrated than explained:
|
|
|
|
days = OrderedDict(
|
|
Date(2021, 5, 27): [
|
|
[ # room group 1
|
|
{'room': <room1>, 'timeslots': [<room1 timeslot1>, <room1 timeslot2>]},
|
|
{'room': <room2>, 'timeslots': [<room2 timeslot1>, <room2 timeslot2>]},
|
|
{'room': <room3>, 'timeslots': [<room3 timeslot1>, <room3 timeslot2>]},
|
|
],
|
|
[ # room group 2
|
|
{'room': <room4>, 'timeslots': [<room4 timeslot1>]},
|
|
],
|
|
],
|
|
Date(2021, 5, 28): [
|
|
[ # room group 1
|
|
{'room': <room1>, 'timeslots': [<room1 timeslot3>]},
|
|
{'room': <room2>, 'timeslots': [<room2 timeslot3>]},
|
|
{'room': <room3>, 'timeslots': [<room3 timeslot3>]},
|
|
],
|
|
[ # room group 2
|
|
{'room': <room4>, 'timeslots': []},
|
|
],
|
|
],
|
|
)
|
|
"""
|
|
|
|
# Populate room_data. This collects the timeslots for each room binned by
|
|
# day, plus data needed for sorting the rooms for display.
|
|
room_data = dict()
|
|
all_days = set()
|
|
# timeslots_qs is already sorted by location, name, and time
|
|
for t in timeslots:
|
|
if t.location not in rooms:
|
|
continue
|
|
|
|
t.layout_width = timedelta_to_css_ems(t.duration)
|
|
if t.location_id not in room_data:
|
|
room_data[t.location_id] = dict(
|
|
timeslots_by_day=dict(),
|
|
timeslot_count=0,
|
|
start_and_duration=[],
|
|
first_timeslot = t,
|
|
)
|
|
rd = room_data[t.location_id]
|
|
rd['timeslot_count'] += 1
|
|
rd['start_and_duration'].append((t.time, t.duration))
|
|
ttd = t.local_start_time().date() # date in meeting timezone
|
|
all_days.add(ttd)
|
|
if ttd not in rd['timeslots_by_day']:
|
|
rd['timeslots_by_day'][ttd] = []
|
|
rd['timeslots_by_day'][ttd].append(t)
|
|
|
|
all_days = sorted(all_days) # changes set to a list
|
|
# Note the maximum timeslot count for any room
|
|
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.
|
|
rooms_with_timeslots = [r for r in rooms if r.pk in room_data]
|
|
# Then sort the remaining rooms.
|
|
sorted_rooms = sorted(
|
|
rooms_with_timeslots,
|
|
key=lambda room: (
|
|
# Sort lower capacity rooms first.
|
|
room.capacity if room.capacity is not None else math.inf, # sort rooms with capacity = None at end
|
|
# Sort regular session rooms ahead of others - these will usually
|
|
# have more timeslots than other room types.
|
|
0 if room_data[room.pk]['timeslot_count'] == max_timeslots else 1,
|
|
# Sort rooms with earlier timeslots ahead of later
|
|
room_data[room.pk]['first_timeslot'].time,
|
|
# Sort rooms with more sessions ahead of rooms with fewer
|
|
0 - room_data[room.pk]['timeslot_count'],
|
|
# Sort by list of starting time and duration so that groups with identical
|
|
# timeslot structure will be neighbors. The grouping algorithm relies on this!
|
|
room_data[room.pk]['start_and_duration'],
|
|
# Finally, sort alphabetically by name
|
|
room.name
|
|
)
|
|
)
|
|
|
|
# Rooms are now ordered so rooms with identical timeslot arrangements are neighbors.
|
|
# Walk the list, splitting these into groups.
|
|
room_groups = []
|
|
last_start_and_duration = None # Used to watch for changes in start_and_duration
|
|
for room in sorted_rooms:
|
|
if last_start_and_duration != room_data[room.pk]['start_and_duration']:
|
|
room_groups.append([]) # start a new room_group
|
|
last_start_and_duration = room_data[room.pk]['start_and_duration']
|
|
room_groups[-1].append(room)
|
|
|
|
# Next, build the structure that will hold the data for the view. This makes it
|
|
# easier to arrange that every room has an entry for every day, even if there is
|
|
# no timeslot for that day. This makes the HTML template much easier to write.
|
|
# Use OrderedDicts instead of lists so that we can easily put timeslot data in the
|
|
# right place.
|
|
days = OrderedDict(
|
|
(
|
|
day, # key in the Ordered Dict
|
|
[
|
|
# each value is an OrderedDict of room group data
|
|
OrderedDict(
|
|
(room.pk, dict(room=room, timeslots=[]))
|
|
for room in rg
|
|
) for rg in room_groups
|
|
]
|
|
) for day in all_days
|
|
)
|
|
|
|
# With the structure's skeleton built, now fill in the data. The loops must
|
|
# preserve the order of room groups and rooms within each group.
|
|
for rg_num, rgroup in enumerate(room_groups):
|
|
for room in rgroup:
|
|
for day, ts_for_day in room_data[room.pk]['timeslots_by_day'].items():
|
|
days[day][rg_num][room.pk]['timeslots'] = ts_for_day
|
|
|
|
# Now convert the OrderedDict entries into lists since we don't need to
|
|
# do lookup by pk any more.
|
|
for day in days.keys():
|
|
days[day] = [list(rg.values()) for rg in days[day]]
|
|
|
|
return days
|
|
|
|
def _json_response(success, status=None, **extra_data):
|
|
if status is None:
|
|
status = 200 if success else 400
|
|
data = dict(success=success, **extra_data)
|
|
return JsonResponse(data, status=status)
|
|
|
|
if request.method == 'POST':
|
|
action = request.POST.get('action')
|
|
if action == 'updateview':
|
|
# allow updateview action even if can_edit is false, it affects only the user's session
|
|
sess_data = request.session.setdefault('edit_meeting_schedule', {})
|
|
enabled_types = [ts_type.slug for ts_type in TimeSlotTypeName.objects.filter(
|
|
used=True,
|
|
slug__in=request.POST.getlist('enabled_timeslot_types[]', [])
|
|
)]
|
|
sess_data['enabled_timeslot_types'] = enabled_types
|
|
return _json_response(True)
|
|
elif not can_edit:
|
|
permission_denied(request, "Can't edit this schedule.")
|
|
|
|
# Handle ajax requests. Most of these return JSON responses with at least a 'success' key.
|
|
# For the swapdays and swaptimeslots actions, the response is either a redirect to the
|
|
# updated page or a simple BadRequest error page. The latter should not normally be seen
|
|
# by the user, because the front end should be preventing most invalid requests.
|
|
if action == 'assign' and request.POST.get('session', '').isdigit() and request.POST.get('timeslot', '').isdigit():
|
|
session = get_object_or_404(sessions, pk=request.POST['session'])
|
|
timeslot = get_object_or_404(timeslots_qs, pk=request.POST['timeslot'])
|
|
if timeslot_locked(timeslot):
|
|
return _json_response(False, error="Can't assign to this timeslot.")
|
|
|
|
tombstone_session = None
|
|
|
|
existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule)
|
|
|
|
if existing_assignments:
|
|
assertion('len(existing_assignments) <= 1',
|
|
note='Multiple assignments for {} in schedule {}'.format(session, schedule))
|
|
|
|
if timeslot_locked(existing_assignments[0].timeslot):
|
|
return _json_response(False, error="Can't reassign this session.")
|
|
|
|
if schedule.pk == meeting.schedule_id and session.current_status == 'sched':
|
|
old_timeslot = existing_assignments[0].timeslot
|
|
# clone session and leave it as a tombstone
|
|
tombstone_session = session
|
|
tombstone_session.tombstone_for_id = session.pk
|
|
tombstone_session.pk = None
|
|
tombstone_session.save()
|
|
|
|
session = None
|
|
|
|
SchedulingEvent.objects.create(
|
|
session=tombstone_session,
|
|
status=SessionStatusName.objects.get(slug='resched'),
|
|
by=request.user.person,
|
|
)
|
|
|
|
tombstone_session.current_status = 'resched' # rematerialize status for the rendering
|
|
|
|
SchedTimeSessAssignment.objects.create(
|
|
session=tombstone_session,
|
|
schedule=schedule,
|
|
timeslot=old_timeslot,
|
|
)
|
|
|
|
existing_assignments.update(timeslot=timeslot, modified=timezone.now())
|
|
else:
|
|
SchedTimeSessAssignment.objects.create(
|
|
session=session,
|
|
schedule=schedule,
|
|
timeslot=timeslot,
|
|
)
|
|
|
|
if tombstone_session:
|
|
prepare_sessions_for_display([tombstone_session])
|
|
return _json_response(
|
|
True,
|
|
tombstone=render_to_string("meeting/edit_meeting_schedule_session.html",
|
|
{'session': tombstone_session})
|
|
)
|
|
else:
|
|
return _json_response(True)
|
|
|
|
elif action == 'unassign' and request.POST.get('session', '').isdigit():
|
|
session = get_object_or_404(sessions, pk=request.POST['session'])
|
|
existing_assignments = SchedTimeSessAssignment.objects.filter(session=session, schedule=schedule)
|
|
assertion('len(existing_assignments) <= 1',
|
|
note='Multiple assignments for {} in schedule {}'.format(session, schedule))
|
|
if not any(timeslot_locked(ea.timeslot) for ea in existing_assignments):
|
|
existing_assignments.delete()
|
|
else:
|
|
return _json_response(False, error="Can't unassign this session.")
|
|
|
|
return _json_response(True)
|
|
|
|
elif action == 'swapdays':
|
|
# updating the client side is a bit complicated, so just
|
|
# do a full refresh
|
|
|
|
swap_days_form = SwapDaysForm(request.POST)
|
|
if not swap_days_form.is_valid():
|
|
return HttpResponseBadRequest("Invalid swap: {}".format(swap_days_form.errors))
|
|
|
|
source_day = swap_days_form.cleaned_data['source_day']
|
|
target_day = swap_days_form.cleaned_data['target_day']
|
|
|
|
source_timeslots = [ts for ts in timeslots_qs if ts.local_start_time().date() == source_day]
|
|
target_timeslots = [ts for ts in timeslots_qs if ts.local_start_time().date() == target_day]
|
|
if any(timeslot_locked(ts) for ts in source_timeslots + target_timeslots):
|
|
return HttpResponseBadRequest("Can't swap these days.")
|
|
|
|
swap_meeting_schedule_timeslot_assignments(schedule, source_timeslots, target_timeslots, target_day - source_day)
|
|
|
|
return HttpResponseRedirect(request.get_full_path())
|
|
|
|
elif action == 'swaptimeslots':
|
|
# Swap sets of timeslots with equal start/end time for a given set of rooms.
|
|
# Gets start and end times from TimeSlot instances for the origin and target,
|
|
# then swaps all timeslots for the requested rooms whose start/end match those.
|
|
# The origin/target timeslots do not need to be the same duration.
|
|
swap_timeslots_form = SwapTimeslotsForm(meeting, request.POST)
|
|
if not swap_timeslots_form.is_valid():
|
|
return HttpResponseBadRequest("Invalid swap: {}".format(swap_timeslots_form.errors))
|
|
|
|
affected_rooms = swap_timeslots_form.cleaned_data['rooms']
|
|
origin_timeslot = swap_timeslots_form.cleaned_data['origin_timeslot']
|
|
target_timeslot = swap_timeslots_form.cleaned_data['target_timeslot']
|
|
|
|
origin_timeslots = meeting.timeslot_set.filter(
|
|
location__in=affected_rooms,
|
|
time=origin_timeslot.time,
|
|
duration=origin_timeslot.duration,
|
|
)
|
|
target_timeslots = meeting.timeslot_set.filter(
|
|
location__in=affected_rooms,
|
|
time=target_timeslot.time,
|
|
duration=target_timeslot.duration,
|
|
)
|
|
if (any(timeslot_locked(ts) for ts in origin_timeslots)
|
|
or any(timeslot_locked(ts) for ts in target_timeslots)):
|
|
return HttpResponseBadRequest("Can't swap these timeslots.")
|
|
|
|
swap_meeting_schedule_timeslot_assignments(
|
|
schedule,
|
|
list(origin_timeslots),
|
|
list(target_timeslots),
|
|
target_timeslot.time - origin_timeslot.time,
|
|
)
|
|
return HttpResponseRedirect(request.get_full_path())
|
|
|
|
return _json_response(False, error="Invalid parameters")
|
|
|
|
# Show only rooms that have regular sessions
|
|
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)
|
|
|
|
# possible timeslot start/ends
|
|
timeslot_groups = defaultdict(set)
|
|
for ts in timeslots_qs:
|
|
ts.start_end_group = "ts-group-{}-{}".format(ts.local_start_time().strftime("%Y%m%d-%H%M"), int(ts.duration.total_seconds() / 60))
|
|
timeslot_groups[ts.local_start_time().date()].add((ts.local_start_time(), ts.local_end_time(), ts.start_end_group))
|
|
|
|
# prepare sessions
|
|
prepare_sessions_for_display(sessions)
|
|
|
|
for ts in timeslots_qs:
|
|
ts.session_assignments = []
|
|
timeslots_by_pk = {ts.pk: ts for ts in timeslots_qs}
|
|
|
|
unassigned_sessions = []
|
|
for s in sessions:
|
|
assigned = False
|
|
for a in assignments_by_session.get(s.pk, []):
|
|
timeslot = timeslots_by_pk.get(a.timeslot_id)
|
|
if timeslot:
|
|
timeslot.session_assignments.append((a, s))
|
|
assigned = True
|
|
|
|
if not assigned:
|
|
unassigned_sessions.append(s)
|
|
|
|
# group parent colors
|
|
def cubehelix(i, total, hue=1.2, start_angle=0.5):
|
|
# theory in https://arxiv.org/pdf/1108.5083.pdf
|
|
rotations = total // 4
|
|
x = float(i + 1) / (total + 1)
|
|
phi = 2 * math.pi * (start_angle / 3 + rotations * x)
|
|
a = hue * x * (1 - x) / 2.0
|
|
|
|
return (
|
|
max(0, min(x + a * (-0.14861 * math.cos(phi) + 1.78277 * math.sin(phi)), 1)),
|
|
max(0, min(x + a * (-0.29227 * math.cos(phi) + -0.90649 * math.sin(phi)), 1)),
|
|
max(0, min(x + a * (1.97294 * math.cos(phi)), 1)),
|
|
)
|
|
|
|
session_parents = sorted(set(
|
|
s.group.parent for s in sessions
|
|
if s.group and s.group.parent and (s.group.parent.type_id == 'area' or s.group.parent.acronym in ('irtf','iab'))
|
|
), key=lambda p: p.acronym)
|
|
|
|
liz_preferred_colors = {
|
|
'art' : { 'dark' : (204, 121, 167) , 'light' : (234, 232, 230) },
|
|
'gen' : { 'dark' : (29, 78, 17) , 'light' : (232, 237, 231) },
|
|
'iab' : { 'dark' : (255, 165, 0) , 'light' : (255, 246, 230) },
|
|
'int' : { 'dark' : (132, 240, 240) , 'light' : (232, 240, 241) },
|
|
'irtf' : { 'dark' : (154, 119, 230) , 'light' : (243, 239, 248) },
|
|
'ops' : { 'dark' : (199, 133, 129) , 'light' : (250, 240, 242) },
|
|
'rtg' : { 'dark' : (222, 219, 124) , 'light' : (247, 247, 233) },
|
|
'sec' : { 'dark' : (0, 114, 178) , 'light' : (245, 252, 248) },
|
|
'tsv' : { 'dark' : (117,201,119) , 'light' : (251, 252, 255) },
|
|
'wit' : { 'dark' : (117,201,119) , 'light' : (251, 252, 255) }, # intentionally the same as tsv
|
|
}
|
|
for i, p in enumerate(session_parents):
|
|
if p.acronym in liz_preferred_colors:
|
|
colors = liz_preferred_colors[p.acronym]
|
|
p.scheduling_color = "rgb({}, {}, {})".format(*colors['dark'])
|
|
p.light_scheduling_color = "rgb({}, {}, {})".format(*colors['light'])
|
|
else:
|
|
rgb_color = cubehelix(i, len(session_parents))
|
|
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,
|
|
)
|
|
|
|
# extract view configuration from session store
|
|
session_data = request.session.get('edit_meeting_schedule', None)
|
|
if session_data is None:
|
|
enabled_timeslot_types = ['regular']
|
|
else:
|
|
enabled_timeslot_types = [
|
|
ts_type.slug for ts_type in timeslot_types
|
|
if ts_type.slug in session_data.get('enabled_timeslot_types', [])
|
|
]
|
|
|
|
with timezone.override(meeting.tz()):
|
|
return render(request, "meeting/edit_meeting_schedule.html", {
|
|
'meeting': meeting,
|
|
'schedule': schedule,
|
|
'can_edit': can_edit,
|
|
'can_edit_properties': can_edit or secretariat,
|
|
'secretariat': secretariat,
|
|
'days': days,
|
|
'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,
|
|
'enabled_timeslot_types': enabled_timeslot_types,
|
|
})
|
|
|
|
|
|
class RoomNameModelChoiceField(forms.ModelChoiceField):
|
|
def label_from_instance(self, obj):
|
|
return obj.name
|
|
|
|
class TimeSlotForm(forms.Form):
|
|
day = forms.TypedChoiceField(coerce=lambda t: datetime.datetime.strptime(t, "%Y-%m-%d").date()) # all dates, no tz
|
|
time = forms.TimeField()
|
|
duration = CustomDurationField() # this is just to make 1:30 turn into 1.5 hours instead of 1.5 minutes
|
|
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'),
|
|
help_text='''Select a group to associate with this session.<br>For example: Tutorials = Education, Code Sprint = Tools Team''',
|
|
required=False)
|
|
agenda_note = forms.CharField(required=False)
|
|
|
|
def __init__(self, meeting, schedule, *args, timeslot=None, **kwargs):
|
|
super().__init__(*args,**kwargs)
|
|
|
|
self.fields["time"].widget.attrs["placeholder"] = "HH:MM"
|
|
self.fields["duration"].widget.attrs["placeholder"] = "HH:MM"
|
|
self.fields["duration"].initial = ""
|
|
|
|
self.fields["day"].choices = [
|
|
((meeting.date + datetime.timedelta(days=i)).isoformat(), (meeting.date + datetime.timedelta(days=i)).strftime("%a %b %d"))
|
|
for i in range(meeting.days)
|
|
]
|
|
|
|
self.fields['location'].queryset = self.fields['location'].queryset.filter(meeting=meeting)
|
|
|
|
self.fields['group'].widget.attrs['data-ietf'] = Group.objects.get(acronym='ietf').pk
|
|
|
|
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.local_start_time().date(),
|
|
'time': timeslot.local_start_time().time(),
|
|
'duration': timeslot.duration,
|
|
'location': timeslot.location_id,
|
|
'show_location': timeslot.show_location,
|
|
'type': timeslot.type_id,
|
|
'name': timeslot.name,
|
|
}
|
|
|
|
assignments = sorted(SchedTimeSessAssignment.objects.filter(
|
|
timeslot=timeslot,
|
|
schedule__in=[schedule, schedule.base if schedule else None]
|
|
).select_related('session', 'session__group'), key=lambda a: 0 if a.schedule_id == schedule.pk else 1)
|
|
|
|
if assignments:
|
|
self.active_assignment = assignments[0]
|
|
|
|
self.initial['short'] = self.active_assignment.session.short
|
|
self.initial['group'] = self.active_assignment.session.group_id
|
|
|
|
if not self.active_assignment or timeslot.type_id != 'regular':
|
|
del self.fields['agenda_note'] # at the moment, the UI only shows this field for regular sessions
|
|
|
|
self.timeslot = timeslot
|
|
|
|
def clean(self):
|
|
group = self.cleaned_data.get('group')
|
|
ts_type = self.cleaned_data.get('type')
|
|
short = self.cleaned_data.get('short')
|
|
|
|
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')
|
|
else:
|
|
if not group:
|
|
self.add_error('group', 'When scheduling this type of timeslot, a group must be associated')
|
|
if not short:
|
|
self.add_error('short', 'When scheduling this type of timeslot, a short name is required')
|
|
|
|
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 timeslots for regular sessions when a session has been assigned")
|
|
|
|
# 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")
|
|
|
|
|
|
@role_required('Area Director', 'Secretariat')
|
|
def edit_meeting_timeslots_and_misc_sessions(request, num=None, owner=None, name=None):
|
|
meeting = get_meeting(num)
|
|
if name is None:
|
|
schedule = meeting.schedule
|
|
else:
|
|
schedule = get_schedule_by_name(meeting, get_person_by_email(owner), name)
|
|
|
|
if schedule is None:
|
|
raise Http404("No meeting information for meeting %s owner %s schedule %s available" % (num, owner, name))
|
|
|
|
rooms = list(Room.objects.filter(meeting=meeting).prefetch_related('session_types').order_by('-capacity', 'name'))
|
|
rooms.append(Room(name="(No location)"))
|
|
|
|
timeslot_qs = TimeSlot.objects.filter(meeting=meeting).prefetch_related('type').order_by('time')
|
|
|
|
can_edit = has_role(request.user, 'Secretariat')
|
|
|
|
with timezone.override(meeting.tz()):
|
|
if request.method == 'GET' and request.GET.get('action') == "edit-timeslot":
|
|
timeslot_pk = request.GET.get('timeslot')
|
|
if not timeslot_pk or not timeslot_pk.isdecimal():
|
|
raise Http404
|
|
timeslot = get_object_or_404(timeslot_qs, pk=timeslot_pk)
|
|
|
|
assigned_session = add_event_info_to_session_qs(Session.objects.filter(
|
|
timeslotassignments__schedule__in=[schedule, schedule.base],
|
|
timeslotassignments__timeslot=timeslot,
|
|
)).first()
|
|
|
|
timeslot.can_cancel = not assigned_session or assigned_session.current_status not in ['canceled', 'canceled', 'resched']
|
|
|
|
return JsonResponse({
|
|
'form': render_to_string("meeting/edit_timeslot_form.html", {
|
|
'timeslot_form_action': 'edit',
|
|
'timeslot_form': TimeSlotForm(meeting, schedule, timeslot=timeslot),
|
|
'timeslot': timeslot,
|
|
'schedule': schedule,
|
|
'meeting': meeting,
|
|
'can_edit': can_edit,
|
|
}, request=request)
|
|
})
|
|
|
|
scroll = request.POST.get('scroll')
|
|
|
|
def redirect_with_scroll():
|
|
url = request.get_full_path()
|
|
if scroll and scroll.isdecimal():
|
|
url += "#scroll={}".format(scroll)
|
|
return HttpResponseRedirect(url)
|
|
|
|
add_timeslot_form = None
|
|
if request.method == 'POST' and request.POST.get('action') == 'add-timeslot' and can_edit:
|
|
add_timeslot_form = TimeSlotForm(meeting, schedule, request.POST)
|
|
if add_timeslot_form.is_valid():
|
|
c = add_timeslot_form.cleaned_data
|
|
|
|
timeslot, created = TimeSlot.objects.get_or_create(
|
|
meeting=meeting,
|
|
type=c['type'],
|
|
name=c['name'],
|
|
time=meeting.tz().localize(datetime.datetime.combine(c['day'], c['time'])),
|
|
duration=c['duration'],
|
|
location=c['location'],
|
|
show_location=c['show_location'],
|
|
)
|
|
|
|
if timeslot.type_id != 'regular':
|
|
if not created:
|
|
Session.objects.filter(timeslotassignments__timeslot=timeslot).delete()
|
|
|
|
session = Session.objects.create(
|
|
meeting=meeting,
|
|
name=c['name'],
|
|
short=c['short'],
|
|
group=c['group'],
|
|
type=c['type'],
|
|
purpose=c['purpose'],
|
|
agenda_note=c.get('agenda_note') or "",
|
|
)
|
|
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=SessionStatusName.objects.get(slug='sched'),
|
|
by=request.user.person,
|
|
)
|
|
|
|
SchedTimeSessAssignment.objects.create(
|
|
timeslot=timeslot,
|
|
session=session,
|
|
schedule=schedule
|
|
)
|
|
|
|
return redirect_with_scroll()
|
|
|
|
edit_timeslot_form = None
|
|
if request.method == 'POST' and request.POST.get('action') == 'edit-timeslot' and can_edit:
|
|
timeslot_pk = request.POST.get('timeslot')
|
|
if not timeslot_pk or not timeslot_pk.isdecimal():
|
|
raise Http404
|
|
|
|
timeslot = get_object_or_404(TimeSlot, pk=timeslot_pk)
|
|
|
|
edit_timeslot_form = TimeSlotForm(meeting, schedule, request.POST, timeslot=timeslot)
|
|
if edit_timeslot_form.is_valid() and edit_timeslot_form.active_assignment.schedule_id == schedule.pk:
|
|
|
|
c = edit_timeslot_form.cleaned_data
|
|
|
|
timeslot.type = c['type']
|
|
timeslot.name = c['name']
|
|
timeslot.time = meeting.tz().localize(datetime.datetime.combine(c['day'], c['time']))
|
|
timeslot.duration = c['duration']
|
|
timeslot.location = c['location']
|
|
timeslot.show_location = c['show_location']
|
|
timeslot.save()
|
|
|
|
session = Session.objects.filter(
|
|
timeslotassignments__schedule__in=[schedule, schedule.base if schedule else None],
|
|
timeslotassignments__timeslot=timeslot,
|
|
).select_related('group').first()
|
|
|
|
if session:
|
|
if timeslot.type_id != 'regular':
|
|
session.name = c['name']
|
|
session.short = c['short']
|
|
session.group = c['group']
|
|
session.type = c['type']
|
|
session.agenda_note = c.get('agenda_note') or ""
|
|
session.save()
|
|
|
|
return redirect_with_scroll()
|
|
|
|
if request.method == 'POST' and request.POST.get('action') == 'cancel-timeslot' and can_edit:
|
|
timeslot_pk = request.POST.get('timeslot')
|
|
if not timeslot_pk or not timeslot_pk.isdecimal():
|
|
raise Http404
|
|
|
|
timeslot = get_object_or_404(TimeSlot, pk=timeslot_pk)
|
|
if timeslot.type_id != 'break':
|
|
sessions = add_event_info_to_session_qs(
|
|
Session.objects.filter(timeslotassignments__schedule=schedule, timeslotassignments__timeslot=timeslot),
|
|
).exclude(current_status__in=['canceled', 'resched'])
|
|
for session in sessions:
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=SessionStatusName.objects.get(slug='canceled'),
|
|
by=request.user.person,
|
|
)
|
|
|
|
return redirect_with_scroll()
|
|
|
|
if request.method == 'POST' and request.POST.get('action') == 'delete-timeslot' and can_edit:
|
|
timeslot_pk = request.POST.get('timeslot')
|
|
if not timeslot_pk or not timeslot_pk.isdecimal():
|
|
raise Http404
|
|
|
|
timeslot = get_object_or_404(TimeSlot, pk=timeslot_pk)
|
|
|
|
if timeslot.type_id != 'regular':
|
|
for session in Session.objects.filter(timeslotassignments__schedule=schedule, timeslotassignments__timeslot=timeslot):
|
|
for doc in session.materials.all():
|
|
doc.set_state(State.objects.get(type=doc.type_id, slug='deleted'))
|
|
e = DocEvent(doc=doc, rev=doc.rev, by=request.user.person, type='deleted')
|
|
e.desc = "Deleted meeting session"
|
|
e.save()
|
|
|
|
session.delete()
|
|
|
|
timeslot.delete()
|
|
|
|
return redirect_with_scroll()
|
|
|
|
sessions_by_pk = {
|
|
s.pk: s for s in
|
|
add_event_info_to_session_qs(
|
|
Session.objects.filter(
|
|
meeting=meeting,
|
|
).order_by('pk'),
|
|
requested_time=True,
|
|
requested_by=True,
|
|
).filter(
|
|
current_status__in=['appr', 'schedw', 'scheda', 'sched', 'canceled', 'canceledpa', 'resched']
|
|
).prefetch_related(
|
|
'group', 'group', 'group__type',
|
|
)
|
|
}
|
|
|
|
assignments_by_timeslot = defaultdict(list)
|
|
for a in SchedTimeSessAssignment.objects.filter(schedule__in=[schedule, schedule.base]):
|
|
assignments_by_timeslot[a.timeslot_id].append(a)
|
|
|
|
days = [meeting.date + datetime.timedelta(days=i) for i in range(meeting.days)]
|
|
|
|
timeslots_by_day_and_room = defaultdict(list)
|
|
for t in timeslot_qs:
|
|
timeslots_by_day_and_room[(t.time.date(), t.location_id)].append(t)
|
|
|
|
# Calculate full time range for display in meeting-local time, always showing at least 8am to 10pm
|
|
min_time = min([t.local_start_time().time() for t in timeslot_qs] + [datetime.time(8)])
|
|
max_time = max([t.local_end_time().time() for t in timeslot_qs] + [datetime.time(22)])
|
|
min_max_delta = datetime.datetime.combine(meeting.date, max_time) - datetime.datetime.combine(meeting.date, min_time)
|
|
|
|
day_grid = []
|
|
for d in days:
|
|
room_timeslots = []
|
|
for r in rooms:
|
|
ts = []
|
|
for t in timeslots_by_day_and_room.get((d, r.pk), []):
|
|
# FIXME: the database (as of 2020) contains spurious
|
|
# regular timeslots in rooms not intended for regular
|
|
# sessions - once those are gone, this filter can go
|
|
# away
|
|
if t.type_id == 'regular' and not any(t.slug == 'regular' for t in r.session_types.all()):
|
|
continue
|
|
|
|
t.assigned_sessions = []
|
|
for a in assignments_by_timeslot.get(t.pk, []):
|
|
s = sessions_by_pk.get(a.session_id)
|
|
if s:
|
|
t.assigned_sessions.append(s)
|
|
|
|
local_start_dt = t.local_start_time()
|
|
local_min_dt = local_start_dt.replace(
|
|
hour=min_time.hour,
|
|
minute=min_time.minute,
|
|
second=min_time.second,
|
|
microsecond=min_time.microsecond,
|
|
)
|
|
t.left_offset = 100.0 * (local_start_dt - local_min_dt) / min_max_delta
|
|
t.layout_width = min(100.0 * t.duration / min_max_delta, 100 - t.left_offset)
|
|
ts.append(t)
|
|
|
|
room_timeslots.append((r, ts))
|
|
|
|
day_grid.append({
|
|
'day': d,
|
|
'room_timeslots': room_timeslots
|
|
})
|
|
|
|
return render(request, "meeting/edit_meeting_timeslots_and_misc_sessions.html", {
|
|
'meeting': meeting,
|
|
'schedule': schedule,
|
|
'can_edit': can_edit,
|
|
'day_grid': day_grid,
|
|
'empty_timeslot_form': TimeSlotForm(meeting, schedule),
|
|
'add_timeslot_form': add_timeslot_form,
|
|
'edit_timeslot_form': edit_timeslot_form,
|
|
'scroll': scroll,
|
|
'hide_menu': True,
|
|
})
|
|
|
|
|
|
class SchedulePropertiesForm(forms.ModelForm):
|
|
class Meta:
|
|
model = Schedule
|
|
fields = ['name', 'notes', 'visible', 'public', 'base']
|
|
|
|
def __init__(self, meeting, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
self.fields['base'].queryset = self.fields['base'].queryset.filter(meeting=meeting)
|
|
if self.instance.pk is not None:
|
|
self.fields['base'].queryset = self.fields['base'].queryset.exclude(pk=self.instance.pk)
|
|
|
|
@role_required('Area Director','Secretariat')
|
|
def edit_schedule_properties(request, 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:
|
|
raise Http404("No agenda information for meeting %s owner %s schedule %s available" % (num, owner, name))
|
|
|
|
can_see, can_edit, secretariat = schedule_permissions(meeting, schedule, request.user)
|
|
|
|
can_edit_properties = can_edit or secretariat
|
|
|
|
if not can_edit_properties:
|
|
permission_denied(request, "You may not edit this schedule.")
|
|
|
|
if request.method == 'POST':
|
|
# use a new copy of the Schedule instance for the form so the template isn't fouled if validation fails
|
|
form = SchedulePropertiesForm(meeting, instance=Schedule.objects.get(pk=schedule.pk), data=request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
if request.GET.get('next'):
|
|
return HttpResponseRedirect(request.GET.get('next'))
|
|
return redirect('ietf.meeting.views.edit_meeting_schedule', num=num, owner=owner, name=form.instance.name)
|
|
else:
|
|
form = SchedulePropertiesForm(meeting, instance=schedule)
|
|
|
|
return render(request, "meeting/properties_edit.html", {
|
|
"schedule": schedule,
|
|
"form": form,
|
|
"meeting": meeting,
|
|
})
|
|
|
|
|
|
nat_sort_re = re.compile('([0-9]+)')
|
|
def natural_sort_key(s): # from https://stackoverflow.com/questions/4836710/is-there-a-built-in-function-for-string-natural-sort
|
|
return [int(text) if text.isdecimal() else text.lower() for text in nat_sort_re.split(s)]
|
|
|
|
@role_required('Area Director','Secretariat')
|
|
def list_schedules(request, num):
|
|
meeting = get_meeting(num)
|
|
|
|
schedules = Schedule.objects.filter(
|
|
meeting=meeting
|
|
).prefetch_related('owner', 'assignments', 'origin', 'origin__assignments', 'base').order_by('owner', '-name', '-public').distinct()
|
|
if not has_role(request.user, 'Secretariat'):
|
|
schedules = schedules.filter(Q(visible=True) | Q(owner=request.user.person))
|
|
|
|
official_schedules = []
|
|
own_schedules = []
|
|
other_public_schedules = []
|
|
other_private_schedules = []
|
|
|
|
is_secretariat = has_role(request.user, 'Secretariat')
|
|
|
|
for s in schedules:
|
|
s.can_edit_properties = is_secretariat or user_is_person(request.user, s.owner)
|
|
|
|
if s.origin:
|
|
s.changes_from_origin = len(diff_meeting_schedules(s.origin, s))
|
|
|
|
if s in [meeting.schedule, meeting.schedule.base if meeting.schedule else None]:
|
|
official_schedules.append(s)
|
|
elif user_is_person(request.user, s.owner):
|
|
own_schedules.append(s)
|
|
elif s.public:
|
|
other_public_schedules.append(s)
|
|
else:
|
|
other_private_schedules.append(s)
|
|
|
|
schedule_groups = [
|
|
(official_schedules, False, "Official Agenda"),
|
|
(own_schedules, True, "Own Draft Agendas"),
|
|
(other_public_schedules, False, "Other Draft Agendas"),
|
|
(other_private_schedules, False, "Other Private Draft Agendas"),
|
|
]
|
|
|
|
schedule_groups = [(sorted(l, reverse=True, key=lambda s: natural_sort_key(s.name)), own, *t) for l, own, *t in schedule_groups if l or own]
|
|
|
|
return render(request, "meeting/schedule_list.html", {
|
|
'meeting': meeting,
|
|
'schedule_groups': schedule_groups,
|
|
'can_edit_timeslots': is_secretariat,
|
|
})
|
|
|
|
class DiffSchedulesForm(forms.Form):
|
|
from_schedule = forms.ChoiceField()
|
|
to_schedule = forms.ChoiceField()
|
|
|
|
def __init__(self, meeting, user, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
qs = Schedule.objects.filter(meeting=meeting).prefetch_related('owner').order_by('-public').distinct()
|
|
|
|
if not has_role(user, 'Secretariat'):
|
|
qs = qs.filter(Q(visible=True) | Q(owner__user=user))
|
|
|
|
sorted_schedules = sorted(qs, reverse=True, key=lambda s: natural_sort_key(s.name))
|
|
|
|
schedule_choices = [(schedule.name, "{} ({})".format(schedule.name, schedule.owner)) for schedule in sorted_schedules]
|
|
|
|
self.fields['from_schedule'].choices = schedule_choices
|
|
self.fields['to_schedule'].choices = schedule_choices
|
|
|
|
@role_required('Area Director','Secretariat')
|
|
def diff_schedules(request, num):
|
|
meeting = get_meeting(num)
|
|
|
|
diffs = None
|
|
from_schedule = None
|
|
to_schedule = None
|
|
|
|
if 'from_schedule' in request.GET:
|
|
form = DiffSchedulesForm(meeting, request.user, request.GET)
|
|
if form.is_valid():
|
|
from_schedule = get_object_or_404(Schedule, name=form.cleaned_data['from_schedule'], meeting=meeting)
|
|
to_schedule = get_object_or_404(Schedule, name=form.cleaned_data['to_schedule'], meeting=meeting)
|
|
raw_diffs = diff_meeting_schedules(from_schedule, to_schedule)
|
|
|
|
diffs = prefetch_schedule_diff_objects(raw_diffs)
|
|
for d in diffs:
|
|
s = d['session']
|
|
s.session_label = s.short_name
|
|
if s.requested_duration:
|
|
s.session_label = "{} ({}h)".format(s.session_label, round(s.requested_duration.seconds / 60.0 / 60.0, 1))
|
|
else:
|
|
form = DiffSchedulesForm(meeting, request.user)
|
|
|
|
return render(request, "meeting/diff_schedules.html", {
|
|
'meeting': meeting,
|
|
'form': form,
|
|
'diffs': diffs,
|
|
'from_schedule': from_schedule,
|
|
'to_schedule': to_schedule,
|
|
})
|
|
|
|
@ensure_csrf_cookie
|
|
def session_materials(request, session_id):
|
|
"""Session details for agenda page pop-up"""
|
|
session = get_object_or_404(Session, id=session_id)
|
|
assignments = SchedTimeSessAssignment.objects.filter(session=session)
|
|
if len(assignments) == 0:
|
|
raise Http404('No such scheduled session')
|
|
assignments = preprocess_assignments_for_agenda(assignments, session.meeting)
|
|
assignment = assignments[0]
|
|
return render(request, 'meeting/session_materials.html', dict(item=assignment))
|
|
|
|
|
|
def get_assignments_for_agenda(schedule):
|
|
"""Get queryset containing assignments to show on the agenda"""
|
|
return SchedTimeSessAssignment.objects.filter(
|
|
schedule__in=[schedule, schedule.base],
|
|
session__on_agenda=True,
|
|
)
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
def agenda_plain(request, num=None, name=None, base=None, ext=None, owner=None, utc=None):
|
|
base = base if base else 'agenda'
|
|
ext = ext if ext else '.txt'
|
|
mimetype = {
|
|
".txt": "text/plain; charset=%s"%settings.DEFAULT_CHARSET,
|
|
".csv": "text/csv; charset=%s"%settings.DEFAULT_CHARSET,
|
|
}
|
|
if ext not in mimetype:
|
|
raise Http404('Extension not allowed')
|
|
|
|
# We do not have the appropriate data in the datatracker for IETF 64 and earlier.
|
|
# So that we're not producing misleading pages, redirect to their proceedings.
|
|
# The datatracker DB does include a Meeting instance for every IETF meeting, though,
|
|
# so we can use that to validate that num is a valid meeting number.
|
|
meeting = get_ietf_meeting(num)
|
|
if meeting is None:
|
|
raise Http404("No such full IETF meeting")
|
|
elif int(meeting.number) <= 64:
|
|
return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}')
|
|
else:
|
|
pass
|
|
|
|
# Select the schedule to show
|
|
if name is None:
|
|
schedule = get_schedule(meeting, name)
|
|
else:
|
|
person = get_person_by_email(owner)
|
|
schedule = get_schedule_by_name(meeting, person, name)
|
|
|
|
if schedule is None:
|
|
base = base.replace("-utc", "")
|
|
return render(request, "meeting/no-"+base+ext, {'meeting':meeting }, content_type=mimetype[ext])
|
|
|
|
updated = meeting.updated()
|
|
|
|
# Select and prepare sessions that should be included
|
|
filtered_assignments = preprocess_assignments_for_agenda(
|
|
get_assignments_for_agenda(schedule),
|
|
meeting
|
|
)
|
|
AgendaKeywordTagger(assignments=filtered_assignments).apply()
|
|
|
|
# Done processing for CSV output
|
|
if ext == ".csv":
|
|
return agenda_csv(schedule, filtered_assignments, utc=utc is not None)
|
|
|
|
filter_organizer = AgendaFilterOrganizer(assignments=filtered_assignments)
|
|
|
|
is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num())
|
|
|
|
display_timezone = meeting.time_zone if utc is None else 'UTC'
|
|
with timezone.override(display_timezone):
|
|
rendered_page = render(
|
|
request,
|
|
"meeting/" + base + ext,
|
|
{
|
|
"personalize": False,
|
|
"schedule": schedule,
|
|
"filtered_assignments": filtered_assignments,
|
|
"updated": updated,
|
|
"filter_categories": filter_organizer.get_filter_categories(),
|
|
"non_area_keywords": filter_organizer.get_non_area_keywords(),
|
|
"now": timezone.now().astimezone(meeting.tz()),
|
|
"display_timezone": display_timezone,
|
|
"is_current_meeting": is_current_meeting,
|
|
"cache_time": 150 if is_current_meeting else 3600,
|
|
},
|
|
content_type=mimetype[ext],
|
|
)
|
|
|
|
return rendered_page
|
|
|
|
@ensure_csrf_cookie
|
|
def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""):
|
|
# Get current meeting if not specified
|
|
if num is None:
|
|
num = get_current_ietf_meeting_num()
|
|
|
|
# We do not have the appropriate data in the datatracker for IETF 64 and earlier.
|
|
# So that we're not producing misleading pages, redirect to their proceedings.
|
|
# The datatracker DB does include a Meeting instance for every IETF meeting, though,
|
|
# so we can use that to validate that num is a valid meeting number.
|
|
if int(num) <= 64:
|
|
meeting = get_ietf_meeting(num)
|
|
if meeting is None:
|
|
raise Http404("No such full IETF meeting")
|
|
else:
|
|
return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}')
|
|
|
|
return render(request, "meeting/agenda.html", {
|
|
"meetingData": {
|
|
"meetingNumber": num
|
|
}
|
|
})
|
|
|
|
|
|
def generate_agenda_data(num=None, force_refresh=False):
|
|
"""Generate data for the api_get_agenda_data endpoint
|
|
|
|
:num: meeting number
|
|
:force_refresh: True to force a refresh of the cache
|
|
"""
|
|
cache = caches["default"]
|
|
cache_timeout = 6 * 60
|
|
|
|
meeting = get_ietf_meeting(num)
|
|
if meeting is None:
|
|
raise Http404("No such full IETF meeting")
|
|
elif int(meeting.number) <= 64:
|
|
return Http404("Pre-IETF 64 meetings are not available through this API")
|
|
else:
|
|
pass
|
|
|
|
cache_key = f"generate_agenda_data_{meeting.number}"
|
|
if not force_refresh:
|
|
cached_value = cache.get(cache_key)
|
|
if cached_value is not None:
|
|
return cached_value
|
|
|
|
# Select the schedule to show
|
|
schedule = get_schedule(meeting, None)
|
|
|
|
updated = meeting.updated()
|
|
|
|
# Select and prepare sessions that should be included
|
|
filtered_assignments = preprocess_assignments_for_agenda(
|
|
get_assignments_for_agenda(schedule),
|
|
meeting
|
|
)
|
|
AgendaKeywordTagger(assignments=filtered_assignments).apply()
|
|
|
|
filter_organizer = AgendaFilterOrganizer(assignments=filtered_assignments)
|
|
|
|
is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num())
|
|
|
|
# Get Floor Plans
|
|
floors = FloorPlan.objects.filter(meeting=meeting).order_by('order')
|
|
|
|
result = {
|
|
"meeting": {
|
|
"number": schedule.meeting.number,
|
|
"city": schedule.meeting.city,
|
|
"startDate": schedule.meeting.date.isoformat(),
|
|
"endDate": schedule.meeting.end_date().isoformat(),
|
|
"updated": updated,
|
|
"timezone": meeting.time_zone,
|
|
"infoNote": schedule.meeting.agenda_info_note,
|
|
"warningNote": schedule.meeting.agenda_warning_note
|
|
},
|
|
"categories": filter_organizer.get_filter_categories(),
|
|
"isCurrentMeeting": is_current_meeting,
|
|
"usesNotes": meeting.uses_notes(),
|
|
"schedule": list(map(agenda_extract_schedule, filtered_assignments)),
|
|
"floors": list(map(agenda_extract_floorplan, floors))
|
|
}
|
|
cache.set(cache_key, result, timeout=cache_timeout)
|
|
return result
|
|
|
|
|
|
def api_get_agenda_data(request, num=None):
|
|
return JsonResponse(generate_agenda_data(num, force_refresh=False))
|
|
|
|
|
|
def api_get_session_materials(request, session_id=None):
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
|
|
minutes = session.minutes()
|
|
slides_actions = []
|
|
if can_manage_session_materials(request.user, session.group, session) or not session.is_material_submission_cutoff():
|
|
slides_actions.append(
|
|
{
|
|
"label": "Upload slides",
|
|
"url": reverse(
|
|
"ietf.meeting.views.upload_session_slides",
|
|
kwargs={"num": session.meeting.number, "session_id": session.pk},
|
|
),
|
|
}
|
|
)
|
|
else:
|
|
pass # no action available if it's past cutoff
|
|
|
|
agenda = session.agenda()
|
|
agenda_url = agenda.get_href() if agenda is not None else None
|
|
return JsonResponse(
|
|
{
|
|
"url": agenda_url,
|
|
"slides": {
|
|
"decks": [
|
|
agenda_extract_slide(slide) | {"order": order} # add "order" field
|
|
for order, slide in enumerate(session.slides())
|
|
],
|
|
"actions": slides_actions,
|
|
},
|
|
"minutes": {
|
|
"id": minutes.id,
|
|
"title": minutes.title,
|
|
"url": minutes.get_href(),
|
|
"ext": minutes.file_extension(),
|
|
}
|
|
if minutes is not None
|
|
else None,
|
|
}
|
|
)
|
|
|
|
|
|
def agenda_extract_schedule (item):
|
|
return {
|
|
"id": item.id,
|
|
"sessionId": item.session.id,
|
|
"room": item.room_name if item.timeslot.show_location else None,
|
|
"location": {
|
|
"short": item.timeslot.location.floorplan.short,
|
|
"name": item.timeslot.location.floorplan.name,
|
|
} if (item.timeslot.show_location and item.timeslot.location and item.timeslot.location.floorplan) else {},
|
|
"acronym": item.acronym,
|
|
"duration": item.timeslot.duration.seconds,
|
|
"name": item.session.name,
|
|
"slotName": item.timeslot.name,
|
|
"startDateTime": item.timeslot.time.isoformat(),
|
|
"status": item.session.current_status,
|
|
"type": item.session.type.slug,
|
|
"purpose": item.session.purpose.slug,
|
|
"isBoF": item.session.group_at_the_time().state_id == "bof",
|
|
"isProposed": item.session.group_at_the_time().state_id == "proposed",
|
|
"filterKeywords": item.filter_keywords,
|
|
"groupAcronym": item.session.group_at_the_time().acronym,
|
|
"groupName": item.session.group_at_the_time().name,
|
|
"groupParent": ({
|
|
"acronym": item.session.group_parent_at_the_time().acronym
|
|
} if item.session.group_parent_at_the_time() else {}),
|
|
"note": item.session.agenda_note,
|
|
"remoteInstructions": item.session.remote_instructions,
|
|
"flags": {
|
|
"agenda": True if item.session.agenda() is not None else False,
|
|
"showAgenda": True if (item.session.agenda() is not None or item.session.remote_instructions) else False
|
|
},
|
|
"agenda": {
|
|
"url": item.session.agenda().get_href()
|
|
} if item.session.agenda() is not None else {
|
|
"url": None
|
|
},
|
|
"orderInMeeting": item.session.order_number,
|
|
"short": item.session.short if item.session.short else item.session.short_name,
|
|
"sessionToken": item.session.docname_token_only_for_multiple(),
|
|
"links": {
|
|
"chat" : item.session.chat_room_url(),
|
|
"chatArchive" : item.session.chat_archive_url(),
|
|
"recordings": list(map(agenda_extract_recording, item.session.recordings())),
|
|
"videoStream": item.session.video_stream_url() or "",
|
|
"audioStream": item.session.audio_stream_url() or "",
|
|
"webex": item.timeslot.location.webex_url() if item.timeslot.location else "",
|
|
"onsiteTool": item.session.onsite_tool_url() or "",
|
|
"calendar": reverse(
|
|
'ietf.meeting.views.agenda_ical',
|
|
kwargs={'num': item.schedule.meeting.number, 'session_id': item.session.id},
|
|
),
|
|
}
|
|
# "slotType": {
|
|
# "slug": item.slot_type.slug
|
|
# }
|
|
}
|
|
|
|
|
|
def agenda_extract_floorplan(item):
|
|
try:
|
|
item.image.width
|
|
except FileNotFoundError:
|
|
return {}
|
|
|
|
return {
|
|
"id": item.id,
|
|
"image": item.image.url,
|
|
"name": item.name,
|
|
"short": item.short,
|
|
"width": item.image.width,
|
|
"height": item.image.height,
|
|
"rooms": list(map(agenda_extract_room, item.room_set.all())),
|
|
}
|
|
|
|
|
|
def agenda_extract_room(item):
|
|
return {
|
|
"id": item.id,
|
|
"name": item.name,
|
|
"functionalName": item.functional_name,
|
|
"slug": xslugify(item.name),
|
|
"left": item.left(),
|
|
"right": item.right(),
|
|
"top": item.top(),
|
|
"bottom": item.bottom()
|
|
}
|
|
|
|
|
|
def agenda_extract_recording(item):
|
|
return {
|
|
"id": item.id,
|
|
"name": item.name,
|
|
"title": item.title,
|
|
"url": item.external_url
|
|
}
|
|
|
|
|
|
def agenda_extract_slide(item):
|
|
return {
|
|
"id": item.id,
|
|
"title": item.title,
|
|
"rev": item.rev,
|
|
"url": item.get_href(),
|
|
"ext": item.file_extension(),
|
|
}
|
|
|
|
|
|
def agenda_csv(schedule, filtered_assignments, utc=False):
|
|
encoding = 'utf-8'
|
|
response = HttpResponse(content_type=f"text/csv; charset={encoding}")
|
|
writer = csv.writer(response, delimiter=str(','), quoting=csv.QUOTE_ALL)
|
|
|
|
headings = ["Date", "Start", "End", "Session", "Room", "Area", "Acronym", "Type", "Description", "Session ID", "Agenda", "Slides"]
|
|
|
|
def write_row(row):
|
|
if len(row) < len(headings):
|
|
padding = [None] * (len(headings) - len(row)) # produce empty entries at the end as necessary
|
|
else:
|
|
padding = []
|
|
writer.writerow(row + padding)
|
|
|
|
def agenda_field(item):
|
|
agenda_doc = item.session.agenda()
|
|
if agenda_doc:
|
|
return "http://www.ietf.org/proceedings/{schedule.meeting.number}/agenda/{agenda.uploaded_filename}".format(schedule=schedule, agenda=agenda_doc)
|
|
else:
|
|
return ""
|
|
|
|
def slides_field(item):
|
|
return "|".join("http://www.ietf.org/proceedings/{schedule.meeting.number}/slides/{slide.uploaded_filename}".format(schedule=schedule, slide=slide) for slide in item.session.slides())
|
|
|
|
write_row(headings)
|
|
|
|
tz = datetime.timezone.utc if utc else schedule.meeting.tz()
|
|
for item in filtered_assignments:
|
|
row = []
|
|
row.append(item.timeslot.time.astimezone(tz).strftime("%Y-%m-%d"))
|
|
row.append(item.timeslot.time.astimezone(tz).strftime("%H%M"))
|
|
row.append(item.timeslot.end_time().astimezone(tz).strftime("%H%M"))
|
|
|
|
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.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.slot_type().slug == "other":
|
|
row.append("None")
|
|
row.append(item.timeslot.location.name if item.timeslot.location else "")
|
|
row.append("")
|
|
row.append(item.session.group_at_the_time().acronym)
|
|
row.append(item.session.group_parent_at_the_time().acronym.upper() if item.session.group_parent_at_the_time() else "")
|
|
row.append(item.session.name)
|
|
row.append(item.session.pk)
|
|
elif item.slot_type().slug == "plenary":
|
|
row.append(item.session.name)
|
|
row.append(item.timeslot.location.name if item.timeslot.location else "")
|
|
row.append("")
|
|
row.append(item.session.group_at_the_time().acronym)
|
|
row.append("")
|
|
row.append(item.session.name)
|
|
row.append(item.session.pk)
|
|
row.append(agenda_field(item))
|
|
row.append(slides_field(item))
|
|
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.group_parent_at_the_time().acronym.upper() if item.session.group_parent_at_the_time() else "")
|
|
row.append(item.session.group_at_the_time().acronym)
|
|
row.append("BOF" if item.session.group_at_the_time().state_id in ("bof", "bof-conc") else item.session.group_at_the_time().type.name)
|
|
row.append(item.session.group_at_the_time().name)
|
|
row.append(item.session.pk)
|
|
row.append(agenda_field(item))
|
|
row.append(slides_field(item))
|
|
|
|
if len(row) > 3:
|
|
write_row(row)
|
|
|
|
return response
|
|
|
|
@role_required('Area Director','Secretariat','IAB')
|
|
def agenda_by_type_ics(request,num=None,type=None):
|
|
meeting = get_meeting(num)
|
|
schedule = get_schedule(meeting)
|
|
assignments = SchedTimeSessAssignment.objects.filter(
|
|
schedule__in=[schedule, schedule.base if schedule else None]
|
|
).prefetch_related(
|
|
'timeslot', 'timeslot__location', 'session', 'session__group', 'session__group__parent'
|
|
).order_by('session__type__slug','timeslot__time')
|
|
if type:
|
|
assignments = assignments.filter(session__type__slug=type)
|
|
updated = meeting.updated()
|
|
return render(request,"meeting/agenda.ics",{"schedule":schedule,"updated":updated,"assignments":assignments},content_type="text/calendar")
|
|
|
|
def session_draft_list(num, acronym):
|
|
try:
|
|
agendas = Document.objects.filter(type="agenda",
|
|
session__meeting__number=num,
|
|
session__group__acronym=acronym,
|
|
states=State.objects.get(type="agenda", slug="active")).distinct()
|
|
except Document.DoesNotExist:
|
|
raise Http404
|
|
|
|
drafts = set()
|
|
for agenda in agendas:
|
|
content, _ = read_agenda_file(num, agenda)
|
|
if content:
|
|
drafts.update(re.findall(b'(draft-[-a-z0-9]*)', content))
|
|
|
|
result = []
|
|
for draft in drafts:
|
|
draft = force_str(draft)
|
|
try:
|
|
if re.search('-[0-9]{2}$', draft):
|
|
doc_name = draft
|
|
else:
|
|
doc = Document.objects.get(name=draft)
|
|
doc_name = draft + "-" + doc.rev
|
|
|
|
if doc_name not in result:
|
|
result.append(doc_name)
|
|
except Document.DoesNotExist:
|
|
pass
|
|
|
|
for sp in SessionPresentation.objects.filter(session__meeting__number=num, session__group__acronym=acronym, document__type='draft'):
|
|
doc_name = sp.document.name + "-" + sp.document.rev
|
|
if doc_name not in result:
|
|
result.append(doc_name)
|
|
|
|
return sorted(result)
|
|
|
|
def session_draft_tarfile(request, num, acronym):
|
|
drafts = session_draft_list(num, acronym);
|
|
|
|
response = HttpResponse(content_type='application/octet-stream')
|
|
response['Content-Disposition'] = 'attachment; filename=%s-drafts.tgz'%(acronym)
|
|
tarstream = tarfile.open('','w:gz',response)
|
|
mfh, mfn = mkstemp()
|
|
os.close(mfh)
|
|
manifest = io.open(mfn, "w")
|
|
|
|
for doc_name in drafts:
|
|
pdf_path = os.path.join(settings.INTERNET_DRAFT_PDF_PATH, doc_name + ".pdf")
|
|
|
|
if not os.path.exists(pdf_path):
|
|
convert_draft_to_pdf(doc_name)
|
|
|
|
if os.path.exists(pdf_path):
|
|
try:
|
|
tarstream.add(pdf_path, str(doc_name + ".pdf"))
|
|
manifest.write("Included: "+pdf_path+"\n")
|
|
except Exception as e:
|
|
manifest.write(("Failed (%s): "%e)+pdf_path+"\n")
|
|
else:
|
|
manifest.write("Not found: "+pdf_path+"\n")
|
|
|
|
manifest.close()
|
|
tarstream.add(mfn, "manifest.txt")
|
|
tarstream.close()
|
|
os.unlink(mfn)
|
|
return response
|
|
|
|
def session_draft_pdf(request, num, acronym):
|
|
drafts = session_draft_list(num, acronym);
|
|
curr_page = 1
|
|
pmh, pmn = mkstemp()
|
|
os.close(pmh)
|
|
pdfmarks = io.open(pmn, "w")
|
|
pdf_list = ""
|
|
|
|
for draft in drafts:
|
|
pdf_path = os.path.join(settings.INTERNET_DRAFT_PDF_PATH, draft + ".pdf")
|
|
if not os.path.exists(pdf_path):
|
|
convert_draft_to_pdf(draft)
|
|
|
|
if os.path.exists(pdf_path):
|
|
pages = pdf_pages(pdf_path)
|
|
pdfmarks.write("[/Page "+str(curr_page)+" /View [/XYZ 0 792 1.0] /Title (" + draft + ") /OUT pdfmark\n")
|
|
pdf_list = pdf_list + " " + pdf_path
|
|
curr_page = curr_page + pages
|
|
|
|
pdfmarks.close()
|
|
pdfh, pdfn = mkstemp()
|
|
os.close(pdfh)
|
|
gs = settings.GHOSTSCRIPT_COMMAND
|
|
code, out, err = pipe(gs + " -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite -sOutputFile=" + pdfn + " " + pdf_list + " " + pmn)
|
|
assertion('code == 0')
|
|
|
|
pdf = io.open(pdfn,"rb")
|
|
pdf_contents = pdf.read()
|
|
pdf.close()
|
|
|
|
os.unlink(pmn)
|
|
os.unlink(pdfn)
|
|
return HttpResponse(pdf_contents, content_type="application/pdf")
|
|
|
|
def ical_session_status(assignment):
|
|
if assignment.session.current_status == 'canceled':
|
|
return "CANCELLED"
|
|
elif assignment.session.current_status == 'resched':
|
|
t = "RESCHEDULED"
|
|
if assignment.session.tombstone_for_id is not None:
|
|
other_assignment = SchedTimeSessAssignment.objects.filter(schedule=assignment.schedule_id, session=assignment.session.tombstone_for_id).first()
|
|
if other_assignment:
|
|
t = "RESCHEDULED TO {}-{}".format(
|
|
other_assignment.timeslot.time.strftime("%A %H:%M").upper(),
|
|
other_assignment.timeslot.end_time().strftime("%H:%M")
|
|
)
|
|
return t
|
|
else:
|
|
return "CONFIRMED"
|
|
|
|
def parse_agenda_filter_params(querydict):
|
|
"""Parse agenda filter parameters from a request"""
|
|
if len(querydict) == 0:
|
|
return None
|
|
|
|
# Parse group filters from GET parameters. Other params are ignored.
|
|
filt_params = {'show': set(), 'hide': set(), 'showtypes': set(), 'hidetypes': set()}
|
|
|
|
for key, value in querydict.items():
|
|
if key in filt_params:
|
|
vals = unquote(value).lower().split(',')
|
|
vals = [v.strip() for v in vals]
|
|
filt_params[key] = set([v for v in vals if len(v) > 0]) # remove empty strings
|
|
|
|
return filt_params
|
|
|
|
|
|
def should_include_assignment(filter_params, assignment):
|
|
"""Decide whether to include an assignment"""
|
|
shown = len(set(filter_params['show']).intersection(assignment.filter_keywords)) > 0
|
|
hidden = len(set(filter_params['hide']).intersection(assignment.filter_keywords)) > 0
|
|
return shown and not hidden
|
|
|
|
def agenda_ical(request, num=None, acronym=None, session_id=None):
|
|
"""Agenda ical view
|
|
|
|
If num is None, looks for the next IETF meeting. Otherwise, uses the requested meeting
|
|
regardless of its type.
|
|
|
|
By default, all agenda items will be shown. A filter can be specified in
|
|
the querystring. It has the format
|
|
|
|
?show=...&hide=...&showtypes=...&hidetypes=...
|
|
|
|
where any of the parameters can be omitted. The right-hand side of each
|
|
'=' is a comma separated list, which can be empty. If none of the filter
|
|
parameters are specified, no filtering will be applied, even if the query
|
|
string is not empty.
|
|
|
|
The show and hide parameters each take a list of working group (wg) acronyms.
|
|
The showtypes and hidetypes parameters take a list of session types.
|
|
|
|
Hiding (by wg or type) takes priority over showing.
|
|
"""
|
|
if num is None:
|
|
meeting = get_ietf_meeting()
|
|
if meeting is None:
|
|
raise Http404
|
|
else:
|
|
meeting = get_meeting(num, type_in=None) # get requested meeting, whatever its type
|
|
schedule = get_schedule(meeting)
|
|
updated = meeting.updated()
|
|
|
|
if schedule is None and acronym is None and session_id is None:
|
|
raise Http404
|
|
|
|
assignments = SchedTimeSessAssignment.objects.filter(
|
|
schedule__in=[schedule, schedule.base],
|
|
session__on_agenda=True,
|
|
)
|
|
assignments = preprocess_assignments_for_agenda(assignments, meeting)
|
|
AgendaKeywordTagger(assignments=assignments).apply()
|
|
|
|
try:
|
|
filt_params = parse_agenda_filter_params(request.GET)
|
|
except ValueError as e:
|
|
return HttpResponseBadRequest(str(e))
|
|
|
|
if filt_params is not None:
|
|
# Apply the filter
|
|
assignments = [a for a in assignments if should_include_assignment(filt_params, a)]
|
|
|
|
if acronym:
|
|
assignments = [ a for a in assignments if a.session.group_at_the_time().acronym == acronym ]
|
|
elif session_id:
|
|
assignments = [ a for a in assignments if a.session_id == int(session_id) ]
|
|
|
|
for a in assignments:
|
|
if a.session:
|
|
a.session.ical_status = ical_session_status(a)
|
|
|
|
return render(request, "meeting/agenda.ics", {
|
|
"schedule": schedule,
|
|
"assignments": assignments,
|
|
"updated": updated
|
|
}, content_type="text/calendar")
|
|
|
|
@cache_page(15 * 60)
|
|
def agenda_json(request, num=None):
|
|
if num is None:
|
|
meeting = get_ietf_meeting()
|
|
if meeting is None:
|
|
raise Http404
|
|
else:
|
|
meeting = get_meeting(num, type_in=None) # get requested meeting, whatever its type
|
|
|
|
sessions = []
|
|
locations = set()
|
|
parent_acronyms = set()
|
|
assignments = SchedTimeSessAssignment.objects.filter(
|
|
schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None],
|
|
session__on_agenda=True,
|
|
).exclude(
|
|
session__type__in=['break', 'reg']
|
|
)
|
|
# Update the assignments with historic information, i.e., valid at the
|
|
# time of the meeting
|
|
assignments = preprocess_assignments_for_agenda(assignments, meeting, extra_prefetches=[
|
|
"session__materials__docevent_set",
|
|
"session__presentations",
|
|
"timeslot__meeting"
|
|
])
|
|
for asgn in assignments:
|
|
sessdict = dict()
|
|
sessdict['objtype'] = 'session'
|
|
sessdict['id'] = asgn.pk
|
|
sessdict['is_bof'] = False
|
|
if asgn.session.group_at_the_time():
|
|
sessdict['group'] = {
|
|
"acronym": asgn.session.group_at_the_time().acronym,
|
|
"name": asgn.session.group_at_the_time().name,
|
|
"type": asgn.session.group_at_the_time().type_id,
|
|
"state": asgn.session.group_at_the_time().state_id,
|
|
}
|
|
if asgn.session.group_at_the_time().is_bof():
|
|
sessdict['is_bof'] = True
|
|
if asgn.session.group_at_the_time().type_id in ['wg','rg', 'ag', 'rag'] or asgn.session.group_at_the_time().acronym in ['iesg',]: # TODO: should that first list be groupfeatures driven?
|
|
if asgn.session.group_parent_at_the_time():
|
|
sessdict['group']['parent'] = asgn.session.group_parent_at_the_time().acronym
|
|
parent_acronyms.add(asgn.session.group_parent_at_the_time().acronym)
|
|
if asgn.session.name:
|
|
sessdict['name'] = asgn.session.name
|
|
else:
|
|
sessdict['name'] = asgn.session.group_at_the_time().name
|
|
if asgn.session.short:
|
|
sessdict['short'] = asgn.session.short
|
|
if asgn.session.agenda_note:
|
|
sessdict['agenda_note'] = asgn.session.agenda_note
|
|
if asgn.session.remote_instructions:
|
|
sessdict['remote_instructions'] = asgn.session.remote_instructions
|
|
utc_start = asgn.timeslot.utc_start_time()
|
|
if utc_start:
|
|
sessdict['start'] = utc_start.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
sessdict['duration'] = str(asgn.timeslot.duration)
|
|
sessdict['location'] = asgn.room_name
|
|
if asgn.timeslot.location: # Some socials have an assignment but no location
|
|
locations.add(asgn.timeslot.location)
|
|
if asgn.session.agenda():
|
|
sessdict['agenda'] = asgn.session.agenda().get_href()
|
|
|
|
if asgn.session.minutes():
|
|
sessdict['minutes'] = asgn.session.minutes().get_href()
|
|
if asgn.session.slides():
|
|
sessdict['presentations'] = []
|
|
presentations = SessionPresentation.objects.filter(session=asgn.session, document__type__slug='slides')
|
|
for pres in presentations:
|
|
sessdict['presentations'].append(
|
|
{
|
|
'name': pres.document.name,
|
|
'title': pres.document.title,
|
|
'order': pres.order,
|
|
'rev': pres.rev,
|
|
'resource_uri': '/api/v1/meeting/sessionpresentation/%s/'%pres.id,
|
|
})
|
|
sessdict['session_res_uri'] = '/api/v1/meeting/session/%s/'%asgn.session.id
|
|
sessdict['session_id'] = asgn.session.id
|
|
modified = asgn.session.modified
|
|
for doc in asgn.session.materials.all():
|
|
rev_docevent = doc.latest_event(NewRevisionDocEvent,'new_revision')
|
|
modified = max(modified, (rev_docevent and rev_docevent.time) or modified)
|
|
sessdict['modified'] = modified
|
|
sessdict['status'] = asgn.session.current_status
|
|
sessions.append(sessdict)
|
|
|
|
rooms = []
|
|
for room in locations:
|
|
roomdict = dict()
|
|
roomdict['id'] = room.pk
|
|
roomdict['objtype'] = 'location'
|
|
roomdict['name'] = room.name
|
|
if room.floorplan:
|
|
roomdict['level_name'] = room.floorplan.name
|
|
roomdict['level_sort'] = room.floorplan.order
|
|
if room.x1 is not None:
|
|
roomdict['x'] = (room.x1+room.x2)/2.0
|
|
roomdict['y'] = (room.y1+room.y2)/2.0
|
|
roomdict['modified'] = room.modified
|
|
if room.floorplan and room.floorplan.image:
|
|
roomdict['map'] = room.floorplan.image.url
|
|
roomdict['modified'] = max(room.modified, room.floorplan.modified)
|
|
rooms.append(roomdict)
|
|
|
|
parents = []
|
|
for parent in Group.objects.filter(acronym__in=parent_acronyms):
|
|
parentdict = dict()
|
|
parentdict['id'] = parent.pk
|
|
parentdict['objtype'] = 'parent'
|
|
parentdict['name'] = parent.acronym
|
|
parentdict['description'] = parent.name
|
|
parentdict['modified'] = parent.time
|
|
parents.append(parentdict)
|
|
|
|
meetinfo = []
|
|
meetinfo.extend(sessions)
|
|
meetinfo.extend(rooms)
|
|
meetinfo.extend(parents)
|
|
meetinfo.sort(key=lambda x: x['modified'],reverse=True)
|
|
last_modified = meetinfo and meetinfo[0]['modified']
|
|
|
|
for obj in meetinfo:
|
|
obj['modified'] = obj['modified'].astimezone(pytz.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
|
|
data = {"%s"%num: meetinfo}
|
|
|
|
response = HttpResponse(json.dumps(data, indent=2, sort_keys=True), content_type='application/json;charset=%s'%settings.DEFAULT_CHARSET)
|
|
if last_modified:
|
|
last_modified = last_modified.astimezone(pytz.utc)
|
|
response['Last-Modified'] = format_date_time(timegm(last_modified.timetuple()))
|
|
return response
|
|
|
|
def request_summary_filter(session):
|
|
if (session.group.area is None
|
|
or session.group.type.slug in request_summary_exclude_group_types
|
|
or session.current_status == 'notmeet'):
|
|
return False
|
|
return True
|
|
|
|
def get_area_column(area):
|
|
if area is None:
|
|
return ''
|
|
if area.type.slug in ['rfcedtyp']:
|
|
name = 'OTHER'
|
|
else:
|
|
name = area.acronym.upper()
|
|
return name
|
|
|
|
def get_summary_by_area(sessions):
|
|
"""Returns summary by area for list of session requests.
|
|
Summary is a two dimensional array[row=session duration][col=session area count]
|
|
It also includes row and column headers as well as a totals row.
|
|
"""
|
|
|
|
# first build a dictionary of counts, key=(duration,area)
|
|
durations = set()
|
|
areas = set()
|
|
duration_totals = defaultdict(int)
|
|
data = defaultdict(int)
|
|
for session in sessions:
|
|
area_column = get_area_column(session.group.area)
|
|
duration = session.requested_duration.seconds / 3600
|
|
key = (duration, area_column)
|
|
data[key] = data[key] + 1
|
|
durations.add(duration)
|
|
areas.add(area_column)
|
|
duration_totals[duration] = duration_totals[duration] + 1
|
|
|
|
# build two dimensional array for use in template
|
|
rows = []
|
|
sorted_areas = sorted(areas)
|
|
# move "other" to end
|
|
if 'OTHER' in sorted_areas:
|
|
sorted_areas.remove('OTHER')
|
|
sorted_areas.append('OTHER')
|
|
# add header row
|
|
rows.append(['Duration'] + sorted_areas + ['TOTAL SLOTS', 'TOTAL HOURS'])
|
|
for duration in sorted(durations):
|
|
rows.append([duration] + [data[(duration, a)] for a in sorted_areas] + [duration_totals[duration]] + [duration_totals[duration] * duration])
|
|
# add total row
|
|
rows.append(['Total Slots'] + [sum([rows[r][c] for r in range(1, len(rows))]) for c in range(1, len(rows[0]))])
|
|
rows.append(['Total Hours'] + [sum([d * data[(d, area)] for d in durations]) for area in sorted_areas])
|
|
return rows
|
|
|
|
def get_summary_by_type(sessions):
|
|
counter = Counter([s.group.type.name for s in sessions])
|
|
data = counter.most_common()
|
|
data.insert(0, ('Group Type', 'Count'))
|
|
return data
|
|
|
|
def get_summary_by_purpose(sessions):
|
|
counter = Counter([s.purpose.name for s in sessions])
|
|
data = counter.most_common()
|
|
data.insert(0, ('Purpose', 'Count'))
|
|
return data
|
|
|
|
def meeting_requests(request, num=None):
|
|
meeting = get_meeting(num)
|
|
groups_to_show = Group.objects.filter(
|
|
state_id__in=('active', 'bof', 'proposed'),
|
|
type__features__has_meetings=True,
|
|
)
|
|
sessions = list(
|
|
Session.objects.requests().filter(
|
|
meeting__number=meeting.number,
|
|
group__in=groups_to_show,
|
|
).exclude(
|
|
purpose__in=('admin', 'social'),
|
|
).with_current_status().with_requested_by().exclude(
|
|
requested_by=0
|
|
).prefetch_related(
|
|
"group", "group__ad_role__person", "group__type"
|
|
)
|
|
)
|
|
|
|
status_names = {n.slug: n.name for n in SessionStatusName.objects.all()}
|
|
session_requesters = {p.pk: p for p in Person.objects.filter(pk__in=[s.requested_by for s in sessions if s.requested_by is not None])}
|
|
|
|
for s in sessions:
|
|
s.current_status_name = status_names.get(s.current_status, s.current_status)
|
|
s.requested_by_person = session_requesters.get(s.requested_by)
|
|
if s.group.parent and s.group.parent.type.slug in ('area', 'irtf'):
|
|
s.display_area = s.group.parent
|
|
else:
|
|
s.display_area = None
|
|
sessions.sort(
|
|
key=lambda s: (
|
|
s.display_area.acronym if s.display_area is not None else 'zzzz',
|
|
s.current_status,
|
|
s.group.acronym,
|
|
),
|
|
)
|
|
|
|
groups_not_meeting = groups_to_show.exclude(
|
|
acronym__in=[session.group.acronym for session in sessions]
|
|
).order_by(
|
|
"parent__acronym",
|
|
"acronym",
|
|
).prefetch_related("parent")
|
|
|
|
summary_sessions = list(filter(request_summary_filter, sessions))
|
|
|
|
return render(
|
|
request,
|
|
"meeting/requests.html",
|
|
{
|
|
"meeting": meeting,
|
|
"sessions": sessions,
|
|
"groups_not_meeting": groups_not_meeting,
|
|
"summary_by_area": get_summary_by_area(summary_sessions),
|
|
"summary_by_group_type": get_summary_by_type(summary_sessions),
|
|
"summary_by_purpose": get_summary_by_purpose(summary_sessions),
|
|
},
|
|
)
|
|
|
|
|
|
def get_sessions(num, acronym):
|
|
return sorted(
|
|
get_meeting_sessions(num, acronym).with_current_status(),
|
|
key=lambda s: session_time_for_sorting(s, use_meeting_date=False)
|
|
)
|
|
|
|
|
|
def session_details(request, num, acronym):
|
|
meeting = get_meeting(num=num,type_in=None)
|
|
sessions = get_sessions(num, acronym)
|
|
|
|
if not sessions:
|
|
raise Http404
|
|
|
|
status_names = {n.slug: n.name for n in SessionStatusName.objects.all()}
|
|
for session in sessions:
|
|
|
|
session.type_counter = Counter()
|
|
ss = session.timeslotassignments.filter(schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None]).order_by('timeslot__time')
|
|
if ss:
|
|
if meeting.type_id == 'interim' and not (meeting.city or meeting.country):
|
|
session.times = [ x.timeslot.utc_start_time() for x in ss ]
|
|
else:
|
|
session.times = [ x.timeslot.local_start_time() for x in ss ]
|
|
session.cancelled = session.current_status in Session.CANCELED_STATUSES
|
|
session.status = ''
|
|
elif meeting.type_id=='interim':
|
|
session.times = [ meeting.date ]
|
|
session.cancelled = session.current_status in Session.CANCELED_STATUSES
|
|
session.status = ''
|
|
else:
|
|
session.times = []
|
|
session.cancelled = session.current_status in Session.CANCELED_STATUSES
|
|
session.status = status_names.get(session.current_status, session.current_status)
|
|
|
|
if session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final:
|
|
artifact_types = ['agenda','minutes','narrativeminutes']
|
|
if Attended.objects.filter(session=session).exists():
|
|
session.type_counter.update(['bluesheets'])
|
|
ota = session.official_timeslotassignment()
|
|
sess_time = ota and ota.timeslot.time
|
|
session.bluesheet_title = 'Attendance IETF%s: %s : %s' % (session.meeting.number,
|
|
session.group.acronym,
|
|
sess_time.strftime("%a %H:%M"))
|
|
else:
|
|
artifact_types = ['agenda','minutes','narrativeminutes','bluesheets']
|
|
session.filtered_artifacts = list(session.presentations.filter(document__type__slug__in=artifact_types))
|
|
session.filtered_artifacts.sort(key=lambda d:artifact_types.index(d.document.type.slug))
|
|
session.filtered_slides = session.presentations.filter(document__type__slug='slides').order_by('order')
|
|
session.filtered_drafts = session.presentations.filter(document__type__slug='draft')
|
|
session.filtered_chatlog_and_polls = session.presentations.filter(document__type__slug__in=('chatlog', 'polls')).order_by('document__type__slug')
|
|
# TODO FIXME Deleted materials shouldn't be in the presentations
|
|
for qs in [session.filtered_artifacts,session.filtered_slides,session.filtered_drafts]:
|
|
qs = [p for p in qs if p.document.get_state_slug(p.document.type_id)!='deleted']
|
|
session.type_counter.update([p.document.type.slug for p in qs])
|
|
|
|
session.order_number = session.order_in_meeting()
|
|
|
|
# we somewhat arbitrarily use the group of the last session we get from
|
|
# get_sessions() above when checking can_manage_session_materials()
|
|
group = session.group
|
|
can_manage = can_manage_session_materials(request.user, group, session)
|
|
can_view_request = can_view_interim_request(meeting, request.user)
|
|
|
|
scheduled_sessions = [s for s in sessions if s.current_status == 'sched']
|
|
unscheduled_sessions = [s for s in sessions if s.current_status != 'sched']
|
|
|
|
pending_suggestions = None
|
|
if request.user.is_authenticated:
|
|
if can_manage:
|
|
pending_suggestions = session.slidesubmission_set.filter(status__slug='pending')
|
|
else:
|
|
pending_suggestions = session.slidesubmission_set.filter(status__slug='pending', submitter=request.user.person)
|
|
|
|
return render(request, "meeting/session_details.html",
|
|
{ 'scheduled_sessions':scheduled_sessions ,
|
|
'unscheduled_sessions':unscheduled_sessions ,
|
|
'pending_suggestions' : pending_suggestions,
|
|
'meeting' :meeting ,
|
|
'group': group,
|
|
'is_materials_manager' : session.group.has_role(request.user, session.group.features.matman_roles),
|
|
'can_manage_materials' : can_manage,
|
|
'can_view_request': can_view_request,
|
|
'thisweek': datetime_today()-datetime.timedelta(days=7),
|
|
})
|
|
|
|
class SessionDraftsForm(forms.Form):
|
|
drafts = SearchableDocumentsField(required=False)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.already_linked = kwargs.pop('already_linked')
|
|
super(self.__class__, self).__init__(*args, **kwargs)
|
|
|
|
def clean(self):
|
|
selected = self.cleaned_data['drafts']
|
|
problems = set(selected).intersection(set(self.already_linked))
|
|
if problems:
|
|
raise forms.ValidationError("Already linked: %s" % ', '.join([d.name for d in problems]))
|
|
return self.cleaned_data
|
|
|
|
def add_session_drafts(request, session_id, num):
|
|
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session,pk=session_id)
|
|
if not session.can_manage_materials(request.user):
|
|
raise Http404
|
|
if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"):
|
|
raise Http404
|
|
|
|
already_linked = [sp.document for sp in session.presentations.filter(document__type_id='draft')]
|
|
|
|
session_number = None
|
|
sessions = get_sessions(session.meeting.number,session.group.acronym)
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(session)
|
|
|
|
if request.method == 'POST':
|
|
form = SessionDraftsForm(request.POST,already_linked=already_linked)
|
|
if form.is_valid():
|
|
for draft in form.cleaned_data['drafts']:
|
|
session.presentations.create(document=draft,rev=None)
|
|
c = DocEvent(type="added_comment", doc=draft, rev=draft.rev, by=request.user.person)
|
|
c.desc = "Added to session: %s" % session
|
|
c.save()
|
|
return redirect('ietf.meeting.views.session_details', num=session.meeting.number, acronym=session.group.acronym)
|
|
else:
|
|
form = SessionDraftsForm(already_linked=already_linked)
|
|
|
|
return render(request, "meeting/add_session_drafts.html",
|
|
{ 'session': session,
|
|
'session_number': session_number,
|
|
'already_linked': session.presentations.filter(document__type_id='draft'),
|
|
'form': form,
|
|
})
|
|
|
|
class SessionRecordingsForm(forms.Form):
|
|
title = forms.CharField(max_length=255)
|
|
url = forms.URLField(label="URL of the recording (YouTube only)")
|
|
|
|
def clean_url(self):
|
|
url = self.cleaned_data['url']
|
|
parsed_url = urlparse(url)
|
|
if parsed_url.hostname not in YOUTUBE_DOMAINS:
|
|
raise forms.ValidationError("Must be a YouTube URL")
|
|
return url
|
|
|
|
|
|
def add_session_recordings(request, session_id, num):
|
|
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(
|
|
request, "You don't have permission to manage recordings for this session."
|
|
)
|
|
if session.is_material_submission_cutoff() and not has_role(
|
|
request.user, "Secretariat"
|
|
):
|
|
raise Http404
|
|
|
|
session_number = None
|
|
official_timeslotassignment = session.official_timeslotassignment()
|
|
assertion("official_timeslotassignment is not None")
|
|
initial = {
|
|
"title": "Video recording of {acronym} for {timestamp}".format(
|
|
acronym=session.group.acronym,
|
|
timestamp=official_timeslotassignment.timeslot.utc_start_time().strftime(
|
|
"%Y-%m-%d %H:%M"
|
|
),
|
|
)
|
|
}
|
|
|
|
# find session number if WG has more than one session at the meeting
|
|
sessions = get_sessions(session.meeting.number, session.group.acronym)
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(session)
|
|
|
|
presentations = session.presentations.filter(
|
|
document__in=session.get_material("recording", only_one=False),
|
|
).order_by("document__title", "document__external_url")
|
|
|
|
if request.method == "POST":
|
|
pk_to_delete = request.POST.get("delete", None)
|
|
if pk_to_delete is not None:
|
|
session_presentation = get_object_or_404(presentations, pk=pk_to_delete)
|
|
try:
|
|
delete_recording(session_presentation)
|
|
except ValueError as err:
|
|
log(f"Error deleting recording from session {session.pk}: {err}")
|
|
messages.error(
|
|
request,
|
|
"Unable to delete this recording. Please contact the secretariat for assistance.",
|
|
)
|
|
form = SessionRecordingsForm(initial=initial)
|
|
else:
|
|
form = SessionRecordingsForm(request.POST)
|
|
if form.is_valid():
|
|
title = form.cleaned_data["title"]
|
|
url = form.cleaned_data["url"]
|
|
create_recording(session, url, title=title, user=request.user.person)
|
|
return redirect(
|
|
"ietf.meeting.views.session_details",
|
|
num=session.meeting.number,
|
|
acronym=session.group.acronym,
|
|
)
|
|
else:
|
|
form = SessionRecordingsForm(initial=initial)
|
|
|
|
return render(
|
|
request,
|
|
"meeting/add_session_recordings.html",
|
|
{
|
|
"session": session,
|
|
"session_number": session_number,
|
|
"already_linked": presentations,
|
|
"form": form,
|
|
},
|
|
)
|
|
|
|
|
|
def session_attendance(request, session_id, num):
|
|
"""Session attendance view
|
|
|
|
GET - retrieve the current session attendance or redirect to the published bluesheet if finalized
|
|
|
|
POST - self-attest attendance for logged-in user; falls through to GET for AnonymousUser or invalid request
|
|
"""
|
|
# num is redundant, but we're dragging it along as an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
if session.meeting.type_id != "ietf" or session.meeting.proceedings_final:
|
|
bluesheets = session.presentations.filter(
|
|
document__type_id="bluesheets"
|
|
)
|
|
if bluesheets:
|
|
bluesheet = bluesheets[0].document
|
|
return redirect(bluesheet.get_href(session.meeting))
|
|
else:
|
|
raise Http404("Bluesheets not found")
|
|
|
|
cor_cut_off_date = session.meeting.get_submission_correction_date()
|
|
today_utc = date_today(datetime.timezone.utc)
|
|
was_there = False
|
|
can_add = False
|
|
if request.user.is_authenticated:
|
|
# use getattr() instead of request.user.person because it's a reverse OneToOne field
|
|
person = getattr(request.user, "person", None)
|
|
# Consider allowing self-declared attendance if we have a person and at least one Attended instance exists.
|
|
# The latter condition will be satisfied when Meetecho pushes their attendee records - assuming that at least
|
|
# one person will have accessed the meeting tool. This prevents people from self-declaring before they are
|
|
# marked as attending if they did log in to the meeting tool (except for a tiny window while records are
|
|
# being processed).
|
|
if person is not None and Attended.objects.filter(session=session).exists():
|
|
was_there = Attended.objects.filter(session=session, person=person).exists()
|
|
can_add = (
|
|
today_utc <= cor_cut_off_date
|
|
and MeetingRegistration.objects.filter(
|
|
meeting=session.meeting, person=person
|
|
).exists()
|
|
and not was_there
|
|
)
|
|
if can_add and request.method == "POST":
|
|
session.attended_set.get_or_create(
|
|
person=person, defaults={"origin": "self declared"}
|
|
)
|
|
can_add = False
|
|
was_there = True
|
|
|
|
data = bluesheet_data(session)
|
|
return render(
|
|
request,
|
|
"meeting/attendance.html",
|
|
{
|
|
"session": session,
|
|
"data": data,
|
|
"can_add": can_add,
|
|
"was_there": was_there,
|
|
},
|
|
)
|
|
|
|
|
|
def upload_session_bluesheets(request, session_id, num):
|
|
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session,pk=session_id)
|
|
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(request, "You don't have permission to upload bluesheets for this session.")
|
|
if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"):
|
|
permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.")
|
|
|
|
if session.meeting.type.slug == 'ietf' and not has_role(request.user, 'Secretariat'):
|
|
permission_denied(request, 'Restricted to role Secretariat')
|
|
|
|
session_number = None
|
|
sessions = get_sessions(session.meeting.number,session.group.acronym)
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(session)
|
|
|
|
if request.method == 'POST':
|
|
form = UploadBlueSheetForm(request.POST,request.FILES)
|
|
if form.is_valid():
|
|
file = request.FILES['file']
|
|
|
|
ota = session.official_timeslotassignment()
|
|
sess_time = ota and ota.timeslot.time
|
|
if not sess_time:
|
|
return HttpResponseGone("Cannot receive uploads for an unscheduled session. Please check the session ID.", content_type="text/plain")
|
|
|
|
|
|
save_error = save_bluesheet(request, session, file, encoding=form.file_encoding[file.name])
|
|
if save_error:
|
|
form.add_error(None, save_error)
|
|
else:
|
|
messages.success(request, 'Successfully uploaded bluesheets.')
|
|
return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym)
|
|
else:
|
|
form = UploadBlueSheetForm()
|
|
|
|
bluesheet_sp = session.presentations.filter(document__type='bluesheets').first()
|
|
|
|
return render(request, "meeting/upload_session_bluesheets.html",
|
|
{'session': session,
|
|
'session_number': session_number,
|
|
'bluesheet_sp' : bluesheet_sp,
|
|
'form': form,
|
|
})
|
|
|
|
|
|
def upload_session_minutes(request, session_id, num):
|
|
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session,pk=session_id)
|
|
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(request, "You don't have permission to upload minutes for this session.")
|
|
if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"):
|
|
permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.")
|
|
|
|
session_number = None
|
|
sessions = get_sessions(session.meeting.number,session.group.acronym)
|
|
show_apply_to_all_checkbox = len(sessions) > 1 if session.type_id == 'regular' else False
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(session)
|
|
|
|
minutes_sp = session.presentations.filter(document__type='minutes').first()
|
|
|
|
if request.method == 'POST':
|
|
form = UploadMinutesForm(show_apply_to_all_checkbox,request.POST,request.FILES)
|
|
if form.is_valid():
|
|
file = request.FILES['file']
|
|
_, ext = os.path.splitext(file.name)
|
|
apply_to_all = session.type_id == 'regular'
|
|
if show_apply_to_all_checkbox:
|
|
apply_to_all = form.cleaned_data['apply_to_all']
|
|
|
|
# Set up the new revision
|
|
try:
|
|
save_session_minutes_revision(
|
|
session=session,
|
|
apply_to_all=apply_to_all,
|
|
file=file,
|
|
ext=ext,
|
|
encoding=form.file_encoding[file.name],
|
|
request=request,
|
|
)
|
|
except SessionNotScheduledError:
|
|
return HttpResponseGone(
|
|
"Cannot receive uploads for an unscheduled session. Please check the session ID.",
|
|
content_type="text/plain",
|
|
)
|
|
except SaveMaterialsError as err:
|
|
form.add_error(None, str(err))
|
|
else:
|
|
# no exception -- success!
|
|
messages.success(request, f'Successfully uploaded minutes as revision {session.minutes().rev}.')
|
|
return redirect('ietf.meeting.views.session_details', num=num, acronym=session.group.acronym)
|
|
else:
|
|
form = UploadMinutesForm(show_apply_to_all_checkbox)
|
|
|
|
return render(request, "meeting/upload_session_minutes.html",
|
|
{'session': session,
|
|
'session_number': session_number,
|
|
'minutes_sp' : minutes_sp,
|
|
'form': form,
|
|
})
|
|
|
|
@role_required("Secretariat")
|
|
def upload_session_narrativeminutes(request, session_id, num):
|
|
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session,pk=session_id)
|
|
if session.group.acronym != "iesg":
|
|
raise Http404()
|
|
|
|
session_number = None
|
|
sessions = get_sessions(session.meeting.number,session.group.acronym)
|
|
show_apply_to_all_checkbox = len(sessions) > 1 if session.type_id == 'regular' else False
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(session)
|
|
|
|
narrativeminutes_sp = session.presentations.filter(document__type='narrativeminutes').first()
|
|
|
|
if request.method == 'POST':
|
|
form = UploadNarrativeMinutesForm(show_apply_to_all_checkbox,request.POST,request.FILES)
|
|
if form.is_valid():
|
|
file = request.FILES['file']
|
|
_, ext = os.path.splitext(file.name)
|
|
apply_to_all = session.type_id == 'regular'
|
|
if show_apply_to_all_checkbox:
|
|
apply_to_all = form.cleaned_data['apply_to_all']
|
|
|
|
# Set up the new revision
|
|
try:
|
|
save_session_minutes_revision(
|
|
session=session,
|
|
apply_to_all=apply_to_all,
|
|
file=file,
|
|
ext=ext,
|
|
encoding=form.file_encoding[file.name],
|
|
request=request,
|
|
narrative=True
|
|
)
|
|
except SessionNotScheduledError:
|
|
return HttpResponseGone(
|
|
"Cannot receive uploads for an unscheduled session. Please check the session ID.",
|
|
content_type="text/plain",
|
|
)
|
|
except SaveMaterialsError as err:
|
|
form.add_error(None, str(err))
|
|
else:
|
|
# no exception -- success!
|
|
messages.success(request, f'Successfully uploaded narrative minutes as revision {session.narrative_minutes().rev}.')
|
|
return redirect('ietf.meeting.views.session_details', num=num, acronym=session.group.acronym)
|
|
else:
|
|
form = UploadMinutesForm(show_apply_to_all_checkbox)
|
|
|
|
return render(request, "meeting/upload_session_narrativeminutes.html",
|
|
{'session': session,
|
|
'session_number': session_number,
|
|
'minutes_sp' : narrativeminutes_sp,
|
|
'form': form,
|
|
})
|
|
|
|
class UploadOrEnterAgendaForm(UploadAgendaForm):
|
|
ACTIONS = [
|
|
("upload", "Upload agenda"),
|
|
("enter", "Enter agenda"),
|
|
]
|
|
submission_method = forms.ChoiceField(choices=ACTIONS, widget=forms.RadioSelect)
|
|
|
|
content = forms.CharField(widget=forms.Textarea, required=False, strip=False, label="Agenda text")
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.fields["file"].required=False
|
|
self.order_fields(["submission_method", "file", "content"])
|
|
|
|
def clean_content(self):
|
|
return self.cleaned_data["content"].replace("\r", "")
|
|
|
|
def clean_file(self):
|
|
submission_method = self.cleaned_data.get("submission_method")
|
|
if submission_method == "upload":
|
|
if self.cleaned_data.get("file", None) is not None:
|
|
return super().clean_file()
|
|
return None
|
|
|
|
def clean(self):
|
|
def require_field(f):
|
|
if not self.cleaned_data.get(f):
|
|
self.add_error(f, ValidationError("You must fill in this field."))
|
|
|
|
submission_method = self.cleaned_data.get("submission_method")
|
|
if submission_method == "upload":
|
|
require_field("file")
|
|
elif submission_method == "enter":
|
|
require_field("content")
|
|
|
|
def get_file(self):
|
|
"""Get content as a file-like object"""
|
|
if self.cleaned_data.get("submission_method") == "upload":
|
|
return self.cleaned_data["file"]
|
|
else:
|
|
return SimpleUploadedFile(
|
|
name="uploaded.md",
|
|
content=self.cleaned_data["content"].encode("utf-8"),
|
|
content_type="text/markdown;charset=utf-8",
|
|
)
|
|
|
|
def upload_session_agenda(request, session_id, num):
|
|
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session,pk=session_id)
|
|
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(request, "You don't have permission to upload an agenda for this session.")
|
|
if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"):
|
|
permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.")
|
|
|
|
session_number = None
|
|
sessions = get_sessions(session.meeting.number,session.group.acronym)
|
|
show_apply_to_all_checkbox = len(sessions) > 1 if session.type.slug == 'regular' else False
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(session)
|
|
|
|
agenda_sp = session.presentations.filter(document__type='agenda').first()
|
|
|
|
if request.method == 'POST':
|
|
form = UploadOrEnterAgendaForm(show_apply_to_all_checkbox,request.POST,request.FILES)
|
|
if form.is_valid():
|
|
file = form.get_file()
|
|
_, ext = os.path.splitext(file.name)
|
|
apply_to_all = session.type.slug == 'regular'
|
|
if show_apply_to_all_checkbox:
|
|
apply_to_all = form.cleaned_data['apply_to_all']
|
|
if agenda_sp:
|
|
doc = agenda_sp.document
|
|
doc.rev = '%02d' % (int(doc.rev)+1)
|
|
agenda_sp.rev = doc.rev
|
|
agenda_sp.save()
|
|
else:
|
|
ota = session.official_timeslotassignment()
|
|
sess_time = ota and ota.timeslot.time
|
|
if not sess_time:
|
|
return HttpResponseGone("Cannot receive uploads for an unscheduled session. Please check the session ID.", content_type="text/plain")
|
|
if session.meeting.type_id=='ietf':
|
|
name = 'agenda-%s-%s' % (session.meeting.number,
|
|
session.group.acronym)
|
|
title = 'Agenda IETF%s: %s' % (session.meeting.number,
|
|
session.group.acronym)
|
|
if not apply_to_all:
|
|
name += '-%s' % (session.docname_token(),)
|
|
if sess_time:
|
|
title += ': %s' % (sess_time.strftime("%a %H:%M"),)
|
|
else:
|
|
name = 'agenda-%s-%s' % (session.meeting.number, session.docname_token())
|
|
title = 'Agenda %s' % (session.meeting.number, )
|
|
if sess_time:
|
|
title += ': %s' % (sess_time.strftime("%a %H:%M"),)
|
|
if Document.objects.filter(name=name).exists():
|
|
doc = Document.objects.get(name=name)
|
|
doc.rev = '%02d' % (int(doc.rev)+1)
|
|
else:
|
|
doc = Document.objects.create(
|
|
name = name,
|
|
type_id = 'agenda',
|
|
title = title,
|
|
group = session.group,
|
|
rev = '00',
|
|
)
|
|
doc.states.add(State.objects.get(type_id='agenda',slug='active'))
|
|
if session.presentations.filter(document=doc).exists():
|
|
sp = session.presentations.get(document=doc)
|
|
sp.rev = doc.rev
|
|
sp.save()
|
|
else:
|
|
session.presentations.create(document=doc,rev=doc.rev)
|
|
if apply_to_all:
|
|
for other_session in sessions:
|
|
if other_session != session:
|
|
other_session.presentations.filter(document__type='agenda').delete()
|
|
other_session.presentations.create(document=doc,rev=doc.rev)
|
|
filename = '%s-%s%s'% ( doc.name, doc.rev, ext)
|
|
doc.uploaded_filename = filename
|
|
e = NewRevisionDocEvent.objects.create(doc=doc,by=request.user.person,type='new_revision',desc='New revision available: %s'%doc.rev,rev=doc.rev)
|
|
# The way this function builds the filename it will never trigger the file delete in handle_file_upload.
|
|
try:
|
|
encoding=form.file_encoding[file.name]
|
|
except AttributeError:
|
|
encoding=None
|
|
save_error = handle_upload_file(file, filename, session.meeting, 'agenda', request=request, encoding=encoding)
|
|
if save_error:
|
|
form.add_error(None, save_error)
|
|
else:
|
|
doc.save_with_history([e])
|
|
messages.success(request, f'Successfully uploaded agenda as revision {doc.rev}.')
|
|
return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym)
|
|
else:
|
|
initial={'apply_to_all':session.type_id=='regular', 'submission_method':'upload'}
|
|
if agenda_sp:
|
|
doc = agenda_sp.document
|
|
initial['content'] = doc.text()
|
|
form = UploadOrEnterAgendaForm(show_apply_to_all_checkbox, initial=initial)
|
|
|
|
return render(request, "meeting/upload_session_agenda.html",
|
|
{'session': session,
|
|
'session_number': session_number,
|
|
'agenda_sp' : agenda_sp,
|
|
'form': form,
|
|
})
|
|
|
|
|
|
@login_required
|
|
def upload_session_slides(request, session_id, num, name=None):
|
|
"""Upload new or replacement slides for a session
|
|
|
|
If name is None or "", expects a new set of slides. Otherwise, replaces the named slides with a new rev.
|
|
"""
|
|
# num is redundant, but we're dragging it along an artifact of where we are in the current URL structure
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
can_manage = session.can_manage_materials(request.user)
|
|
if session.is_material_submission_cutoff() and not has_role(
|
|
request.user, "Secretariat"
|
|
):
|
|
permission_denied(
|
|
request,
|
|
"The materials cutoff for this session has passed. Contact the secretariat for further action.",
|
|
)
|
|
|
|
session_number = None
|
|
sessions = get_sessions(session.meeting.number, session.group.acronym)
|
|
show_apply_to_all_checkbox = (
|
|
len(sessions) > 1 if session.type_id == "regular" else False
|
|
)
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(session)
|
|
|
|
doc = None
|
|
if name:
|
|
doc = get_object_or_404(
|
|
session.presentations, document__name=name, document__type_id="slides"
|
|
).document
|
|
|
|
if request.method == "POST":
|
|
form = UploadSlidesForm(
|
|
session, show_apply_to_all_checkbox, can_manage, request.POST, request.FILES
|
|
)
|
|
if form.is_valid():
|
|
file = request.FILES["file"]
|
|
_, ext = os.path.splitext(file.name)
|
|
apply_to_all = session.type_id == "regular"
|
|
if show_apply_to_all_checkbox:
|
|
apply_to_all = form.cleaned_data["apply_to_all"]
|
|
if can_manage:
|
|
approved = form.cleaned_data["approved"]
|
|
else:
|
|
approved = False
|
|
|
|
# Propose slides if not auto-approved
|
|
if not approved:
|
|
title = form.cleaned_data['title']
|
|
submission = SlideSubmission.objects.create(session = session, title = title, filename = '', apply_to_all = apply_to_all, submitter=request.user.person)
|
|
|
|
if session.meeting.type_id=='ietf':
|
|
name = 'slides-%s-%s' % (session.meeting.number,
|
|
session.group.acronym)
|
|
if not apply_to_all:
|
|
name += '-%s' % (session.docname_token(),)
|
|
else:
|
|
name = 'slides-%s-%s' % (session.meeting.number, session.docname_token())
|
|
name = name + '-' + slugify(title).replace('_', '-')[:128]
|
|
filename = '%s-ss%d%s'% (name, submission.id, ext)
|
|
destination = io.open(os.path.join(settings.SLIDE_STAGING_PATH, filename),'wb+')
|
|
for chunk in file.chunks():
|
|
destination.write(chunk)
|
|
destination.close()
|
|
|
|
submission.filename = filename
|
|
submission.save()
|
|
|
|
(to, cc) = gather_address_lists('slides_proposed', group=session.group, proposer=request.user.person).as_strings()
|
|
msg_txt = render_to_string("meeting/slides_proposed.txt", {
|
|
"to": to,
|
|
"cc": cc,
|
|
"submission": submission,
|
|
"settings": settings,
|
|
})
|
|
msg = infer_message(msg_txt)
|
|
msg.by = request.user.person
|
|
msg.save()
|
|
send_mail_message(request, msg)
|
|
messages.success(request, 'Successfully submitted proposed slides.')
|
|
return redirect('ietf.meeting.views.session_details',num=num,acronym=session.group.acronym)
|
|
|
|
# Handle creation / update of the Document (but do not save yet)
|
|
if doc is not None:
|
|
# This is a revision - bump the version and update the title.
|
|
doc.rev = "%02d" % (int(doc.rev) + 1)
|
|
doc.title = form.cleaned_data["title"]
|
|
else:
|
|
# This is a new slide deck - create a new doc unless one exists with that name
|
|
title = form.cleaned_data["title"]
|
|
if session.meeting.type_id == "ietf":
|
|
name = "slides-%s-%s" % (
|
|
session.meeting.number,
|
|
session.group.acronym,
|
|
)
|
|
if not apply_to_all:
|
|
name += "-%s" % (session.docname_token(),)
|
|
else:
|
|
name = "slides-%s-%s" % (
|
|
session.meeting.number,
|
|
session.docname_token(),
|
|
)
|
|
name = name + "-" + slugify(title).replace("_", "-")[:128]
|
|
if Document.objects.filter(name=name).exists():
|
|
doc = Document.objects.get(name=name)
|
|
doc.rev = "%02d" % (int(doc.rev) + 1)
|
|
doc.title = form.cleaned_data["title"]
|
|
else:
|
|
doc = Document.objects.create(
|
|
name=name,
|
|
type_id="slides",
|
|
title=title,
|
|
group=session.group,
|
|
rev="00",
|
|
)
|
|
doc.states.add(State.objects.get(type_id="slides", slug="active"))
|
|
doc.states.add(State.objects.get(type_id="reuse_policy", slug="single"))
|
|
|
|
# Now handle creation / update of the SessionPresentation(s)
|
|
sessions_to_apply = sessions if apply_to_all else [session]
|
|
added_presentations = []
|
|
revised_presentations = []
|
|
for sess in sessions_to_apply:
|
|
sp = sess.presentations.filter(document=doc).first()
|
|
if sp is not None:
|
|
sp.rev = doc.rev
|
|
sp.save()
|
|
revised_presentations.append(sp)
|
|
else:
|
|
max_order = (
|
|
sess.presentations.filter(document__type="slides").aggregate(
|
|
Max("order")
|
|
)["order__max"]
|
|
or 0
|
|
)
|
|
sp = sess.presentations.create(
|
|
document=doc, rev=doc.rev, order=max_order + 1
|
|
)
|
|
added_presentations.append(sp)
|
|
|
|
# Now handle the uploaded file
|
|
filename = "%s-%s%s" % (doc.name, doc.rev, ext)
|
|
doc.uploaded_filename = filename
|
|
e = NewRevisionDocEvent.objects.create(
|
|
doc=doc,
|
|
by=request.user.person,
|
|
type="new_revision",
|
|
desc="New revision available: %s" % doc.rev,
|
|
rev=doc.rev,
|
|
)
|
|
# The way this function builds the filename it will never trigger the file delete in handle_file_upload.
|
|
save_error = handle_upload_file(
|
|
file,
|
|
filename,
|
|
session.meeting,
|
|
"slides",
|
|
request=request,
|
|
encoding=form.file_encoding[file.name],
|
|
)
|
|
if save_error:
|
|
form.add_error(None, save_error)
|
|
else:
|
|
doc.save_with_history([e])
|
|
post_process(doc)
|
|
|
|
# Send MeetEcho updates even if we had a problem saving - that will keep it in sync with the
|
|
# SessionPresentation, which was already saved regardless of problems saving the file.
|
|
if hasattr(settings, "MEETECHO_API_CONFIG"):
|
|
sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG)
|
|
for sp in added_presentations:
|
|
try:
|
|
sm.add(session=sp.session, slides=doc, order=sp.order)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.add(): {err}")
|
|
for sp in revised_presentations:
|
|
try:
|
|
sm.revise(session=sp.session, slides=doc)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.revise(): {err}")
|
|
|
|
if not save_error:
|
|
messages.success(
|
|
request,
|
|
f"Successfully uploaded slides as revision {doc.rev} of {doc.name}.",
|
|
)
|
|
return redirect(
|
|
"ietf.meeting.views.session_details",
|
|
num=num,
|
|
acronym=session.group.acronym,
|
|
)
|
|
else:
|
|
initial = {}
|
|
if doc is not None:
|
|
initial = {"title": doc.title}
|
|
form = UploadSlidesForm(session, show_apply_to_all_checkbox, can_manage, initial=initial)
|
|
|
|
return render(
|
|
request,
|
|
"meeting/upload_session_slides.html",
|
|
{
|
|
"session": session,
|
|
"session_number": session_number,
|
|
"slides_sp": session.presentations.filter(document=doc).first() if doc else None,
|
|
"manage": session.can_manage_materials(request.user),
|
|
"form": form,
|
|
},
|
|
)
|
|
|
|
|
|
def remove_sessionpresentation(request, session_id, num, name):
|
|
sp = get_object_or_404(
|
|
SessionPresentation, session_id=session_id, document__name=name
|
|
)
|
|
session = sp.session
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(
|
|
request, "You don't have permission to manage materials for this session."
|
|
)
|
|
if session.is_material_submission_cutoff() and not has_role(
|
|
request.user, "Secretariat"
|
|
):
|
|
permission_denied(
|
|
request,
|
|
"The materials cutoff for this session has passed. Contact the secretariat for further action.",
|
|
)
|
|
if request.method == "POST":
|
|
session.presentations.filter(pk=sp.pk).delete()
|
|
c = DocEvent(
|
|
type="added_comment",
|
|
doc=sp.document,
|
|
rev=sp.document.rev,
|
|
by=request.user.person,
|
|
)
|
|
c.desc = "Removed from session: %s" % (session)
|
|
c.save()
|
|
messages.success(request, f"Successfully removed {name}.")
|
|
if sp.document.type_id == "slides" and hasattr(settings, "MEETECHO_API_CONFIG"):
|
|
sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG)
|
|
try:
|
|
sm.delete(session=session, slides=sp.document)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.delete(): {err}")
|
|
|
|
return redirect(
|
|
"ietf.meeting.views.session_details",
|
|
num=session.meeting.number,
|
|
acronym=session.group.acronym,
|
|
)
|
|
|
|
return render(request, "meeting/remove_sessionpresentation.html", {"sp": sp})
|
|
|
|
|
|
def ajax_add_slides_to_session(request, session_id, num):
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(
|
|
request, "You don't have permission to upload slides for this session."
|
|
)
|
|
if session.is_material_submission_cutoff() and not has_role(
|
|
request.user, "Secretariat"
|
|
):
|
|
permission_denied(
|
|
request,
|
|
"The materials cutoff for this session has passed. Contact the secretariat for further action.",
|
|
)
|
|
|
|
if request.method != "POST" or not request.POST:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "No data submitted or not POST"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
order_str = request.POST.get("order", None)
|
|
try:
|
|
order = int(order_str)
|
|
except (ValueError, TypeError):
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied order is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
if (
|
|
order < 1
|
|
or order > session.presentations.filter(document__type_id="slides").count() + 1
|
|
):
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied order is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
name = request.POST.get("name", None)
|
|
doc = Document.objects.filter(name=name).first()
|
|
if not doc:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied name is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
if not session.presentations.filter(document=doc).exists():
|
|
condition_slide_order(session)
|
|
session.presentations.filter(
|
|
document__type_id="slides", order__gte=order
|
|
).update(order=F("order") + 1)
|
|
session.presentations.create(document=doc, rev=doc.rev, order=order)
|
|
DocEvent.objects.create(
|
|
type="added_comment",
|
|
doc=doc,
|
|
rev=doc.rev,
|
|
by=request.user.person,
|
|
desc="Added to session: %s" % session,
|
|
)
|
|
|
|
# Notify Meetecho of new slides if the API is configured
|
|
if hasattr(settings, "MEETECHO_API_CONFIG"):
|
|
sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG)
|
|
try:
|
|
sm.add(session=session, slides=doc, order=order)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.add(): {err}")
|
|
|
|
return HttpResponse(json.dumps({"success": True}), content_type="application/json")
|
|
|
|
|
|
def ajax_remove_slides_from_session(request, session_id, num):
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(
|
|
request, "You don't have permission to upload slides for this session."
|
|
)
|
|
if session.is_material_submission_cutoff() and not has_role(
|
|
request.user, "Secretariat"
|
|
):
|
|
permission_denied(
|
|
request,
|
|
"The materials cutoff for this session has passed. Contact the secretariat for further action.",
|
|
)
|
|
|
|
if request.method != "POST" or not request.POST:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "No data submitted or not POST"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
oldIndex_str = request.POST.get("oldIndex", None)
|
|
try:
|
|
oldIndex = int(oldIndex_str)
|
|
except (ValueError, TypeError):
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied index is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
if (
|
|
oldIndex < 1
|
|
or oldIndex > session.presentations.filter(document__type_id="slides").count()
|
|
):
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied index is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
name = request.POST.get("name", None)
|
|
doc = Document.objects.filter(name=name).first()
|
|
if not doc:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied name is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
condition_slide_order(session)
|
|
affected_presentations = session.presentations.filter(document=doc).first()
|
|
if affected_presentations:
|
|
if affected_presentations.order == oldIndex:
|
|
affected_presentations.delete()
|
|
session.presentations.filter(
|
|
document__type_id="slides", order__gt=oldIndex
|
|
).update(order=F("order") - 1)
|
|
DocEvent.objects.create(
|
|
type="added_comment",
|
|
doc=doc,
|
|
rev=doc.rev,
|
|
by=request.user.person,
|
|
desc="Removed from session: %s" % session,
|
|
)
|
|
# Notify Meetecho of removed slides if the API is configured
|
|
if hasattr(settings, "MEETECHO_API_CONFIG"):
|
|
sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG)
|
|
try:
|
|
sm.delete(session=session, slides=doc)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.delete(): {err}")
|
|
# Report success
|
|
return HttpResponse(
|
|
json.dumps({"success": True}), content_type="application/json"
|
|
)
|
|
else:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Name does not match index"}),
|
|
content_type="application/json",
|
|
)
|
|
else:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "SessionPresentation not found"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
|
|
def ajax_reorder_slides_in_session(request, session_id, num):
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(
|
|
request, "You don't have permission to upload slides for this session."
|
|
)
|
|
if session.is_material_submission_cutoff() and not has_role(
|
|
request.user, "Secretariat"
|
|
):
|
|
permission_denied(
|
|
request,
|
|
"The materials cutoff for this session has passed. Contact the secretariat for further action.",
|
|
)
|
|
|
|
if request.method != "POST" or not request.POST:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "No data submitted or not POST"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
session_slides = session.presentations.filter(document__type_id="slides")
|
|
num_slides_in_session = session_slides.count()
|
|
oldIndex_str = request.POST.get("oldIndex", None)
|
|
try:
|
|
oldIndex = int(oldIndex_str)
|
|
except (ValueError, TypeError):
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied index is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
if oldIndex < 1 or oldIndex > num_slides_in_session:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied index is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
newIndex_str = request.POST.get("newIndex", None)
|
|
try:
|
|
newIndex = int(newIndex_str)
|
|
except (ValueError, TypeError):
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied index is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
if newIndex < 1 or newIndex > num_slides_in_session:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied index is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
if newIndex == oldIndex:
|
|
return HttpResponse(
|
|
json.dumps({"success": False, "error": "Supplied index is not valid"}),
|
|
content_type="application/json",
|
|
)
|
|
|
|
condition_slide_order(session)
|
|
sp = session_slides.get(order=oldIndex)
|
|
if oldIndex < newIndex:
|
|
session_slides.filter(order__gt=oldIndex, order__lte=newIndex).update(
|
|
order=F("order") - 1
|
|
)
|
|
else:
|
|
session_slides.filter(order__gte=newIndex, order__lt=oldIndex).update(
|
|
order=F("order") + 1
|
|
)
|
|
sp.order = newIndex
|
|
sp.save()
|
|
|
|
# Update slide order with Meetecho if the API is configured
|
|
if hasattr(settings, "MEETECHO_API_CONFIG"):
|
|
sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG)
|
|
try:
|
|
sm.send_update(session)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.send_update(): {err}")
|
|
|
|
return HttpResponse(json.dumps({"success": True}), content_type="application/json")
|
|
|
|
|
|
@role_required('Secretariat')
|
|
def make_schedule_official(request, 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:
|
|
raise Http404
|
|
|
|
if request.method == 'POST':
|
|
if not (schedule.public and schedule.visible):
|
|
schedule.public = True
|
|
schedule.visible = True
|
|
schedule.save()
|
|
if schedule.base and not (schedule.base.public and schedule.base.visible):
|
|
schedule.base.public = True
|
|
schedule.base.visible = True
|
|
schedule.base.save()
|
|
meeting.schedule = schedule
|
|
meeting.save()
|
|
return HttpResponseRedirect(reverse('ietf.meeting.views.list_schedules',kwargs={'num':num}))
|
|
|
|
if not schedule.public:
|
|
messages.warning(request,"This schedule will be made public as it is made official.")
|
|
if not schedule.visible:
|
|
messages.warning(request,"This schedule will be made visible as it is made official.")
|
|
if schedule.base:
|
|
if not schedule.base.public:
|
|
messages.warning(request,"The base schedule will be made public as it is made official.")
|
|
if not schedule.base.visible:
|
|
messages.warning(request,"The base schedule will be made visible as it is made official.")
|
|
|
|
return render(request, "meeting/make_schedule_official.html",
|
|
{ 'schedule' : schedule,
|
|
'meeting' : meeting,
|
|
}
|
|
)
|
|
|
|
|
|
@role_required('Secretariat','Area Director')
|
|
def delete_schedule(request, num, owner, name):
|
|
|
|
meeting = get_meeting(num)
|
|
person = get_person_by_email(owner)
|
|
schedule = get_schedule_by_name(meeting, person, name)
|
|
|
|
# FIXME: we ought to put these checks in a function and only show
|
|
# the delete button if the checks pass
|
|
if schedule == meeting.schedule:
|
|
permission_denied(request, 'You may not delete the official schedule for %s'%meeting)
|
|
|
|
if Schedule.objects.filter(base=schedule).exists():
|
|
return HttpResponseForbidden('You may not delete a schedule serving as the base for other schedules')
|
|
|
|
if not ( has_role(request.user, 'Secretariat') or person.user == request.user ):
|
|
permission_denied(request, "You may not delete other user's schedules")
|
|
|
|
if request.method == 'POST':
|
|
# remove schedule from origin tree
|
|
replacement_origin = schedule.origin
|
|
Schedule.objects.filter(origin=schedule).update(origin=replacement_origin)
|
|
|
|
schedule.delete()
|
|
return HttpResponseRedirect(reverse('ietf.meeting.views.list_schedules',kwargs={'num':num}))
|
|
|
|
return render(request, "meeting/delete_schedule.html",
|
|
{ 'schedule' : schedule,
|
|
'meeting' : meeting,
|
|
}
|
|
)
|
|
|
|
# -------------------------------------------------
|
|
# Interim Views
|
|
# -------------------------------------------------
|
|
def interim_announce(request):
|
|
'''View which shows interim meeting requests awaiting announcement'''
|
|
meetings = data_for_meetings_overview(Meeting.objects.filter(type='interim').order_by('date'), interim_status='scheda')
|
|
menu_entries = get_interim_menu_entries(request)
|
|
selected_menu_entry = 'announce'
|
|
|
|
return render(request, "meeting/interim_announce.html", {
|
|
'menu_entries': menu_entries,
|
|
'selected_menu_entry': selected_menu_entry,
|
|
'meetings': meetings})
|
|
|
|
|
|
@role_required('Secretariat',)
|
|
def interim_send_announcement(request, number):
|
|
'''View for sending the announcement of a new interim meeting'''
|
|
meeting = get_object_or_404(Meeting, number=number)
|
|
group = meeting.session_set.first().group
|
|
|
|
if request.method == 'POST':
|
|
form = InterimAnnounceForm(request.POST,
|
|
initial=get_announcement_initial(meeting))
|
|
if form.is_valid():
|
|
message = form.save(user=request.user)
|
|
message.related_groups.add(group)
|
|
for session in meeting.session_set.not_canceled():
|
|
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=SessionStatusName.objects.get(slug='sched'),
|
|
by=request.user.person,
|
|
)
|
|
send_mail_message(request, message)
|
|
messages.success(request, 'Interim meeting announcement sent')
|
|
return redirect(interim_announce)
|
|
|
|
form = InterimAnnounceForm(initial=get_announcement_initial(meeting))
|
|
|
|
return render(request, "meeting/interim_send_announcement.html", {
|
|
'meeting': meeting,
|
|
'form': form})
|
|
|
|
|
|
@role_required('Secretariat',)
|
|
def interim_skip_announcement(request, number):
|
|
'''View to change status of interim meeting to Scheduled without
|
|
first announcing. Only applicable to IRTF groups.
|
|
'''
|
|
meeting = get_object_or_404(Meeting, number=number)
|
|
|
|
if request.method == 'POST':
|
|
for session in meeting.session_set.not_canceled():
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=SessionStatusName.objects.get(slug='sched'),
|
|
by=request.user.person,
|
|
)
|
|
messages.success(request, 'Interim meeting scheduled. No announcement sent.')
|
|
return redirect(interim_announce)
|
|
|
|
return render(request, "meeting/interim_skip_announce.html", {
|
|
'meeting': meeting})
|
|
|
|
|
|
def interim_pending(request):
|
|
|
|
'''View which shows interim meeting requests pending approval'''
|
|
meetings = data_for_meetings_overview(Meeting.objects.filter(type='interim').order_by('date'), interim_status='apprw')
|
|
|
|
menu_entries = get_interim_menu_entries(request)
|
|
selected_menu_entry = 'pending'
|
|
|
|
for meeting in meetings:
|
|
if can_approve_interim_request(meeting, request.user):
|
|
meeting.can_approve = True
|
|
|
|
return render(request, "meeting/interim_pending.html", {
|
|
'menu_entries': menu_entries,
|
|
'selected_menu_entry': selected_menu_entry,
|
|
'meetings': meetings})
|
|
|
|
|
|
@login_required
|
|
def interim_request(request):
|
|
|
|
if not can_manage_some_groups(request.user):
|
|
permission_denied(request, "You don't have permission to request any interims")
|
|
|
|
'''View for requesting an interim meeting'''
|
|
SessionFormset = inlineformset_factory(
|
|
Meeting,
|
|
Session,
|
|
form=InterimSessionModelForm,
|
|
formset=InterimSessionInlineFormSet,
|
|
can_delete=False, extra=2)
|
|
|
|
if request.method == 'POST':
|
|
form = InterimMeetingModelForm(request, data=request.POST)
|
|
formset = SessionFormset(instance=Meeting(), data=request.POST)
|
|
if form.is_valid() and formset.is_valid():
|
|
group = form.cleaned_data.get('group')
|
|
is_approved = form.cleaned_data.get('approved', False)
|
|
is_virtual = form.is_virtual()
|
|
meeting_type = form.cleaned_data.get('meeting_type')
|
|
|
|
requires_approval = not ( is_approved or ( is_virtual and not settings.VIRTUAL_INTERIMS_REQUIRE_APPROVAL ))
|
|
|
|
# pre create meeting
|
|
if meeting_type in ('single', 'multi-day'):
|
|
meeting = form.save(date=get_earliest_session_date(formset))
|
|
|
|
# need to use partialmethod here to pass custom variable to form init
|
|
SessionFormset.form.__init__ = partialmethod(
|
|
InterimSessionModelForm.__init__,
|
|
user=request.user,
|
|
group=group,
|
|
requires_approval=requires_approval)
|
|
formset = SessionFormset(instance=meeting, data=request.POST)
|
|
formset.is_valid()
|
|
formset.save()
|
|
sessions_post_save(request, formset)
|
|
|
|
if requires_approval:
|
|
send_interim_approval_request(meetings=[meeting])
|
|
else:
|
|
send_interim_approval(request.user, meeting=meeting)
|
|
if not has_role(request.user, 'Secretariat'):
|
|
send_interim_announcement_request(meeting=meeting)
|
|
|
|
# series require special handling, each session gets it's own
|
|
# meeting object we won't see this on edit because series are
|
|
# subsequently dealt with individually
|
|
elif meeting_type == 'series':
|
|
series = []
|
|
SessionFormset.form.__init__ = partialmethod(
|
|
InterimSessionModelForm.__init__,
|
|
user=request.user,
|
|
group=group,
|
|
requires_approval=requires_approval)
|
|
formset = SessionFormset(instance=Meeting(), data=request.POST)
|
|
formset.is_valid() # re-validate
|
|
for session_form in formset.forms:
|
|
if not session_form.has_changed():
|
|
continue
|
|
# create meeting
|
|
form = InterimMeetingModelForm(request, data=request.POST)
|
|
form.is_valid()
|
|
meeting = form.save(date=session_form.cleaned_data['date'])
|
|
# create save session
|
|
session = session_form.save(commit=False)
|
|
session.meeting = meeting
|
|
session.save()
|
|
series.append(meeting)
|
|
sessions_post_save(request, [session_form])
|
|
|
|
if requires_approval:
|
|
send_interim_approval_request(meetings=series)
|
|
else:
|
|
send_interim_approval(request.user, meeting=meeting)
|
|
if not has_role(request.user, 'Secretariat'):
|
|
send_interim_announcement_request(meeting=meeting)
|
|
|
|
messages.success(request, 'Interim meeting request submitted')
|
|
return redirect(upcoming)
|
|
|
|
else:
|
|
initial = {'meeting_type': 'single', 'group': request.GET.get('group', '')}
|
|
form = InterimMeetingModelForm(request=request,
|
|
initial=initial)
|
|
formset = SessionFormset()
|
|
|
|
return render(request, "meeting/interim_request.html", {
|
|
"form": form,
|
|
"formset": formset})
|
|
|
|
|
|
@login_required
|
|
def interim_request_cancel(request, number):
|
|
'''View for cancelling an interim meeting request'''
|
|
meeting = get_object_or_404(Meeting, number=number)
|
|
first_session = meeting.session_set.first()
|
|
group = first_session.group
|
|
if not can_manage_group(request.user, group):
|
|
permission_denied(request, "You do not have permissions to cancel this meeting request")
|
|
session_status = current_session_status(first_session)
|
|
|
|
if request.method == 'POST':
|
|
form = InterimCancelForm(request.POST)
|
|
if form.is_valid():
|
|
if 'comments' in form.changed_data:
|
|
meeting.session_set.update(agenda_note=form.cleaned_data.get('comments'))
|
|
|
|
was_scheduled = session_status.slug == 'sched'
|
|
|
|
result_status = SessionStatusName.objects.get(slug='canceled' if was_scheduled else 'canceledpa')
|
|
sessions_to_cancel = meeting.session_set.not_canceled()
|
|
for session in sessions_to_cancel:
|
|
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=result_status,
|
|
by=request.user.person,
|
|
)
|
|
|
|
if was_scheduled:
|
|
send_interim_meeting_cancellation_notice(meeting)
|
|
|
|
sessions_post_cancel(request, sessions_to_cancel)
|
|
|
|
messages.success(request, 'Interim meeting cancelled')
|
|
return redirect(upcoming)
|
|
else:
|
|
form = InterimCancelForm(initial={'group': group.acronym, 'date': meeting.date})
|
|
|
|
return render(request, "meeting/interim_request_cancel.html", {
|
|
"form": form,
|
|
"meeting": meeting,
|
|
"session_status": session_status,
|
|
})
|
|
|
|
|
|
@login_required
|
|
def interim_request_session_cancel(request, sessionid):
|
|
'''View for cancelling an interim meeting request'''
|
|
session = get_object_or_404(Session, pk=sessionid)
|
|
group = session.group
|
|
if not can_manage_group(request.user, group):
|
|
permission_denied(request, "You do not have permissions to cancel this session")
|
|
session_status = current_session_status(session)
|
|
|
|
if request.method == 'POST':
|
|
form = InterimCancelForm(request.POST)
|
|
if form.is_valid():
|
|
remaining_sessions = session.meeting.session_set.with_current_status().exclude(
|
|
current_status__in=['canceled', 'canceledpa']
|
|
)
|
|
if remaining_sessions.count() <= 1:
|
|
return HttpResponse('Cannot cancel only remaining session. Cancel the request instead.',
|
|
status=409)
|
|
|
|
if 'comments' in form.changed_data:
|
|
session.agenda_note=form.cleaned_data.get('comments')
|
|
session.save()
|
|
|
|
was_scheduled = session_status.slug == 'sched'
|
|
|
|
result_status = SessionStatusName.objects.get(slug='canceled' if was_scheduled else 'canceledpa')
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=result_status,
|
|
by=request.user.person,
|
|
)
|
|
|
|
if was_scheduled:
|
|
send_interim_session_cancellation_notice(session)
|
|
|
|
sessions_post_cancel(request, [session])
|
|
|
|
messages.success(request, 'Interim meeting session cancelled')
|
|
return redirect(interim_request_details, number=session.meeting.number)
|
|
else:
|
|
session_time = session.official_timeslotassignment().timeslot.time
|
|
form = InterimCancelForm(initial={'group': group.acronym, 'date': session_time.date()})
|
|
|
|
return render(request, "meeting/interim_request_cancel.html", {
|
|
"form": form,
|
|
"session": session,
|
|
"session_status": session_status,
|
|
})
|
|
|
|
|
|
@login_required
|
|
def interim_request_details(request, number):
|
|
'''View details of an interim meeting request'''
|
|
meeting = get_object_or_404(Meeting, number=number)
|
|
sessions_not_canceled = meeting.session_set.not_canceled()
|
|
first_session = meeting.session_set.first() # first, whether or not canceled
|
|
group = first_session.group
|
|
|
|
if not can_manage_group(request.user, group):
|
|
permission_denied(request, "You do not have permissions to manage this meeting request")
|
|
can_edit = can_edit_interim_request(meeting, request.user)
|
|
can_approve = can_approve_interim_request(meeting, request.user)
|
|
|
|
if request.method == 'POST':
|
|
if request.POST.get('approve') and can_approve_interim_request(meeting, request.user):
|
|
for session in sessions_not_canceled:
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=SessionStatusName.objects.get(slug='scheda'),
|
|
by=request.user.person,
|
|
)
|
|
messages.success(request, 'Interim meeting approved')
|
|
if has_role(request.user, 'Secretariat'):
|
|
return redirect(interim_send_announcement, number=number)
|
|
else:
|
|
send_interim_announcement_request(meeting)
|
|
return redirect(interim_pending)
|
|
if request.POST.get('disapprove') and can_approve_interim_request(meeting, request.user):
|
|
for session in sessions_not_canceled:
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status=SessionStatusName.objects.get(slug='disappr'),
|
|
by=request.user.person,
|
|
)
|
|
messages.success(request, 'Interim meeting disapproved')
|
|
return redirect(interim_pending)
|
|
|
|
# Determine meeting status from non-canceled sessions, if any.
|
|
# N.b., meeting_status may be None after either of these code paths,
|
|
# though I am not sure what circumstances would cause this.
|
|
if sessions_not_canceled.count() > 0:
|
|
meeting_status = current_session_status(sessions_not_canceled.first())
|
|
else:
|
|
meeting_status = current_session_status(first_session)
|
|
|
|
meeting_assignments = SchedTimeSessAssignment.objects.filter(
|
|
schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None]
|
|
).select_related(
|
|
'session', 'timeslot'
|
|
)
|
|
for ma in meeting_assignments:
|
|
ma.status = current_session_status(ma.session)
|
|
ma.can_be_canceled = ma.status.slug in ('sched', 'scheda', 'apprw')
|
|
|
|
return render(request, "meeting/interim_request_details.html", {
|
|
"meeting": meeting,
|
|
"meeting_assignments": meeting_assignments,
|
|
"group": group,
|
|
"requester": session_requested_by(first_session),
|
|
"meeting_status": meeting_status or SessionStatusName.objects.get(slug='canceled'),
|
|
"can_edit": can_edit,
|
|
"can_approve": can_approve})
|
|
|
|
@login_required
|
|
def interim_request_edit(request, number):
|
|
'''Edit details of an interim meeting reqeust'''
|
|
meeting = get_object_or_404(Meeting, number=number)
|
|
if not can_edit_interim_request(meeting, request.user):
|
|
permission_denied(request, "You do not have permissions to edit this meeting request")
|
|
|
|
SessionFormset = inlineformset_factory(
|
|
Meeting,
|
|
Session,
|
|
form=InterimSessionModelForm,
|
|
can_delete=False,
|
|
extra=1)
|
|
|
|
if request.method == 'POST':
|
|
form = InterimMeetingModelForm(request=request, instance=meeting,
|
|
data=request.POST)
|
|
group = Group.objects.get(pk=form.data['group'])
|
|
is_approved = is_interim_meeting_approved(meeting)
|
|
|
|
SessionFormset.form.__init__ = partialmethod(
|
|
InterimSessionModelForm.__init__,
|
|
user=request.user,
|
|
group=group,
|
|
requires_approval= not is_approved)
|
|
|
|
formset = SessionFormset(instance=meeting, data=request.POST)
|
|
|
|
if form.is_valid() and formset.is_valid():
|
|
meeting = form.save(date=get_earliest_session_date(formset))
|
|
formset.save()
|
|
sessions_post_save(request, formset)
|
|
|
|
message = 'Interim meeting request saved'
|
|
meeting_is_scheduled = add_event_info_to_session_qs(meeting.session_set).filter(current_status='sched').exists()
|
|
if (form.has_changed() or formset.has_changed()) and meeting_is_scheduled:
|
|
send_interim_change_notice(request, meeting)
|
|
message = message + ' and change announcement sent'
|
|
messages.success(request, message)
|
|
return redirect(interim_request_details, number=number)
|
|
|
|
else:
|
|
form = InterimMeetingModelForm(request=request, instance=meeting)
|
|
formset = SessionFormset(instance=meeting)
|
|
|
|
return render(request, "meeting/interim_request_edit.html", {
|
|
"meeting": meeting,
|
|
"form": form,
|
|
"formset": formset})
|
|
|
|
def past(request):
|
|
'''List of past meetings'''
|
|
today = timezone.now()
|
|
|
|
meetings = data_for_meetings_overview(Meeting.objects.filter(date__lte=today).order_by('-date'))
|
|
|
|
return render(request, 'meeting/past.html', {
|
|
'meetings': meetings,
|
|
})
|
|
|
|
def upcoming(request):
|
|
'''List of upcoming meetings'''
|
|
today = datetime_today()
|
|
|
|
# Get ietf meetings starting 7 days ago, and interim meetings starting today
|
|
ietf_meetings = Meeting.objects.filter(type_id='ietf', date__gte=today-datetime.timedelta(days=7))
|
|
|
|
interim_sessions = add_event_info_to_session_qs(
|
|
Session.objects.filter(
|
|
meeting__type_id='interim',
|
|
timeslotassignments__schedule=F('meeting__schedule'),
|
|
timeslotassignments__timeslot__time__gte=today
|
|
)
|
|
).filter(current_status__in=('sched','canceled'))
|
|
|
|
# Set up for agenda filtering - only one filter_category here
|
|
AgendaKeywordTagger(sessions=interim_sessions).apply()
|
|
filter_organizer = AgendaFilterOrganizer(sessions=interim_sessions, single_category=True)
|
|
# Allow filtering to show only IETF Meetings. This adds a button labeled "IETF Meetings" to the
|
|
# "Other" column of the filter UI. When enabled, this adds the keyword "ietf-meetings" to the "show"
|
|
# filter list. The IETF meetings are explicitly labeled with this keyword in upcoming.html.
|
|
filter_organizer.add_extra_filter('IETF Meetings')
|
|
|
|
entries = list(ietf_meetings)
|
|
entries.extend(list(interim_sessions))
|
|
entries.sort(
|
|
key=lambda o: (
|
|
pytz.utc.localize(datetime.datetime.combine(o.date, datetime.datetime.min.time())) if isinstance(o, Meeting) else o.official_timeslotassignment().timeslot.utc_start_time(),
|
|
o.number if isinstance(o, Meeting) else o.meeting.number,
|
|
)
|
|
)
|
|
for o in entries:
|
|
if isinstance(o, Meeting):
|
|
o.start_timestamp = int(pytz.utc.localize(datetime.datetime.combine(o.date, datetime.time.min)).timestamp())
|
|
o.end_timestamp = int(pytz.utc.localize(datetime.datetime.combine(o.end_date(), datetime.time.max)).timestamp())
|
|
else:
|
|
o.start_timestamp = int(o.official_timeslotassignment().timeslot.utc_start_time().timestamp())
|
|
o.end_timestamp = int(o.official_timeslotassignment().timeslot.utc_end_time().timestamp())
|
|
|
|
# add menu entries
|
|
menu_entries = get_interim_menu_entries(request)
|
|
selected_menu_entry = 'upcoming'
|
|
|
|
# add menu actions
|
|
actions = []
|
|
if can_request_interim_meeting(request.user):
|
|
actions.append(dict(
|
|
label='Request new interim meeting',
|
|
url=reverse('ietf.meeting.views.interim_request'),
|
|
append_filter=False)
|
|
)
|
|
actions.append(dict(
|
|
label='Download as .ics',
|
|
url=reverse('ietf.meeting.views.upcoming_ical'),
|
|
append_filter=True)
|
|
)
|
|
actions.append(dict(
|
|
label='Subscribe with webcal',
|
|
url='webcal://'+request.get_host()+reverse('ietf.meeting.views.upcoming_ical'),
|
|
append_filter=True)
|
|
)
|
|
|
|
return render(request, 'meeting/upcoming.html', {
|
|
'entries': entries,
|
|
'filter_categories': filter_organizer.get_filter_categories(),
|
|
'menu_actions': actions,
|
|
'menu_entries': menu_entries,
|
|
'selected_menu_entry': selected_menu_entry,
|
|
'now': timezone.now(),
|
|
})
|
|
|
|
|
|
def upcoming_ical(request):
|
|
"""Return Upcoming meetings in iCalendar file
|
|
|
|
Filters by wg name and session type.
|
|
"""
|
|
try:
|
|
filter_params = parse_agenda_filter_params(request.GET)
|
|
except ValueError as e:
|
|
return HttpResponseBadRequest(str(e))
|
|
|
|
today = datetime_today()
|
|
|
|
# get meetings starting 7 days ago -- we'll filter out sessions in the past further down
|
|
meetings = data_for_meetings_overview(Meeting.objects.filter(date__gte=today-datetime.timedelta(days=7)).prefetch_related('schedule').order_by('date'))
|
|
|
|
assignments = list(SchedTimeSessAssignment.objects.filter(
|
|
schedule__in=[m.schedule_id for m in meetings] + [m.schedule.base_id for m in meetings if m.schedule],
|
|
session__in=[s.pk for m in meetings for s in m.sessions if m.type_id != 'ietf'],
|
|
timeslot__time__gte=today,
|
|
).order_by(
|
|
'schedule__meeting__date', 'session__type', 'timeslot__time', 'schedule__meeting__number',
|
|
).select_related(
|
|
'session__group', 'session__group__parent', 'timeslot', 'schedule', 'schedule__meeting'
|
|
).distinct())
|
|
|
|
AgendaKeywordTagger(assignments=assignments).apply()
|
|
|
|
# apply filters
|
|
if filter_params is not None:
|
|
assignments = [a for a in assignments if should_include_assignment(filter_params, a)]
|
|
|
|
# we already collected sessions with current_status, so reuse those
|
|
sessions = {s.pk: s for m in meetings for s in m.sessions}
|
|
for a in assignments:
|
|
if a.session_id is not None:
|
|
a.session = sessions.get(a.session_id) or a.session
|
|
a.session.ical_status = ical_session_status(a)
|
|
|
|
# Handle IETFs separately. Manually apply the 'ietf-meetings' filter.
|
|
if filter_params is None or (
|
|
'ietf-meetings' in filter_params['show'] and 'ietf-meetings' not in filter_params['hide']
|
|
):
|
|
ietfs = [m for m in meetings if m.type_id == 'ietf']
|
|
preprocess_meeting_important_dates(ietfs)
|
|
else:
|
|
ietfs = []
|
|
|
|
meeting_vtz = {meeting.vtimezone() for meeting in meetings}
|
|
meeting_vtz.discard(None)
|
|
|
|
# icalendar response file should have '\r\n' line endings per RFC5545
|
|
response = render_to_string('meeting/upcoming.ics', {
|
|
'vtimezones': ''.join(sorted(meeting_vtz)),
|
|
'assignments': assignments,
|
|
'ietfs': ietfs,
|
|
}, request=request)
|
|
response = re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", response)
|
|
|
|
response = HttpResponse(response, content_type='text/calendar')
|
|
response['Content-Disposition'] = 'attachment; filename="upcoming.ics"'
|
|
return response
|
|
|
|
|
|
def upcoming_json(request):
|
|
'''Return Upcoming meetings in json format'''
|
|
today = date_today()
|
|
|
|
# get meetings starting 7 days ago -- we'll filter out sessions in the past further down
|
|
meetings = data_for_meetings_overview(Meeting.objects.filter(date__gte=today-datetime.timedelta(days=7)).order_by('date'))
|
|
|
|
data = {}
|
|
for m in meetings:
|
|
data[m.number] = {
|
|
'date': m.date.strftime("%Y-%m-%d"),
|
|
}
|
|
|
|
response = HttpResponse(json.dumps(data, indent=2, sort_keys=False), content_type='application/json;charset=%s'%settings.DEFAULT_CHARSET)
|
|
return response
|
|
|
|
def organize_proceedings_sessions(sessions):
|
|
# Collect sessions by Group, then bin by session name (including sessions with blank names).
|
|
# If all of a group's sessions are 'notmeet', the processed data goes in not_meeting_sessions.
|
|
# Otherwise, the data goes in meeting_sessions.
|
|
meeting_groups = []
|
|
not_meeting_groups = []
|
|
for group_acronym, group_sessions in itertools.groupby(sessions, key=lambda s: s.group.acronym):
|
|
by_name = {}
|
|
is_meeting = False
|
|
all_canceled = True
|
|
group = None
|
|
for s in sorted(
|
|
group_sessions,
|
|
key=lambda gs: (
|
|
gs.official_timeslotassignment().timeslot.time
|
|
if gs.official_timeslotassignment() else datetime.datetime(datetime.MAXYEAR, 1, 1)
|
|
),
|
|
):
|
|
group = s.group
|
|
if s.current_status != 'notmeet':
|
|
is_meeting = True
|
|
if s.current_status != 'canceled':
|
|
all_canceled = False
|
|
by_name.setdefault(s.name, [])
|
|
if s.current_status != 'notmeet' or s.presentations.exists():
|
|
by_name[s.name].append(s) # for notmeet, only include sessions with materials
|
|
for sess_name, ss in by_name.items():
|
|
session = ss[0] if ss else None
|
|
def _format_materials(items):
|
|
"""Format session/material for template
|
|
|
|
Input is a list of (session, materials) pairs. The materials value can be a single value or a list.
|
|
"""
|
|
material_times = {} # key is material, value is first timestamp it appeared
|
|
for s, mats in items:
|
|
tsa = s.official_timeslotassignment()
|
|
timestamp = tsa.timeslot.time if tsa else None
|
|
if not isinstance(mats, list):
|
|
mats = [mats]
|
|
for mat in mats:
|
|
if mat and mat not in material_times:
|
|
material_times[mat] = timestamp
|
|
n_mats = len(material_times)
|
|
result = []
|
|
if n_mats == 1:
|
|
result.append({'material': list(material_times)[0]}) # no 'time' when only a single material
|
|
elif n_mats > 1:
|
|
for mat, timestamp in material_times.items():
|
|
result.append({'material': mat, 'time': timestamp})
|
|
return result
|
|
|
|
entry = {
|
|
'group': group,
|
|
'name': sess_name,
|
|
'session': session,
|
|
'canceled': all_canceled,
|
|
'has_materials': s.presentations.exists(),
|
|
'agendas': _format_materials((s, s.agenda()) for s in ss),
|
|
'minutes': _format_materials((s, s.minutes()) for s in ss),
|
|
'bluesheets': _format_materials((s, s.bluesheets()) for s in ss),
|
|
'recordings': _format_materials((s, s.recordings()) for s in ss),
|
|
'meetecho_recordings': _format_materials((s, [s.session_recording_url()]) for s in ss),
|
|
'chatlogs': _format_materials((s, s.chatlogs()) for s in ss),
|
|
'slides': _format_materials((s, s.slides()) for s in ss),
|
|
'drafts': _format_materials((s, s.drafts()) for s in ss),
|
|
'last_update': session.last_update if hasattr(session, 'last_update') else None
|
|
}
|
|
if session and session.meeting.type_id == 'ietf' and not session.meeting.proceedings_final:
|
|
entry['attendances'] = _format_materials((s, s) for s in ss if Attended.objects.filter(session=s).exists())
|
|
if is_meeting:
|
|
meeting_groups.append(entry)
|
|
else:
|
|
not_meeting_groups.append(entry)
|
|
return meeting_groups, not_meeting_groups
|
|
|
|
|
|
def proceedings(request, num=None):
|
|
|
|
def area_and_group_acronyms_from_session(s):
|
|
area = s.group_parent_at_the_time()
|
|
if area == None:
|
|
area = s.group.parent
|
|
group = s.group_at_the_time()
|
|
return (area.acronym, group.acronym)
|
|
|
|
meeting = get_meeting(num)
|
|
|
|
# Early proceedings were hosted on www.ietf.org rather than the datatracker
|
|
if meeting.proceedings_format_version == 1:
|
|
return HttpResponseRedirect(settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting))
|
|
|
|
if not meeting.schedule or not meeting.schedule.assignments.exists():
|
|
kwargs = dict()
|
|
if num:
|
|
kwargs['num'] = num
|
|
return redirect('ietf.meeting.views.materials', **kwargs)
|
|
|
|
begin_date = meeting.get_submission_start_date()
|
|
cut_off_date = meeting.get_submission_cut_off_date()
|
|
cor_cut_off_date = meeting.get_submission_correction_date()
|
|
today_utc = date_today(datetime.timezone.utc)
|
|
|
|
schedule = get_schedule(meeting, None)
|
|
sessions = (
|
|
meeting.session_set.with_current_status()
|
|
.filter(Q(timeslotassignments__schedule__in=[schedule, schedule.base if schedule else None])
|
|
| Q(current_status='notmeet'))
|
|
.select_related()
|
|
.order_by('-current_status')
|
|
)
|
|
|
|
plenaries, _ = organize_proceedings_sessions(
|
|
sessions.filter(name__icontains='plenary')
|
|
.exclude(current_status='notmeet')
|
|
)
|
|
irtf_meeting, irtf_not_meeting = organize_proceedings_sessions(
|
|
sessions.filter(group__parent__acronym = 'irtf').order_by('group__acronym')
|
|
)
|
|
# per Colin (datatracker #5010) - don't report not meeting rags
|
|
irtf_not_meeting = [item for item in irtf_not_meeting if item["group"].type_id != "rag"]
|
|
irtf = {"meeting_groups":irtf_meeting, "not_meeting_groups":irtf_not_meeting}
|
|
|
|
training, _ = organize_proceedings_sessions(
|
|
sessions.filter(group__acronym__in=['edu','iaoc'], type_id__in=['regular', 'other',])
|
|
.exclude(current_status='notmeet')
|
|
)
|
|
iab, _ = organize_proceedings_sessions(
|
|
sessions.filter(group__parent__acronym = 'iab')
|
|
.exclude(current_status='notmeet')
|
|
)
|
|
editorial, _ = organize_proceedings_sessions(
|
|
sessions.filter(group__acronym__in=['rsab','rswg'])
|
|
.exclude(current_status='notmeet')
|
|
)
|
|
|
|
ietf = sessions.filter(group__parent__type__slug = 'area').exclude(group__acronym__in=['edu','iepg','tools'])
|
|
ietf = list(ietf)
|
|
ietf.sort(key=lambda s: area_and_group_acronyms_from_session(s))
|
|
ietf_areas = []
|
|
for area, area_sessions in itertools.groupby(ietf, key=lambda s: s.group_parent_at_the_time()):
|
|
meeting_groups, not_meeting_groups = organize_proceedings_sessions(area_sessions)
|
|
ietf_areas.append((area, meeting_groups, not_meeting_groups))
|
|
|
|
cache_version = Document.objects.filter(session__meeting__number=meeting.number).aggregate(Max('time'))["time__max"]
|
|
|
|
with timezone.override(meeting.tz()):
|
|
return render(request, "meeting/proceedings.html", {
|
|
'meeting': meeting,
|
|
'plenaries': plenaries,
|
|
'training': training,
|
|
'irtf': irtf,
|
|
'iab': iab,
|
|
'editorial': editorial,
|
|
'ietf_areas': ietf_areas,
|
|
'cut_off_date': cut_off_date,
|
|
'cor_cut_off_date': cor_cut_off_date,
|
|
'submission_started': today_utc > begin_date,
|
|
'cache_version': cache_version,
|
|
'attendance': meeting.get_attendance(),
|
|
'meetinghost_logo': {
|
|
'max_height': settings.MEETINGHOST_LOGO_MAX_DISPLAY_HEIGHT,
|
|
'max_width': settings.MEETINGHOST_LOGO_MAX_DISPLAY_WIDTH,
|
|
}
|
|
})
|
|
|
|
@role_required('Secretariat')
|
|
def finalize_proceedings(request, num=None):
|
|
|
|
meeting = get_meeting(num)
|
|
if (meeting.number.isdigit() and int(meeting.number) <= 64) or not meeting.schedule or not meeting.schedule.assignments.exists() or meeting.proceedings_final:
|
|
raise Http404
|
|
|
|
if request.method=='POST':
|
|
finalize(request, meeting)
|
|
return HttpResponseRedirect(reverse('ietf.meeting.views.proceedings',kwargs={'num':meeting.number}))
|
|
|
|
return render(request, "meeting/finalize.html", {'meeting':meeting,})
|
|
|
|
def proceedings_acknowledgements(request, num=None):
|
|
'''Display Acknowledgements for meeting'''
|
|
if not (num and num.isdigit()):
|
|
raise Http404
|
|
meeting = get_meeting(num)
|
|
if meeting.proceedings_format_version == 1:
|
|
return HttpResponseRedirect( f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}/acknowledgement.html')
|
|
return render(request, "meeting/proceedings_acknowledgements.html", {
|
|
'meeting': meeting,
|
|
})
|
|
|
|
def proceedings_attendees(request, num=None):
|
|
'''Display list of meeting attendees'''
|
|
if not (num and num.isdigit()):
|
|
raise Http404
|
|
meeting = get_meeting(num)
|
|
if meeting.proceedings_format_version == 1:
|
|
return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}/attendee.html')
|
|
|
|
template = None
|
|
meeting_registrations = None
|
|
|
|
if int(meeting.number) >= 118:
|
|
checked_in, attended = participants_for_meeting(meeting)
|
|
regs = list(MeetingRegistration.objects.filter(meeting__number=num, reg_type='onsite', checkedin=True))
|
|
|
|
for mr in MeetingRegistration.objects.filter(meeting__number=num, reg_type='remote').select_related('person'):
|
|
if mr.person.pk in attended and mr.person.pk not in checked_in:
|
|
regs.append(mr)
|
|
|
|
meeting_registrations = sorted(regs, key=lambda x: (x.last_name, x.first_name))
|
|
else:
|
|
overview_template = "/meeting/proceedings/%s/attendees.html" % meeting.number
|
|
try:
|
|
template = render_to_string(overview_template, {})
|
|
except TemplateDoesNotExist:
|
|
raise Http404
|
|
|
|
return render(request, "meeting/proceedings_attendees.html", {
|
|
'meeting': meeting,
|
|
'meeting_registrations': meeting_registrations,
|
|
'template': template,
|
|
})
|
|
|
|
def proceedings_overview(request, num=None):
|
|
'''Display Overview for given meeting'''
|
|
if not (num and num.isdigit()):
|
|
raise Http404
|
|
meeting = get_meeting(num)
|
|
if meeting.proceedings_format_version == 1:
|
|
return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}/overview.html')
|
|
overview_template = '/meeting/proceedings/%s/overview.rst' % meeting.number
|
|
try:
|
|
template = render_to_string(overview_template, {})
|
|
except TemplateDoesNotExist:
|
|
raise Http404
|
|
return render(request, "meeting/proceedings_overview.html", {
|
|
'meeting': meeting,
|
|
'template': template,
|
|
})
|
|
|
|
def proceedings_activity_report(request, num=None):
|
|
'''Display Activity Report (stats since last meeting)'''
|
|
if not (num and num.isdigit()):
|
|
raise Http404
|
|
meeting = get_meeting(num)
|
|
if meeting.proceedings_format_version == 1:
|
|
return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}/progress-report.html')
|
|
sdate = meeting.previous_meeting().date
|
|
edate = meeting.date
|
|
context = get_activity_stats(sdate,edate)
|
|
context['meeting'] = meeting
|
|
context['is_meeting_report'] = True
|
|
return render(request, "meeting/proceedings_activity_report.html", context)
|
|
|
|
class OldUploadRedirect(RedirectView):
|
|
def get_redirect_url(self, **kwargs):
|
|
return reverse_lazy('ietf.meeting.views.session_details',kwargs=self.kwargs)
|
|
|
|
|
|
@require_api_key
|
|
@role_required("Recording Manager")
|
|
@csrf_exempt
|
|
def api_set_meetecho_recording_name(request):
|
|
"""Set name for meetecho recording
|
|
|
|
parameters:
|
|
apikey: the poster's personal API key
|
|
session_id: id of the session to update
|
|
name: the name to use for the recording at meetecho player
|
|
"""
|
|
def err(code, text):
|
|
return HttpResponse(text, status=code, content_type='text/plain')
|
|
|
|
if request.method != "POST":
|
|
return HttpResponseNotAllowed(
|
|
content="Method not allowed", content_type="text/plain", permitted_methods=('POST',)
|
|
)
|
|
|
|
session_id = request.POST.get('session_id', None)
|
|
if session_id is None:
|
|
return err(400, 'Missing session_id parameter')
|
|
name = request.POST.get('name', None)
|
|
if name is None:
|
|
return err(400, 'Missing name parameter')
|
|
|
|
try:
|
|
session = Session.objects.get(pk=session_id)
|
|
except Session.DoesNotExist:
|
|
return err(400, f"Session not found with session_id '{session_id}'")
|
|
except ValueError:
|
|
return err(400, "Invalid session_id: {session_id}")
|
|
|
|
session.meetecho_recording_name = name
|
|
session.save()
|
|
|
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
|
|
|
@require_api_key
|
|
@role_required('Recording Manager')
|
|
@csrf_exempt
|
|
def api_set_session_video_url(request):
|
|
"""Set video URL for session
|
|
|
|
parameters:
|
|
apikey: the poster's personal API key
|
|
session_id: id of session to update
|
|
url: The recording url (on YouTube, or whatever)
|
|
"""
|
|
def err(code, text):
|
|
return HttpResponse(text, status=code, content_type='text/plain')
|
|
|
|
if request.method != 'POST':
|
|
return HttpResponseNotAllowed(
|
|
content="Method not allowed", content_type="text/plain", permitted_methods=('POST',)
|
|
)
|
|
|
|
# Temporary: fall back to deprecated interface if we have old-style parameters.
|
|
# Do away with this once meetecho is using the new pk-based interface.
|
|
if any(k in request.POST for k in ['meeting', 'group', 'item']):
|
|
return deprecated_api_set_session_video_url(request)
|
|
|
|
session_id = request.POST.get('session_id', None)
|
|
if session_id is None:
|
|
return err(400, 'Missing session_id parameter')
|
|
incoming_url = request.POST.get('url', None)
|
|
if incoming_url is None:
|
|
return err(400, 'Missing url parameter')
|
|
|
|
try:
|
|
session = Session.objects.get(pk=session_id)
|
|
except Session.DoesNotExist:
|
|
return err(400, f"Session not found with session_id '{session_id}'")
|
|
except ValueError:
|
|
return err(400, "Invalid session_id: {session_id}")
|
|
|
|
try:
|
|
URLValidator()(incoming_url)
|
|
except ValidationError:
|
|
return err(400, f"Invalid url value: '{incoming_url}'")
|
|
|
|
recordings = [(r.name, r.title, r) for r in session.recordings() if 'video' in r.title.lower()]
|
|
if recordings:
|
|
r = recordings[-1][-1]
|
|
if r.external_url != incoming_url:
|
|
e = DocEvent.objects.create(doc=r, rev=r.rev, type="added_comment", by=request.user.person,
|
|
desc="External url changed from %s to %s" % (r.external_url, incoming_url))
|
|
r.external_url = incoming_url
|
|
r.save_with_history([e])
|
|
else:
|
|
time = session.official_timeslotassignment().timeslot.time
|
|
title = 'Video recording for %s on %s at %s' % (session.group.acronym, time.date(), time.time())
|
|
create_recording(session, incoming_url, title=title, user=request.user.person)
|
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
|
|
|
|
|
def deprecated_api_set_session_video_url(request):
|
|
"""Set video URL for session (deprecated)
|
|
|
|
Uses meeting/group/item to identify session.
|
|
"""
|
|
def err(code, text):
|
|
return HttpResponse(text, status=code, content_type='text/plain')
|
|
if request.method == 'POST':
|
|
# parameters:
|
|
# apikey: the poster's personal API key
|
|
# meeting: '101', or 'interim-2018-quic-02'
|
|
# group: 'quic' or 'plenary'
|
|
# item: '1', '2', '3' (the group's first, second, third etc.
|
|
# session during the week)
|
|
# url: The recording url (on YouTube, or whatever)
|
|
user = request.user.person
|
|
for item in ['meeting', 'group', 'item', 'url',]:
|
|
value = request.POST.get(item)
|
|
if not value:
|
|
return err(400, "Missing %s parameter" % item)
|
|
number = request.POST.get('meeting')
|
|
sessions = Session.objects.filter(meeting__number=number)
|
|
if not sessions.exists():
|
|
return err(400, "No sessions found for meeting '%s'" % (number, ))
|
|
acronym = request.POST.get('group')
|
|
sessions = sessions.filter(group__acronym=acronym)
|
|
if not sessions.exists():
|
|
return err(400, "No sessions found in meeting '%s' for group '%s'" % (number, acronym))
|
|
session_times = [ (s.official_timeslotassignment().timeslot.time, s.id, s) for s in sessions if s.official_timeslotassignment() ]
|
|
session_times.sort()
|
|
item = request.POST.get('item')
|
|
if not item.isdigit():
|
|
return err(400, "Expected a numeric value for 'item', found '%s'" % (item, ))
|
|
n = int(item)-1 # change 1-based to 0-based
|
|
try:
|
|
time, __, session = session_times[n]
|
|
except IndexError:
|
|
return err(400, "No item '%s' found in list of sessions for group" % (item, ))
|
|
url = request.POST.get('url')
|
|
try:
|
|
URLValidator()(url)
|
|
except ValidationError:
|
|
return err(400, "Invalid url value: '%s'" % (url, ))
|
|
recordings = [ (r.name, r.title, r) for r in session.recordings() if 'video' in r.title.lower() ]
|
|
if recordings:
|
|
r = recordings[-1][-1]
|
|
if r.external_url != url:
|
|
e = DocEvent.objects.create(doc=r, rev=r.rev, type="added_comment", by=request.user.person,
|
|
desc="External url changed from %s to %s" % (r.external_url, url))
|
|
r.external_url = url
|
|
r.save_with_history([e])
|
|
else:
|
|
return err(400, "URL is the same")
|
|
else:
|
|
time = session.official_timeslotassignment().timeslot.time
|
|
title = 'Video recording for %s on %s at %s' % (acronym, time.date(), time.time())
|
|
create_recording(session, url, title=title, user=user)
|
|
else:
|
|
return err(405, "Method not allowed")
|
|
|
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
|
|
|
|
|
@require_api_key
|
|
@role_required('Recording Manager') # TODO : Rework how Meetecho interacts via APIs. There may be better paths to pursue than Personal API keys as they are currently defined.
|
|
@csrf_exempt
|
|
def api_add_session_attendees(request):
|
|
"""Upload attendees for one or more sessions
|
|
|
|
parameters:
|
|
apikey: the poster's personal API key
|
|
attended: json blob with
|
|
{
|
|
"session_id": session pk,
|
|
"attendees": [
|
|
{"user_id": user-pk-1, "join_time": "2024-02-21T18:00:00Z"},
|
|
{"user_id": user-pk-2, "join_time": "2024-02-21T18:00:01Z"},
|
|
{"user_id": user-pk-3, "join_time": "2024-02-21T18:00:02Z"},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
json_validator = jsonschema.Draft202012Validator(
|
|
schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"session_id": {"type": "integer"},
|
|
"attendees": {
|
|
# Allow either old or new format until after IETF 119
|
|
"anyOf": [
|
|
{"type": "array", "items": {"type": "integer"}}, # old: array of user PKs
|
|
{
|
|
# new: array of user_id / join_time objects
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"user_id": {"type": "integer", },
|
|
"join_time": {"type": "string", "format": "date-time"}
|
|
},
|
|
"required": ["user_id", "join_time"],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
},
|
|
"required": ["session_id", "attendees"],
|
|
},
|
|
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER, # format-checks disabled by default
|
|
)
|
|
|
|
def err(code, text):
|
|
return HttpResponse(text, status=code, content_type="text/plain")
|
|
|
|
if request.method != "POST":
|
|
return err(405, "Method not allowed")
|
|
attended_post = request.POST.get("attended")
|
|
if not attended_post:
|
|
return err(400, "Missing attended parameter")
|
|
|
|
# Validate the request payload
|
|
try:
|
|
payload = json.loads(attended_post)
|
|
json_validator.validate(payload)
|
|
except (json.decoder.JSONDecodeError, jsonschema.exceptions.ValidationError):
|
|
return err(400, "Malformed post")
|
|
|
|
session_id = payload["session_id"]
|
|
session = Session.objects.filter(pk=session_id).first()
|
|
if not session:
|
|
return err(400, "Invalid session")
|
|
|
|
attendees = payload["attendees"]
|
|
if len(attendees) > 0:
|
|
# Check whether we have old or new format
|
|
if type(attendees[0]) == int:
|
|
# it's the old format
|
|
users = User.objects.filter(pk__in=attendees)
|
|
if users.count() != len(payload["attendees"]):
|
|
return err(400, "Invalid attendee")
|
|
for user in users:
|
|
session.attended_set.get_or_create(person=user.person)
|
|
else:
|
|
# it's the new format
|
|
join_time_by_pk = {
|
|
att["user_id"]: datetime.datetime.fromisoformat(
|
|
att["join_time"].replace("Z", "+00:00") # Z not understood until py311
|
|
)
|
|
for att in attendees
|
|
}
|
|
persons = list(Person.objects.filter(user__pk__in=join_time_by_pk))
|
|
if len(persons) != len(join_time_by_pk):
|
|
return err(400, "Invalid attendee")
|
|
to_create = [
|
|
Attended(session=session, person=person, time=join_time_by_pk[person.user_id])
|
|
for person in persons
|
|
]
|
|
# Create in bulk, ignoring any that already exist
|
|
Attended.objects.bulk_create(to_create, ignore_conflicts=True)
|
|
|
|
if session.meeting.type_id == "interim":
|
|
save_error = generate_bluesheet(request, session)
|
|
if save_error:
|
|
return err(400, save_error)
|
|
|
|
return HttpResponse("Done", status=200, content_type="text/plain")
|
|
|
|
|
|
@require_api_key
|
|
@role_required('Recording Manager')
|
|
@csrf_exempt
|
|
def api_upload_chatlog(request):
|
|
def err(code, text):
|
|
return HttpResponse(text, status=code, content_type='text/plain')
|
|
if request.method != 'POST':
|
|
return err(405, "Method not allowed")
|
|
apidata_post = request.POST.get('apidata')
|
|
if not apidata_post:
|
|
return err(400, "Missing apidata parameter")
|
|
try:
|
|
apidata = json.loads(apidata_post)
|
|
except json.decoder.JSONDecodeError:
|
|
return err(400, "Malformed post")
|
|
if not ( 'session_id' in apidata and type(apidata['session_id']) is int ):
|
|
return err(400, "Malformed post")
|
|
session_id = apidata['session_id']
|
|
if not ( 'chatlog' in apidata and type(apidata['chatlog']) is list and all([type(el) is dict for el in apidata['chatlog']]) ):
|
|
return err(400, "Malformed post")
|
|
session = Session.objects.filter(pk=session_id).first()
|
|
if not session:
|
|
return err(400, "Invalid session")
|
|
chatlog_sp = session.presentations.filter(document__type='chatlog').first()
|
|
if chatlog_sp:
|
|
doc = chatlog_sp.document
|
|
doc.rev = f"{(int(doc.rev)+1):02d}"
|
|
chatlog_sp.rev = doc.rev
|
|
chatlog_sp.save()
|
|
else:
|
|
doc = new_doc_for_session('chatlog', session)
|
|
if doc is None:
|
|
return err(400, "Could not find official timeslot for session")
|
|
filename = f"{doc.name}-{doc.rev}.json"
|
|
doc.uploaded_filename = filename
|
|
write_doc_for_session(session, 'chatlog', filename, json.dumps(apidata['chatlog']))
|
|
e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev)
|
|
doc.save_with_history([e])
|
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
|
|
|
@require_api_key
|
|
@role_required('Recording Manager')
|
|
@csrf_exempt
|
|
def api_upload_polls(request):
|
|
def err(code, text):
|
|
return HttpResponse(text, status=code, content_type='text/plain')
|
|
if request.method != 'POST':
|
|
return err(405, "Method not allowed")
|
|
apidata_post = request.POST.get('apidata')
|
|
if not apidata_post:
|
|
return err(400, "Missing apidata parameter")
|
|
try:
|
|
apidata = json.loads(apidata_post)
|
|
except json.decoder.JSONDecodeError:
|
|
return err(400, "Malformed post")
|
|
if not ( 'session_id' in apidata and type(apidata['session_id']) is int ):
|
|
return err(400, "Malformed post")
|
|
session_id = apidata['session_id']
|
|
if not ( 'polls' in apidata and type(apidata['polls']) is list and all([type(el) is dict for el in apidata['polls']]) ):
|
|
return err(400, "Malformed post")
|
|
session = Session.objects.filter(pk=session_id).first()
|
|
if not session:
|
|
return err(400, "Invalid session")
|
|
polls_sp = session.presentations.filter(document__type='polls').first()
|
|
if polls_sp:
|
|
doc = polls_sp.document
|
|
doc.rev = f"{(int(doc.rev)+1):02d}"
|
|
polls_sp.rev = doc.rev
|
|
polls_sp.save()
|
|
else:
|
|
doc = new_doc_for_session('polls', session)
|
|
if doc is None:
|
|
return err(400, "Could not find official timeslot for session")
|
|
filename = f"{doc.name}-{doc.rev}.json"
|
|
doc.uploaded_filename = filename
|
|
write_doc_for_session(session, 'polls', filename, json.dumps(apidata['polls']))
|
|
e = NewRevisionDocEvent.objects.create(doc=doc, rev=doc.rev, by=request.user.person, type='new_revision', desc='New revision available: %s'%doc.rev)
|
|
doc.save_with_history([e])
|
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
|
|
|
@require_api_key
|
|
@role_required('Recording Manager', 'Secretariat')
|
|
@csrf_exempt
|
|
def api_upload_bluesheet(request):
|
|
"""Upload bluesheet for a session
|
|
|
|
parameters:
|
|
apikey: the poster's personal API key
|
|
session_id: id of session to update
|
|
bluesheet: json blob with
|
|
[{'name': 'Name', 'affiliation': 'Organization', }, ...]
|
|
"""
|
|
def err(code, text):
|
|
return HttpResponse(text, status=code, content_type='text/plain')
|
|
|
|
if request.method != 'POST':
|
|
return HttpResponseNotAllowed(
|
|
content="Method not allowed", content_type="text/plain", permitted_methods=('POST',)
|
|
)
|
|
|
|
session_id = request.POST.get('session_id', None)
|
|
if session_id is None:
|
|
return err(400, 'Missing session_id parameter')
|
|
bjson = request.POST.get('bluesheet', None)
|
|
if bjson is None:
|
|
return err(400, 'Missing bluesheet parameter')
|
|
|
|
try:
|
|
session = Session.objects.get(pk=session_id)
|
|
except Session.DoesNotExist:
|
|
return err(400, f"Session not found with session_id '{session_id}'")
|
|
except ValueError:
|
|
return err(400, f"Invalid session_id '{session_id}'")
|
|
|
|
try:
|
|
data = json.loads(bjson)
|
|
except json.decoder.JSONDecodeError:
|
|
return err(400, f"Invalid json value: '{bjson}'")
|
|
|
|
text = render_to_string('meeting/bluesheet.txt', {
|
|
'data': data,
|
|
'session': session,
|
|
})
|
|
|
|
fd, name = tempfile.mkstemp(suffix=".txt", text=True)
|
|
os.close(fd)
|
|
with open(name, "w") as file:
|
|
file.write(text)
|
|
with open(name, "br") as file:
|
|
save_err = save_bluesheet(request, session, file)
|
|
if save_err:
|
|
return err(400, save_err)
|
|
|
|
return HttpResponse("Done", status=200, content_type='text/plain')
|
|
|
|
|
|
def important_dates(request, num=None, output_format=None):
|
|
assert num is None or num.isdigit()
|
|
preview_roles = ['Area Director', 'Secretariat', 'IETF Chair', 'IAD', ]
|
|
|
|
meeting = get_ietf_meeting(num)
|
|
if not meeting:
|
|
raise Http404
|
|
base_num = int(meeting.number)
|
|
|
|
user = request.user
|
|
today = date_today()
|
|
meetings = []
|
|
if meeting.show_important_dates or meeting.date < today:
|
|
meetings.append(meeting)
|
|
for i in range(1,3):
|
|
future_meeting = get_ietf_meeting(base_num+i)
|
|
if future_meeting and ( future_meeting.show_important_dates
|
|
or (user and user.is_authenticated and has_role(user, preview_roles))):
|
|
meetings.append(future_meeting)
|
|
|
|
if output_format == 'ics':
|
|
preprocess_meeting_important_dates(meetings)
|
|
|
|
ics = render_to_string('meeting/important_dates.ics', {
|
|
'meetings': meetings,
|
|
}, request=request)
|
|
# icalendar response file should have '\r\n' line endings per RFC5545
|
|
response = HttpResponse(re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", ics), content_type='text/calendar')
|
|
response['Content-Disposition'] = 'attachment; filename="important-dates.ics"'
|
|
return response
|
|
|
|
return render(request, 'meeting/important-dates.html', {
|
|
'meetings': meetings
|
|
})
|
|
|
|
TimeSlotTypeForm = modelform_factory(TimeSlot, fields=('type',))
|
|
|
|
@role_required('Secretariat')
|
|
def edit_timeslot_type(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 = TimeSlotTypeForm(instance=timeslot,data=request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
return HttpResponseRedirect(reverse('ietf.meeting.views.edit_timeslots',kwargs={'num':num}))
|
|
|
|
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()
|
|
with timezone.override(meeting.tz()): # specifies current_timezone used for rendering and form handling
|
|
if request.method == 'POST':
|
|
form = TimeSlotEditForm(instance=timeslot, data=request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
redirect_to = reverse('ietf.meeting.views.edit_timeslots', kwargs={'num': num})
|
|
if 'sched' in request.GET:
|
|
# Preserve 'sched' as a query parameter
|
|
urlparts = list(urlsplit(redirect_to))
|
|
query = parse_qs(urlparts[3])
|
|
query['sched'] = request.GET['sched']
|
|
urlparts[3] = urlencode(query)
|
|
redirect_to = urlunsplit(urlparts)
|
|
return HttpResponseRedirect(redirect_to)
|
|
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,
|
|
[meeting.tz().localize(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'],
|
|
)
|
|
)
|
|
redirect_to = reverse('ietf.meeting.views.edit_timeslots',kwargs={'num':num})
|
|
if 'sched' in request.GET:
|
|
# Preserve 'sched' as a query parameter
|
|
urlparts = list(urlsplit(redirect_to))
|
|
query = parse_qs(urlparts[3])
|
|
query['sched'] = request.GET['sched']
|
|
urlparts[3] = urlencode(query)
|
|
redirect_to = urlunsplit(urlparts)
|
|
return HttpResponseRedirect(redirect_to)
|
|
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)
|
|
schedule = Schedule.objects.filter(pk=request.GET.get('sched', None)).first()
|
|
editor_url = _schedule_edit_url(session.meeting, schedule)
|
|
if request.method == 'POST':
|
|
form = SessionEditForm(instance=session, data=request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
return HttpResponseRedirect(editor_url)
|
|
else:
|
|
form = SessionEditForm(instance=session)
|
|
return render(
|
|
request,
|
|
'meeting/edit_session.html',
|
|
{'session': session, 'form': form, 'editor_url': editor_url},
|
|
)
|
|
|
|
def _schedule_edit_url(meeting, schedule):
|
|
"""Get the preferred URL to edit a schedule
|
|
|
|
Returns a link to the official schedule if schedule is None
|
|
"""
|
|
url_args = {'num': meeting.number}
|
|
if schedule and not schedule.is_official:
|
|
url_args.update({
|
|
'name': schedule.name if schedule and not schedule.is_official else None,
|
|
'owner': schedule.owner_email() if schedule and not schedule.is_official else None,
|
|
})
|
|
return reverse('ietf.meeting.views.edit_meeting_schedule', kwargs=url_args)
|
|
|
|
@role_required('Secretariat')
|
|
def cancel_session(request, session_id):
|
|
session = get_object_or_404(Session.objects.with_current_status(), pk=session_id)
|
|
schedule = Schedule.objects.filter(pk=request.GET.get('sched', None)).first()
|
|
editor_url = _schedule_edit_url(session.meeting, schedule)
|
|
if session.current_status in Session.CANCELED_STATUSES:
|
|
messages.info(request, 'Session is already canceled.')
|
|
return HttpResponseRedirect(editor_url)
|
|
if request.method == 'POST':
|
|
form = SessionCancelForm(data=request.POST)
|
|
if form.is_valid():
|
|
SchedulingEvent.objects.create(
|
|
session=session,
|
|
status_id='canceled',
|
|
by=request.user.person,
|
|
)
|
|
messages.success(request, 'Session canceled.')
|
|
return HttpResponseRedirect(editor_url)
|
|
else:
|
|
form = SessionCancelForm()
|
|
return render(
|
|
request,
|
|
'meeting/cancel_session.html',
|
|
{'session': session, 'form': form, 'editor_url': editor_url},
|
|
)
|
|
|
|
|
|
@role_required('Secretariat')
|
|
def request_minutes(request, num=None):
|
|
meeting = get_ietf_meeting(num)
|
|
if request.method=='POST':
|
|
form = RequestMinutesForm(data=request.POST)
|
|
if form.is_valid():
|
|
send_mail_text(request,
|
|
to=form.cleaned_data.get('to'),
|
|
frm=request.user.person.email_address(),
|
|
subject=form.cleaned_data.get('subject'),
|
|
txt=form.cleaned_data.get('body'),
|
|
cc=form.cleaned_data.get('cc'),
|
|
)
|
|
return HttpResponseRedirect(reverse('ietf.meeting.views.materials',kwargs={'num':num}))
|
|
else:
|
|
needs_minutes = set()
|
|
session_qs = add_event_info_to_session_qs(
|
|
Session.objects.filter(
|
|
timeslotassignments__schedule__meeting=meeting,
|
|
timeslotassignments__schedule__meeting__schedule=F('timeslotassignments__schedule'),
|
|
group__type__in=['wg','rg','ag','rag','program'],
|
|
)
|
|
).filter(~Q(current_status='canceled')).select_related('group', 'group__parent')
|
|
for session in session_qs:
|
|
if not session.all_meeting_minutes():
|
|
group = session.group
|
|
if group.parent and group.parent.type_id in ('area','irtf'):
|
|
needs_minutes.add(group)
|
|
needs_minutes = list(needs_minutes)
|
|
needs_minutes.sort(key=lambda g: ('zzz' if g.parent.acronym == 'irtf' else g.parent.acronym)+":"+g.acronym)
|
|
body_context = {'meeting':meeting,
|
|
'needs_minutes':needs_minutes,
|
|
'settings':settings,
|
|
}
|
|
body = render_to_string('meeting/request_minutes.txt', body_context)
|
|
initial = {'to': 'wgchairs@ietf.org',
|
|
'cc': 'irsg@irtf.org',
|
|
'subject': 'Request for IETF WG and BOF Session Minutes',
|
|
'body': body,
|
|
}
|
|
form = RequestMinutesForm(initial=initial)
|
|
context = {'meeting':meeting, 'form': form}
|
|
return render(request, 'meeting/request_minutes.html', context)
|
|
|
|
class ApproveSlidesForm(forms.Form):
|
|
title = forms.CharField(max_length=255)
|
|
apply_to_all = forms.BooleanField(label='Apply to all group sessions at this meeting',initial=False,required=False)
|
|
|
|
def __init__(self, show_apply_to_all_checkbox, *args, **kwargs):
|
|
super(ApproveSlidesForm, self).__init__(*args, **kwargs )
|
|
if not show_apply_to_all_checkbox:
|
|
self.fields.pop('apply_to_all')
|
|
|
|
@login_required
|
|
def approve_proposed_slides(request, slidesubmission_id, num):
|
|
submission = get_object_or_404(SlideSubmission,pk=slidesubmission_id)
|
|
if not submission.session.can_manage_materials(request.user):
|
|
permission_denied(request, "You don't have permission to manage slides for this session.")
|
|
if submission.session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"):
|
|
permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.")
|
|
|
|
session_number = None
|
|
sessions = get_sessions(submission.session.meeting.number,submission.session.group.acronym)
|
|
show_apply_to_all_checkbox = len(sessions) > 1 if submission.session.type_id == 'regular' else False
|
|
if len(sessions) > 1:
|
|
session_number = 1 + sessions.index(submission.session)
|
|
name, _ = os.path.splitext(submission.filename)
|
|
name = name[:name.rfind('-ss')]
|
|
existing_doc = Document.objects.filter(name=name).first()
|
|
if request.method == 'POST' and submission.status.slug == 'pending':
|
|
form = ApproveSlidesForm(show_apply_to_all_checkbox, request.POST)
|
|
if form.is_valid():
|
|
apply_to_all = submission.session.type_id == 'regular'
|
|
if show_apply_to_all_checkbox:
|
|
apply_to_all = form.cleaned_data['apply_to_all']
|
|
if request.POST.get('approve'):
|
|
# Ensure that we have a file to approve. The system gets cranky otherwise.
|
|
if submission.filename is None or submission.filename == '' or not os.path.isfile(submission.staged_filepath()):
|
|
return HttpResponseNotFound("The slides you attempted to approve could not be found. Please disapprove and delete them instead.")
|
|
title = form.cleaned_data['title']
|
|
if existing_doc:
|
|
doc = Document.objects.get(name=name)
|
|
doc.rev = '%02d' % (int(doc.rev)+1)
|
|
doc.title = form.cleaned_data['title']
|
|
else:
|
|
doc = Document.objects.create(
|
|
name = name,
|
|
type_id = 'slides',
|
|
title = title,
|
|
group = submission.session.group,
|
|
rev = '00',
|
|
)
|
|
doc.states.add(State.objects.get(type_id='slides',slug='active'))
|
|
doc.states.add(State.objects.get(type_id='reuse_policy',slug='single'))
|
|
added_presentations = []
|
|
revised_presentations = []
|
|
if submission.session.presentations.filter(document=doc).exists():
|
|
sp = submission.session.presentations.get(document=doc)
|
|
sp.rev = doc.rev
|
|
sp.save()
|
|
revised_presentations.append(sp)
|
|
else:
|
|
max_order = submission.session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0
|
|
added_presentations.append(
|
|
submission.session.presentations.create(document=doc,rev=doc.rev,order=max_order+1)
|
|
)
|
|
if apply_to_all:
|
|
for other_session in sessions:
|
|
if other_session != submission.session and not other_session.presentations.filter(document=doc).exists():
|
|
max_order = other_session.presentations.filter(document__type='slides').aggregate(Max('order'))['order__max'] or 0
|
|
added_presentations.append(
|
|
other_session.presentations.create(document=doc,rev=doc.rev,order=max_order+1)
|
|
)
|
|
sub_name, sub_ext = os.path.splitext(submission.filename)
|
|
target_filename = '%s-%s%s' % (sub_name[:sub_name.rfind('-ss')],doc.rev,sub_ext)
|
|
doc.uploaded_filename = target_filename
|
|
e = NewRevisionDocEvent.objects.create(doc=doc,by=submission.submitter,type='new_revision',desc='New revision available: %s'%doc.rev,rev=doc.rev)
|
|
doc.save_with_history([e])
|
|
path = os.path.join(submission.session.meeting.get_materials_path(),'slides')
|
|
if not os.path.exists(path):
|
|
os.makedirs(path)
|
|
shutil.move(submission.staged_filepath(), os.path.join(path, target_filename))
|
|
post_process(doc)
|
|
DocEvent.objects.create(type="approved_slides", doc=doc, rev=doc.rev, by=request.user.person, desc="Slides approved")
|
|
|
|
# update meetecho slide info if configured
|
|
if hasattr(settings, "MEETECHO_API_CONFIG"):
|
|
sm = SlidesManager(api_config=settings.MEETECHO_API_CONFIG)
|
|
for sp in added_presentations:
|
|
try:
|
|
sm.add(session=sp.session, slides=doc, order=sp.order)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.add(): {err}")
|
|
for sp in revised_presentations:
|
|
try:
|
|
sm.revise(session=sp.session, slides=doc)
|
|
except MeetechoAPIError as err:
|
|
log(f"Error in SlidesManager.revise(): {err}")
|
|
|
|
acronym = submission.session.group.acronym
|
|
submission.status = SlideSubmissionStatusName.objects.get(slug='approved')
|
|
submission.doc = doc
|
|
submission.save()
|
|
(to, cc) = gather_address_lists('slides_approved', group=submission.session.group, proposer=submission.submitter).as_strings()
|
|
subject = f"Slides approved for {submission.session.meeting} : {submission.session.group.acronym}{' : '+submission.session.name if submission.session.name else ''}"
|
|
body = render_to_string("meeting/slides_approved.txt", {
|
|
"to": to,
|
|
"cc": cc,
|
|
"submission": submission,
|
|
"settings": settings,
|
|
"approver": request.user.person
|
|
})
|
|
send_mail_text(request, to, None, subject, body, cc=cc)
|
|
return redirect('ietf.meeting.views.session_details',num=num,acronym=acronym)
|
|
elif request.POST.get('disapprove'):
|
|
# Errors in processing a submit request sometimes result
|
|
# in a SlideSubmission object without a file. Handle
|
|
# this case and keep processing the 'disapprove' even if
|
|
# the filename doesn't exist.
|
|
try:
|
|
if submission.filename != None and submission.filename != '':
|
|
os.unlink(submission.staged_filepath())
|
|
except (FileNotFoundError, IsADirectoryError):
|
|
pass
|
|
acronym = submission.session.group.acronym
|
|
submission.status = SlideSubmissionStatusName.objects.get(slug='rejected')
|
|
submission.save()
|
|
return redirect('ietf.meeting.views.session_details',num=num,acronym=acronym)
|
|
else:
|
|
pass
|
|
elif not submission.status.slug == 'pending':
|
|
return render(request, "meeting/previously_approved_slides.html",
|
|
{'submission': submission })
|
|
else:
|
|
initial = {
|
|
'title': submission.title,
|
|
'apply_to_all' : submission.apply_to_all,
|
|
}
|
|
form = ApproveSlidesForm(show_apply_to_all_checkbox, initial=initial )
|
|
|
|
return render(request, "meeting/approve_proposed_slides.html",
|
|
{'submission': submission,
|
|
'session_number': session_number,
|
|
'existing_doc' : existing_doc,
|
|
'form': form,
|
|
})
|
|
|
|
|
|
def import_session_minutes(request, session_id, num):
|
|
"""Import session minutes from the ietf.notes.org site
|
|
|
|
A GET pulls in the markdown for a session's notes using the HedgeDoc API. An HTML preview of how
|
|
the datatracker will render the result is sent back. The confirmation form presented to the user
|
|
contains a hidden copy of the markdown source that will be submitted back if approved.
|
|
|
|
A POST accepts the hidden source and creates a new revision of the notes. This step does *not*
|
|
retrieve the note from the HedgeDoc API again - it posts the hidden source from the form. Any
|
|
changes to the HedgeDoc site after the preview was retrieved will be ignored. We could also pull
|
|
the source again and re-display the updated preview with an explanatory message, but there will
|
|
always be a race condition. Rather than add that complication, we assume that the user previewing
|
|
the imported minutes will be aware of anyone else changing the notes and coordinate with them.
|
|
|
|
A consequence is that the user could tamper with the hidden form and it would be accepted. This is
|
|
ok, though, because they could more simply upload whatever they want through the upload form with
|
|
the same effect so no exploit is introduced.
|
|
"""
|
|
session = get_object_or_404(Session, pk=session_id)
|
|
note = Note(session.notes_id())
|
|
|
|
if not session.can_manage_materials(request.user):
|
|
permission_denied(request, "You don't have permission to import minutes for this session.")
|
|
if session.is_material_submission_cutoff() and not has_role(request.user, "Secretariat"):
|
|
permission_denied(request, "The materials cutoff for this session has passed. Contact the secretariat for further action.")
|
|
|
|
if request.method == 'POST':
|
|
form = ImportMinutesForm(request.POST)
|
|
if not form.is_valid():
|
|
import_contents = form.data['markdown_text']
|
|
else:
|
|
import_contents = form.cleaned_data['markdown_text']
|
|
try:
|
|
save_session_minutes_revision(
|
|
session=session,
|
|
file=io.BytesIO(import_contents.encode('utf8')),
|
|
ext='.md',
|
|
request=request,
|
|
)
|
|
except SessionNotScheduledError:
|
|
return HttpResponseGone(
|
|
"Cannot import minutes for an unscheduled session. Please check the session ID.",
|
|
content_type="text/plain",
|
|
)
|
|
except SaveMaterialsError as err:
|
|
form.add_error(None, str(err))
|
|
else:
|
|
messages.success(request, f'Successfully imported minutes as revision {session.minutes().rev}.')
|
|
return redirect('ietf.meeting.views.session_details', num=num, acronym=session.group.acronym)
|
|
else:
|
|
try:
|
|
import_contents = note.get_source()
|
|
except NoteError as err:
|
|
messages.error(request, f'Could not import notes with id {note.id}: {err}.')
|
|
return redirect('ietf.meeting.views.session_details', num=num, acronym=session.group.acronym)
|
|
form = ImportMinutesForm(initial={'markdown_text': import_contents})
|
|
|
|
# Try to prevent pointless revision creation. Note that we do not block replacing
|
|
# a document with an identical copy in the validation above. We cannot entirely
|
|
# avoid a race condition and the likelihood/amount of damage is very low so no
|
|
# need to complicate things further.
|
|
current_minutes = session.minutes()
|
|
contents_changed = True
|
|
if current_minutes:
|
|
try:
|
|
with open(current_minutes.get_file_name()) as f:
|
|
if import_contents == f.read():
|
|
contents_changed = False
|
|
messages.warning(request, 'This document is identical to the current revision, no need to import.')
|
|
except Exception:
|
|
pass # Do not let a failure here prevent minutes from being updated.
|
|
|
|
return render(
|
|
request,
|
|
'meeting/import_minutes.html',
|
|
{
|
|
'form': form,
|
|
'note': note,
|
|
'session': session,
|
|
'contents_unchanged': not contents_changed,
|
|
},
|
|
)
|