From 2060173f3a206943afd2d3e54a09d08b94cc49d6 Mon Sep 17 00:00:00 2001 From: Jennifer Richards Date: Mon, 30 Aug 2021 17:02:49 +0000 Subject: [PATCH] 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 --- docker/settings_local.py | 1 + ietf/doc/factories.py | 15 + .../migrations/0044_procmaterials_states.py | 34 + ietf/doc/models.py | 82 +- ietf/doc/views_doc.py | 14 +- ietf/doc/views_help.py | 1 + ietf/doc/views_material.py | 35 +- ietf/group/utils.py | 2 +- ietf/meeting/admin.py | 17 +- ietf/meeting/factories.py | 50 +- .../migrations/0045_proceedingsmaterial.py | 30 + ietf/meeting/migrations/0046_meetinghost.py | 34 + ietf/meeting/models.py | 146 ++- ietf/meeting/resources.py | 41 +- ietf/meeting/test_procmat.pdf | Bin 0 -> 14791 bytes ietf/meeting/tests_js.py | 102 +- ietf/meeting/tests_models.py | 51 + ietf/meeting/tests_views.py | 1036 +++++++++++++++-- ietf/meeting/urls.py | 15 +- ietf/meeting/views.py | 40 +- ietf/meeting/views_proceedings.py | 312 +++++ ietf/name/admin.py | 6 +- ietf/name/fixtures/names.json | 116 +- .../0028_proceedingsmaterialtypename.py | 29 + ...29_populate_proceedingsmaterialtypename.py | 34 + .../name/migrations/0030_add_procmaterials.py | 42 + ietf/name/models.py | 2 + ietf/name/resources.py | 18 +- ietf/settings.py | 24 +- ietf/static/ietf/js/upload-material.js | 60 + ietf/templates/doc/document_material.html | 47 +- .../templates/doc/material/edit_material.html | 43 +- ietf/templates/meeting/materials.html | 3 + ietf/templates/meeting/proceedings.html | 62 +- .../meeting/proceedings/edit_material.html | 13 + .../proceedings/edit_material_base.html | 59 + .../proceedings/edit_meetinghosts.html | 59 + .../meeting/proceedings/introduction.html | 57 + .../meeting/proceedings/material_details.html | 152 +++ .../meeting/proceedings/materials_table.html | 57 + .../proceedings/remove_restore_material.html | 23 + ietf/templates/meeting/proceedings/title.html | 52 + .../meeting/proceedings/upload_material.html | 43 + ietf/utils/fields.py | 16 + ietf/utils/templatetags/misc_filters.py | 33 + ietf/utils/validators.py | 64 +- 46 files changed, 2987 insertions(+), 185 deletions(-) create mode 100644 ietf/doc/migrations/0044_procmaterials_states.py create mode 100644 ietf/meeting/migrations/0045_proceedingsmaterial.py create mode 100644 ietf/meeting/migrations/0046_meetinghost.py create mode 100644 ietf/meeting/test_procmat.pdf create mode 100644 ietf/meeting/tests_models.py create mode 100644 ietf/meeting/views_proceedings.py create mode 100644 ietf/name/migrations/0028_proceedingsmaterialtypename.py create mode 100644 ietf/name/migrations/0029_populate_proceedingsmaterialtypename.py create mode 100644 ietf/name/migrations/0030_add_procmaterials.py create mode 100644 ietf/static/ietf/js/upload-material.js create mode 100644 ietf/templates/meeting/proceedings/edit_material.html create mode 100644 ietf/templates/meeting/proceedings/edit_material_base.html create mode 100644 ietf/templates/meeting/proceedings/edit_meetinghosts.html create mode 100644 ietf/templates/meeting/proceedings/introduction.html create mode 100644 ietf/templates/meeting/proceedings/material_details.html create mode 100644 ietf/templates/meeting/proceedings/materials_table.html create mode 100644 ietf/templates/meeting/proceedings/remove_restore_material.html create mode 100644 ietf/templates/meeting/proceedings/title.html create mode 100644 ietf/templates/meeting/proceedings/upload_material.html diff --git a/docker/settings_local.py b/docker/settings_local.py index 46724ef9e..6318f1b19 100644 --- a/docker/settings_local.py +++ b/docker/settings_local.py @@ -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' diff --git a/ietf/doc/factories.py b/ietf/doc/factories.py index 946f2ebe7..824f13cba 100644 --- a/ietf/doc/factories.py +++ b/ietf/doc/factories.py @@ -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')) diff --git a/ietf/doc/migrations/0044_procmaterials_states.py b/ietf/doc/migrations/0044_procmaterials_states.py new file mode 100644 index 000000000..2a7e90a2d --- /dev/null +++ b/ietf/doc/migrations/0044_procmaterials_states.py @@ -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) + ] diff --git a/ietf/doc/models.py b/ietf/doc/models.py index cf3605d83..ff71e4a86 100644 --- a/ietf/doc/models.py +++ b/ietf/doc/models.py @@ -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 diff --git a/ietf/doc/views_doc.py b/ietf/doc/views_doc.py index 5f443814b..70e0e45cc 100644 --- a/ietf/doc/views_doc.py +++ b/ietf/doc/views_doc.py @@ -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, )) diff --git a/ietf/doc/views_help.py b/ietf/doc/views_help.py index 56b354cc4..43c029ac7 100644 --- a/ietf/doc/views_help.py +++ b/ietf/doc/views_help.py @@ -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) diff --git a/ietf/doc/views_material.py b/ietf/doc/views_material.py index 60f9dbc1a..21b93397a 100644 --- a/ietf/doc/views_material.py +++ b/ietf/doc/views_material.py @@ -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, }) + + diff --git a/ietf/group/utils.py b/ietf/group/utils.py index 101c8e1b4..b5fbd1387 100644 --- a/ietf/group/utils.py +++ b/ietf/group/utils.py @@ -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()) diff --git a/ietf/meeting/admin.py b/ietf/meeting/admin.py index d520b0896..f7420637d 100644 --- a/ietf/meeting/admin.py +++ b/ietf/meeting/admin.py @@ -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) diff --git a/ietf/meeting/factories.py b/ietf/meeting/factories.py index 927ed68d2..e54c39f6d 100644 --- a/ietf/meeting/factories.py +++ b/ietf/meeting/factories.py @@ -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' + )) + diff --git a/ietf/meeting/migrations/0045_proceedingsmaterial.py b/ietf/meeting/migrations/0045_proceedingsmaterial.py new file mode 100644 index 000000000..4ea4b9e9a --- /dev/null +++ b/ietf/meeting/migrations/0045_proceedingsmaterial.py @@ -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')}, + }, + ), + ] diff --git a/ietf/meeting/migrations/0046_meetinghost.py b/ietf/meeting/migrations/0046_meetinghost.py new file mode 100644 index 000000000..02392913b --- /dev/null +++ b/ietf/meeting/migrations/0046_meetinghost.py @@ -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')}, + }, + ), + ] diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py index a3644343b..24874817b 100644 --- a/ietf/meeting/models.py +++ b/ietf/meeting/models.py @@ -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',) diff --git a/ietf/meeting/resources.py b/ietf/meeting/resources.py index 926eec589..c9e3d6d4c 100644 --- a/ietf/meeting/resources.py +++ b/ietf/meeting/resources.py @@ -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()) diff --git a/ietf/meeting/test_procmat.pdf b/ietf/meeting/test_procmat.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bd2747cb6b2035a39559fbeca394cffaf8f7ee58 GIT binary patch literal 14791 zcmaib1yr2Pwk7V8;7((~p>YlF?(XjH?oM#`;DO*y@Zb&!?ryp`HWfdX} zrC6I-yFl~tsrI-W&ZX)LIeQSJZ?{1odvA0)1{8^F6>8hb`6+he9QJr`(SN9 z+jA1wcaE1H$i)&K80dM9g46?q8j2=25JHm>#W4tw49FNmiqx1j@MRN0)^D`3vNrl2 z$KE6tp@r5cX0NZNZQQ74TXDf|dt*IYV%xBOXPMr9%Lfa=bYp$i;lHweW`<&Y)_AAq z{iku>Ink(Zt=49M>@h>!Jy-JnR$G{^~} z?+TyEh15I62gs%kcFsZ}i2)6Q)FS~vot;8x6}cs((cvA0VyW+fZ~A^F&5)I%YG8=o zl+0wwKG++q-QM-qiNvE#WQK0qml#_~T_ znj$_5UE(N|E0R63bAJiH5L1D;rx}RVt+{;Nflm(29?m0Q513P*ws-WqgwujvI8&gZ z5%HetA|YD}^UHq5As-j-I56}f^dnwWKwTPCbxIokV?pf&@j}oH)`Su}x~A|sFwAfc zk`pmcqBH6F^CLsJ;s;J&B42RwTro-<#$_s=5D%4rDuH7~FfsnI41H0jdE63=Md$h09Yf`LXlUme=5O8#K?VJ~&nC=Xoaf0XFm&NVy)> zeFuszNNJnQpQ48$Y)uPD8{h|S3t8% zV=stBQTG+kg<>hH*Mf2>v=c@RR1@k8wExetfJ@WUr#fp>^CW$!RjmA7fH$pzJ=EG$ zx74f!Ox3($T6uc7?yrp-4@jb)PZzo#FkO-BCOa{)i#^XgSqAzh{{8d`!-Wk}rD0xp_7`GsdPZXP7@^qC8VvJ#}Ru<^oMqpshL2B4dqv9lw$P zh-hoyigw-ax`2}!QXmialT;mrCmE@x5Q-BfDaSXF-y8gp8?wuSop3seE`)7FvR{{b z^gAO^3i@i!%%>3#_7CO{;taq;Yyr-2YQ*5x?eOa;^We`^eklILT>}9)b-s1syNop1QL0gT zyVAsYT>3tpSI^7!7VPG?EcNc=d>l_tJ9|5vLuUd+JJ6E$;MAxAsC;nq96jiHBK!d> zFNie9`b@#58yBDG0ytWFtZqCg^pNMdeJFIYHkN{#dojB^Jm9K6dqDUTtq0iuY~Ey? zJ|Ut#LRXA& zoy#fS9$^F`I)1`x5J%v^jtmda*xJOa412(29j_mvd>x6K_a(R+^cArUj=foKzuO#5 zg(>XRcdAHKmNVx4^mS+4mK;xFo>u?9b;Dj8GO|+78&!tjc0ri^4_AmPqbCED>1XvE zH5}x5f{k@g=R9>!>FOUQbBt!Dcs~4Y2DPm=YI!IC#OYvldt%O=sLN zx@9}%yVh1ScnzD}Y6n<9Lsf5HW>nx3^C98h@lFVvpQB*(=nMHhW4yBE3gYnh^2N+E zYt^U)O>bIpKKk`8om*GpxuW4Zf+qzor`L~ty0v_eY(g*|LgN>nH^NE_Ivevfo2C-! z&XFP6X)qi5RK4Y7BIGXI$ zL``so;NoI-qMb*|D2Kf~<7;_T#BS&e>ZI})x}~w|*MxI?KJney?GjS&%ecbV;D`Mk z=%YRTl8s-qN5XOzfUBRO8~W_98l(3UX$hH`F__bSTTO7UgSHjf4JU}jiatA%F<;@* z?iXCsa{LdHR#aEYGkLvGnCXEXvUq>@Po>jnF42`qi*(M8De=bo-Rr?u2WcL}JAYIU z=!e8nY=quW=9>k-n%0$0k>4Q><=hNwC-IBp=A-ghlwr$F&sspez+X2-eGa5Cn`aSE zq+Ni^RyfP2O+uyot`QrS*cb1aCx%L667T&AMV87zpYj~Y|cNgD;${YIHk>%znL{mFj$1T7IWj7pFD)U|=t!mdOd>vZRld93iy zNNZlEup7;6@HswPNd5DQK2clT-L~xM-5gnAnfFkqn_1|zZYZ+scdX#;WT5MR3P;6z zh}SD->9K|wiJ0zKVVina zuNC-}1Bx(ZX z>cqYw&e=+t=z1@?6u^(g#u7Hu5wO)KjhB3o&rC{cK93YuccUX{OsK0z_aMg^jLIFx ztU3Rl-=be0zRci2Pbo74l-;wLna@2yXi2S z;H`FtI(c?XzR4Y~Nu)#b0Et!eOHtu4^0s*Wi_h)W$GE2m5>=Js1dNUl3s=O1jc1&G zDy|+a_OWA3^vICk8z}Idaj=ryPz%b))In?(b=5FK8?%vyYF~*@K1!A0d4ED~oU#X8 zp)-dIZcy?I82E3F5o6y$6%n=eor9B7T_k#n2VZdUZaPs!MMnlx;q`n4Y%?!aUADMS z0O3iDHVPna3fQM?^Xr?SlI`n1UY%xL6zW-!f2un9u*`eJCQs%cS;5kC9iM>iOop2^ zH1u<2==qum{Q2JTku@So(D8?9;gr+v97?BWo%oEc^wZZ= zPCXX>Plg%BepJwoQG+1V-jbbv2E4%%x zGxufi2(@~norlWb?k1^{H@X{OuV~T2a#f8M%bONohb38t>rJq}Gsq;}CYQO=+cE0? z(j(7xON0(=g(xZZJ>DtunqEMVP)kaph4Sk+nKUu@#5GNO4r6$Uv)tn*BbMpCH^!gD z+c}x7;b={1V+1$vwV^@Tl+Xh$KY-A13KyJb)QNZ(i?Fw7Nxp5Ce#SRJ+86|oH%R`T zJenKo2vgevSAOp1!^Yf-L_C*Mt!>Y#-hApq_ziaDoC(9UxJgC(8zce}<<=mt_m*)N zk}s4yB4sy8YR^L|K;Gs6+Mj+Un@QWbdCzumIpa_3@>McLlb3eIAEL$8J#iD%3{0en z@zI7%WcKzEtGQ*UP)Px`fqeBPvvl-VR!m;`hkB)>STBQlnC5VTOvCJT#cwB?8EWcl zGM6Fzp-6?i!g}s&1-uMroZBHRk3SVVyP_lft14=@aYm$b)8|w^URuU>&g>q!QN?pT@8m1gYl7 zsRdqtsRVN`Cs$`GZHM3;|NeL*cr$7x_qmUE+ub9ce%<<}!PATPBPz#c#w8FQRg5Tj zKV-is{OVeTzahM=p~6}`*`wF97RD8K;~T%Se%jHMufv}4b08w0X>siK>oq~N47GbY zB3+#gq8gPmMk^MQ7xZphRTt4&M37m#TR@3&8eKWnx%^r9!Tie9$4{GYn^Kg|qj+&ySBZZtZFn zqjS4i&kT8y*SmD(-?N!F+0jp#L}ipqgiGo_WmsyZ`vokSi~ri?7LIX3Iihmie`J|f zDVzjo!un*U4?(w`^(yM*W+X6RpFpdoNYCtH%HHU3mL7cFDwd9pK2G&xXsaf2&$*#wT27y)SIA_)hl(pTd#M_>&}I{)icO z=Fd7j3TX{ro}(r-7quYG)D&tYT(zFq%p$e-=H+T8?gOpT(_(P&^_x13pXy^9$7>G# zWJTh2aT0tFaE||6_E2X>V@zW{!fVQ9(520J zFzx2**1AFckr!u(l|o4JmG7?O;jYb8H>U>Q|69`JZF%-0---GY4j2nOW+P*Gh-lN& zekcP6bc($75X)TYc~JnF*V0L$Y7;8NWyc|F9zad2ELY~LRo0S}v2aM*K7=BsCS-bl zFVEZEgW0MbCJI6x=w7JSu3Xigf_!e}(WB`t+wK%XR% zjieC*qK1Gd5dk19D592Zp5VsDVoJN<`a5d;N8?j8BOl8gpUvpd%%V-|lUG(Vl-c^N z$LY&SqAO-OOkt|8&8@h*jrMlABbKN7t*`A}Ag?Qmp6Br&^~cQW+;2Cwi;nWCeEm?v z$lv914ex|ZQVVI*WnXIsm^FKFI2d}w){e5u{6%hxGTGW<+NV#WvZndQJ83+tnCj;g z_!TG1>8wqycgKC|eQm#Y`Po+~<$T>p@MW`8te&f^vMRT$)b;Jh8qeSR^gH$lpY8E> z44)a-+SmQ_Y+U5>Nz)QdaFguleZj$W9q)HHD=u3? zlTMCjQ9S*M@?{M6T8fg-p9ZeED!}mxJfmi~{Rx%#_egW$QMb!!oYE2nq<2}@(1}hC z9rF=#)U-7ftUsrmG#OVSl;V4(Rbx@{3EEp+BMxv_l|=egmKu|I#*-Lb%^Ms444;B|j>N!uJl zk>~~{NY2bM6C7ae zP&MyyGb|r77i1feB2rhiioptoE?p=nOPSY<*zRfDFj9zTX=-Un4>81mu!8+;L&Ek8 zpM-dW0Xs^F_p=_1_SFQryez)OjY(8>X83sG)Kub8vOc6_=KOsf7~RiJXI4HooQfq$ zE4E)&WvU7le4FepMu%{GFNr^UtmhdUaeixh1+y12Fe|7o>twYs(Z1^V``?g+hzK0h7}(o=<1H^%DQhmqG!r7J9AeUOzfN%%KYhz1$L2AwpIc-M-^+V%24DN#EpqY1iHE+ zylT&v8-!9@O{)Un^v)lu*XE;i%8CeYyW8KG`(3N8l|zfm>eH)(NQ=zE6$nH2Kj%Kl z*W)ijNr|`06!I@G*dC3X3VuGUuCKFMgYYxDFny%wqlGtf-OiLMZu}_cIKzA7qBe6n ziQ!EOpT$?SXztvQM@Z6&NlnU(_-1quhVg z;mZSlmRZPw3LXZGY1~(F+`xd93NN_}vKP(J)INmOSfmQ>T;9-~jiy*>725QaZ)(;& zvsFi?U&q!OIu>DPI;$Sryu=Z?m~|J(U6zwu*u_F_oi)qe6m|PxfXM zEzMvO*v1c7+{{mgIpOxUnVH9`ozk-;bF<~{9Jd~N5*ivt4z@>FGb_dxP9#*R^pwoF z$N79ep_rczujCDzo~m~injlMcp*7{pP{~v8j@Xx&I9M<~_>vvp z;!7((Q`=J0Nih|jQ=cqiWDAii1~Mz-UE%eUtwU>?uRai751j=aA(3N4a_tq~m;{HK`vDjsY*eLKV^UrrXx7<}@#r#9jLGUu z@?Uw7zG9@e-yN+-MAkD8oZO_606pK*sfg<7qC#%xfnzW39?_dTSn04Gm($Etcg3rrkA1rrP&78Vwq zVj)WruCVMG0)IJj@znYyY552*hV$H)GTPlKzh;#wQ&u845Rqvu)Z)EcIk`geM6)*ub@IX##Dka4zD>+cvFx6t2} z_3)ZaHWe%sqJt*EqVY3I^Hx8HEP$5-N`>oGELB+KN%zoWh@7MZXmaqC2BSgF$9z0Y zq1U7FT*}?C9G+l&!oSb1#w|Zn%I6t66k+ci(q}o!!CNk z>W|vtG7b|YX~qn~Q~U;=kkuPC{*ZyCK)n64_&#c%$k)#8B+Z+>D}2=4-^Eo&zFt{syd)vpmx65peSNH0G}eI9{d4DsnQ>QX9KsEEQ1m95C>f%eOpsT^v| zthaG%jWv{zPZ*;M2Mjg9d#X7H$~v^>SQV!x^;}R|xVF)uq9Mf3ZkS}Vj8*uF8ybQq zWfar7a1o1&;$Ws=Jw%g=>P#o=*r{%)LHl8yR-2up`>{#Y?(eSc>*l*B4h-e|{&H0+ zTKcA(w&_E7|9OY=A{8Om%4f%&!~8V@X@B49f-{5w-kgLBS{a&=?3H=nNFOUUUTok3 z(trKeH)l=OYcC*(x#xC#jb;p?7aPUR?lGoZVFT#7$wJXx8j@Ck}%tx+>G9Lj}oWIGnUFw*Wg0Fp)!`GY_Ibk+xHZ}mEDc;~}a z{6vSCON;2@dZilCUQLYtezb`K-e*ksG!k?^hD|4vLH;BMXfMdTBnJu_1HD8C%(je& zijWG^tVL^bo#3w{c~UJ{$YsxHRZ09+UJ|{^Q>tO#ue_FxJ(PNxW=hp0IfF6EWzi-` z^{t{qoCTZ`1=|mEwgh^Q3l6g|0A@7L1C|#SerM1L35-)UN6@@>f7lltPCx>W!k%A## z%Ib?TneWq!F{*kArv0=SmDf_fv^sbEZHCV^&*OviBDClW0gnYg-&uioWH&&4gSE?8>@GUgV zIJ^Q(=(BEfmWc29cT|`eapTx~hk$rlrIFLkzYAx)Oow3AsV^lo zxc=A%;Pxrl2XF-ggtI4>z@Cl36dTY-{4~fy#4L#8=7c+`X!Jgr70F^vQ-w!C= z6rR?{_82ququFOujIVeo66XYlXWidO7UVI+jKCmkq^AA*-iRIBfzsK|hGO^@PJ}W3 z-0Swr6(6&%`xf9*e0i=vmcQQp85U-o7K2lZRUVFf^|V0GZ29SmE-@6GZNm3Z!Cbe- zO{H!42i!`Xlq3Tis)~u`DgNiUH++T&F+yLPND183mp9>WfE!(*=4dH4&MrHNC?wRI ze$Es_9IpY2&mxCaaOxb_HYL}NOOu6-D*hr#g)Z_x#c9h@!Vc&Z8J)FI^7TeC^3qO) zbkMlIs(ACRBH|+J6b*8nI{)Farf+Dm7;IEo^BWc`R7qmmz|Hq5bfJWtA#zjHe99;H z*>0c|@VrsZCGQn0C-^Bu&JI5wFM=KncTn841fE_NZuqTT&*Db03sI?6tk}ySQ2gX8 zGZLhIuXV_PxX#v+I2(Gfb$}RdS9gi83QHg~mvFMJ9NE|3Fy*-rE_QFE%Yz3?U(GLm z@x5T+7iu9Yb@;R~Vo2d3C=&T1l73B7@^8zF_;@b)ME|R%tWZ&MX8M6IQFOp!Zo60p zLu7uc48M48NzyZ{h!ew={8sc}h3k8iUPF@Edd?M-pbKHj+?NS$i>Duxx&-=PiC$ZA z(A}G}y}lgGZ+bq6c46EAX?Cyr@vwcX^0f4?Pno_MCd}fjwzrt!?}~B`6!6yosvD+l zK1;ch4mevRa71%CWlOQknEyPG3#X{Y|LTS$aV%EsBkqP>m`bdT!7rkP=IG3fX1`Ic z9(4 zqog?El_XO?NqzT}bW&{k0 zYBBwi^^8`e+#5W2#y!l*d+>Mf*Zqa&%yrRAb4((&L~^7K#@gF~+@oEPCNhbT`B}oB z<=Q4Bapk2l1id5U)s*zmR-C)2#9?#gVF#XNp1%QrEkaHoH)+1-f12E3P!VrpI^Dci zx6XMp-^%uMu&%D(LaUK(y1(;r+tQ>57b|}n;5AqF^RqxP+zgBAHE?S`XU2-0+g<9R zE6waI-o+{M_?c{cY&GdmqMQzzbOAJ%gJCK*nWeyGs`Mc(-JfU+x)!RWiT)GH`QtQZ z$z-wD1)qU~6uSEJlOHZHdX>}{bPq-9K2E#w7=^qR3<76;j4Q=Z2V5VEJP&8A(f`;d zPDotH;J$^>i}28QQevbagoBUmj-Sm1vT=D9Uwp+n@f3QG9E6PzR=6F(`xus{IHiD#W9F4&S>Y8)=+s z7cZitKGKi*7O_K&U3X|i{MbOKdmi`OD$&QQJtK2x;?&;w_H}qrhmF z&*zMi-h!ifItB7={>Ld=h!hA?l!gQoxm+ka50TIG9G*&!y|aJ^`B>8Kip}AC7%%C9 zQVfPm?IhVU(XVu&)66*K$>&xcVPz%tsrfV>UjYIwZ`S6kZ?D6g9BNJpw|kR)w-*u) zfGO;quAXL8=m*!$X$OL~`vC`5v@bAQ1?N=6xTjZ_vVk5OT_iLlWB0L}n@lt4+v;(> zKbZ5Xe?f}2rrF~V?Qw*_gl{qV2?McWPLXb#!^MJ_{Cpj-yGVG^a8^GM5a6ST>|>R6 zSfr;7SaZM@GSykMHkV|(S0Ubr_SK#X{5T+e3^j3q6FU%C9&r7GJ=2nMj2!u%MfOB~368NKA-N%CwPQ;XHA)^=Ke=W5rv+jD{lZ zDa#@bz;r_neGI!-$X>^cxOnN&uy9~qINN>r?dS*RWn|n@yYYo7-q0ia4EZ)xJjlm` zRpS1m=m~;&_>=Lo1P)Y0?9C0dacy9&Ar0O-HnI}oKmh(ns3vwCJY=M+m#tIdX9fk( zZ}DaugJMs?)6tCza1fzid-$v()-Ix5Az;$i^QeFltOzpV@L0%k$S8rj_Zw^%8y0j8 zEO1#!XcZ7xkztW#`CoH3H*nGsbVCVFmA|!W!it12<9Ipm4={ zCMkLZonL&2%e*TYQACWHnCt0rVxhK;!H;Nd`vRvu;7$n8ej^caO@xO(J!tq4$SiYU zM8Fs@I7Ej(8u^^1TjUA&1B5sS`kn!c;^|T2qbKt#&y#*%*ad_w;X`es?Ee7!x zY@gw|OgsfIGm4Tu{0(eRyfM?XyjS1mA10b92?L;mtpzI%dIu?92wa3P8J(Afe~Z1$ z3MmN+(qM0LB|k zw$frotByw8=Yv#{5{$Udl$56kDCqJ<%5%+4)q1sUwFSPe$6lST&|Pk>##fKA<34>O z$m8S3ne)xZzqr|Aa~=Xn?myf>00}LSvLDb$q}?%t=!2Ugo?^oXhsHY34Lq5*6X)S~+ku&E(IR2l01&nDfF&lBC}II`4%8gw}N zoXey3K@lMmxOvOaCz2x}8zJC)Sn#)@-J*Q0&O{9oK0fgi$k%t_5(L8c;)fXyW zX=h3^pjmOUq9yX?%p|nL^!I1W@W#1?xeEpn9TTGThYfLeORL<$wU7^?mPEx|ea(`> z)B5q@_ux?=ViuVq+%SV!T44SmqL_Fwp$J7QwWAFcFH8>}@+neJppef-(lVus!7$)w zIYxAdonIUPi|#`>RhNNrU2OpQO?UC4Qo=svwV>ZI@&r?mE5wDCP>rQABmvDt=ExCg zXItffvX-XSHzixEz=@f!E*S7n?Rszf#Aq zHi)51qxIQn_cA(Vlu_;fK6uUZsjWL`CdNJoEAINW(2hM0&+yn0;j!;)ncMfP54)k2 zw<^YZQ_V?T;842(Vz}omV$=(dh5Rm}owyg?hd7H6Jr9N*5wCFj%5j%y*#5n1BtYi z$c{yA>2nSY+Y|dE3s_(0cqgQvm^q=V*XShsK|W-Cp0HO;0w$r6e=Hgp(q+9p;W#4| z|2^FkTFhHI=qq0ovwG=s#i@{e#C7X}VkV|}CVBB^^oHA&ZBqw7UjQwek_nnu=-qpJTtwan38%86Sub$#in3uFJbsJWe$#$jAF_s zKp}t*Hh3$$pQaI4e>@$5_Mlwpo&NcR$Sy91*0y)Re9`GwiO5Ex*j(^YK>pB@Zg_>a z%E#X!QEX)%s^ufqR?5S@Cq!=iqgsn-O}VzF?V7j`d7njXp&!mlQ!baHsK-fUg`9<) zmB^%r}PIZbOG_TKsU5aJ`{CsCD|C{LYEBrC#FV2F&Tr~K{0dWn^} z0)p78CGro&+Xz@OH~<0&-5vO)y&%uL8s-4T+9jB#D$EaxA}AGngM6bjKUGi~)x|4+ zR=AqT>*kur_S&TgIUg248auKtzPI<`vEBqK|AUxve%kX@H#OBH8j=BWgUi&6pfs!s zWk#H!5M(m106v)XlN@-!yZ{%68cpmS*wEpJiSydo{ z@_-Ex^`iO?MgevMW(bCRh4beH>)H>|t1DCsybat0c&ts{FJPH6An_*1Ee2$spu;ma zyo!^XS8HIe3~g9OcK8qgy%yJdUzn`*lc?|oo=SfDRW0)M!yif&wSZPna(KJqLp$1# zO*IwNDdZ2}@das%^aq4s8z{w?0ElxOutB>}YsB`@2P~a9=D0e$t}42|C}x(;dteC- zGBS)9VrPC|+|(tJ)@iZnXZoubYHJtbs%c;M*wa0>HX#8VN&*{hA4+;#KiS~Z3m9GN z;Hok~*p<3?j!LpMaHLQfHatQq7dMy7;Q>XUKjl-&dExb8a5C zksc!jTZAUfx*{u#%guieIeK@;ng%%O%HA-;v9AtO;<0mct2<5=_1~ zY|(7+BHc(iI$bqnjaiW*j%^HB79f2SBHf+U5U_ROI&Ki)$cTm-=?#LSX~5;O$O*bp z@gS_5)#9SpLDkubEEMR%mb(>`HXp z(W`b04%<-reZD>KlTrT1g$pyBQy0eis_#5Dx|``ez?16W{%0%e?;jc;^V`i}s`mL{%}_>n?OuJb8kP8>HDZ>wO?=2$1!GJ>wKr}mfGWA+1Q_{PFa3pcu!p{Ee6Ko~D_ zE(|l&v}Bmlkb0<;aLFWnV@BMzco7MtODV!=0%As{uH)scz=0i%9rL_ro-8VO%7h|J10W)s%7Qou2X|mW7oRcTDb#uK5PHQ z376%*+t0Tzx52lACr-=`s)I?dZNazv%Zp6sp9sR`ozZojk*w}X#_u&^d_K^9fc%i= z9NUV=gLhJfdg{frQsAwC7XYRJUh`2P>=ib-8sZsd!yb&F4Sa+Lj0lWV%1Z1phhv2XM>Y0wTbL92uh|>SBbd)C0md!_BgBwDpwG|1 z-k<@-ql-=>J~G!!6F5<=zzF$HWHPy^bXvJCd`NWHL1PANQUL-2I)P4RFveT&5MyIF z6>yk9ga`}!7#V!Bo0WZkaOg7lo&SZa@y^2d8&`v!iRm9~4UT`ZHB>wuOc}-O?Oa4m zosFF=9bD`|Occ;i&d}DBQAqIpp;fW8HFcIVbyu>tHMCPumQ_J`C##q{16V;FkgkxB zy@xhE8#^a}o(0GbUy%2E21jln_9Sk^do`aQ?H5 zzY}VhIoSR`bQ;}+NxNVm(!iN#6hXUiia=l!n$Quq>Q)Uu-Z7+rkw($SuOCo;yr6~K z&Y(p4ed(I4BXs+oXZ4-HyFSUHds@%Z(l8f`Hoy%?C7YNmMX`dlNk1_6VXtGHn+L~w zRo+x`p?z|EqRPcRK^Yb`q(ci{?J)54bberP+-M9tCZBM(=v!IH+PtUTxHJ!h@O)<~ z-}jZayHx(3L92ig9LicuCdqVJ+U_PVFeN9w=}Wf?IEx3yG@H~a?7Z(cGe4!dChJF> zqja(%h)MGx&4&hsHwd_P1Eb3%A&3iMj3-*|t0%h%1L)`lK{O1Jv zjlS!HGZ31iD$^o&y%~K%*YW5Cr(Ox^|6f4E_0M(v2Rs;+U5#Acv7+MSYWkN{$k5sJ zeRKX_D6w>Mb`iEPbb8->8NT^s+fAX=>9AC{>AFA$?L+B~xd6S0`grX8_Z?vJ#`LsfnfG`&oGpz|O=B;NW7`WfXU^cXa@9!jwTvYiQ@} z@b1FclTlb1bVS@NjZKxrg%~9PE}&)S<6{&CB?yY(40tD#DgDb?cn|r0&_Ry=k&ZLM zf8~Ji&m=$w|H$HfVWllioV5XeW%@o8ws(DBi+>3LO8DPn2pfWqx4k*2YI9JLe-(|u zDDPluCurX_fH(!hznpyxmR~wtZy#YbKWen}isZ8zYRh6m#ZAt%!Sq3UX7({;q z-9@}&qGDn|AR7k|$iWP{Qn)}ZE2sqlhJ%?8;eC%xT#ZftM~_wbZ#wKC-M{ofdaNLO zcJ{x&K=$k`pchCEQUS7n{4$GzS|*UMze0lY76I|_s6@Cy*BBF!lNrdw$;QOWMGs`5 z1Oh3k|7ATqoJ`FSfB+^|1dyk{KLB<%HWoI38Q^a+CKe7ZP*Lv>!0zudP@f3|!vByl z|Cfx3g^LAregCH(%YWK}Xn3HA|E>p;v4Kv;zu7XeFo90cf6CrZ-hawCK$rM`$yh)K z>;3sh9Zami|BlPb!~#Oe|LD0m8G>$Zr*}{)TY8y-mWoly-X4UiztD@oC~0SA4`BL> z1qm960JH(ToLnM;>?~ZIKp`Pf779NlA^ab&8bufY literal 0 HcmV?d00001 diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py index ac82a71bf..78025d7a7 100644 --- a/ietf/meeting/tests_js.py +++ b/ietf/meeting/tests_js.py @@ -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 diff --git a/ietf/meeting/tests_models.py b/ietf/meeting/tests_models.py new file mode 100644 index 000000000..e28741a02 --- /dev/null +++ b/ietf/meeting/tests_models.py @@ -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) diff --git a/ietf/meeting/tests_views.py b/ietf/meeting/tests_views.py index ee61b95f4..c7ba2fd8a 100644 --- a/ietf/meeting/tests_views.py +++ b/ietf/meeting/tests_views.py @@ -1,7 +1,5 @@ # Copyright The IETF Trust 2009-2020, All Rights Reserved # -*- coding: utf-8 -*- - - import datetime import io import json @@ -12,12 +10,15 @@ import shutil import pytz from unittest import skipIf -from mock import patch +from mock import patch, PropertyMock from pyquery import PyQuery from lxml.etree import tostring from io import StringIO, BytesIO from bs4 import BeautifulSoup from urllib.parse import urlparse, urlsplit +from PIL import Image +from pathlib import Path + from django.urls import reverse as urlreverse from django.conf import settings @@ -44,7 +45,7 @@ from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, from ietf.meeting.utils import finalize, condition_slide_order from ietf.meeting.utils import add_event_info_to_session_qs from ietf.meeting.views import session_draft_list, parse_agenda_filter_params -from ietf.name.models import SessionStatusName, ImportantDateName, RoleName +from ietf.name.models import SessionStatusName, ImportantDateName, RoleName, ProceedingsMaterialTypeName from ietf.utils.decorators import skip_coverage from ietf.utils.mail import outbox, empty_outbox, get_payload_text from ietf.utils.test_utils import TestCase, login_testing_unauthorized, unicontent @@ -53,7 +54,8 @@ from ietf.utils.text import xslugify from ietf.person.factories import PersonFactory from ietf.group.factories import GroupFactory, GroupEventFactory, RoleFactory from ietf.meeting.factories import ( SessionFactory, SessionPresentationFactory, ScheduleFactory, - MeetingFactory, FloorPlanFactory, TimeSlotFactory, SlideSubmissionFactory, RoomFactory ) + MeetingFactory, FloorPlanFactory, TimeSlotFactory, SlideSubmissionFactory, RoomFactory, MeetingHostFactory, + ProceedingsMaterialFactory ) from ietf.doc.factories import DocumentFactory, WgDraftFactory from ietf.submit.tests import submission_file from ietf.utils.test_utils import assert_ical_response_is_valid @@ -68,11 +70,21 @@ else: print(" "+skip_message) -class MeetingTests(TestCase): +class BaseMeetingTestCase(TestCase): + """Base class for meeting-related tests that need to set up temporary directories + + This creates temporary directories for meeting-related uploads, then updates settings + to point to them. It also patches the Storage class to use the temporary directories. + When done, removes its files, resets the settings, and shuts off the patched Storage. + + If subclasses have their own setUp/tearDown routines, they must remember to call the + superclass methods. + """ def setUp(self): self.materials_dir = self.tempdir('materials') self.id_dir = self.tempdir('id') self.archive_dir = self.tempdir('id-archive') + self.storage_dir = self.tempdir('storage') # os.mkdir(os.path.join(self.archive_dir, "unknown_ids")) os.mkdir(os.path.join(self.archive_dir, "deleted_tombstones")) @@ -81,13 +93,28 @@ class MeetingTests(TestCase): self.saved_agenda_path = settings.AGENDA_PATH self.saved_id_dir = settings.INTERNET_DRAFT_PATH self.saved_archive_dir = settings.INTERNET_DRAFT_ARCHIVE_DIR + self.saved_meetinghost_logo_path = settings.MEETINGHOST_LOGO_PATH # settings.AGENDA_PATH = self.materials_dir settings.INTERNET_DRAFT_PATH = self.id_dir settings.INTERNET_DRAFT_ARCHIVE_DIR = self.archive_dir + settings.MEETINGHOST_LOGO_PATH = self.storage_dir + # The FileSystemStorage has already set its location before + # the settings were changed. Mock the method it uses to get the + # location and fill in our temporary location. Without this, test + # files will upload to the locations specified in settings.py. + # Note that this will affect any use of the storage class in + # meeting.models - i.e., FloorPlan.image and MeetingHost.logo + self.patcher = patch('ietf.meeting.models.NoLocationMigrationFileSystemStorage.base_location', + new_callable=PropertyMock) + mocked = self.patcher.start() + mocked.return_value = self.storage_dir def tearDown(self): + self.patcher.stop() + # + shutil.rmtree(self.storage_dir) shutil.rmtree(self.id_dir) shutil.rmtree(self.archive_dir) shutil.rmtree(self.materials_dir) @@ -95,7 +122,7 @@ class MeetingTests(TestCase): settings.AGENDA_PATH = self.saved_agenda_path settings.INTERNET_DRAFT_PATH = self.saved_id_dir settings.INTERNET_DRAFT_ARCHIVE_DIR = self.saved_archive_dir - + settings.MEETINGHOST_LOGO_PATH = self.saved_meetinghost_logo_path def write_materials_file(self, meeting, doc, content): path = os.path.join(self.materials_dir, "%s/%s/%s" % (meeting.number, doc.type_id, doc.uploaded_filename)) @@ -104,7 +131,9 @@ class MeetingTests(TestCase): if not os.path.exists(dirname): os.makedirs(dirname) - with io.open(path, "w") as f: + if isinstance(content, str): + content = content.encode() + with io.open(path, "wb") as f: f.write(content) def write_materials_files(self, meeting, session): @@ -119,8 +148,10 @@ class MeetingTests(TestCase): self.write_materials_file(meeting, session.materials.filter(type="slides").exclude(states__type__slug='slides',states__slug='deleted').first(), "This is a slideshow") - + + +class MeetingTests(BaseMeetingTestCase): def test_meeting_agenda(self): meeting = make_meeting_test_data() session = Session.objects.filter(meeting=meeting, group__acronym="mars").first() @@ -601,81 +632,6 @@ class MeetingTests(TestCase): r = self.client.get(url) self.assertEqual(r.status_code, 200) - def test_proceedings(self): - meeting = make_meeting_test_data(meeting=MeetingFactory(type_id='ietf', number='100')) - session = Session.objects.filter(meeting=meeting, group__acronym="mars").first() - GroupEventFactory(group=session.group,type='status_update') - SessionPresentationFactory(document__type_id='recording',session=session) - SessionPresentationFactory(document__type_id='recording',session=session,document__title="Audio recording for tests") - - self.write_materials_files(meeting, session) - - url = urlreverse("ietf.meeting.views.proceedings", kwargs=dict(num=meeting.number)) - r = self.client.get(url) - self.assertEqual(r.status_code, 200) - - def test_proceedings_no_agenda(self): - # Meeting number must be larger than the last special-cased proceedings (currently 96) - meeting = MeetingFactory(type_id='ietf',populate_schedule=False,date=datetime.date.today(), number='100') - url = urlreverse('ietf.meeting.views.proceedings') - r = self.client.get(url) - self.assertRedirects(r, urlreverse('ietf.meeting.views.materials')) - url = urlreverse('ietf.meeting.views.proceedings', kwargs=dict(num=meeting.number)) - r = self.client.get(url) - self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) - - def test_proceedings_acknowledgements(self): - make_meeting_test_data() - meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="96") - meeting.acknowledgements = 'test acknowledgements' - meeting.save() - url = urlreverse('ietf.meeting.views.proceedings_acknowledgements',kwargs={'num':meeting.number}) - response = self.client.get(url) - self.assertContains(response, 'test acknowledgements') - - @patch('ietf.meeting.utils.requests.get') - def test_proceedings_attendees(self, mockobj): - mockobj.return_value.text = b'[{"LastName":"Smith","FirstName":"John","Company":"ABC","Country":"US"}]' - mockobj.return_value.json = lambda: json.loads(b'[{"LastName":"Smith","FirstName":"John","Company":"ABC","Country":"US"}]') - make_meeting_test_data() - meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="96") - finalize(meeting) - url = urlreverse('ietf.meeting.views.proceedings_attendees',kwargs={'num':96}) - response = self.client.get(url) - self.assertContains(response, 'Attendee List') - q = PyQuery(response.content) - self.assertEqual(1,len(q("#id_attendees tbody tr"))) - - @patch('urllib.request.urlopen') - def test_proceedings_overview(self, mock_urlopen): - '''Test proceedings IETF Overview page. - Note: old meetings aren't supported so need to add a new meeting then test. - ''' - mock_urlopen.return_value = BytesIO(b'[{"LastName":"Smith","FirstName":"John","Company":"ABC","Country":"US"}]') - make_meeting_test_data() - meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="96") - finalize(meeting) - url = urlreverse('ietf.meeting.views.proceedings_overview',kwargs={'num':96}) - response = self.client.get(url) - self.assertContains(response, 'The Internet Engineering Task Force') - - def test_proceedings_progress_report(self): - make_meeting_test_data() - MeetingFactory(type_id='ietf', date=datetime.date(2016,4,3), number="95") - MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="96") - - url = urlreverse('ietf.meeting.views.proceedings_progress_report',kwargs={'num':96}) - response = self.client.get(url) - self.assertContains(response, 'Progress Report') - - def test_feed(self): - meeting = make_meeting_test_data() - session = Session.objects.filter(meeting=meeting, group__acronym="mars").first() - - r = self.client.get("/feed/wg-proceedings/") - self.assertContains(r, "agenda") - self.assertContains(r, session.group.acronym) - def test_important_dates(self): meeting=MeetingFactory(type_id='ietf') meeting.show_important_dates = True @@ -5317,3 +5273,915 @@ class AgendaFilterTests(TestCase): expected_filter_item='keyword31', expected_filter_keywords='bof') + +def logo_file(width=128, height=128, format='PNG', ext=None): + img = Image.new('RGB', (width, height)) # just a black image + data = BytesIO() + img.save(data, format=format) + data.seek(0) + data.name = f'logo.{ext if ext is not None else format.lower()}' + return data + + +class MeetingHostTests(BaseMeetingTestCase): + def _assertHostFieldCountGreaterEqual(self, r, min_count): + q = PyQuery(r.content) + self.assertGreaterEqual( + len(q('input[type="text"][name^="meetinghosts-"][name$="-name"]')), + min_count, + f'Must have at least {min_count} host name field(s)', + ) + self.assertGreaterEqual( + len(q('input[type="file"][name^="meetinghosts-"][name$="-logo"]')), + min_count, + f'Must have at least {min_count} host logo field(s)', + ) + + def _create_first_host(self, meeting, logo, url): + """Helper to create a first host via POST""" + return self.client.post( + url, + { + 'meetinghosts-TOTAL_FORMS': '2', + 'meetinghosts-INITIAL_FORMS': '0', + 'meetinghosts-MIN_NUM_FORMS': '0', + 'meetinghosts-MAX_NUM_FORMS': '1000', + 'meetinghosts-0-id': '', + 'meetinghosts-0-meeting': str(meeting.pk), + 'meetinghosts-0-name': 'Some Sponsor, Inc.', + 'meetinghosts-0-logo': logo, + 'meetinghosts-1-id': '', + 'meetinghosts-1-meeting': str(meeting.pk), + 'meetinghosts-1-name': '', + }, + ) + + def test_permissions(self): + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + self.client.logout() + login_testing_unauthorized(self, 'ad', url) + login_testing_unauthorized(self, 'secretary', url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + self.client.logout() + login_testing_unauthorized(self, 'ad', url, method='post') + login_testing_unauthorized(self, 'secretary', url, method='post') + # don't bother checking a real post - it'll be tested in other methods + + def _assertMatch(self, value, pattern): + self.assertIsNotNone(re.match(pattern, value)) + + def test_add(self): + """Can add a new meeting host""" + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + + # get the edit page to check that it has the necessary fields + self.client.login(username='secretary', password='secretary+password') + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self._assertHostFieldCountGreaterEqual(r, 1) + + # post our response + logos = [logo_file() for _ in range(2)] + r = self._create_first_host(meeting, logos[0], url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + logo_filename = Path(host.logo.path) + self._assertMatch(logo_filename.name, r'logo-[a-z]+.png') + self.assertCountEqual( + logo_filename.parent.iterdir(), + [logo_filename], + 'Unexpected or missing files in the output directory', + ) + + # retrieve the page again to ensure we have more fields + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self._assertHostFieldCountGreaterEqual(r, 2) # must have at least one extra + + # post our response to add an additional host + r = self.client.post( + url, + { + 'meetinghosts-TOTAL_FORMS': '3', + 'meetinghosts-INITIAL_FORMS': '1', + 'meetinghosts-MIN_NUM_FORMS': '0', + 'meetinghosts-MAX_NUM_FORMS': '1000', + 'meetinghosts-0-id': str(host.pk), + 'meetinghosts-0-meeting': str(meeting.pk), + 'meetinghosts-0-name': 'Some Sponsor, Inc.', + 'meetinghosts-1-id':'', + 'meetinghosts-1-meeting': str(meeting.pk), + 'meetinghosts-1-name': 'Another Sponsor, Ltd.', + 'meetinghosts-1-logo': logos[1], + 'meetinghosts-2-id':'', + 'meetinghosts-2-meeting': str(meeting.pk), + 'meetinghosts-2-name': '', + }, + ) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 2) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + logo_filename = Path(host.logo.path) + self._assertMatch(logo_filename.name, r'logo-[a-z]+.png') + host = meeting.meetinghosts.last() + self.assertEqual(host.name, 'Another Sponsor, Ltd.') + logo2_filename = Path(host.logo.path) + self._assertMatch(logo2_filename.name, r'logo-[a-z]+.png') + self.assertCountEqual( + logo_filename.parent.iterdir(), + [logo_filename, logo2_filename], + 'Unexpected or missing files in the output directory', + ) + + # retrieve the page again to ensure we have yet more fields + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + self._assertHostFieldCountGreaterEqual(r, 3) # must have at least one extra + + def test_edit_name(self): + """Can change name of meeting host + + The main complication is checking that the file has been + renamed to match the new host name. + """ + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + + # create via UI so we don't have to deal with creating storage paths + self.client.login(username='secretary', password='secretary+password') + logo = logo_file() + r = self._create_first_host(meeting, logo, url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + orig_logopath = Path(host.logo.path) + self._assertMatch(orig_logopath.name, r'logo-[a-z]+.png') + self.assertTrue(orig_logopath.exists()) + + # post our response to modify the name + r = self.client.post( + url, + { + 'meetinghosts-TOTAL_FORMS': '3', + 'meetinghosts-INITIAL_FORMS': '1', + 'meetinghosts-MIN_NUM_FORMS': '0', + 'meetinghosts-MAX_NUM_FORMS': '1000', + 'meetinghosts-0-id': str(host.pk), + 'meetinghosts-0-meeting': str(meeting.pk), + 'meetinghosts-0-name': 'Modified Sponsor, Inc.', + 'meetinghosts-1-id':'', + 'meetinghosts-1-meeting': str(meeting.pk), + 'meetinghosts-1-name': '', + 'meetinghosts-2-id':'', + 'meetinghosts-2-meeting': str(meeting.pk), + 'meetinghosts-2-name': '', + }, + ) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Modified Sponsor, Inc.') + second_logopath = Path(host.logo.path) + self.assertEqual(second_logopath, orig_logopath) + self.assertTrue(second_logopath.exists()) + with second_logopath.open('rb') as f: + self.assertEqual(f.read(), logo.getvalue()) + + def test_meeting_host_replace_logo(self): + """Can replace logo of a meeting host""" + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + + # create via UI so we don't have to deal with creating storage paths + self.client.login(username='secretary', password='secretary+password') + logo = logo_file() + r = self._create_first_host(meeting, logo, url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + orig_logopath = Path(host.logo.path) + self._assertMatch(orig_logopath.name, r'logo-[a-z]+.png') + self.assertTrue(orig_logopath.exists()) + + # post our response to replace the logo + new_logo = logo_file(200, 200) # different size to distinguish images + r = self.client.post( + url, + { + 'meetinghosts-TOTAL_FORMS': '3', + 'meetinghosts-INITIAL_FORMS': '1', + 'meetinghosts-MIN_NUM_FORMS': '0', + 'meetinghosts-MAX_NUM_FORMS': '1000', + 'meetinghosts-0-id': str(host.pk), + 'meetinghosts-0-meeting': str(meeting.pk), + 'meetinghosts-0-name': 'Some Sponsor, Inc.', + 'meetinghosts-0-logo': new_logo, + 'meetinghosts-1-id':'', + 'meetinghosts-1-meeting': str(meeting.pk), + 'meetinghosts-1-name': '', + 'meetinghosts-2-id':'', + 'meetinghosts-2-meeting': str(meeting.pk), + 'meetinghosts-2-name': '', + }, + ) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + second_logopath = Path(host.logo.path) + self._assertMatch(second_logopath.name, r'logo-[a-z]+.png') + self.assertTrue(second_logopath.exists()) + with second_logopath.open('rb') as f: + self.assertEqual(f.read(), new_logo.getvalue()) + + def test_change_name_and_replace_logo(self): + """Can simultaneously change name and replace logo""" + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + + # create via UI so we don't have to deal with creating storage paths + self.client.login(username='secretary', password='secretary+password') + logo = logo_file() + r = self._create_first_host(meeting, logo, url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + orig_logopath = Path(host.logo.path) + self._assertMatch(orig_logopath.name, r'logo-[a-z]+.png') + self.assertTrue(orig_logopath.exists()) + + # post our response to replace the logo + new_logo = logo_file(200, 200) # different size to distinguish images + r = self.client.post( + url, + { + 'meetinghosts-TOTAL_FORMS': '3', + 'meetinghosts-INITIAL_FORMS': '1', + 'meetinghosts-MIN_NUM_FORMS': '0', + 'meetinghosts-MAX_NUM_FORMS': '1000', + 'meetinghosts-0-id': str(host.pk), + 'meetinghosts-0-meeting': str(meeting.pk), + 'meetinghosts-0-name': 'Modified Sponsor, Ltd.', + 'meetinghosts-0-logo': new_logo, + 'meetinghosts-1-id':'', + 'meetinghosts-1-meeting': str(meeting.pk), + 'meetinghosts-1-name': '', + 'meetinghosts-2-id':'', + 'meetinghosts-2-meeting': str(meeting.pk), + 'meetinghosts-2-name': '', + }, + ) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Modified Sponsor, Ltd.') + second_logopath = Path(host.logo.path) + self._assertMatch(second_logopath.name, r'logo-[a-z]+.png') + self.assertTrue(second_logopath.exists()) + with second_logopath.open('rb') as f: + self.assertEqual(f.read(), new_logo.getvalue()) + self.assertFalse(orig_logopath.exists()) + + def test_remove(self): + """Can delete a meeting host and its logo""" + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + + # create via UI so we don't have to deal with creating storage paths + self.client.login(username='secretary', password='secretary+password') + logo = logo_file() + r = self._create_first_host(meeting, logo, url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + logopath = Path(host.logo.path) + self._assertMatch(logopath.name, r'logo-[a-z]+.png') + self.assertTrue(logopath.exists()) + + # now delete + r = self.client.post( + url, + { + 'meetinghosts-TOTAL_FORMS': '3', + 'meetinghosts-INITIAL_FORMS': '1', + 'meetinghosts-MIN_NUM_FORMS': '0', + 'meetinghosts-MAX_NUM_FORMS': '1000', + 'meetinghosts-0-id': str(host.pk), + 'meetinghosts-0-meeting': str(meeting.pk), + 'meetinghosts-0-name': 'Modified Sponsor, Ltd.', + 'meetinghosts-0-DELETE': 'on', + 'meetinghosts-1-id':'', + 'meetinghosts-1-meeting': str(meeting.pk), + 'meetinghosts-1-name': '', + 'meetinghosts-2-id':'', + 'meetinghosts-2-meeting': str(meeting.pk), + 'meetinghosts-2-name': '', + }, + ) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 0) + self.assertFalse(logopath.exists()) + + def test_remove_with_selected_logo(self): + """Can delete a meeting host after selecting a replacement file""" + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + + # create via UI so we don't have to deal with creating storage paths + self.client.login(username='secretary', password='secretary+password') + logo = logo_file() + r = self._create_first_host(meeting, logo, url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + host = meeting.meetinghosts.first() + self.assertEqual(host.name, 'Some Sponsor, Inc.') + logopath = Path(host.logo.path) + self._assertMatch(logopath.name, r'logo-[a-z]+.png') + self.assertTrue(logopath.exists()) + + # now delete + r = self.client.post( + url, + { + 'meetinghosts-TOTAL_FORMS': '3', + 'meetinghosts-INITIAL_FORMS': '1', + 'meetinghosts-MIN_NUM_FORMS': '0', + 'meetinghosts-MAX_NUM_FORMS': '1000', + 'meetinghosts-0-id': str(host.pk), + 'meetinghosts-0-meeting': str(meeting.pk), + 'meetinghosts-0-name': 'Modified Sponsor, Ltd.', + 'meetinghosts-0-DELETE': 'on', + 'meetinghosts-0-logo': logo_file(format='JPEG'), + 'meetinghosts-1-id':'', + 'meetinghosts-1-meeting': str(meeting.pk), + 'meetinghosts-1-name': '', + 'meetinghosts-2-id':'', + 'meetinghosts-2-meeting': str(meeting.pk), + 'meetinghosts-2-name': '', + }, + ) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 0) + self.assertFalse(logopath.exists()) + + def test_logo_types_checked(self): + """Only allowed image types should be accepted""" + allowed_formats = [('JPEG', 'jpg'), ('JPEG', 'jpeg'), ('PNG', 'png')] + + meeting = MeetingFactory(type_id='ietf') + url = urlreverse('ietf.meeting.views_proceedings.edit_meetinghosts', kwargs=dict(num=meeting.number)) + self.client.login(username='secretary', password='secretary+password') + + junk = BytesIO() + junk.write(b'this is not an image') + junk.seek(0) + r = self._create_first_host(meeting, junk, url) + self.assertContains(r, 'Upload a valid image', status_code=200) + self.assertEqual(meeting.meetinghosts.count(), 0) + + for fmt, ext in allowed_formats: + r = self._create_first_host( + meeting, + logo_file(format=fmt, ext=ext), + url + ) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + self.assertEqual(meeting.meetinghosts.count(), 1) + meeting.meetinghosts.all().delete() + + +# Keep these settings consistent with the assumptions in these tests +@override_settings(PROCEEDINGS_VERSION_CHANGES=[0, 97, 111]) +class ProceedingsTests(BaseMeetingTestCase): + """Tests related to meeting proceedings display + + Fills in all material types + """ + def _create_proceedings_materials(self, meeting): + """Create various types of proceedings materials for meeting""" + MeetingHostFactory.create_batch(2, meeting=meeting) # create a couple of meeting hosts/logos + ProceedingsMaterialFactory( + # default title, not removed + meeting=meeting, + type=ProceedingsMaterialTypeName.objects.get(slug='supporters') + ) + ProceedingsMaterialFactory( + # custom title, not removed + meeting=meeting, + type=ProceedingsMaterialTypeName.objects.get(slug='host_speaker_series'), + document__title='Speakers' + ) + ProceedingsMaterialFactory( + # default title, removed + meeting=meeting, + type=ProceedingsMaterialTypeName.objects.get(slug='social_event'), + document__states=[('procmaterials', 'removed')] + ) + ProceedingsMaterialFactory( + # custom title, removed + meeting=meeting, + type=ProceedingsMaterialTypeName.objects.get(slug='additional_information'), + document__title='Party', document__states=[('procmaterials', 'removed')] + ) + ProceedingsMaterialFactory( + # url + meeting=meeting, + type=ProceedingsMaterialTypeName.objects.get(slug='wiki'), + document__external_url='https://example.com/wiki' + ) + + @staticmethod + def _proceedings_file(): + """Get a file containing content suitable for a proceedings document + + Currently returns the same file every time. + """ + path = Path(settings.BASE_DIR) / 'meeting/test_procmat.pdf' + return path.open('rb') + + def _assertMeetingHostsDisplayed(self, response, meeting): + pq = PyQuery(response.content) + host_divs = pq('div.host-logo') + self.assertEqual(len(host_divs), meeting.meetinghosts.count(), 'Should have a logo for every meeting host') + self.assertEqual( + [(img.attr('title'), img.attr('src')) for img in host_divs.items('img')], + [ + (host.name, + urlreverse( + 'ietf.meeting.views_proceedings.meetinghost_logo', + kwargs=dict(num=meeting.number, host_id=host.pk), + )) + for host in meeting.meetinghosts.all() + ], + 'Correct image and name for each host should appear in the correct order' + ) + + def _assertProceedingsMaterialsDisplayed(self, response, meeting): + """Checks that all (and only) active materials are linked with correct href and title""" + expected_materials = [ + m for m in meeting.proceedings_materials.order_by('type__order') if m.active() + ] + pq = PyQuery(response.content) + links = pq('div.proceedings-material a') + self.assertEqual(len(links), len(expected_materials), 'Should have an entry for each active ProceedingsMaterial') + self.assertEqual( + [(link.eq(0).text(), link.eq(0).attr('href')) for link in links.items()], + [(str(pm), pm.get_href()) for pm in expected_materials], + 'Correct title and link for each ProceedingsMaterial should appear in the correct order' + ) + + def test_proceedings(self): + """Proceedings should be displayed correctly""" + meeting = make_meeting_test_data(meeting=MeetingFactory(type_id='ietf', number='100')) + session = Session.objects.filter(meeting=meeting, group__acronym="mars").first() + GroupEventFactory(group=session.group,type='status_update') + SessionPresentationFactory(document__type_id='recording',session=session) + SessionPresentationFactory(document__type_id='recording',session=session,document__title="Audio recording for tests") + + self.write_materials_files(meeting, session) + self._create_proceedings_materials(meeting) + + url = urlreverse("ietf.meeting.views.proceedings", kwargs=dict(num=meeting.number)) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + if len(meeting.city) > 0: + self.assertContains(r, meeting.city) + if len(meeting.venue_name) > 0: + self.assertContains(r, meeting.venue_name) + + # standard items on every proceedings + pq = PyQuery(r.content) + self.assertNotEqual( + pq('a[href="{}"]'.format( + urlreverse('ietf.meeting.views.proceedings_overview', kwargs=dict(num=meeting.number))) + ), + [], + 'Should have a link to IETF overview', + ) + self.assertNotEqual( + pq('a[href="{}"]'.format( + urlreverse('ietf.meeting.views.proceedings_attendees', kwargs=dict(num=meeting.number))) + ), + [], + 'Should have a link to attendees', + ) + self.assertNotEqual( + pq('a[href="{}"]'.format( + urlreverse('ietf.meeting.views.proceedings_progress_report', kwargs=dict(num=meeting.number))) + ), + [], + 'Should have a link to activity report', + ) + self.assertNotEqual( + pq('a[href="{}"]'.format( + urlreverse('ietf.meeting.views.important_dates', kwargs=dict(num=meeting.number))) + ), + [], + 'Should have a link to important dates', + ) + + # configurable contents + self._assertMeetingHostsDisplayed(r, meeting) + self._assertProceedingsMaterialsDisplayed(r, meeting) + + def test_proceedings_no_agenda(self): + # Meeting number must be larger than the last special-cased proceedings (currently 96) + meeting = MeetingFactory(type_id='ietf',populate_schedule=False,date=datetime.date.today(), number='100') + url = urlreverse('ietf.meeting.views.proceedings') + r = self.client.get(url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials')) + url = urlreverse('ietf.meeting.views.proceedings', kwargs=dict(num=meeting.number)) + r = self.client.get(url) + self.assertRedirects(r, urlreverse('ietf.meeting.views.materials', kwargs=dict(num=meeting.number))) + + def test_proceedings_acknowledgements(self): + make_meeting_test_data() + meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97") + meeting.acknowledgements = 'test acknowledgements' + meeting.save() + url = urlreverse('ietf.meeting.views.proceedings_acknowledgements',kwargs={'num':meeting.number}) + response = self.client.get(url) + self.assertContains(response, 'test acknowledgements') + + def test_proceedings_acknowledgements_link(self): + """Link to proceedings_acknowledgements view should not appear for 'new' meetings + + With the PROCEEDINGS_VERSION_CHANGES settings value used here, expect the proceedings_acknowledgements + view to be linked for meetings 95-110. + """ + meeting_with_acks = MeetingFactory(type_id='ietf', date=datetime.date(2020,7,25), number='108') + SessionFactory(meeting=meeting_with_acks) # make sure meeting has a scheduled session + meeting_with_acks.acknowledgements = 'these acknowledgements should appear' + meeting_with_acks.save() + url = urlreverse('ietf.meeting.views.proceedings',kwargs={'num':meeting_with_acks.number}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertEqual( + len(q('a[href="{}"]'.format( + urlreverse('ietf.meeting.views.proceedings_acknowledgements', + kwargs={'num':meeting_with_acks.number}) + ))), + 1, + ) + + meeting_without_acks = MeetingFactory(type_id='ietf', date=datetime.date(2022,7,25), number='113') + SessionFactory(meeting=meeting_without_acks) # make sure meeting has a scheduled session + meeting_without_acks.acknowledgements = 'these acknowledgements should not appear' + meeting_without_acks.save() + url = urlreverse('ietf.meeting.views.proceedings',kwargs={'num':meeting_without_acks.number}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + q = PyQuery(response.content) + self.assertEqual( + len(q('a[href="{}"]'.format( + urlreverse('ietf.meeting.views.proceedings_acknowledgements', + kwargs={'num':meeting_without_acks.number}) + ))), + 0, + ) + + @patch('ietf.meeting.utils.requests.get') + def test_proceedings_attendees(self, mockobj): + mockobj.return_value.text = b'[{"LastName":"Smith","FirstName":"John","Company":"ABC","Country":"US"}]' + mockobj.return_value.json = lambda: json.loads(b'[{"LastName":"Smith","FirstName":"John","Company":"ABC","Country":"US"}]') + make_meeting_test_data() + meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97") + finalize(meeting) + url = urlreverse('ietf.meeting.views.proceedings_attendees',kwargs={'num':97}) + response = self.client.get(url) + self.assertContains(response, 'Attendee List') + q = PyQuery(response.content) + self.assertEqual(1,len(q("#id_attendees tbody tr"))) + + @patch('urllib.request.urlopen') + def test_proceedings_overview(self, mock_urlopen): + '''Test proceedings IETF Overview page. + Note: old meetings aren't supported so need to add a new meeting then test. + ''' + mock_urlopen.return_value = BytesIO(b'[{"LastName":"Smith","FirstName":"John","Company":"ABC","Country":"US"}]') + make_meeting_test_data() + meeting = MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97") + finalize(meeting) + url = urlreverse('ietf.meeting.views.proceedings_overview',kwargs={'num':97}) + response = self.client.get(url) + self.assertContains(response, 'The Internet Engineering Task Force') + + def test_proceedings_progress_report(self): + make_meeting_test_data() + MeetingFactory(type_id='ietf', date=datetime.date(2016,4,3), number="96") + MeetingFactory(type_id='ietf', date=datetime.date(2016,7,14), number="97") + + url = urlreverse('ietf.meeting.views.proceedings_progress_report',kwargs={'num':97}) + response = self.client.get(url) + self.assertContains(response, 'Progress Report') + + def test_feed(self): + meeting = make_meeting_test_data() + session = Session.objects.filter(meeting=meeting, group__acronym="mars").first() + + r = self.client.get("/feed/wg-proceedings/") + self.assertContains(r, "agenda") + self.assertContains(r, session.group.acronym) + + def _procmat_test_meeting(self): + """Generate a meeting for proceedings material test""" + # meeting number 123 avoids various legacy cases that affect these tests + # (as of Aug 2021, anything above 96 is probably ok) + return MeetingFactory(type_id='ietf', number='123', date=datetime.date.today()) + + def _secretary_only_permission_test(self, url, include_post=True): + self.client.logout() + login_testing_unauthorized(self, 'ad', url) + login_testing_unauthorized(self, 'secretary', url) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + if include_post: + self.client.logout() + login_testing_unauthorized(self, 'ad', url, method='post') + login_testing_unauthorized(self, 'secretary', url, method='post') + # don't bother checking a real post - it'll be tested in other methods + + def test_material_management_permissions(self): + """Only the secreatariat should be able to manage proceedings materials""" + meeting = self._procmat_test_meeting() + # test all materials types in case they wind up treated differently + # (unlikely, but more likely than an unwieldy number of types are introduced) + for mat_type in ProceedingsMaterialTypeName.objects.filter(used=True): + self._secretary_only_permission_test( + urlreverse( + 'ietf.meeting.views_proceedings.material_details', + kwargs=dict(num=meeting.number), + )) + self._secretary_only_permission_test( + urlreverse( + 'ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat_type.slug), + )) + + # remaining tests need material to exist, so create + ProceedingsMaterialFactory(meeting=meeting, type=mat_type) + self._secretary_only_permission_test( + urlreverse( + 'ietf.meeting.views_proceedings.edit_material', + kwargs=dict(num=meeting.number, material_type=mat_type.slug), + )) + self._secretary_only_permission_test( + urlreverse( + 'ietf.meeting.views_proceedings.remove_material', + kwargs=dict(num=meeting.number, material_type=mat_type.slug), + )) + # it's ok to use active materials for restore test - no restore is actually issued + self._secretary_only_permission_test( + urlreverse( + 'ietf.meeting.views_proceedings.restore_material', + kwargs=dict(num=meeting.number, material_type=mat_type.slug), + )) + + def test_proceedings_material_details(self): + """Material details page should correctly show materials""" + meeting = self._procmat_test_meeting() + url = urlreverse('ietf.meeting.views_proceedings.material_details', kwargs=dict(num=meeting.number)) + self.client.login(username='secretary', password='secretary+password') + procmat_types = ProceedingsMaterialTypeName.objects.filter(used=True) + + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + pq = PyQuery(r.content) + body_rows = pq('tbody > tr') + self.assertEqual(len(body_rows), procmat_types.count()) + for row, mat_type in zip(body_rows.items(), procmat_types.all()): + cells = row.find('td') + # no materials, so rows should be empty except for label and 'Add' button + self.assertEqual(len(cells), 3) # label, blank, buttons + self.assertEqual(cells.eq(0).text(), str(mat_type), 'First column should be material type name') + self.assertEqual(cells.eq(1).text(), '', 'Second column should be empty') + add_url = urlreverse('ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat_type.slug)) + self.assertEqual(len(cells.eq(2).find(f'a[href="{add_url}"]')), 1, 'Third column should have Add link') + + self._create_proceedings_materials(meeting) + r = self.client.get(url) + self.assertEqual(r.status_code, 200) + + pq = PyQuery(r.content) + body_rows = pq('tbody > tr') + self.assertEqual(len(body_rows), procmat_types.count()) + # n.b., this loop is over materials, not the type names! + for row, mat in zip(body_rows.items(), meeting.proceedings_materials.order_by('type__order')): + add_url = urlreverse('ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat.type.slug)) + edit_url = urlreverse('ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat.type.slug)) + remove_url = urlreverse('ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat.type.slug)) + restore_url = urlreverse('ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat.type.slug)) + cells = row.find('td') + # no materials, so rows should be empty except for label and 'Add' button + self.assertEqual(cells.eq(0).text(), str(mat.type), 'First column should be material type name') + if mat.active(): + self.assertEqual(len(cells), 5) # label, title, doc, updated, buttons + self.assertEqual(cells.eq(1).text(), str(mat), 'Second column should be active material title') + self.assertEqual( + cells.eq(2).text(), + '{} ({})'.format( + str(mat.document), + 'external URL' if mat.document.external_url else 'uploaded file', + )) + mod_time = mat.document.time.astimezone(pytz.utc) + c3text = cells.eq(3).text() + self.assertIn(mod_time.strftime('%Y-%m-%d'), c3text, 'Updated date incorrect') + self.assertIn(mod_time.strftime('%H:%M:%S'), c3text, 'Updated time incorrect') + self.assertEqual(len(cells.eq(4).find(f'a[href="{add_url}"]')), 1, + 'Fourth column should have a Replace link') + self.assertEqual(len(cells.eq(4).find(f'a[href="{edit_url}"]')), 1, + 'Fourth column should have an Edit link') + self.assertEqual(len(cells.eq(4).find(f'a[href="{remove_url}"]')), 1, + 'Fourth column should have a Remove link') + else: + self.assertEqual(len(cells), 3) # label, blank, buttons + self.assertEqual(cells.eq(0).text(), str(mat.type), 'First column should be material type name') + self.assertEqual(cells.eq(1).text(), '', 'Second column should be empty') + add_url = urlreverse('ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat.type.slug)) + self.assertEqual(len(cells.eq(2).find(f'a[href="{add_url}"]')), 1, + 'Third column should have Add link') + self.assertEqual(len(cells.eq(2).find(f'a[href="{restore_url}"]')), 1, + 'Third column should have Restore link') + + def upload_proceedings_material_test(self, meeting, mat_type, post_data): + """Test the upload_proceedings view using provided POST data""" + url = urlreverse( + 'ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=mat_type.slug), + ) + self.client.login(username='secretary', password='secretary+password') + mats_before = [m.pk for m in meeting.proceedings_materials.all()] + r = self.client.post(url, post_data) + self.assertRedirects( + r, + urlreverse('ietf.meeting.views_proceedings.material_details', + kwargs=dict(num=meeting.number)), + ) + + self.assertEqual(meeting.proceedings_materials.count(), len(mats_before) + 1) + mat = meeting.proceedings_materials.exclude(pk__in=mats_before).first() + self.assertEqual(mat.type, mat_type) + self.assertEqual(str(mat), mat_type.name) + self.assertEqual(mat.document.rev, '00') + return mat + + # use a simple and predictable href format for this test + @override_settings(MEETING_DOC_HREFS={'procmaterials': '{doc.name}:{doc.rev}'}) + def test_add_proceedings_material_doc(self): + """Upload proceedings materials document""" + meeting = self._procmat_test_meeting() + for mat_type in ProceedingsMaterialTypeName.objects.filter(used=True): + mat = self.upload_proceedings_material_test( + meeting, + mat_type, + {'file': self._proceedings_file(), 'external_url': ''}, + ) + self.assertEqual(mat.get_href(), f'{mat.document.name}:00') + + def test_add_proceedings_material_url(self): + """Add a URL as proceedings material""" + meeting = self._procmat_test_meeting() + for mat_type in ProceedingsMaterialTypeName.objects.filter(used=True): + mat = self.upload_proceedings_material_test( + meeting, + mat_type, + {'use_url': 'on', 'external_url': 'https://example.com'}, + ) + self.assertEqual(mat.get_href(), 'https://example.com') + + @override_settings(MEETING_DOC_HREFS={'procmaterials': '{doc.name}:{doc.rev}'}) + def test_replace_proceedings_material(self): + """Replace uploaded document with new uploaded document""" + # Set up a meeting with a proceedings material in place + meeting = self._procmat_test_meeting() + pm_doc = ProceedingsMaterialFactory(meeting=meeting) + with self._proceedings_file() as f: + self.write_materials_file(meeting, pm_doc.document, f.read()) + pm_url = ProceedingsMaterialFactory(meeting=meeting, document__external_url='https://example.com/first') + success_url = urlreverse('ietf.meeting.views_proceedings.material_details', kwargs=dict(num=meeting.number)) + self.assertNotEqual(pm_doc.type, pm_url.type) + self.assertEqual(meeting.proceedings_materials.count(), 2) + + # Replace the uploaded document with another uploaded document + pm_doc_url = urlreverse( + 'ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=pm_doc.type.slug), + ) + self.client.login(username='secretary', password='secretary+password') + r = self.client.post(pm_doc_url, {'file': self._proceedings_file(), 'external_url': ''}) + self.assertRedirects(r, success_url) + self.assertEqual(meeting.proceedings_materials.count(), 2) + pm_doc = meeting.proceedings_materials.get(pk=pm_doc.pk) # refresh from DB + self.assertEqual(pm_doc.document.rev, '01') + self.assertEqual(pm_doc.get_href(), f'{pm_doc.document.name}:01') + + # Replace the uploaded document with a URL + r = self.client.post(pm_doc_url, {'use_url': 'on', 'external_url': 'https://example.com/second'}) + self.assertRedirects(r, success_url) + self.assertEqual(meeting.proceedings_materials.count(), 2) + pm_doc = meeting.proceedings_materials.get(pk=pm_doc.pk) # refresh from DB + self.assertEqual(pm_doc.document.rev, '02') + self.assertEqual(pm_doc.get_href(), 'https://example.com/second') + + # Now replace the URL doc with another URL + pm_url_url = urlreverse( + 'ietf.meeting.views_proceedings.upload_material', + kwargs=dict(num=meeting.number, material_type=pm_url.type.slug), + ) + r = self.client.post(pm_url_url, {'use_url': 'on', 'external_url': 'https://example.com/third'}) + self.assertRedirects(r, success_url) + self.assertEqual(meeting.proceedings_materials.count(), 2) + pm_url = meeting.proceedings_materials.get(pk=pm_url.pk) # refresh from DB + self.assertEqual(pm_url.document.rev, '01') + self.assertEqual(pm_url.get_href(), 'https://example.com/third') + + # Now replace the URL doc with an uploaded file + r = self.client.post(pm_url_url, {'file': self._proceedings_file(), 'external_url': ''}) + self.assertRedirects(r, success_url) + self.assertEqual(meeting.proceedings_materials.count(), 2) + pm_url = meeting.proceedings_materials.get(pk=pm_url.pk) # refresh from DB + self.assertEqual(pm_url.document.rev, '02') + self.assertEqual(pm_url.get_href(), f'{pm_url.document.name}:02') + + def test_remove_proceedings_material(self): + """Proceedings material can be removed""" + meeting = self._procmat_test_meeting() + pm = ProceedingsMaterialFactory(meeting=meeting) + + self.assertEqual(pm.active(), True) + + url = urlreverse( + 'ietf.meeting.views_proceedings.remove_material', + kwargs=dict(num=meeting.number, material_type=pm.type.slug), + ) + self.client.login(username='secretary', password='secretary+password') + r = self.client.post(url) + self.assertRedirects( + r, + urlreverse('ietf.meeting.views_proceedings.material_details', + kwargs=dict(num=meeting.number)), + ) + pm = meeting.proceedings_materials.get(pk=pm.pk) + self.assertEqual(pm.active(), False) + + def test_restore_proceedings_material(self): + """Proceedings material can be removed""" + meeting = self._procmat_test_meeting() + pm = ProceedingsMaterialFactory(meeting=meeting, document__states=[('procmaterials', 'removed')]) + + self.assertEqual(pm.active(), False) + + url = urlreverse( + 'ietf.meeting.views_proceedings.restore_material', + kwargs=dict(num=meeting.number, material_type=pm.type.slug), + ) + self.client.login(username='secretary', password='secretary+password') + r = self.client.post(url) + self.assertRedirects( + r, + urlreverse('ietf.meeting.views_proceedings.material_details', + kwargs=dict(num=meeting.number)), + ) + pm = meeting.proceedings_materials.get(pk=pm.pk) + self.assertEqual(pm.active(), True) + + def test_rename_proceedings_material(self): + """Proceedings material can be renamed""" + meeting = self._procmat_test_meeting() + pm = ProceedingsMaterialFactory(meeting=meeting) + self.assertEqual(str(pm), pm.type.name) + orig_rev = pm.document.rev + url = urlreverse( + 'ietf.meeting.views_proceedings.edit_material', + kwargs=dict(num=meeting.number, material_type=pm.type.slug), + ) + self.client.login(username='secretary', password='secretary+password') + r = self.client.post(url, {'title': 'This Is Not the Default Name'}) + self.assertRedirects( + r, + urlreverse('ietf.meeting.views_proceedings.material_details', + kwargs=dict(num=meeting.number)), + ) + pm = meeting.proceedings_materials.get(pk=pm.pk) + self.assertEqual(str(pm), 'This Is Not the Default Name') + self.assertEqual(pm.document.rev, orig_rev, 'Renaming should not change document revision') diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py index 5d7214ceb..1bc18cac6 100644 --- a/ietf/meeting/urls.py +++ b/ietf/meeting/urls.py @@ -4,7 +4,7 @@ from django.conf.urls import include from django.views.generic import RedirectView from django.conf import settings -from ietf.meeting import views, ajax +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[a-z_]+)/$', views_proceedings.edit_material), + url(r'^proceedings/materials/(?P[a-z_]+)/new/$', views_proceedings.upload_material), + url(r'^proceedings/materials/(?P[a-z_]+)/remove/$', + views_proceedings.remove_restore_material, + {'action': 'remove'}, + 'ietf.meeting.views_proceedings.remove_material'), + url(r'^proceedings/materials/(?P[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.(?Pics)$', views.important_dates), + url(r'^proceedings/meetinghosts/edit/', views_proceedings.edit_meetinghosts), + url(r'^proceedings/meetinghosts/(?P\d+)/logo/$', views_proceedings.meetinghost_logo), ] urlpatterns = [ diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 6f822e93f..b9c4beb5d 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -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) diff --git a/ietf/meeting/views_proceedings.py b/ietf/meeting/views_proceedings.py new file mode 100644 index 000000000..9b2bd276b --- /dev/null +++ b/ietf/meeting/views_proceedings.py @@ -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: %s-%s" % (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 {}".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 {title}' + 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()) diff --git a/ietf/name/admin.py b/ietf/name/admin.py index 81182f79a..d14778082 100644 --- a/ietf/name/admin.py +++ b/ietf/name/admin.py @@ -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) diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json index 5f2700677..2c2e3af4a 100644 --- a/ietf/name/fixtures/names.json +++ b/ietf/name/fixtures/names.json @@ -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", diff --git a/ietf/name/migrations/0028_proceedingsmaterialtypename.py b/ietf/name/migrations/0028_proceedingsmaterialtypename.py new file mode 100644 index 000000000..e35d47ee5 --- /dev/null +++ b/ietf/name/migrations/0028_proceedingsmaterialtypename.py @@ -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, + }, + ), + ] diff --git a/ietf/name/migrations/0029_populate_proceedingsmaterialtypename.py b/ietf/name/migrations/0029_populate_proceedingsmaterialtypename.py new file mode 100644 index 000000000..85672c8ad --- /dev/null +++ b/ietf/name/migrations/0029_populate_proceedingsmaterialtypename.py @@ -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), + ] diff --git a/ietf/name/migrations/0030_add_procmaterials.py b/ietf/name/migrations/0030_add_procmaterials.py new file mode 100644 index 000000000..734fc3f0a --- /dev/null +++ b/ietf/name/migrations/0030_add_procmaterials.py @@ -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) + ] diff --git a/ietf/name/models.py b/ietf/name/models.py index 3117487f4..3c07c7afc 100644 --- a/ietf/name/models.py +++ b/ietf/name/models.py @@ -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): diff --git a/ietf/name/resources.py b/ietf/name/resources.py index 40d2cc076..37d49167a 100644 --- a/ietf/name/resources.py +++ b/ietf/name/resources.py @@ -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()) diff --git a/ietf/settings.py b/ietf/settings.py index 1e058e61b..126508e6b 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -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' diff --git a/ietf/static/ietf/js/upload-material.js b/ietf/static/ietf/js/upload-material.js new file mode 100644 index 000000000..deea0c2f9 --- /dev/null +++ b/ietf/static/ietf/js/upload-material.js @@ -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(); + } +)(); \ No newline at end of file diff --git a/ietf/templates/doc/document_material.html b/ietf/templates/doc/document_material.html index 83443879f..2c5fca3f3 100644 --- a/ietf/templates/doc/document_material.html +++ b/ietf/templates/doc/document_material.html @@ -35,9 +35,10 @@ {% if doc.meeting_related %}Meeting{% endif %} {{ doc.type.name }} - {{ doc.group.name }} - ({{ doc.group.acronym }}) {{ doc.group.type.name }} - + {% if doc.group %} + {{ doc.group.name }} + ({{ doc.group.acronym }}) {{ doc.group.type.name }} + {% endif %} {% if snapshot %} Snapshot {% endif %} @@ -88,25 +89,37 @@ {% endif %} - {% if presentations or can_manage_material %} + {% if doc.type_id == 'procmaterials' and doc.external_url|length > 0 %} - On agenda - - {% if not snapshot and can_manage_material %} - {% doc_edit_button "ietf.doc.views_doc.all_presentations" name=doc.name %} - {% endif %} - - + External URL + - {% 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 %} + {{ doc.external_url }} {% endif %} + {% if doc.type_id != 'procmaterials' %} + {% if presentations or can_manage_material %} + + On agenda + + {% if not snapshot and can_manage_material %} + {% doc_edit_button "ietf.doc.views_doc.all_presentations" name=doc.name %} + {% endif %} + + + + {% 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 %} + + + {% endif %} + {% endif %} + Last updated @@ -116,7 +129,7 @@

- {% if not snapshot and can_manage_material and in_group_materials_types %} + {% if can_upload %} Upload New Revision {% endif %}

diff --git a/ietf/templates/doc/material/edit_material.html b/ietf/templates/doc/material/edit_material.html index 0370f225f..28931306d 100644 --- a/ietf/templates/doc/material/edit_material.html +++ b/ietf/templates/doc/material/edit_material.html @@ -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 %} -

{% if action == "new" or action == "revise" %}Upload{% else %}Edit{% endif %} {{ document_type.name }}
{{ group.name }} ({{ group.acronym }})

+

+ {% if action == "new" or action == "revise" %} + Upload + {% else %} + Edit + {% endif %} + {{ doc.type.name }} +
+ {% 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 %} +

{% if action == "new" %}

- Below you can upload a document for the group {{ group.name }} - ({{ group.acronym }}). - 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 }} + ({{ group.acronym }}). + 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 %}

