Improve proceedings display with new title block, configurable host logos, and additional PDF or URL materials. Fixes #3147. Commit ready for merge.

- Legacy-Id: 19306
This commit is contained in:
Jennifer Richards 2021-08-30 17:02:49 +00:00
parent ca78da6ff4
commit 2060173f3a
46 changed files with 2987 additions and 185 deletions

View file

@ -32,6 +32,7 @@ INTERNET_ALL_DRAFTS_ARCHIVE_DIR = "test/archive/"
RFC_PATH = "test/rfc/"
AGENDA_PATH = 'data/developers/www6s/proceedings/'
MEETINGHOST_LOGO_PATH = AGENDA_PATH
USING_DEBUG_EMAIL_SERVER=True
EMAIL_HOST='localhost'

View file

@ -439,3 +439,18 @@ class BofreqFactory(BaseDocumentFactory):
else:
obj.set_state(State.objects.get(type_id='bofreq',slug='proposed'))
class ProceedingsMaterialDocFactory(BaseDocumentFactory):
type_id = 'procmaterials'
abstract = ''
expires = None
@factory.post_generation
def states(obj, create, extracted, **kwargs):
if not create:
return
if extracted:
for (state_type_id,state_slug) in extracted:
obj.set_state(State.objects.get(type_id=state_type_id,slug=state_slug))
else:
obj.set_state(State.objects.get(type_id='procmaterials', slug='active'))

View file

@ -0,0 +1,34 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.23 on 2021-05-21 13:29
from django.db import migrations
def forward(apps, schema_editor):
StateType = apps.get_model('doc', 'StateType')
State = apps.get_model('doc', 'State')
StateType.objects.create(slug='procmaterials', label='Proceedings Materials State')
active = State.objects.create(type_id='procmaterials', slug='active', name='Active', used=True, desc='The material is active', order=0)
removed = State.objects.create(type_id='procmaterials', slug='removed', name='Removed', used=True, desc='The material is removed', order=1)
active.next_states.set([removed])
removed.next_states.set([active])
def reverse(apps, schema_editor):
StateType = apps.get_model('doc', 'StateType')
State = apps.get_model('doc', 'State')
State.objects.filter(type_id='procmaterials').delete()
StateType.objects.filter(slug='procmaterials').delete()
class Migration(migrations.Migration):
dependencies = [
('doc', '0043_bofreq_docevents'),
('name', '0030_add_procmaterials'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -9,6 +9,8 @@ import os
import rfc2html
import time
from typing import Optional, TYPE_CHECKING
from django.db import models
from django.core import checks
from django.core.cache import caches
@ -34,6 +36,9 @@ from ietf.utils.decorators import memoize
from ietf.utils.validators import validate_no_control_chars
from ietf.utils.mail import formataddr
from ietf.utils.models import ForeignKey
if TYPE_CHECKING:
# importing other than for type checking causes errors due to cyclic imports
from ietf.meeting.models import ProceedingsMaterial, Session
logger = logging.getLogger('django')
@ -129,10 +134,11 @@ class DocumentInfo(models.Model):
self._cached_file_path = settings.INTERNET_DRAFT_PATH
else:
self._cached_file_path = settings.INTERNET_ALL_DRAFTS_ARCHIVE_DIR
elif self.type_id in ("agenda", "minutes", "slides", "bluesheets") and self.meeting_related():
doc = self.doc if isinstance(self, DocHistory) else self
if doc.session_set.exists():
meeting = doc.session_set.first().meeting
elif self.meeting_related() and self.type_id in (
"agenda", "minutes", "slides", "bluesheets", "procmaterials"
):
meeting = self.get_related_meeting()
if meeting is not None:
self._cached_file_path = os.path.join(meeting.get_materials_path(), self.type_id) + "/"
else:
self._cached_file_path = ""
@ -160,8 +166,9 @@ class DocumentInfo(models.Model):
self._cached_base_name = "%s.txt" % self.canonical_name()
else:
self._cached_base_name = "%s-%s.txt" % (self.name, self.rev)
elif self.type_id in ["slides", "agenda", "minutes", "bluesheets", ] and self.meeting_related():
self._cached_base_name = "%s-%s.txt" % (self.canonical_name(), self.rev)
elif self.type_id in ["slides", "agenda", "minutes", "bluesheets", "procmaterials", ] and self.meeting_related():
ext = 'pdf' if self.type_id == 'procmaterials' else 'txt'
self._cached_base_name = f'{self.canonical_name()}-{self.rev}.{ext}'
elif self.type_id == 'review':
# TODO: This will be wrong if a review is updated on the same day it was created (or updated more than once on the same day)
self._cached_base_name = "%s.txt" % self.name
@ -218,7 +225,6 @@ class DocumentInfo(models.Model):
log.unreachable('2018-12-28')
pass
if self.type_id in settings.DOC_HREFS and self.type_id in meeting_doc_refs:
if self.meeting_related():
self.is_meeting_related = True
@ -239,16 +245,13 @@ class DocumentInfo(models.Model):
if self.is_meeting_related:
if not meeting:
# we need to do this because DocHistory items don't have
# any session_set entry:
doc = self.doc if isinstance(self, DocHistory) else self
sess = doc.session_set.first()
if not sess:
return ""
meeting = sess.meeting
meeting = self.get_related_meeting()
if meeting is None:
return ''
# After IETF 96, meeting materials acquired revision
# handling, and the document naming changed.
if meeting.number.isdigit() and int(meeting.number) <= 96:
if meeting.proceedings_format_version == 1:
format = settings.MEETING_DOC_OLD_HREFS[self.type_id]
else:
# This branch includes interims
@ -420,10 +423,44 @@ class DocumentInfo(models.Model):
return e != None and (e.text != "")
def meeting_related(self):
if self.type_id in ("agenda","minutes","bluesheets","slides","recording"):
if self.type_id in ("agenda","minutes","bluesheets","slides","recording","procmaterials"):
return self.type_id != "slides" or self.get_state_slug('reuse_policy')=='single'
return False
def get_related_session(self) -> Optional['Session']:
"""Get the meeting session related to this document
Return None if there is no related session.
Must define this in DocumentInfo subclasses.
"""
raise NotImplementedError(f'Class {self.__class__} must define get_related_session()')
def get_related_proceedings_material(self) -> Optional['ProceedingsMaterial']:
"""Get the proceedings material related to this document
Return None if there is no related proceedings material.
Must define this in DocumentInfo subclasses.
"""
raise NotImplementedError(f'Class {self.__class__} must define get_related_proceedings_material()')
def get_related_meeting(self):
"""Get the meeting this document relates to"""
if not self.meeting_related():
return None # no related meeting if not meeting_related!
elif self.type_id in ("agenda", "minutes", "slides", "bluesheets",):
# session-related
session = self.get_related_session()
if session is not None:
return session.meeting
elif self.type_id == "procmaterials":
# proceedings-related
material = self.get_related_proceedings_material()
if material is not None:
return material.meeting
else:
log.unreachable('2021-08-29') # if meeting_related, there must be a way to retrieve the meeting!
return None
def relations_that(self, relationship):
"""Return the related-document objects that describe a given relationship targeting self."""
if isinstance(relationship, str):
@ -713,6 +750,13 @@ class Document(DocumentInfo):
self._cached_absolute_url = url
return self._cached_absolute_url
def get_related_session(self):
sessions = self.session_set.all()
return sessions.first()
def get_related_proceedings_material(self):
return self.proceedingsmaterial_set.first()
def file_tag(self):
return "<%s>" % self.filename_with_rev()
@ -1003,6 +1047,12 @@ class DocHistory(DocumentInfo):
def __str__(self):
return force_text(self.doc.name)
def get_related_session(self):
return self.doc.get_related_session()
def get_related_proceedings_material(self):
return self.doc.get_related_proceedings_material()
def canonical_name(self):
if hasattr(self, '_canonical_name'):
return self._canonical_name

View file

@ -614,7 +614,7 @@ def document_main(request, name, rev=None):
# TODO : Add "recording", and "bluesheets" here when those documents are appropriately
# created and content is made available on disk
if doc.type_id in ("slides", "agenda", "minutes", "bluesheets",):
if doc.type_id in ("slides", "agenda", "minutes", "bluesheets","procmaterials",):
can_manage_material = can_manage_materials(request.user, doc.group)
presentations = doc.future_presentations()
if doc.uploaded_filename:
@ -645,6 +645,16 @@ def document_main(request, name, rev=None):
t = "markdown"
other_types.append((t, url))
# determine whether uploads are allowed
can_upload = can_manage_material and not snapshot
if doc.group is None:
can_upload = can_upload and (doc.type_id == 'procmaterials')
else:
can_upload = (
can_upload
and doc.group.features.has_nonsession_materials
and doc.type_id in doc.group.features.material_types
)
return render(request, "doc/document_material.html",
dict(doc=doc,
top=top,
@ -654,7 +664,7 @@ def document_main(request, name, rev=None):
latest_rev=latest_rev,
snapshot=snapshot,
can_manage_material=can_manage_material,
in_group_materials_types = doc.group and doc.group.features.has_nonsession_materials and doc.type_id in doc.group.features.material_types,
can_upload = can_upload,
other_types=other_types,
presentations=presentations,
))

View file

@ -18,6 +18,7 @@ def state_help(request, type):
"conflict-review": ("conflrev", "Conflict Review States"),
"status-change": ("statchg", "RFC Status Change States"),
"bofreq": ("bofreq", "BOF Request States"),
"procmaterials": ("procmaterials", "Proceedings Materials States"),
}.get(type, (None, None))
state_type = get_object_or_404(StateType, slug=slug)

View file

@ -54,7 +54,7 @@ class UploadMaterialForm(forms.Form):
self.fields["state"].widget = forms.HiddenInput()
self.fields["state"].queryset = self.fields["state"].queryset.filter(slug="active")
self.fields["state"].initial = self.fields["state"].queryset[0].pk
self.fields["name"].initial = "%s-%s-" % (doc_type.slug, group.acronym)
self.fields["name"].initial = self._default_name()
else:
del self.fields["name"]
@ -69,6 +69,12 @@ class UploadMaterialForm(forms.Form):
if fieldname != action:
del self.fields[fieldname]
if doc_type.slug == 'procmaterials' and 'abstract' in self.fields:
del self.fields['abstract']
def _default_name(self):
return "%s-%s-" % (self.doc_type.slug, self.group.acronym)
def clean_name(self):
name = self.cleaned_data["name"].strip().rstrip("-")
@ -101,10 +107,13 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None):
group = doc.group
document_type = doc.type
if (document_type not in DocTypeName.objects.filter(slug__in=group.features.material_types)
and document_type.slug not in ['minutes','agenda','bluesheets',]):
valid_doctypes = ['procmaterials']
if group is not None:
valid_doctypes.extend(['minutes','agenda','bluesheets'])
valid_doctypes.extend(group.features.material_types)
if document_type.slug not in valid_doctypes:
raise Http404
if not can_manage_materials(request.user, group):
permission_denied(request, "You don't have permission to access this view")
@ -186,10 +195,26 @@ def edit_material(request, name=None, acronym=None, action=None, doc_type=None):
else:
form = UploadMaterialForm(document_type, action, group, doc)
# decide where to go if upload is canceled
if doc:
back_href = urlreverse('ietf.doc.views_doc.document_main', kwargs={'name': doc.name})
else:
back_href = urlreverse('ietf.group.views.materials', kwargs={'acronym': group.acronym})
if document_type.slug == 'procmaterials':
name_prefix = 'proceedings-'
else:
name_prefix = f'{document_type.slug}-{group.acronym}-'
return render(request, 'doc/material/edit_material.html', {
'group': group,
'form': form,
'action': action,
'document_type': document_type,
'material_type': document_type,
'name_prefix': name_prefix,
'doc': doc,
'doc_name': doc.name if doc else "",
'back_href': back_href,
})