Upload

{% elif action == "revise" %}

- Below you can upload a new revision of {{ doc_name }} for the group {{ group.name }} - ({{ group.acronym }}). + {% if group is not None %} + Below you can upload a new revision of {{ doc_name }} for the group {{ group.name }} + ({{ group.acronym }}). + {% elif doc.meeting_related %} + Below you can upload a new revision of {{ doc_name }} for the {{ doc.get_related_meeting }} meeting. + {% endif %}

Upload New Revision

diff --git a/ietf/templates/meeting/materials.html b/ietf/templates/meeting/materials.html index 42a156d47..18e62f0e0 100644 --- a/ietf/templates/meeting/materials.html +++ b/ietf/templates/meeting/materials.html @@ -29,6 +29,7 @@

{% if user|has_role:"Secretariat" %} + Edit meeting hosts Secretariat proceedings functions {% if meeting.end_date.today > meeting.end_date %} Send request for minutes @@ -37,6 +38,8 @@ Meeting requests/conflicts

+ {% include 'meeting/proceedings/materials_table.html' with meeting=meeting proceedings_materials=proceedings_materials user=user only %} + {% with "True" as show_agenda %} {% if plenaries %} diff --git a/ietf/templates/meeting/proceedings.html b/ietf/templates/meeting/proceedings.html index 013fe923e..f9c29f83d 100644 --- a/ietf/templates/meeting/proceedings.html +++ b/ietf/templates/meeting/proceedings.html @@ -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 %} {% endblock %} @@ -17,26 +56,19 @@
-

IETF {{ meeting.number }} {% if not meeting.proceedings_final %}Draft{% endif %} Proceedings - {% if user|has_role:"Secretariat" and not meeting.proceedings_final %} - Finalize Proceedings - {% endif %} -

+ {% if user|has_role:"Secretariat" and not meeting.proceedings_final %} + + Finalize Proceedings + + {% 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 %} -

Introduction

- - {% 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 %} diff --git a/ietf/templates/meeting/proceedings/edit_material.html b/ietf/templates/meeting/proceedings/edit_material.html new file mode 100644 index 000000000..26b02284f --- /dev/null +++ b/ietf/templates/meeting/proceedings/edit_material.html @@ -0,0 +1,13 @@ +{% extends "meeting/proceedings/edit_material_base.html" %} +{# Copyright The IETF Trust 2021, All Rights Reserved #} +{% load tz %} + +{% block intro %} +

+ {% if material.active %} + This item will be listed on the proceedings as "{{ material }}". To change this, set the title below.
+ {% else %} + This item currently will not appear on the proceedings.
+ {% endif %} +

+{% endblock %} diff --git a/ietf/templates/meeting/proceedings/edit_material_base.html b/ietf/templates/meeting/proceedings/edit_material_base.html new file mode 100644 index 000000000..7f0cbf88b --- /dev/null +++ b/ietf/templates/meeting/proceedings/edit_material_base.html @@ -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 %} + +