View file

@ -125,7 +125,7 @@ def milestone_reviewer_for_group_type(group_type):
return "Area Director"
def can_manage_materials(user, group):
return has_role(user, 'Secretariat') or group.has_role(user, group.features.matman_roles)
return has_role(user, 'Secretariat') or (group is not None and group.has_role(user, group.features.matman_roles))
def can_manage_session_materials(user, group, session):
return has_role(user, 'Secretariat') or (group.has_role(user, group.features.matman_roles) and not session.is_material_submission_cutoff())

View file

@ -6,7 +6,8 @@ from django.contrib import admin
from ietf.meeting.models import (Meeting, Room, Session, TimeSlot, Constraint, Schedule,
SchedTimeSessAssignment, ResourceAssociation, FloorPlan, UrlResource,
SessionPresentation, ImportantDate, SlideSubmission, SchedulingEvent, BusinessConstraint)
SessionPresentation, ImportantDate, SlideSubmission, SchedulingEvent, BusinessConstraint,
ProceedingsMaterial, MeetingHost)
class UrlResourceAdmin(admin.ModelAdmin):
@ -186,3 +187,17 @@ class SlideSubmissionAdmin(admin.ModelAdmin):
raw_id_fields = ['submitter', 'session']
admin.site.register(SlideSubmission, SlideSubmissionAdmin)
class ProceedingsMaterialAdmin(admin.ModelAdmin):
model = ProceedingsMaterial
list_display = ['meeting', 'type', 'document']
raw_id_fields = ['meeting', 'document']
admin.site.register(ProceedingsMaterial, ProceedingsMaterialAdmin)
class MeetingHostAdmin(admin.ModelAdmin):
model = MeetingHost
list_display = ['name', 'meeting']
raw_id_fields = ['meeting']
admin.site.register(MeetingHost, MeetingHostAdmin)

View file

@ -8,10 +8,14 @@ import datetime
from django.core.files.base import ContentFile
from ietf.meeting.models import Meeting, Session, SchedulingEvent, Schedule, TimeSlot, SessionPresentation, FloorPlan, Room, SlideSubmission
from ietf.name.models import ConstraintName, SessionStatusName
from ietf.meeting.models import (Meeting, Session, SchedulingEvent, Schedule, TimeSlot, SessionPresentation,
FloorPlan, Room, SlideSubmission, MeetingHost, ProceedingsMaterial)
from ietf.name.models import ConstraintName, SessionStatusName, ProceedingsMaterialTypeName
from ietf.doc.factories import ProceedingsMaterialDocFactory
from ietf.group.factories import GroupFactory
from ietf.person.factories import PersonFactory
from ietf.utils.text import xslugify
class MeetingFactory(factory.django.DjangoModelFactory):
class Meta:
@ -220,3 +224,45 @@ class SlideSubmissionFactory(factory.django.DjangoModelFactory):
make_file = factory.PostGeneration(
lambda obj, create, extracted, **kwargs: open(obj.staged_filepath(),'a').close()
)
class MeetingHostFactory(factory.django.DjangoModelFactory):
class Meta:
model = MeetingHost
meeting = factory.SubFactory(MeetingFactory, type_id='ietf')
name = factory.Faker('company')
logo = factory.django.ImageField() # generates an image
def _pmf_doc_name(doc):
"""Helper to generate document name for a ProceedingsMaterialFactory LazyAttribute"""
return 'proceedings-{number}-{slug}'.format(
number=doc.factory_parent.meeting.number,
slug=xslugify(doc.factory_parent.type.slug).replace("_", "-")[:128]
)
class ProceedingsMaterialFactory(factory.django.DjangoModelFactory):
"""Create a ProceedingsMaterial for testing
Note: if you want to specify a type, use type=ProceedingsMaterialTypeName.objects.get(slug='slug')
rather than the type_id='slug' shortcut. The latter will advance the Iterator used to generate
types. This value is then used by the document SubFactory to set the document's title. This will
not match the type of material created.
"""
class Meta:
model = ProceedingsMaterial
meeting = factory.SubFactory(MeetingFactory, type_id='ietf')
type = factory.Iterator(ProceedingsMaterialTypeName.objects.filter(used=True))
# The SelfAttribute atnd LazyAttribute allow the document to be a SubFactory instead
# of a generic LazyAttribute. This allows other attributes on the document to be
# specified as document__external_url, etc.
document = factory.SubFactory(
ProceedingsMaterialDocFactory,
type_id='procmaterials',
title=factory.SelfAttribute('..type.name'),
name=factory.LazyAttribute(_pmf_doc_name),
uploaded_filename=factory.LazyAttribute(
lambda doc: f'{_pmf_doc_name(doc)}-{doc.rev}.pdf'
))

View file

@ -0,0 +1,30 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-07-26 17:09
from django.db import migrations, models
import django.db.models.deletion
import ietf.utils.models
class Migration(migrations.Migration):
dependencies = [
('name', '0028_proceedingsmaterialtypename'),
('meeting', '0044_again_assign_correct_constraintnames'),
]
operations = [
migrations.CreateModel(
name='ProceedingsMaterial',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('meeting', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='proceedings_materials', to='meeting.Meeting')),
('type', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='name.ProceedingsMaterialTypeName')),
('document', ietf.utils.models.ForeignKey(limit_choices_to={'type_id': 'procmaterials'}, on_delete=django.db.models.deletion.CASCADE, to='doc.Document', unique=True)),
],
options={
'unique_together': {('meeting', 'type')},
},
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 2.2.24 on 2021-08-26 10:18
from django.db import migrations, models
import django.db.models.deletion
import ietf.meeting.models
import ietf.utils.fields
import ietf.utils.models
import ietf.utils.storage
import ietf.utils.validators
class Migration(migrations.Migration):
dependencies = [
('meeting', '0045_proceedingsmaterial'),
]
operations = [
migrations.CreateModel(
name='MeetingHost',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('logo', ietf.utils.fields.MissingOkImageField(height_field='logo_height', storage=ietf.utils.storage.NoLocationMigrationFileSystemStorage(location=None), upload_to=ietf.meeting.models._host_upload_path, validators=[ietf.utils.validators.MaxImageSizeValidator(1600, 1600), ietf.utils.validators.WrappedValidator(ietf.utils.validators.validate_file_size, True), ietf.utils.validators.WrappedValidator(ietf.utils.validators.validate_file_extension, ['.png', '.jpg', '.jpeg']), ietf.utils.validators.WrappedValidator(ietf.utils.validators.validate_mime_type, ['image/jpeg', 'image/png'], True)], width_field='logo_width')),
('logo_width', models.PositiveIntegerField(null=True)),
('logo_height', models.PositiveIntegerField(null=True)),
('meeting', ietf.utils.models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetinghosts', to='meeting.Meeting')),
],
options={
'ordering': ('pk',),
'unique_together': {('meeting', 'name')},
},
),
]

View file