+ {% block content_header %} + Edit Proceedings Material
+ + {{ meeting }} {{ material_type.name }} + + {% endblock %} +

+ + {% if meeting.proceedings_final %} +
+ The proceedings for this meeting have already been finalized. +
+ {% endif %} + + {% if material is not None %} +

+ {% 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 %} +

+ {% endif %} + {% block intro %}{% endblock %} + + {% block edit_form %} +
+ {% 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 %} + Back + + {% endbuttons %} + {% endblock %} +
+ {% endblock %} +{% endblock content %} diff --git a/ietf/templates/meeting/proceedings/edit_meetinghosts.html b/ietf/templates/meeting/proceedings/edit_meetinghosts.html new file mode 100644 index 000000000..38fb78748 --- /dev/null +++ b/ietf/templates/meeting/proceedings/edit_meetinghosts.html @@ -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 %} + +

+ Edit Meeting Hosts
+ + {{ meeting }} + +

+ + {% if meeting.proceedings_final %} +
+ The proceedings for this meeting have already been finalized. +
+ {% endif %} + +

+ 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. +

+ +
+ {% csrf_token %} + {{ formset.management_form }} + {{ formset.non_form_errors }} + + {% for form in formset %} + + + + + {% endfor %} +
+ {% if form.instance.pk and form.instance.logo %} + + {% endif %} + + {% bootstrap_form form %} +
+ {% buttons %} + Back + + {% endbuttons %} +
+{% endblock content %} diff --git a/ietf/templates/meeting/proceedings/introduction.html b/ietf/templates/meeting/proceedings/introduction.html new file mode 100644 index 000000000..ca9d2131b --- /dev/null +++ b/ietf/templates/meeting/proceedings/introduction.html @@ -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 %} +
+
+ + + + + + + + + + + {% if meeting.proceedings_format_version < 3 %} + + {% endif %} +
+ + {% with materials=meeting.get_proceedings_materials %} + {% if materials|length > 0 %} +
+ {% for mat in materials %} +
+ {{ mat }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} +
diff --git a/ietf/templates/meeting/proceedings/material_details.html b/ietf/templates/meeting/proceedings/material_details.html new file mode 100644 index 000000000..f81ad39d3 --- /dev/null +++ b/ietf/templates/meeting/proceedings/material_details.html @@ -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 %} + + + +

{{ meeting }} : Proceedings Materials

+ + {% if meeting.proceedings_final %} +
+ The proceedings have been finalized for this meeting. +
+ {% endif %} + + +
+
Proceedings Materials
+
+ + + + + + + + + + + + {% for mat_type, mat in proceedings_materials %} + + + {% if mat and mat.active %} + {% url 'ietf.doc.views_doc.document_main' name=mat.document.name as url %} + + + + {% else %} + + {% endif %} + + {% if user|has_role:"Secretariat" %} + + {% endif %} + + {% endfor %} + +
TypeTitleDocumentUpdated
{{ mat_type }} + {{ mat }} + + {{ mat.document }} + {% if mat.is_url %} (external URL) {% else %} (uploaded file) {% endif %} + + {% with timestamp=mat.document.time|utc %} + {{ timestamp|date:"Y-m-d" }}
{{ timestamp|date:"H:i:s" }} UTC + {% endwith %} +
+ {% 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 %} + Add Material + {% elif mat.active %} + Replace Material + Change title + Remove + {% else %} + Add Material + Restore + {% endif %} +
+
+
+ + Back +{% endblock %} + +{% comment %}{% block js %} + {% if can_manage_materials %} + + + + + + + {% endif %} +{% endblock %} +{% endcomment %} \ No newline at end of file diff --git a/ietf/templates/meeting/proceedings/materials_table.html b/ietf/templates/meeting/proceedings/materials_table.html new file mode 100644 index 000000000..3dcdaee5e --- /dev/null +++ b/ietf/templates/meeting/proceedings/materials_table.html @@ -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