@ -8,9 +8,12 @@ import datetime
import io
import os
import pytz
import random
import re
import string
from collections import namedtuple
from pathlib import Path
from urllib.parse import urljoin
import debug # pyflakes:ignore
@ -30,13 +33,22 @@ from ietf.dbtemplate.models import DBTemplate
from ietf.doc.models import Document
from ietf.group.models import Group
from ietf.group.utils import can_manage_materials
from ietf.name.models import MeetingTypeName, TimeSlotTypeName, SessionStatusName, ConstraintName, RoomResourceName, ImportantDateName, TimerangeName, SlideSubmissionStatusName
from ietf.name.models import (
MeetingTypeName, TimeSlotTypeName, SessionStatusName, ConstraintName, RoomResourceName,
ImportantDateName, TimerangeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName,
)
from ietf.person.models import Person
from ietf.utils.decorators import memoize
from ietf.utils.storage import NoLocationMigrationFileSystemStorage
from ietf.utils.text import xslugify
from ietf.utils.timezone import date2datetime
from ietf.utils.models import ForeignKey
from ietf.utils.validators import (
MaxImageSizeValidator, WrappedValidator, validate_file_size, validate_mime_type,
validate_file_extension,
)
from ietf.utils.fields import MissingOkImageField
from ietf.utils.log import unreachable
countries = list(pytz.country_names.items())
countries.sort(key=lambda x: x[1])
@ -219,6 +231,63 @@ class Meeting(models.Model):
else:
return None
def get_proceedings_materials(self):
"""Get proceedings materials"""
return self.proceedings_materials.filter(
document__states__slug='active', document__states__type_id='procmaterials'
).order_by('type__order')
def get_attendance(self):
"""Get the meeting attendance from the MeetingRegistrations
Returns a NamedTuple with onsite and online attributes. Returns None if the record is unavailable
for this meeting.
"""
number = self.get_number()
if number is None or number < 110:
return None
Attendance = namedtuple('Attendance', 'onsite online')
return Attendance(
onsite=Person.objects.filter(
meetingregistration__meeting=self,
meetingregistration__attended=True,
meetingregistration__reg_type__contains='in_person',
).distinct().count(),
online=Person.objects.filter(
meetingregistration__meeting=self,
meetingregistration__attended=True,
meetingregistration__reg_type__contains='remote',
).distinct().count(),
)
@property
def proceedings_format_version(self):
"""Indicate version of proceedings that should be used for this meeting
Only makes sense for IETF meeting. Returns None for any meeting without a purely numeric number.
Uses settings.PROCEEDINGS_VERSION_CHANGES. Versions start at 1. Entries
in the array are the first meeting number using each version.
"""
if not hasattr(self, '_proceedings_format_version'):
if not self.number.isdigit():
version = None # no version for non-IETF meeting
else:
version = len(settings.PROCEEDINGS_VERSION_CHANGES) # start assuming latest version
mtg_number = self.get_number()
if mtg_number is None:
unreachable('2021-08-10')
else:
# Find the index of the first entry in the version change array that
# is >= this meeting's number. The first entry in the array is 0, so the
# version is always >= 1 for positive meeting numbers.
for vers, threshold in enumerate(settings.PROCEEDINGS_VERSION_CHANGES):
if mtg_number < threshold:
version = vers
break
self._proceedings_format_version = version # save this for later
return self._proceedings_format_version
@property
def session_constraintnames(self):
"""Gets a list of the constraint names that should be used for this meeting
@ -1407,3 +1476,78 @@ class SlideSubmission(models.Model):
def staged_url(self):
return "".join([settings.SLIDE_STAGING_URL, self.filename])
class ProceedingsMaterial(models.Model):
meeting = ForeignKey(Meeting, related_name='proceedings_materials')
document = ForeignKey(
Document,
limit_choices_to=dict(type_id='procmaterials'),
unique=True,
)
type = ForeignKey(ProceedingsMaterialTypeName)
class Meta:
unique_together = (('meeting', 'type'),)
def __str__(self):
return self.document.title
def get_href(self):
return f'{self.document.get_href(self.meeting)}'
def active(self):
return self.document.get_state().slug == 'active'
def is_url(self):
return len(self.document.external_url) > 0
def _host_upload_path(instance : 'MeetingHost', filename):
"""Compute filename relative to the storage location
Must live outside a class to allow migrations to deconstruct fields that use it
"""
num = instance.meeting.number
path = (
Path(num) / 'meetinghosts' / f'logo-{"".join(random.choices(string.ascii_lowercase, k=10))}'
).with_suffix(
Path(filename).suffix
)
return str(path)
class MeetingHost(models.Model):
"""Meeting sponsor"""
meeting = ForeignKey(Meeting, related_name='meetinghosts')
name = models.CharField(max_length=255, blank=False)
logo = MissingOkImageField(
storage=NoLocationMigrationFileSystemStorage(location=settings.MEETINGHOST_LOGO_PATH),
upload_to=_host_upload_path,
width_field='logo_width',
height_field='logo_height',
blank=False,
validators=[
MaxImageSizeValidator(
settings.MEETINGHOST_LOGO_MAX_UPLOAD_WIDTH,
settings.MEETINGHOST_LOGO_MAX_UPLOAD_HEIGHT,
),
WrappedValidator(validate_file_size, True),
WrappedValidator(
validate_file_extension,
settings.MEETING_VALID_UPLOAD_EXTENSIONS['meetinghostlogo'],
),
WrappedValidator(
validate_mime_type,
settings.MEETING_VALID_UPLOAD_MIME_TYPES['meetinghostlogo'],
True,
),
],
)
# These are filled in by the ImageField allow retrieval of image dimensions
# without processing the image each time it's loaded.
logo_width = models.PositiveIntegerField(null=True)
logo_height = models.PositiveIntegerField(null=True)
class Meta:
unique_together = (('meeting', 'name'),)
ordering = ('pk',)

View file

@ -14,7 +14,7 @@ from ietf import api
from ietf.meeting.models import ( Meeting, ResourceAssociation, Constraint, Room, Schedule, Session,
TimeSlot, SchedTimeSessAssignment, SessionPresentation, FloorPlan,
UrlResource, ImportantDate, SlideSubmission, SchedulingEvent,
BusinessConstraint)
BusinessConstraint, ProceedingsMaterial, MeetingHost)
from ietf.name.resources import MeetingTypeNameResource
class MeetingResource(ModelResource):
@ -373,3 +373,42 @@ class BusinessConstraintResource(ModelResource):
"penalty": ALL,
}
api.meeting.register(BusinessConstraintResource())
from ietf.doc.resources import DocumentResource
from ietf.name.resources import ProceedingsMaterialTypeNameResource
class ProceedingsMaterialResource(ModelResource):
meeting = ToOneField(MeetingResource, 'meeting')
document = ToOneField(DocumentResource, 'document')
type = ToOneField(ProceedingsMaterialTypeNameResource, 'type')
class Meta:
queryset = ProceedingsMaterial.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'proceedingsmaterial'
ordering = ['id', ]
filtering = {
"id": ALL,
"meeting": ALL_WITH_RELATIONS,
"document": ALL_WITH_RELATIONS,
"type": ALL_WITH_RELATIONS,
}
api.meeting.register(ProceedingsMaterialResource())
class MeetingHostResource(ModelResource):
meeting = ToOneField(MeetingResource, 'meeting')
class Meta:
queryset = MeetingHost.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'meetinghost'
ordering = ['id', ]
filtering = {
"id": ALL,
"name": ALL,
"logo": ALL,
"logo_width": ALL,
"logo_height": ALL,
"meeting": ALL_WITH_RELATIONS,
}
api.meeting.register(MeetingHostResource())

Binary file not shown.

View file

@ -25,7 +25,8 @@ from ietf.group import colors
from ietf.person.models import Person
from ietf.group.models import Group
from ietf.group.factories import GroupFactory
from ietf.meeting.factories import MeetingFactory, RoomFactory, SessionFactory, TimeSlotFactory
from ietf.meeting.factories import ( MeetingFactory, RoomFactory, SessionFactory, TimeSlotFactory,
ProceedingsMaterialFactory )
from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting
from ietf.meeting.models import (Schedule, SchedTimeSessAssignment, Session,
Room, TimeSlot, Constraint, ConstraintName,
@ -2430,6 +2431,105 @@ class InterimTests(IetfSeleniumTestCase):
)
@ifSeleniumEnabled
class ProceedingsMaterialTests(IetfSeleniumTestCase):
def setUp(self):
super().setUp()
self.wait = WebDriverWait(self.driver, 2)
self.meeting = MeetingFactory(type_id='ietf', number='123', date=datetime.date.today())
def test_add_proceedings_material(self):
url = self.absreverse(
'ietf.meeting.views_proceedings.upload_material',
kwargs=dict(num=self.meeting.number, material_type='supporters'),
)
self.login('secretary')
self.driver.get(url)
# get the UI elements
use_url_checkbox = self.wait.until(
expected_conditions.element_to_be_clickable((By.ID, 'id_use_url'))
)
choose_file_button = self.wait.until(
expected_conditions.presence_of_element_located((By.ID, 'id_file'))
)
external_url_field = self.wait.until(
expected_conditions.presence_of_element_located((By.ID, 'id_external_url'))
)
# should start with use_url unchecked for a new material
self.assertTrue(choose_file_button.is_displayed(),
'File chooser should be shown by default')
self.assertFalse(external_url_field.is_displayed(),
'URL field should be hidden by default')
# enable URL
use_url_checkbox.click()
self.wait.until(expected_conditions.invisibility_of_element(choose_file_button),
'File chooser should be hidden when URL option is checked')
self.wait.until(expected_conditions.visibility_of(external_url_field),
'URL field should appear when URL option is checked')
# disable URL
use_url_checkbox.click()
self.wait.until(expected_conditions.visibility_of(choose_file_button),
'File chooser should appear when URL option is unchecked')
self.wait.until(expected_conditions.invisibility_of_element(external_url_field),
'URL field should be hidden when URL option is unchecked')
def test_replace_proceedings_material_shows_correct_default(self):
doc_mat = ProceedingsMaterialFactory(meeting=self.meeting)
url_mat = ProceedingsMaterialFactory(meeting=self.meeting, document__external_url='https://example.com')
# check the document material
url = self.absreverse(
'ietf.meeting.views_proceedings.upload_material',
kwargs=dict(num=self.meeting.number, material_type=doc_mat.type.slug),
)
self.login('secretary')
self.driver.get(url)
use_url_checkbox = self.wait.until(
expected_conditions.element_to_be_clickable((By.ID, 'id_use_url'))
)
choose_file_button = self.wait.until(
expected_conditions.presence_of_element_located((By.ID, 'id_file'))
)
external_url_field = self.wait.until(
expected_conditions.presence_of_element_located((By.ID, 'id_external_url'))
)
# should start with use_url unchecked for a document material
self.assertFalse(use_url_checkbox.is_selected(), 'URL option should be unchecked for a document material')
self.assertTrue(choose_file_button.is_displayed(),
'File chooser should be shown by default')
self.assertFalse(external_url_field.is_displayed(),
'URL field should be hidden by default')
# check the URL material
url = self.absreverse(
'ietf.meeting.views_proceedings.upload_material',
kwargs=dict(num=self.meeting.number, material_type=url_mat.type.slug),
)
self.driver.get(url)
use_url_checkbox = self.wait.until(
expected_conditions.element_to_be_clickable((By.ID, 'id_use_url'))
)
choose_file_button = self.wait.until(
expected_conditions.presence_of_element_located((By.ID, 'id_file'))
)
external_url_field = self.wait.until(
expected_conditions.presence_of_element_located((By.ID, 'id_external_url'))
)
# should start with use_url unchecked for a document material
self.assertTrue(use_url_checkbox.is_selected(), 'URL option should be checked for URL material')
self.assertFalse(choose_file_button.is_displayed(),
'File chooser should be hidden by default')
self.assertTrue(external_url_field.is_displayed(),
'URL field should be shown by default')
# The following are useful debugging tools
# If you add this to a LiveServerTestCase and run just this test, you can browse

View file

@ -0,0 +1,51 @@
# Copyright The IETF Trust 2021, All Rights Reserved
# -*- coding: utf-8 -*-
"""Tests of models in the Meeting application"""
from ietf.meeting.factories import MeetingFactory
from ietf.stats.factories import MeetingRegistrationFactory
from ietf.utils.test_utils import TestCase
class MeetingTests(TestCase):
def test_get_attendance_pre110(self):
"""Pre-110 meetings do not calculate attendance"""
meeting = MeetingFactory(type_id='ietf', number='109')
MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='')
MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote')
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person')
self.assertIsNone(meeting.get_attendance())
def test_get_attendance(self):
"""Post-110 meetings do calculate attendance"""
meeting = MeetingFactory(type_id='ietf', number='110')
# start with attendees that should be ignored
MeetingRegistrationFactory.create_batch(3, meeting=meeting, reg_type='')
MeetingRegistrationFactory(meeting=meeting, reg_type='', attended=False)
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 0)
self.assertEqual(attendance.onsite, 0)
# add online attendees with at least one who registered but did not attend
MeetingRegistrationFactory.create_batch(4, meeting=meeting, reg_type='remote')
MeetingRegistrationFactory(meeting=meeting, reg_type='remote', attended=False)
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 4)
self.assertEqual(attendance.onsite, 0)
# and the same for onsite attendees
MeetingRegistrationFactory.create_batch(5, meeting=meeting, reg_type='in_person')
MeetingRegistrationFactory(meeting=meeting, reg_type='in_person', attended=False)
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 4)
self.assertEqual(attendance.onsite, 5)
# and once more after removing all the online attendees
meeting.meetingregistration_set.filter(reg_type='remote').delete()
attendance = meeting.get_attendance()
self.assertIsNotNone(attendance)
self.assertEqual(attendance.online, 0)
self.assertEqual(attendance.onsite, 5)

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ from django.conf.urls import include
from django.views.generic import RedirectView
from django.conf import settings
from ietf.meeting import views, ajax
from ietf.meeting import views, ajax, views_proceedings
from ietf.utils.urls import url
safe_for_all_meeting_types = [
@ -104,8 +104,21 @@ type_ietf_only_patterns_id_optional = [
url(r'^proceedings/attendees/$', views.proceedings_attendees),
url(r'^proceedings/overview/$', views.proceedings_overview),
url(r'^proceedings/progress-report/$', views.proceedings_progress_report),
url(r'^proceedings/materials/$', views_proceedings.material_details),
url(r'^proceedings/materials/(?P<material_type>[a-z_]+)/$', views_proceedings.edit_material),
url(r'^proceedings/materials/(?P<material_type>[a-z_]+)/new/$', views_proceedings.upload_material),
url(r'^proceedings/materials/(?P<material_type>[a-z_]+)/remove/$',
views_proceedings.remove_restore_material,
{'action': 'remove'},
'ietf.meeting.views_proceedings.remove_material'),
url(r'^proceedings/materials/(?P<material_type>[a-z_]+)/restore/$',
views_proceedings.remove_restore_material,
{'action': 'restore'},
'ietf.meeting.views_proceedings.restore_material'),
url(r'^important-dates/$', views.important_dates),
url(r'^important-dates.(?P<output_format>ics)$', views.important_dates),
url(r'^proceedings/meetinghosts/edit/', views_proceedings.edit_meetinghosts),
url(r'^proceedings/meetinghosts/(?P<host_id>\d+)/logo/$', views_proceedings.meetinghost_logo),
]
urlpatterns = [

View file

@ -87,7 +87,7 @@ from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_ob
from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments
from ietf.meeting.utils import preprocess_meeting_important_dates
from ietf.message.utils import infer_message
from ietf.name.models import SlideSubmissionStatusName
from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName
from ietf.secr.proceedings.utils import handle_upload_file
from ietf.secr.proceedings.proc_utils import (get_progress_stats, post_process, import_audio_files,
create_recording)
@ -173,9 +173,15 @@ def materials(request, num=None):
for session_list in [plenaries, ietf, training, irtf, iab, 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()
]
return render(request, "meeting/materials.html", {
'meeting': meeting,
'proceedings_materials': proceedings_materials,
'plenaries': plenaries,
'ietf': ietf,
'training': training,
@ -221,7 +227,7 @@ def materials_document(request, document, num=None, ext=None):
if not doc.meeting_related():
raise Http404("Not a meeting related document")
if not doc.session_set.filter(meeting__number=num).exists():
if doc.get_related_meeting() != meeting:
raise Http404("No such document for meeting %s" % num)
if not rev:
filename = doc.get_file_name()
@ -1639,7 +1645,7 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""
meeting = get_ietf_meeting(num)
if not meeting or (meeting.number.isdigit() and int(meeting.number) <= 64 and (not meeting.schedule or not meeting.schedule.assignments.exists())):
if ext == '.html' or (meeting and meeting.number.isdigit() and 0 < int(meeting.number) <= 64):
return HttpResponseRedirect( 'https://www.ietf.org/proceedings/%s' % num )
return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}')
else:
raise Http404("No such meeting")
@ -3790,8 +3796,9 @@ def proceedings(request, num=None):
meeting = get_meeting(num)
if (meeting.number.isdigit() and int(meeting.number) <= 96):
return HttpResponseRedirect('https://www.ietf.org/proceedings/%s' % meeting.number)
# 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()
@ -3839,6 +3846,11 @@ def proceedings(request, num=None):
'cor_cut_off_date': cor_cut_off_date,
'submission_started': now > 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')
@ -3860,8 +3872,8 @@ def proceedings_acknowledgements(request, num=None):
if not (num and num.isdigit()):
raise Http404
meeting = get_meeting(num)
if int(meeting.number) < settings.NEW_PROCEEDINGS_START:
return HttpResponseRedirect( 'https://www.ietf.org/proceedings/%s/acknowledgement.html' % 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,
})
@ -3871,8 +3883,8 @@ def proceedings_attendees(request, num=None):
if not (num and num.isdigit()):
raise Http404
meeting = get_meeting(num)
if int(meeting.number) < settings.NEW_PROCEEDINGS_START:
return HttpResponseRedirect( 'https://www.ietf.org/proceedings/%s/attendees.html' % num )
if meeting.proceedings_format_version == 1:
return HttpResponseRedirect(f'{settings.PROCEEDINGS_V1_BASE_URL.format(meeting=meeting)}/attendee.html')
overview_template = '/meeting/proceedings/%s/attendees.html' % meeting.number
try:
template = render_to_string(overview_template, {})
@ -3888,8 +3900,8 @@ def proceedings_overview(request, num=None):
if not (num and num.isdigit()):
raise Http404
meeting = get_meeting(num)
if int(meeting.number) < settings.NEW_PROCEEDINGS_START:
return HttpResponseRedirect( 'https://www.ietf.org/proceedings/%s/overview.html' % 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, {})
@ -3906,8 +3918,8 @@ def proceedings_progress_report(request, num=None):
if not (num and num.isdigit()):
raise Http404
meeting = get_meeting(num)
if int(meeting.number) < settings.NEW_PROCEEDINGS_START:
return HttpResponseRedirect( 'https://www.ietf.org/proceedings/%s/progress-report.html' % 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_progress_stats(sdate,edate)

View file

@ -0,0 +1,312 @@
# Copyright The IETF Trust 2021 All Rights Reserved
from pathlib import Path
from django import forms
from django.http import Http404, FileResponse, HttpResponseBadRequest
from django.shortcuts import render, redirect, get_object_or_404
import debug # pyflakes:ignore
from ietf.doc.utils import add_state_change_event
from ietf.doc.models import DocAlias, DocEvent, Document, NewRevisionDocEvent, State
from ietf.ietfauth.utils import role_required
from ietf.meeting.forms import FileUploadForm
from ietf.meeting.models import Meeting, MeetingHost
from ietf.meeting.helpers import get_meeting
from ietf.name.models import ProceedingsMaterialTypeName
from ietf.secr.proceedings.utils import handle_upload_file
from ietf.utils.text import xslugify
class UploadProceedingsMaterialForm(FileUploadForm):
use_url = forms.BooleanField(
required=False,
label='Use an external URL instead of uploading a document',
)
external_url = forms.URLField(
required=False,
help_text='External URL to link from the proceedings'
)
field_order = ['use_url', 'external_url'] # will precede superclass fields
class Media:
js = (
'ietf/js/upload-material.js',
)
def __init__(self, *args, **kwargs):
super().__init__(doc_type='procmaterials', *args, **kwargs)
self.fields['file'].label = 'Select a file to upload. Allowed format{}: {}'.format(
'' if len(self.mime_types) == 1 else 's',
', '.join(self.mime_types),
)
self.fields['file'].required = False
def clean_file(self):
if self.cleaned_data.get('file', None) is None:
return None # bypass cleaning the file if none was provided
return super().clean_file()
def clean(self):
if self.cleaned_data['use_url']:
if not self.cleaned_data['external_url']:
self.add_error('external_url', 'This field is required')
else:
self.cleaned_data['external_url'] = None # make sure this is empty
if self.cleaned_data['file'] is None:
self.add_error('file', 'This field is required')
class EditProceedingsMaterialForm(forms.Form):
"""Form to edit proceedings material properties"""
# A note: we use Document._meta to get the max length of a model field.
# The leading underscore makes this look like accessing a private member,
# but it is in fact part of Django's API.
# noinspection PyProtectedMember
title = forms.CharField(
help_text='Label that will appear on the proceedings page',
max_length=Document._meta.get_field("title").max_length,
required=True,
)
def save_proceedings_material_doc(meeting, material_type, title, request, file=None, external_url=None, state=None):
events = []
by = request.user.person
if not (file is None or external_url is None):
raise ValueError('One of file or external_url must be None')
# doc naming duplicates naming of docs elsewhere - use dashes instead of underscores
doc_name = '-'.join([
'proceedings',
meeting.number,
xslugify(
getattr(material_type, 'slug', material_type)
).replace('_', '-')[:128],
])
created = False
doc = Document.objects.filter(type_id='procmaterials', name=doc_name).first()
if doc is None:
if file is None and external_url is None:
raise ValueError('Cannot create a new document without a file or external URL')
doc = Document.objects.create(
type_id='procmaterials',
name=doc_name,
rev="00",
)
created = True
# do this even if we did not create the document, just to be sure the alias exists
alias, _ = DocAlias.objects.get_or_create(name=doc.name)
alias.docs.add(doc)
if file:
if not created:
doc.rev = '{:02}'.format(int(doc.rev) + 1)
filename = f'{doc.name}-{doc.rev}{Path(file.name).suffix}'
save_error = handle_upload_file(file, filename, meeting, 'procmaterials', )
if save_error is not None:
raise RuntimeError(save_error)
doc.uploaded_filename = filename
doc.external_url = ''
e = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=doc,
rev=doc.rev,
by=by,
desc="New version available: <b>%s-%s</b>" % (doc.name, doc.rev),
)
events.append(e)
elif (external_url is not None) and external_url != doc.external_url:
if not created:
doc.rev = '{:02}'.format(int(doc.rev) + 1)
doc.uploaded_filename = ''
doc.external_url = external_url
e = NewRevisionDocEvent.objects.create(
type="new_revision",
doc=doc,
rev=doc.rev,
by=by,
desc="Set external URL to <b>{}</b>".format(external_url),
)
events.append(e)
if doc.title != title and title is not None:
e = DocEvent(doc=doc, rev=doc.rev, by=by, type='changed_document')
e.desc = f'Changed title to <b>{title}</b>'
if doc.title:
e.desc += f' from {doc.title}'
e.save()
events.append(e)
doc.title = title
# Set the state and create a change event if necessary
prev_state = doc.get_state('procmaterials')
new_state = state if state is not None else State.objects.get(type_id='procmaterials', slug='active')
if prev_state != new_state:
if not created:
e = add_state_change_event(doc, by, prev_state, new_state)
events.append(e)
doc.set_state(new_state)
if events:
doc.save_with_history(events)
return doc
@role_required('Secretariat')
def upload_material(request, num, material_type):
meeting = get_meeting(num)
# turn the material_type slug into the actual instance
material_type = get_object_or_404(ProceedingsMaterialTypeName, slug=material_type)
material = meeting.proceedings_materials.filter(type=material_type).first()
if request.method == 'POST':
form = UploadProceedingsMaterialForm(request.POST, request.FILES)
if form.is_valid():
doc = save_proceedings_material_doc(
meeting,
material_type,
request=request,
file=form.cleaned_data.get('file', None),
external_url=form.cleaned_data.get('external_url', None),
title=str(material if material is not None else material_type),
)
if material is None:
meeting.proceedings_materials.create(type=material_type, document=doc)
return redirect('ietf.meeting.views_proceedings.material_details', num=num)
else:
initial = dict()
if material is not None:
ext_url = material.document.external_url
if ext_url != '':
initial['use_url'] = True
initial['external_url'] = ext_url
form = UploadProceedingsMaterialForm(initial=initial)
return render(request, 'meeting/proceedings/upload_material.html', {
'form': form,
'material': material,
'material_type': material_type,
'meeting': meeting,
'submit_button_label': 'Upload',
})
@role_required('Secretariat')
def material_details(request, num):
meeting = get_meeting(num)
proceedings_materials = [
(type_name, meeting.proceedings_materials.filter(type=type_name).first())
for type_name in ProceedingsMaterialTypeName.objects.all()
]
return render(
request,
'meeting/proceedings/material_details.html',
dict(
meeting=meeting,
proceedings_materials=proceedings_materials,
)
)
@role_required('Secretariat')
def edit_material(request, num, material_type):
meeting = get_meeting(num)
material = meeting.proceedings_materials.filter(type_id=material_type).first()
if material is None:
raise Http404('No such material for this meeting')
if request.method == 'POST':
form = EditProceedingsMaterialForm(request.POST, request.FILES)
if form.is_valid():
save_proceedings_material_doc(
meeting,
material_type,
request=request,
title=form.cleaned_data['title'],
)
return redirect("ietf.meeting.views_proceedings.material_details", num=meeting.number)
else:
form = EditProceedingsMaterialForm(
initial=dict(
title=material.document.title,
),
)
return render(request, 'meeting/proceedings/edit_material.html', {
'form': form,
'material': material,
'material_type': material.type,
'meeting': meeting,
})
@role_required('Secretariat')
def remove_restore_material(request, num, material_type, action):
"""Remove or restore proceedings material"""
if action not in ['remove', 'restore']:
return HttpResponseBadRequest('Unsupported action')
meeting = get_meeting(num)
material = meeting.proceedings_materials.filter(type_id=material_type).first()
if material is None:
raise Http404('No such material for this meeting')
if request.method == 'POST':
prev_state = material.document.get_state('procmaterials')
new_state = State.objects.get(
type_id='procmaterials',
slug='active' if action == 'restore' else 'removed',
)
if new_state != prev_state:
material.document.set_state(new_state)
add_state_change_event(material.document, request.user.person, prev_state, new_state)
return redirect('ietf.meeting.views_proceedings.material_details', num=num)
return render(
request,
'meeting/proceedings/remove_restore_material.html',
dict(material=material, action=action)
)
@role_required('Secretariat')
def edit_meetinghosts(request, num):
meeting = get_meeting(num)
MeetingHostFormSet = forms.inlineformset_factory(
Meeting,
MeetingHost,
fields=('name', 'logo',),
extra=2,
)
if request.method == 'POST':
formset = MeetingHostFormSet(request.POST, request.FILES, instance=meeting)
if formset.is_valid():
# If we are removing a MeetingHost or replacing its logo, delete the
# old logo file.
for form in formset:
if form.instance.pk:
deleted = form.cleaned_data.get('DELETE', False)
logo_replaced = 'logo' in form.changed_data
if deleted or logo_replaced:
orig_instance = meeting.meetinghosts.get(pk=form.instance.pk)
orig_instance.logo.delete()
# this will update the DB and add any newly uploaded files
formset.save()
return redirect('ietf.meeting.views.materials', num=meeting.number)
else:
formset = MeetingHostFormSet(instance=meeting)
return render(request, 'meeting/proceedings/edit_meetinghosts.html', {
'formset': formset,
'meeting': meeting,
})
def meetinghost_logo(request, num, host_id):
host = get_object_or_404(MeetingHost, pk=host_id)
if host.meeting.number != num:
raise Http404()
return FileResponse(host.logo.open())

View file

@ -11,7 +11,7 @@ from ietf.name.models import (
ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName,
SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, TopicAudienceName,
DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName, TimerangeName,
ExtResourceName, ExtResourceTypeName, SlideSubmissionStatusName)
ExtResourceName, ExtResourceTypeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName)
from ietf.stats.models import CountryAlias
@ -52,6 +52,10 @@ class ExtResourceNameAdmin(NameAdmin):
list_display = ["slug", "name", "type", "desc", "used",]
admin.site.register(ExtResourceName,ExtResourceNameAdmin)
class ProceedingsMaterialTypeNameAdmin(NameAdmin):
list_display = ["slug", "name", "desc", "used", "order",]
admin.site.register(ProceedingsMaterialTypeName, ProceedingsMaterialTypeNameAdmin)
admin.site.register(AgendaTypeName, NameAdmin)
admin.site.register(BallotPositionName, NameAdmin)
admin.site.register(ConstraintName, NameAdmin)

View file

@ -2310,10 +2310,10 @@
"desc": "The BOF request is proposed",
"name": "Proposed",
"next_states": [
159,
160,
161,
162
178,
179,
180,
181
],
"order": 0,
"slug": "proposed",
@ -2321,7 +2321,7 @@
"used": true
},
"model": "doc.state",
"pk": 158
"pk": 177
},
{
"fields": {
@ -2334,7 +2334,7 @@
"used": true
},
"model": "doc.state",
"pk": 159
"pk": 178
},
{
"fields": {
@ -2347,7 +2347,7 @@
"used": true
},
"model": "doc.state",
"pk": 160
"pk": 179
},
{
"fields": {
@ -2360,7 +2360,7 @@
"used": true
},
"model": "doc.state",
"pk": 161
"pk": 180
},
{
"fields": {
@ -2373,7 +2373,37 @@
"used": true
},
"model": "doc.state",
"pk": 162
"pk": 181
},
{
"fields": {
"desc": "The material is active",
"name": "Active",
"next_states": [
185
],
"order": 0,
"slug": "active",
"type": "procmaterials",
"used": true
},
"model": "doc.state",
"pk": 184
},
{
"fields": {
"desc": "The material is removed",
"name": "Removed",
"next_states": [
184
],
"order": 1,
"slug": "removed",
"type": "procmaterials",
"used": true
},
"model": "doc.state",
"pk": 185
},
{
"fields": {
@ -2508,6 +2538,13 @@
"model": "doc.statetype",
"pk": "minutes"
},
{
"fields": {
"label": "Proceedings Materials State"
},
"model": "doc.statetype",
"pk": "procmaterials"
},
{
"fields": {
"label": "State"
@ -9993,6 +10030,17 @@
"model": "name.doctypename",
"pk": "minutes"
},
{
"fields": {
"desc": "",
"name": "Proceedings Materials",
"order": 0,
"prefix": "proc-materials",
"used": true
},
"model": "name.doctypename",
"pk": "procmaterials"
},
{
"fields": {
"desc": "",
@ -11768,6 +11816,56 @@
"model": "name.nomineepositionstatename",
"pk": "pending"
},
{
"fields": {
"desc": "Any other materials",
"name": "Additional Information",
"order": 4,
"used": true
},
"model": "name.proceedingsmaterialtypename",
"pk": "additional_information"
},
{
"fields": {
"desc": "Host speaker series",
"name": "Host Speaker Series",
"order": 1,
"used": true
},
"model": "name.proceedingsmaterialtypename",
"pk": "host_speaker_series"
},
{
"fields": {
"desc": "Social event",
"name": "Social Event",
"order": 2,
"used": true
},
"model": "name.proceedingsmaterialtypename",
"pk": "social_event"
},
{
"fields": {
"desc": "Sponsors and Supporters",
"name": "Sponsors and supporters",
"order": 0,
"used": true
},
"model": "name.proceedingsmaterialtypename",
"pk": "supporters"
},
{
"fields": {
"desc": "Meeting wiki",
"name": "Meeting Wiki",
"order": 3,
"used": true
},
"model": "name.proceedingsmaterialtypename",
"pk": "wiki"
},
{
"fields": {
"desc": "The reviewer has accepted the assignment",

View file

@ -0,0 +1,29 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-07-26 16:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('name', '0027_add_bofrequest'),
]
operations = [
migrations.CreateModel(
name='ProceedingsMaterialTypeName',
fields=[
('slug', models.CharField(max_length=32, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('desc', models.TextField(blank=True)),
('used', models.BooleanField(default=True)),
('order', models.IntegerField(default=0)),
],
options={
'ordering': ['order', 'name'],
'abstract': False,
},
),
]

View file

@ -0,0 +1,34 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-07-26 16:55
from django.db import migrations
def forward(apps, schema_editor):
ProceedingsMaterialTypeName = apps.get_model('name', 'ProceedingsMaterialTypeName')
names = [
{'slug': 'supporters', 'name': 'Sponsors and Supporters', 'desc': 'Sponsors and supporters', 'order': 0},
{'slug': 'host_speaker_series', 'name': 'Host Speaker Series', 'desc': 'Host speaker series', 'order': 1},
{'slug': 'social_event', 'name': 'Social Event', 'desc': 'Social event', 'order': 2},
{'slug': 'wiki', 'name': 'Meeting Wiki', 'desc': 'Meeting wiki', 'order': 3},
{'slug': 'additional_information', 'name': 'Additional Information', 'desc': 'Any other materials', 'order': 4},
]
for name in names:
ProceedingsMaterialTypeName.objects.create(used=True, **name)
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('name', '0028_proceedingsmaterialtypename'),
('meeting', '0046_meetinghost'),
]
operations = [
migrations.RunPython(forward, reverse),
]

View file

@ -0,0 +1,42 @@
# Copyright The IETF Trust 2021 All Rights Reserved
# Generated by Django 2.2.24 on 2021-07-27 08:04
from django.db import migrations
def forward(apps, schema_editor):
DocTypeName = apps.get_model('name', 'DocTypeName')
DocTypeName.objects.create(
prefix='proc-materials',
slug='procmaterials',
name="Proceedings Materials",
desc="",
used=True,
order=0
)
def reverse(apps, schema_editor):
DocTypeName = apps.get_model('name', 'DocTypeName')
DocTypeName.objects.filter(slug='procmaterials').delete()
class Migration(migrations.Migration):
# Most of these dependencies are needed to permit the reverse migration
# to work. Without them Django does not replay enough migrations and during
# migration believes that there are foreign key references to the old
# PK (name) on the Document model.
dependencies = [
('doc', '0043_bofreq_docevents'),
('group', '0044_populate_groupfeatures_parent_type_fields'),
('liaisons', '0006_document_primary_key_cleanup'),
('meeting', '0018_document_primary_key_cleanup'),
('name', '0029_populate_proceedingsmaterialtypename'),
('review', '0014_document_primary_key_cleanup'),
('submit', '0008_submissionextresource'),
]
operations = [
migrations.RunPython(forward, reverse)
]

View file

@ -60,6 +60,8 @@ class BallotPositionName(NameModel):
blocking = models.BooleanField(default=False)
class MeetingTypeName(NameModel):
"""IETF, Interim"""
class ProceedingsMaterialTypeName(NameModel):
"""social_event, host_speaker_series, supporters, wiki, additional_information"""
class AgendaTypeName(NameModel):
"""ietf, ad, side, workshop, ..."""
class SessionStatusName(NameModel):

View file

@ -18,7 +18,7 @@ from ietf.name.models import ( AgendaTypeName, BallotPositionName, ConstraintNam
ReviewAssignmentStateName, ReviewRequestStateName, ReviewResultName, ReviewTypeName,
RoleName, RoomResourceName, SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName,
TopicAudienceName, ReviewerQueuePolicyName, TimerangeName, ExtResourceTypeName, ExtResourceName,
SlideSubmissionStatusName)
SlideSubmissionStatusName, ProceedingsMaterialTypeName)
class TimeSlotTypeNameResource(ModelResource):
class Meta:
@ -668,3 +668,19 @@ class SlideSubmissionStatusNameResource(ModelResource):
}
api.name.register(SlideSubmissionStatusNameResource())
class ProceedingsMaterialTypeNameResource(ModelResource):
class Meta:
queryset = ProceedingsMaterialTypeName.objects.all()
serializer = api.Serializer()
cache = SimpleCache()
#resource_name = 'proceedingsmaterialtypename'
ordering = ['slug', ]
filtering = {
"slug": ALL,
"name": ALL,
"desc": ALL,
"used": ALL,
"order": ALL,
}
api.name.register(ProceedingsMaterialTypeNameResource())

View file

@ -669,6 +669,7 @@ BOFREQ_PATH = '/a/ietfdata/doc/bofreq/'
CONFLICT_REVIEW_PATH = '/a/ietfdata/doc/conflict-review'
STATUS_CHANGE_PATH = '/a/ietfdata/doc/status-change'
AGENDA_PATH = '/a/www/www6s/proceedings/'
MEETINGHOST_LOGO_PATH = AGENDA_PATH # put these in the same place as other proceedings files
IPR_DOCUMENT_PATH = '/a/www/ietf-ftp/ietf/IPR/'
IESG_TASK_FILE = '/a/www/www6/iesg/internal/task.txt'
IESG_ROLL_CALL_FILE = '/a/www/www6/iesg/internal/rollcall.txt'
@ -702,6 +703,7 @@ DOC_HREFS = {
"draft": "https://www.ietf.org/archive/id/{doc.name}-{doc.rev}.txt",
"rfc": "https://www.rfc-editor.org/rfc/rfc{doc.rfcnum}.txt",
"slides": "https://www.ietf.org/slides/{doc.name}-{doc.rev}",
"procmaterials": "https://www.ietf.org/procmaterials/{doc.name}-{doc.rev}",
"conflrev": "https://www.ietf.org/cr/{doc.name}-{doc.rev}.txt",
"statchg": "https://www.ietf.org/sc/{doc.name}-{doc.rev}.txt",
"liaison": "%s{doc.uploaded_filename}" % LIAISON_ATTACH_URL,
@ -885,6 +887,7 @@ MEETING_DOC_LOCAL_HREFS = {
"slides": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
"recording": "{doc.external_url}",
"bluesheets": "https://www.ietf.org/proceedings/{meeting.number}/bluesheets/{doc.uploaded_filename}",
"procmaterials": "/meeting/{meeting.number}/materials/{doc.name}-{doc.rev}",
}
MEETING_DOC_CDN_HREFS = {
@ -893,6 +896,7 @@ MEETING_DOC_CDN_HREFS = {
"slides": "https://www.ietf.org/proceedings/{meeting.number}/slides/{doc.name}-{doc.rev}",
"recording": "{doc.external_url}",
"bluesheets": "https://www.ietf.org/proceedings/{meeting.number}/bluesheets/{doc.uploaded_filename}",
"procmaterials": "https://www.ietf.org/proceedings/{meeting.number}/procmaterials/{doc.name}-{doc.rev}",
}
MEETING_DOC_HREFS = MEETING_DOC_LOCAL_HREFS if MEETING_MATERIALS_SERVE_LOCALLY else MEETING_DOC_CDN_HREFS
@ -912,6 +916,7 @@ MEETING_DOC_GREFS = {
"slides": "/meeting/{meeting.number}/materials/{doc.name}",
"recording": "{doc.external_url}",
"bluesheets": "https://www.ietf.org/proceedings/{meeting.number}/bluesheets/{doc.uploaded_filename}",
"procmaterials": "/meeting/{meeting.number}/materials/{doc.name}",
}
MEETING_MATERIALS_DEFAULT_SUBMISSION_START_DAYS = 90
@ -923,6 +928,8 @@ MEETING_VALID_UPLOAD_EXTENSIONS = {
'minutes': ['.txt','.html','.htm', '.md', '.pdf', ],
'slides': ['.doc','.docx','.pdf','.ppt','.pptx','.txt', ], # Note the removal of .zip
'bluesheets': ['.pdf', '.txt', ],
'procmaterials':['.pdf', ],
'meetinghostlogo': ['.png', '.jpg', '.jpeg'],
}
MEETING_VALID_UPLOAD_MIME_TYPES = {
@ -930,6 +937,8 @@ MEETING_VALID_UPLOAD_MIME_TYPES = {
'minutes': ['text/plain', 'text/html', 'application/pdf', 'text/markdown', 'text/x-markdown', ],
'slides': [],
'bluesheets': ['application/pdf', 'text/plain', ],
'procmaterials':['application/pdf', ],
'meetinghostlogo': ['image/jpeg', 'image/png', ],
}
MEETING_VALID_MIME_TYPE_EXTENSIONS = {
@ -953,6 +962,14 @@ FLOORPLAN_DIR = os.path.join(MEDIA_ROOT, FLOORPLAN_MEDIA_DIR)
MEETING_USES_CODIMD_DATE = datetime.date(2020,7,6)
# Maximum dimensions to accept at all
MEETINGHOST_LOGO_MAX_UPLOAD_WIDTH = 400
MEETINGHOST_LOGO_MAX_UPLOAD_HEIGHT = 400
# Maximum dimensions to display
MEETINGHOST_LOGO_MAX_DISPLAY_WIDTH = 120
MEETINGHOST_LOGO_MAX_DISPLAY_HEIGHT = 120
# Session assignments on the official schedule lock this long before the timeslot starts
MEETING_SESSION_LOCK_TIME = datetime.timedelta(minutes=10)
@ -994,7 +1011,12 @@ SECR_PROCEEDINGS_DIR = '/a/www/www6s/proceedings/'
SECR_PPT2PDF_COMMAND = ['/usr/bin/soffice','--headless','--convert-to','pdf:writer_globaldocument_pdf_Export','--outdir']
SECR_VIRTUAL_MEETINGS = ['108']
STATS_REGISTRATION_ATTENDEES_JSON_URL = 'https://registration.ietf.org/{number}/attendees/'
NEW_PROCEEDINGS_START = 95
PROCEEDINGS_VERSION_CHANGES = [
0, # version 1
97, # version 2: meeting 97 and later (was number was NEW_PROCEEDINGS_START)
111, # version 3: meeting 111 and later
]
PROCEEDINGS_V1_BASE_URL = 'https://www.ietf.org/proceedings/{meeting.number}'
YOUTUBE_API_KEY = ''
YOUTUBE_API_SERVICE_NAME = 'youtube'
YOUTUBE_API_VERSION = 'v3'

View file

@ -0,0 +1,60 @@
// Copyright The IETF Trust 2021, All Rights Reserved
(
function () {
'use strict';
/**
* Hide the inactive input form-group
* @param form form to process
*/
function showUrlOrFile(form) {
const useUrlInput = form.elements.namedItem('use_url');
const urlGroup = form.elements.namedItem('external_url').closest('.form-group');
const fileGroup = form.elements.namedItem('file').closest('.form-group');
if (useUrlInput.checked) {
urlGroup.hidden = false;
fileGroup.hidden = true;
} else {
urlGroup.hidden = true;
fileGroup.hidden = false;
}
}
/**
* Dispatch showUrlOrFile from a UI event on the enclosing form
* @param evt change event instance
*/
function handleFormChange(evt) {
showUrlOrFile(evt.currentTarget); // currentTarget is the form
}
/**
* Clear hidden file input values before submitting form to avoid
* needlessly sending a file when use_url is selected
* @param evt submit event instance
*/
function handleFormSubmit(evt) {
const form = evt.currentTarget;
const fileInput = form.elements.namedItem('file');
if (fileInput.hidden) {
fileInput.value = '';
}
}
/**
* Register event handlers and other initialization tasks.
*/
function initialize() {
const forms = document.querySelectorAll('form.upload-material');
for (let i = 0; i < forms.length; i++) {
const form = forms[i];
form.addEventListener('change', handleFormChange);
form.addEventListener('submit', handleFormSubmit);
showUrlOrFile(form);
}
}
initialize();
}
)();

View file

@ -35,9 +35,10 @@
<th class="col-md-1">{% if doc.meeting_related %}Meeting{% endif %} {{ doc.type.name }}</th>
<td class="edit col-md-1"></td>
<td class="col-md-10">
{{ doc.group.name }}
<a href="{{ doc.group.about_url }}">({{ doc.group.acronym }})</a> {{ doc.group.type.name }}
{% if doc.group %}
{{ doc.group.name }}
<a href="{{ doc.group.about_url }}">({{ doc.group.acronym }})</a> {{ doc.group.type.name }}
{% endif %}
{% if snapshot %}
<span class="label label-warning">Snapshot</span>
{% endif %}
@ -88,25 +89,37 @@
</tr>
{% endif %}
{% if presentations or can_manage_material %}
{% if doc.type_id == 'procmaterials' and doc.external_url|length > 0 %}
<tr>
<th>On agenda</th>
<td class="edit">
{% if not snapshot and can_manage_material %}
{% doc_edit_button "ietf.doc.views_doc.all_presentations" name=doc.name %}
{% endif %}
</td>
<th>External URL</th>
<td class="edit"></td>
<td>
{% if presentations %}
{% for pres in presentations %}{{ pres.session.short_name }} at {{ pres.session.meeting }} {% if pres.rev != doc.rev %}(version -{{ pres.rev }}){% endif %}{% if not forloop.last %}, {% endif %}{% endfor %}
{% else %}
None
{% endif %}
<a href="{{ doc.external_url }}">{{ doc.external_url }}</a>
</td>
</tr>
{% endif %}
{% if doc.type_id != 'procmaterials' %}
{% if presentations or can_manage_material %}
<tr>
<th>On agenda</th>
<td class="edit">
{% if not snapshot and can_manage_material %}
{% doc_edit_button "ietf.doc.views_doc.all_presentations" name=doc.name %}
{% endif %}
</td>
<td>
{% if presentations %}
{% for pres in presentations %}{{ pres.session.short_name }} at {{ pres.session.meeting }} {% if pres.rev != doc.rev %}(version -{{ pres.rev }}){% endif %}{% if not forloop.last %}, {% endif %}{% endfor %}
{% else %}
None
{% endif %}
</td>
</tr>
{% endif %}
{% endif %}
<tr>
<th>Last updated</th>
<td class="edit"></td>
@ -116,7 +129,7 @@
</table>
<p class="buttonlist">
{% if not snapshot and can_manage_material and in_group_materials_types %}
{% if can_upload %}
<a class="btn btn-default" href="{% url 'ietf.doc.views_material.edit_material' name=doc.name action="revise" %}">Upload New Revision</a>
{% endif %}
</p>

View file

@ -4,25 +4,54 @@
{% load bootstrap3 %}
{% block title %}{% if action == "new" or action == "revise" %}Upload{% else %}Edit{% endif %} {{ document_type.name }} for group {{ group.name }} ({{ group.acronym }}){% endblock %}
{% block title %}
{% if action == "new" or action == "revise" %}
Upload
{% else %}
Edit
{% endif %}
{{ doc.type.name }} for
{% if group is not None %}group {{ group.name }} ({{ group.acronym }})
{% elif doc.meeting_related %}{{ doc.get_related_meeting }} {% endif %}
{% endblock %}
{% block content %}
{% origin %}
<h1>{% if action == "new" or action == "revise" %}Upload{% else %}Edit{% endif %} {{ document_type.name }}<br><small>{{ group.name }} ({{ group.acronym }})</small></h1>
<h1>
{% if action == "new" or action == "revise" %}
Upload
{% else %}
Edit
{% endif %}
{{ doc.type.name }}
<br><small>
{% if group is not None %}{{ group.name }} ({{ group.acronym }})
{% elif doc.meeting_related %}{{ doc.get_related_meeting }}
{% if doc.get_related_proceedings_material %} {{ doc.get_related_proceedings_material }}{% endif %}
{% endif %}
</small></h1>
{% if action == "new" %}
<p class="help-block">
Below you can upload a document for the group {{ group.name }}
<a href="{% url "ietf.group.views.materials" acronym=group.acronym %}">({{ group.acronym }})</a>.
The document will appear under the materials tab in the group pages.
{% if group is not None %}
Below you can upload a document for the group {{ group.name }}
<a href="{% url "ietf.group.views.materials" acronym=group.acronym %}">({{ group.acronym }})</a>.
The document will appear under the materials tab in the group pages.
{% elif doc.meeting_related %}
Below you can upload a document for the {{ doc.get_related_meeting }} meeting.
{% endif %}
</p>
<h2>Upload</h2>
{% elif action == "revise" %}
<p>
Below you can upload a new revision of {{ doc_name }} for the group {{ group.name }}
<a href="{% url "ietf.group.views.materials" acronym=group.acronym %}">({{ group.acronym }})</a>.
{% if group is not None %}
Below you can upload a new revision of {{ doc_name }} for the group {{ group.name }}
<a href="{% url "ietf.group.views.materials" acronym=group.acronym %}">({{ group.acronym }})</a>.
{% elif doc.meeting_related %}
Below you can upload a new revision of {{ doc_name }} for the {{ doc.get_related_meeting }} meeting.
{% endif %}
</p>
<h2>Upload New Revision</h2>

View file

@ -29,6 +29,7 @@
<p>
{% if user|has_role:"Secretariat" %}
<a class="btn btn-default" href="{% url 'ietf.meeting.views_proceedings.edit_meetinghosts' num=meeting.number %}">Edit meeting hosts</a>
<a class="btn btn-default" href="{% url 'ietf.secr.proceedings.views.main' %}">Secretariat proceedings functions</a>
{% if meeting.end_date.today > meeting.end_date %}
<a class="btn btn-default" href="{% url 'ietf.meeting.views.request_minutes' num=meeting.number %}">Send request for minutes</a>
@ -37,6 +38,8 @@
<a class="btn btn-default" href="/meeting/{{meeting.number}}/requests">Meeting requests/conflicts</a>
</p>
{% include 'meeting/proceedings/materials_table.html' with meeting=meeting proceedings_materials=proceedings_materials user=user only %}
{% with "True" as show_agenda %}
<!-- Plenaries -->
{% if plenaries %}

View file

@ -4,6 +4,45 @@
{% load ietf_filters static %}
{% block morecss %}
.proceedings-title {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
font-size: 1.8rem;
}
.proceedings-title > h1 {font-size: xx-large; margin-bottom: 0;}
.proceedings-date {font-size: x-large;}
.proceedings-intro {
font-size: large;
display: flex;
justify-content:
space-around;
margin-bottom: 2rem;
padding-bottom: 1rem;
}
.proceedings-intro.with-divider {border-bottom-style: solid; border-width: 1px;}
.proceedings-intro > .proceedings-column {display: flex; flex-direction: column;}
.proceedings-intro > .proceedings-column > .proceedings-row {display: flex;}
.finalize-button {position: absolute; top: 0; right: 0;}
.proceedings-intro .host-logo {
max-height: {{ meetinghost_logo.max_height }}px;
max-width: {{ meetinghost_logo.max_width }}px;
overflow: hidden;
margin: 0 0 0 1rem;
}
{# Resize logo so longest edge matches the display size, maintaining aspect ratio. #}
{% widthratio meetinghost_logo.max_width meetinghost_logo.max_height 1 as displayed_aspect %}
{% for host in meeting.meetinghosts.all %}
{% widthratio host.logo.width host.logo.height 1 as logo_aspect %}
.host-logo img.host{{ forloop.counter }} {
{% if logo_aspect > displayed_aspect %}width: 100%; height: auto;{% else %}width: auto; height: 100%;{% endif %}
}
{% endfor %}
{% endblock %}
{% block pagehead %}
<link rel="stylesheet" href="{% static "jquery.tablesorter/css/theme.bootstrap.min.css" %}">
{% endblock %}
@ -17,26 +56,19 @@
<div class="row">
<div class="col-md-10">
<h1>IETF {{ meeting.number }} {% if not meeting.proceedings_final %}Draft{% endif %} Proceedings
{% if user|has_role:"Secretariat" and not meeting.proceedings_final %}
<a class="btn btn-default" href="{% url 'ietf.meeting.views.finalize_proceedings' num=meeting.number %}">Finalize Proceedings</a>
{% endif %}
</h1>
{% if user|has_role:"Secretariat" and not meeting.proceedings_final %}
<a class="btn btn-default finalize-button"
href="{% url 'ietf.meeting.views.finalize_proceedings' num=meeting.number %}">
Finalize Proceedings
</a>
{% endif %}
{# cache for 15 minutes, as long as there's no proceedings activity. takes 4-8 seconds to generate. #}
{% load cache %}
{% cache 900 ietf_meeting_proceedings meeting.number cache_version %}
{% if meeting.proceedings_final %}
<h2 class="anchor-target" id="introduction">Introduction</h2>
<div>
<a href="{% url 'ietf.meeting.views.proceedings_acknowledgements' num=meeting.number %}">Acknowledgements</a><br>
<a href="{% url 'ietf.meeting.views.proceedings_overview' num=meeting.number %}">IETF Overview</a><br>
<a href="{% url 'ietf.meeting.views.proceedings_progress_report' num=meeting.number %}">Progress Report</a><br>
<a href="{% url 'ietf.meeting.views.proceedings_attendees' num=meeting.number %}">Attendees</a><br>
<a href="{% url 'ietf.meeting.views.important_dates' num=meeting.number %}">Important Dates</a><br>
</div>
{% endif %}
{% include 'meeting/proceedings/title.html' with meeting=meeting attendance=attendance only %}
{% include 'meeting/proceedings/introduction.html' with meeting=meeting only %}
{% with "True" as show_agenda %}
<!-- Plenaries -->

View file

@ -0,0 +1,13 @@
{% extends "meeting/proceedings/edit_material_base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load tz %}
{% block intro %}
<p class="help-block">
{% if material.active %}
This item will be listed on the proceedings as "{{ material }}". To change this, set the title below.<br>
{% else %}
This item currently will not appear on the proceedings.<br>
{% endif %}
</p>
{% endblock %}

View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin tz %}
{% load bootstrap3 %}
{% block title %}
Edit {{ material_type.name }} for {{ meeting }} Proceedings
{% endblock %}
{% block content %}
{% origin %}
<h1>
{% block content_header %}
Edit Proceedings Material<br>
<small>
{{ meeting }} {{ material_type.name }}
</small>
{% endblock %}
</h1>
{% if meeting.proceedings_final %}
<div class="alert alert-warning">
The proceedings for this meeting have already been finalized.
</div>
{% endif %}
{% if material is not None %}
<p>
{% if material.active %}
{% if material.is_url %} An external URL for this material was set at
{% else %} A file for this material type was uploaded at {% endif %}
{% with tm=material.document.time|utc %}
{{ tm|date:"H:i:s" }} UTC on {{ tm|date:"Y-m-d" }}.{% endwith %}
{% else %}
This material has been removed and will not appear in the proceedings.
{% endif %}
</p>
{% endif %}
{% block intro %}{% endblock %}
{% block edit_form %}
<form class="edit-proceedings-material" method="post">
{% csrf_token %}
{% bootstrap_form form %}
{# To replace the form but keep default buttons, use block.super in the edit_form block #}
{% block form_buttons %}
{% buttons %}
<a class="btn btn-default pull-right"
href="{% url 'ietf.meeting.views_proceedings.material_details' num=meeting.number %}">Back</a>
<button class="btn btn-primary" type="submit">{% firstof submit_button_label 'Save' %}</button>
{% endbuttons %}
{% endblock %}
</form>
{% endblock %}
{% endblock content %}

View file

@ -0,0 +1,59 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin bootstrap3 %}
{% block morecss %}
img.logo {max-width: 30rem; max-height: 30rem;}
{% endblock %}
{% block title %}
Edit Hosts for {{ meeting }} Proceedings
{% endblock %}
{% block content %}
{% origin %}
<h1>
Edit Meeting Hosts<br>
<small>
{{ meeting }}
</small>
</h1>
{% if meeting.proceedings_final %}
<div class="alert alert-warning">
The proceedings for this meeting have already been finalized.
</div>
{% endif %}
<p class="help-block">
The entries below will appear on the proceedings as meeting hosts.
If you need to add more than there are slots, fill out the form below, save, and
reopen this page.
</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ formset.management_form }}
{{ formset.non_form_errors }}
<table class="table table-striped container-fluid">
{% for form in formset %}
<tr class="row">
<td class="col-md-3">
{% if form.instance.pk and form.instance.logo %}
<img class="logo"
src="{% url 'ietf.meeting.views_proceedings.meetinghost_logo' num=meeting.number host_id=form.instance.pk %} ">
{% endif %}
</td>
<td class="col-md-9">
{% bootstrap_form form %}
</td>
</tr>
{% endfor %}
</table>
{% buttons %}
<a class="btn btn-default pull-right" href="{% url 'ietf.meeting.views.materials' num=meeting.number %}">Back</a>
<button class="btn btn-primary" type="submit">Save</button>
{% endbuttons %}
</form>
{% endblock content %}

View file

@ -0,0 +1,57 @@
{% comment %} Copyright The IETF Trust 2021, All Rights Reserved
This renders the list of links below the title on the meeting proceedings page.
{% endcomment %}
<div class="proceedings-intro">
<div class="proceedings-column">
<div class="proceedings-row">
<a href="{% url 'ietf.meeting.views.proceedings_overview' num=meeting.number %}">
IETF Overview
</a>
</div>
<div class="proceedings-row">
<a href="{% url 'ietf.meeting.views.proceedings_attendees' num=meeting.number %}">
Participants
</a>
</div>
<div class="proceedings-row">
<a href="{% url 'ietf.meeting.views.agenda' num=meeting.number %}">
Meeting Agenda
</a>
</div>
<div class="proceedings-row">
<a href="{% url 'ietf.meeting.views.proceedings_progress_report' num=meeting.number %}">
Activity Report
</a>
</div>
<div class="proceedings-row">
<a href="{% url 'ietf.meeting.views.important_dates' num=meeting.number %}">
Important Dates
</a>
</div>
{% if meeting.proceedings_format_version < 3 %}
<div class="proceedings-row">
<a href="{% url 'ietf.meeting.views.proceedings_acknowledgements' num=meeting.number %}">
Acknowledgements
</a>
</div>
{% endif %}
</div>
{% with materials=meeting.get_proceedings_materials %}
{% if materials|length > 0 %}
<div class="proceedings-column">
{% for mat in materials %}
<div class="proceedings-row proceedings-material">
<a href="{{ mat.get_href }}">{{ mat }}</a>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>

View file

@ -0,0 +1,152 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015-2021, All Rights Reserved #}
{% load origin ietf_filters static tz %}
{% block title %}{{ meeting }} : Proceedings Materials{% endblock %}
{% block content %}
{% origin %}
<h1>{{ meeting }} : Proceedings Materials</h1>
{% if meeting.proceedings_final %}
<div class="alert alert-warning">
The proceedings have been finalized for this meeting.
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">Proceedings Materials</div>
<div class="panel-body">
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>Type</th>
<th>Title</th>
<th>Document</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{% for mat_type, mat in proceedings_materials %}
<tr>
<td>{{ mat_type }}</td>
{% if mat and mat.active %}
{% url 'ietf.doc.views_doc.document_main' name=mat.document.name as url %}
<td>
<a href="{{ mat.document.get_href }}">{{ mat }}</a>
</td>
<td>
<a href="{{ url }}">{{ mat.document }}</a>
{% if mat.is_url %} (external URL) {% else %} (uploaded file) {% endif %}
</td>
<td>
{% with timestamp=mat.document.time|utc %}
{{ timestamp|date:"Y-m-d" }}<br><small>{{ timestamp|date:"H:i:s" }}&nbsp;UTC</small>
{% endwith %}
</td>
{% else %}
<td colspan="3"></td>
{% endif %}
{% if user|has_role:"Secretariat" %}
<td class="col-md-2">
{% url 'ietf.meeting.views_proceedings.upload_material' num=meeting.number material_type=mat_type.slug as upload_url %}
{% url 'ietf.meeting.views_proceedings.edit_material' num=meeting.number material_type=mat_type.slug as edit_url %}
{% url 'ietf.meeting.views_proceedings.remove_material' num=meeting.number material_type=mat_type.slug as remove_url %}
{% url 'ietf.meeting.views_proceedings.restore_material' num=meeting.number material_type=mat_type.slug as restore_url %}
{% if mat is None %}
<a class="btn btn-default btn-sm pull-right" href="{{ upload_url }}">Add Material</a>
{% elif mat.active %}
<a class="btn btn-default btn-sm pull-right" href="{{ upload_url }}">Replace Material</a>
<a class="btn btn-default btn-sm pull-right" href="{{ edit_url }}">Change title</a>
<a class="btn btn-default btn-sm pull-right" href="{{ remove_url }}">Remove</a>
{% else %}
<a class="btn btn-default btn-sm pull-right" href="{{ upload_url }}">Add Material</a>
<a class="btn btn-default btn-sm pull-right" href="{{ restore_url }}">Restore</a>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<a class="btn btn-default pull-right" href="{% url 'ietf.meeting.views.materials' num=meeting.number %}">Back</a>
{% endblock %}
{% comment %}{% block js %}
{% if can_manage_materials %}
<script type="text/javascript" src="{% static 'jquery/jquery.min.js' %}"></script>
<script type="text/javascript" src="{% static 'js-cookie/src/js.cookie.js' %}"></script>
<script type="text/javascript" src={% static 'Sortable/Sortable.min.js' %}></script>
<script type="text/javascript">
$.ajaxSetup({
crossDomain: false,
beforeSend: function (xhr, settings) {
if (!csrfSafeMethod(settings.type)) {
xhr.setRequestHeader('X-CSRFToken', Cookies.get('csrftoken'));
}
}
});
var sortables = [];
var options = {
group: 'slides',
animation: 150,
onAdd: function (event) {onAdd(event)},
onRemove: function (event) {onRemove(event)},
onEnd: function (event) {onEnd(event)}
};
function onAdd(event) {
var old_session = event.from.getAttribute('session');
var new_session = event.to.getAttribute('session');
$.post(event.to.getAttribute('addToSession'), {
'order': event.newIndex + 1,
'name': event.item.getAttribute('name')
});
$(event.item).find('td:eq(1)').find('a').each(function () {
$(this).attr('href', $(this).attr('href').replace(old_session, new_session));
});
}
function onRemove(event) {
var old_session = event.from.getAttribute('session');
$.post(event.from.getAttribute('removeFromSession'), {
'oldIndex': event.oldIndex + 1,
'name': event.item.getAttribute('name')
});
}
function onEnd(event) {
if (event.to == event.from) {
$.post(event.from.getAttribute('reorderInSession'), {
'oldIndex': event.oldIndex + 1,
'newIndex': event.newIndex + 1
});
}
}
$(document).ready(function () {
$('.slides tbody').each(function () {
sortables.push(Sortable.create(this, options));
});
});
</script>
{% endif %}
{% endblock %}
{% endcomment %}

View file

@ -0,0 +1,57 @@
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load ietf_filters misc_filters tz %}
{# only show if user is secretariat or at least one material is active #}
{% if proceedings_materials|list_extract:1|keep_only:'active' or user|has_role:'Secretariat' %}
<!-- Proceedings materials not tied to groups -->
<h2 class="anchor-target" id="proceedings-materials">Proceedings Materials</h2>
<table class="table table-condensed table-striped">
<thead>
<tr>
{% if user|has_role:'Secretariat' %}
<th class="col-md-2">Type</th>
<th class="col-md-8">Title</th>
<th class="col-md-1">Updated</th>
<th class="col-md-1">&nbsp;</th>
{% else %}
<th class="col-md-2">Type</th>
<th class="col-md-9">Title</th>
<th class="col-md-1">Updated</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for type_name, material in proceedings_materials %}
{# secretariat sees empty slots, others do not #}
{% if user|has_role:'Secretariat' or meeting and material.active %}
<tr>
<td>{{ type_name }}</td>
{% if material and material.active %}
<td>
<a
href="{{ material.get_href }}">
{{ material.document.title }}
</a>
</td>
<td>
{% with timestamp=material.document.time|utc %}
{{ timestamp|date:"Y-m-d" }}<br><small>{{ timestamp|date:"H:i:s" }}&nbsp;UTC</small>
{% endwith %}
{% else %}
<td colspan="2"></td>
{% endif %}
{% if user|has_role:'Secretariat' %}
{% if forloop.first %}
<td rowspan="{{ proceedings_materials|length }}">
<a href="{% url 'ietf.meeting.views_proceedings.material_details' num=meeting.number %}">
Edit materials
</a>
</td>
{% endif %}
{% endif %}
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2021, All Rights Reserved #}
{% load origin %}
{% load bootstrap3 %}
{% block title %}{{ action|capfirst }} {{material.type.name}} for {{ material.meeting }} proceedings{% endblock %}
{% block content %}
{% origin %}
<h1>{{ action|capfirst }} material</h1>
<p>{{ action|capfirst }} <strong>{{material}}</strong> for the {{ material.meeting }} proceedings?</p>
<form method="post">
{% csrf_token %}
{% buttons %}
<button type="submit" class="btn btn-primary">{{ action|capfirst }} material</button>
<a class="btn btn-default" href="{% url 'ietf.meeting.views_proceedings.material_details' num=material.meeting.number %}">Cancel</a>
{% endbuttons %}
</form>
{% endblock %}

View file

@ -0,0 +1,52 @@
{% comment %} Copyright The IETF Trust 2021, All Rights Reserved
This renders the title block for the meeting proceedings page.
{% endcomment %}
{% load ietf_filters %}
<div class="proceedings-title">
<h1>
IETF {{ meeting.number }} {% if not meeting.proceedings_final %}Draft{% endif %}
Proceedings
</h1>
<div class="proceedings-date">
{{ meeting.date|date:"d F Y" }} - {{ meeting.end_date|date:"d F Y" }}
</div>
{% if attendance is not None %}
<div class="proceedings-info">
{% if attendance.onsite > 0 %}{{ attendance.onsite }} onsite participant{{ attendance.onsite|pluralize }}{% if attendance.online > 0 %},{% endif %}{% endif %}
{% if attendance.online > 0 %}{{ attendance.online }} online participant{{ attendance.online|pluralize }}{% endif %}
</div>
{% endif %}
</div>
<div class="proceedings-intro with-divider">
<div class="proceedings-column">
<div class="proceedings-row">
Location: {{ meeting.city|default:"TBD" }}
</div>
{% if meeting.venue_name %}
<div class="proceedings-row">
Venue: {{ meeting.venue_name }}
</div>
{% endif %}
</div>
{% with hosts=meeting.meetinghosts.all %}
{% if hosts.exists %}
<div class="proceedings-column">
<div class="proceedings-row">
Hosted by:
{% for host in hosts %}
<div class="host-logo">
<img class="host{{ forloop.counter }}"
src="{% url 'ietf.meeting.views_proceedings.meetinghost_logo' num=meeting.number host_id=host.pk %}"
height="{{ host.logo_height }}"
width="{{ host.logo_width }}"
title="{{ host.name }}">
</div>{% endfor %}
</div>
</div>
{% endif %}
{% endwith %}
</div>

View file

@ -0,0 +1,43 @@
{% extends "meeting/proceedings/edit_material_base.html" %}
{# Copyright The IETF Trust 2015-2021, All Rights Reserved #}
{% load bootstrap3 %}
{% block morecss %}
{{ form.media.css }}
{% endblock %}
{% block title %}
Upload {{ material_type.name }} for {{ meeting }} Proceedings
{% endblock %}
{% block content_header %}
Upload Proceedings Material<br>
<small>
{{ meeting }} {{ material_type.name }}
</small>
{% endblock %}
{% block intro %}
<p>
{% if material is None %}
This will be linked from the {{ meeting }} proceedings under
the title "{{ material_type.name }}".
{% else %}
Select a file or external URL to replace the existing material. This will be linked from the
{{ meeting }} proceedings under the title "{{ material.document.title }}".
{% endif %}
</p>
{% endblock %}
{% block edit_form %}
<form class="upload-material" method="post" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form form %}
{% block form_buttons %}{{ block.super }}{% endblock %}
</form>
{% endblock %}
{% block js %}
{{ form.media.js }}
{% endblock %}

View file

@ -264,3 +264,19 @@ class SearchableField(forms.CharField):
))
return objs.first() if self.max_entries == 1 else objs
class MissingOkImageField(models.ImageField):
"""Image field that can validate successfully if file goes missing
The default ImageField fails even to validate if its back-end file goes
missing, at least when width_field and height_field are used. This ignores
the exception that arises. Without this, even deleting a model instance
through a form fails.
"""
def update_dimension_fields(self, *args, **kwargs):
try:
super().update_dimension_fields(*args, **kwargs)
except FileNotFoundError:
pass # don't do anything if the file has gone missing

View file

@ -3,6 +3,7 @@
from django import template
import debug # pyflakes:ignore
register = template.Library()
@ -27,3 +28,35 @@ def merge_media(forms, arg=None):
if arg is None:
return str(combined)
return str(combined[arg])
@register.filter
def list_extract(items, arg):
"""Extract items from a list of containers
Uses Django template lookup rules: tries list index / dict key lookup first, then
tries to getattr. If the result is callable, calls with no arguments and uses the return
value..
Usage: {{ list_of_lists|list_extract:1 }} (gets elt 1 from each item in list)
{{ list_of_dicts|list_extract:'key' }} (gets value of 'key' from each dict in list)
"""
def _extract(item):
try:
return item[arg]
except TypeError:
pass
attr = getattr(item, arg, None)
return attr() if callable(attr) else attr
return [_extract(item) for item in items]
@register.filter
def keep_only(items, arg):
"""Filter list of items based on an attribute
Usage: {{ item_list|keep_only:'attribute' }}
Returns the list, keeping only those whose where item[attribute] or item.attribute is
present and truthy. The attribute can be an int or a string.
"""
return [item for item, value in zip(items, list_extract(items, arg)) if value]

View file

@ -10,7 +10,7 @@ from urllib.parse import urlparse, urlsplit, urlunsplit
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator, URLValidator, EmailValidator, _lazy_re_compile
from django.core.validators import RegexValidator, URLValidator, EmailValidator, _lazy_re_compile, BaseValidator
from django.template.defaultfilters import filesizeformat
from django.utils.deconstruct import deconstructible
from django.utils.ipv6 import is_valid_ipv6_address
@ -58,12 +58,26 @@ class RegexStringValidator(object):
validate_regular_expression_string = RegexStringValidator()
def validate_file_size(file):
if file.size > settings.SECR_MAX_UPLOAD_SIZE:
def validate_file_size(file, missing_ok=False):
try:
size = file.size
except FileNotFoundError:
if missing_ok:
return
else:
raise
if size > settings.SECR_MAX_UPLOAD_SIZE:
raise ValidationError('Please keep filesize under %s. Requested upload size was %s' % (filesizeformat(settings.SECR_MAX_UPLOAD_SIZE), filesizeformat(file.size)))
def validate_mime_type(file, valid):
file.open()
def validate_mime_type(file, valid, missing_ok=False):
try:
file.open()
except FileNotFoundError:
if missing_ok:
return None, None
else:
raise
raw = file.read()
mime_type, encoding = get_mime_type(raw)
# work around mis-identification of text where a line has 'virtual' as
@ -76,6 +90,28 @@ def validate_mime_type(file, valid):
(mime_type, ', '.join(valid) ))
return mime_type, encoding
@deconstructible
class WrappedValidator:
"""Helper for attaching a validate function with parameters to a model
This captures extra arguments to migration functions in a way that is compatible
with Django's migrations. E.g., WrappedValidator(validate_mime_type, valid_type_list)
will arrange to call validate_mime_type.
"""
def __init__(self, validate_method, *args):
self.validate_method = validate_method
self.args = args
def __call__(self, inst):
return self.validate_method(inst, *self.args)
def __eq__(self, other):
return all([
isinstance(other, WrappedValidator),
(self.validate_method == other.validate_method),
(self.kwargs == other.kwargs)
])
def validate_file_extension(file, valid):
name, ext = os.path.splitext(file.name)
if ext.lower() not in valid:
@ -194,3 +230,21 @@ def validate_external_resource_value(name, value):
else:
raise ValidationError('Unknown resource type '+name.type.name)
@deconstructible
class MaxImageSizeValidator(BaseValidator):
"""Validate that an image is no longer than a given size"""
message = 'Ensure this image is smaller than %(limit_value)s (it is %(show_value)s)'
code = 'max_image_size'
def __init__(self, max_width, max_height):
super().__init__(limit_value=(max_width, max_height))
def compare(self, a, b):
return (a[0] > b[0]) or (a[1] > b[1])
def clean(self, x):
try:
return x.width, x.height
except FileNotFoundError:
return 0, 0 # don't fail if the image is missing