+ + + + {% if user|has_role:'Secretariat' %} + + + + + {% else %} + + + + {% endif %} + + + + + {% for type_name, material in proceedings_materials %} + {# secretariat sees empty slots, others do not #} + {% if user|has_role:'Secretariat' or meeting and material.active %} + + + {% if material and material.active %} + + + {% endif %} + {% if user|has_role:'Secretariat' %} + {% if forloop.first %} + + {% endif %} + {% endif %} + + {% endif %} + {% endfor %} + +
TypeTitleUpdated TypeTitleUpdated
{{ type_name }} + + {{ material.document.title }} + + + {% with timestamp=material.document.time|utc %} + {{ timestamp|date:"Y-m-d" }}
{{ timestamp|date:"H:i:s" }} UTC + {% endwith %} + {% else %} +
+ + Edit materials + +
+{% endif %} \ No newline at end of file diff --git a/ietf/templates/meeting/proceedings/remove_restore_material.html b/ietf/templates/meeting/proceedings/remove_restore_material.html new file mode 100644 index 000000000..291960a5d --- /dev/null +++ b/ietf/templates/meeting/proceedings/remove_restore_material.html @@ -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 %} +

{{ action|capfirst }} material

+ +

{{ action|capfirst }} {{material}} for the {{ material.meeting }} proceedings?

+ +
+ {% csrf_token %} + {% buttons %} + + Cancel + {% endbuttons %} +
+ +{% endblock %} diff --git a/ietf/templates/meeting/proceedings/title.html b/ietf/templates/meeting/proceedings/title.html new file mode 100644 index 000000000..5e6fe1456 --- /dev/null +++ b/ietf/templates/meeting/proceedings/title.html @@ -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 %} + +
+

+ IETF {{ meeting.number }} {% if not meeting.proceedings_final %}Draft{% endif %} + Proceedings +

+ +
+ {{ meeting.date|date:"d F Y" }} - {{ meeting.end_date|date:"d F Y" }} +
+ {% if attendance is not None %} +
+ {% 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 %} +
+ {% endif %} +
+ +
+
+
+ Location: {{ meeting.city|default:"TBD" }} +
+ {% if meeting.venue_name %} +
+ Venue: {{ meeting.venue_name }} +
+ {% endif %} +
+ {% with hosts=meeting.meetinghosts.all %} + {% if hosts.exists %} +
+
+ Hosted by: + {% for host in hosts %} + {% endfor %} +
+
+ {% endif %} + {% endwith %} +
diff --git a/ietf/templates/meeting/proceedings/upload_material.html b/ietf/templates/meeting/proceedings/upload_material.html new file mode 100644 index 000000000..c40a2e975 --- /dev/null +++ b/ietf/templates/meeting/proceedings/upload_material.html @@ -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
+ + {{ meeting }} {{ material_type.name }} + +{% endblock %} + +{% block intro %} +

+ {% 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 %} +

+{% endblock %} + +{% block edit_form %} +
+ {% csrf_token %} + {% bootstrap_form form %} + + {% block form_buttons %}{{ block.super }}{% endblock %} +
+{% endblock %} + +{% block js %} + {{ form.media.js }} +{% endblock %} diff --git a/ietf/utils/fields.py b/ietf/utils/fields.py index af2e9dcd2..647ea0722 100644 --- a/ietf/utils/fields.py +++ b/ietf/utils/fields.py @@ -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 + + diff --git a/ietf/utils/templatetags/misc_filters.py b/ietf/utils/templatetags/misc_filters.py index e46dd1196..a6cbd3cbc 100644 --- a/ietf/utils/templatetags/misc_filters.py +++ b/ietf/utils/templatetags/misc_filters.py @@ -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] diff --git a/ietf/utils/validators.py b/ietf/utils/validators.py index fdda6e5bf..5adb29bc3 100644 --- a/ietf/utils/validators.py +++ b/ietf/utils/validators.py @@ -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