diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py index d260793f1..e8a50dd38 100644 --- a/ietf/meeting/forms.py +++ b/ietf/meeting/forms.py @@ -6,6 +6,9 @@ import io import os import datetime import json +import re + +from pathlib import Path from django import forms from django.conf import settings @@ -324,14 +327,19 @@ class InterimCancelForm(forms.Form): self.fields['date'].widget.attrs['disabled'] = True class FileUploadForm(forms.Form): + """Base class for FileUploadForms + + Abstract base class - subclasses must fill in the doc_type value with + the type of document they handle. + """ file = forms.FileField(label='File to upload') + doc_type = '' # subclasses must set this + def __init__(self, *args, **kwargs): - doc_type = kwargs.pop('doc_type') - assert doc_type in settings.MEETING_VALID_UPLOAD_EXTENSIONS - self.doc_type = doc_type - self.extensions = settings.MEETING_VALID_UPLOAD_EXTENSIONS[doc_type] - self.mime_types = settings.MEETING_VALID_UPLOAD_MIME_TYPES[doc_type] + assert self.doc_type in settings.MEETING_VALID_UPLOAD_EXTENSIONS + self.extensions = settings.MEETING_VALID_UPLOAD_EXTENSIONS[self.doc_type] + self.mime_types = settings.MEETING_VALID_UPLOAD_MIME_TYPES[self.doc_type] super(FileUploadForm, self).__init__(*args, **kwargs) label = '%s file to upload. ' % (self.doc_type.capitalize(), ) if self.doc_type == "slides": @@ -344,6 +352,15 @@ class FileUploadForm(forms.Form): file = self.cleaned_data['file'] validate_file_size(file) ext = validate_file_extension(file, self.extensions) + + # override the Content-Type if needed + if file.content_type in 'application/octet-stream': + content_type_map = settings.MEETING_APPLICATION_OCTET_STREAM_OVERRIDES + filename = Path(file.name) + if filename.suffix in content_type_map: + file.content_type = content_type_map[filename.suffix] + self.cleaned_data['file'] = file + mime_type, encoding = validate_mime_type(file, self.mime_types) if not hasattr(self, 'file_encoding'): self.file_encoding = {} @@ -351,15 +368,72 @@ class FileUploadForm(forms.Form): if self.mime_types: if not file.content_type in settings.MEETING_VALID_UPLOAD_MIME_FOR_OBSERVED_MIME[mime_type]: raise ValidationError('Upload Content-Type (%s) is different from the observed mime-type (%s)' % (file.content_type, mime_type)) - if mime_type in settings.MEETING_VALID_MIME_TYPE_EXTENSIONS: - if not ext in settings.MEETING_VALID_MIME_TYPE_EXTENSIONS[mime_type]: + # We just validated that file.content_type is safe to accept despite being identified + # as a different MIME type by the validator. Check extension based on file.content_type + # because that better reflects the intention of the upload client. + if file.content_type in settings.MEETING_VALID_MIME_TYPE_EXTENSIONS: + if not ext in settings.MEETING_VALID_MIME_TYPE_EXTENSIONS[file.content_type]: raise ValidationError('Upload Content-Type (%s) does not match the extension (%s)' % (file.content_type, ext)) - if mime_type in ['text/html', ] or ext in settings.MEETING_VALID_MIME_TYPE_EXTENSIONS['text/html']: + if (file.content_type in ['text/html', ] + or ext in settings.MEETING_VALID_MIME_TYPE_EXTENSIONS.get('text/html', [])): # We'll do html sanitization later, but for frames, we fail here, # as the sanitized version will most likely be useless. validate_no_html_frame(file) return file + +class UploadBlueSheetForm(FileUploadForm): + doc_type = 'bluesheets' + + +class ApplyToAllFileUploadForm(FileUploadForm): + """FileUploadField that adds an apply_to_all checkbox + + Checkbox can be disabled by passing show_apply_to_all_checkbox=False to the constructor. + This entirely removes the field from the form. + """ + # Note: subclasses must set doc_type for FileUploadForm + apply_to_all = forms.BooleanField(label='Apply to all group sessions at this meeting',initial=True,required=False) + + def __init__(self, show_apply_to_all_checkbox, *args, **kwargs): + super().__init__(*args, **kwargs) + if not show_apply_to_all_checkbox: + self.fields.pop('apply_to_all') + else: + self.order_fields( + sorted( + self.fields.keys(), + key=lambda f: 'zzzzzz' if f == 'apply_to_all' else f + ) + ) + +class UploadMinutesForm(ApplyToAllFileUploadForm): + doc_type = 'minutes' + + +class UploadAgendaForm(ApplyToAllFileUploadForm): + doc_type = 'agenda' + + +class UploadSlidesForm(ApplyToAllFileUploadForm): + doc_type = 'slides' + title = forms.CharField(max_length=255) + + def __init__(self, session, *args, **kwargs): + super().__init__(*args, **kwargs) + self.session = session + + def clean_title(self): + title = self.cleaned_data['title'] + # The current tables only handles Unicode BMP: + if ord(max(title)) > 0xffff: + raise forms.ValidationError("The title contains characters outside the Unicode BMP, which is not currently supported") + if self.session.meeting.type_id=='interim': + if re.search(r'-\d{2}$', title): + raise forms.ValidationError("Interim slides currently may not have a title that ends with something that looks like a revision number (-nn)") + return title + + class RequestMinutesForm(forms.Form): to = MultiEmailField() cc = MultiEmailField(required=False) diff --git a/ietf/meeting/tests_forms.py b/ietf/meeting/tests_forms.py index 416498235..871ad1ea3 100644 --- a/ietf/meeting/tests_forms.py +++ b/ietf/meeting/tests_forms.py @@ -1,476 +1,104 @@ # Copyright The IETF Trust 2021, All Rights Reserved # -*- coding: utf-8 -*- +"""Tests of forms in the Meeting application""" +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings -import json - -from datetime import date, timedelta -from unittest.mock import patch - -from django import forms - -import debug # pyflakes: ignore -from ietf.group.factories import GroupFactory - -from ietf.meeting.factories import MeetingFactory, TimeSlotFactory, RoomFactory, SessionFactory -from ietf.meeting.forms import (CsvModelPkInput, CustomDurationField, SwapTimeslotsForm, duration_string, - TimeSlotDurationField, TimeSlotEditForm, TimeSlotCreateForm, DurationChoiceField, - SessionDetailsForm, sessiondetailsformset_factory, SessionEditForm) -from ietf.name.models import SessionPurposeName +from ietf.meeting.forms import FileUploadForm, ApplyToAllFileUploadForm from ietf.utils.test_utils import TestCase -class CsvModelPkInputTests(TestCase): - widget = CsvModelPkInput() +@override_settings( + MEETING_APPLICATION_OCTET_STREAM_OVERRIDES={'.md': 'text/markdown'}, # test relies on .txt not mapping + MEETING_VALID_UPLOAD_EXTENSIONS={'minutes': ['.txt', '.md']}, # test relies on .exe being absent + MEETING_VALID_UPLOAD_MIME_TYPES={'minutes': ['text/plain', 'text/markdown']}, + MEETING_VALID_MIME_TYPE_EXTENSIONS={'text/plain': ['.txt'], 'text/markdown': ['.md']}, + MEETING_VALID_UPLOAD_MIME_FOR_OBSERVED_MIME={'text/plain': ['text/plain', 'text/markdown']}, +) +class FileUploadFormTests(TestCase): + class TestClass(FileUploadForm): + doc_type = 'minutes' - def test_render_none(self): - result = self.widget.render('csv_model', value=None) - self.assertHTMLEqual(result, '') - - def test_render_value(self): - result = self.widget.render('csv_model', value=[1, 2, 3]) - self.assertHTMLEqual(result, '') - - def test_value_from_datadict(self): - result = self.widget.value_from_datadict({'csv_model': '11,23,47'}, {}, 'csv_model') - self.assertEqual(result, ['11', '23', '47']) - - -class SwapTimeslotsFormTests(TestCase): - def setUp(self): - super().setUp() - self.meeting = MeetingFactory(type_id='ietf', populate_schedule=False) - self.timeslots = TimeSlotFactory.create_batch(2, meeting=self.meeting) - self.other_meeting_timeslot = TimeSlotFactory() - - def test_valid(self): - form = SwapTimeslotsForm( - meeting=self.meeting, - data={ - 'origin_timeslot': str(self.timeslots[0].pk), - 'target_timeslot': str(self.timeslots[1].pk), - 'rooms': ','.join(str(rm.pk) for rm in self.meeting.room_set.all()), - } - ) - self.assertTrue(form.is_valid()) - - def test_invalid(self): - # the magic numbers are (very likely) non-existent pks - form = SwapTimeslotsForm( - meeting=self.meeting, - data={ - 'origin_timeslot': '25', - 'target_timeslot': str(self.timeslots[1].pk), - 'rooms': ','.join(str(rm.pk) for rm in self.meeting.room_set.all()), - } - ) - self.assertFalse(form.is_valid()) - form = SwapTimeslotsForm( - meeting=self.meeting, - data={ - 'origin_timeslot': str(self.timeslots[0].pk), - 'target_timeslot': str(self.other_meeting_timeslot.pk), - 'rooms': ','.join(str(rm.pk) for rm in self.meeting.room_set.all()), - } - ) - self.assertFalse(form.is_valid()) - form = SwapTimeslotsForm( - meeting=self.meeting, - data={ - 'origin_timeslot': str(self.timeslots[0].pk), - 'target_timeslot': str(self.timeslots[1].pk), - 'rooms': '1034', - } - ) - self.assertFalse(form.is_valid()) - - -class CustomDurationFieldTests(TestCase): - def test_duration_string(self): - self.assertEqual(duration_string(timedelta(hours=3, minutes=17)), '03:17') - self.assertEqual(duration_string(timedelta(hours=3, minutes=17, seconds=43)), '03:17') - self.assertEqual(duration_string(timedelta(days=1, hours=3, minutes=17, seconds=43)), '1 03:17') - self.assertEqual(duration_string(timedelta(hours=3, minutes=17, seconds=43, microseconds=37438)), '03:17') - - def _render_field(self, field): - """Helper to render a form containing a field""" - - class Form(forms.Form): - f = field - - return str(Form()['f']) - - @patch('ietf.meeting.forms.duration_string', return_value='12:34') - def test_render(self, mock_duration_string): - self.assertHTMLEqual( - self._render_field(CustomDurationField()), - '' - ) - self.assertHTMLEqual( - self._render_field(CustomDurationField(initial=timedelta(hours=1))), - '', - 'Rendered value should come from duration_string when initial value is a timedelta' - ) - self.assertHTMLEqual( - self._render_field(CustomDurationField(initial="01:02")), - '', - 'Rendered value should come from initial when it is not a timedelta' + def test_accepts_valid_data(self): + test_file = SimpleUploadedFile( + name='file.txt', + content=b'plain text', + content_type='text/plain', ) + form = FileUploadFormTests.TestClass(files={'file': test_file}) + self.assertTrue(form.is_valid(), 'Test data are valid input') + cleaned_file = form.cleaned_data['file'] + self.assertEqual(cleaned_file.name, 'file.txt', 'Uploaded filename should not be changed') + with cleaned_file.open('rb') as f: + self.assertEqual(f.read(), b'plain text', 'Uploaded file contents should not be changed') + self.assertEqual(cleaned_file.content_type, 'text/plain', 'Content-Type should be overridden') -class TimeSlotDurationFieldTests(TestCase): - def test_validation(self): - field = TimeSlotDurationField() - with self.assertRaises(forms.ValidationError): - field.clean('-01:00') - with self.assertRaises(forms.ValidationError): - field.clean('12:01') - self.assertEqual(field.clean('00:00'), timedelta(seconds=0)) - self.assertEqual(field.clean('01:00'), timedelta(hours=1)) - self.assertEqual(field.clean('12:00'), timedelta(hours=12)) - - -class TimeSlotEditFormTests(TestCase): - def test_location_options(self): - meeting = MeetingFactory(type_id='ietf', populate_schedule=False) - rooms = [ - RoomFactory(meeting=meeting, capacity=3), - RoomFactory(meeting=meeting, capacity=123), - ] - ts = TimeSlotFactory(meeting=meeting) - rendered = str(TimeSlotEditForm(instance=ts)['location']) - # noinspection PyTypeChecker - self.assertInHTML( - f'', - rendered, - ) - for room in rooms: - # noinspection PyTypeChecker - self.assertInHTML( - f'', - rendered, - ) - - -class TimeSlotCreateFormTests(TestCase): - def setUp(self): - super().setUp() - self.meeting = MeetingFactory(type_id='ietf', date=date(2021, 11, 16), days=3, populate_schedule=False) - - def test_other_date(self): - room = RoomFactory(meeting=self.meeting) - - # no other_date, no day selected - form = TimeSlotCreateForm( - self.meeting, - data={ - 'name': 'time slot', - 'type': 'regular', - 'time': '12:00', - 'duration': '01:00', - 'locations': [str(room.pk)], - }) - self.assertFalse(form.is_valid()) - - # no other_date, day is selected - form = TimeSlotCreateForm( - self.meeting, - data={ - 'name': 'time slot', - 'type': 'regular', - 'days': ['738111'], # date(2021,11,17).toordinal() - 'time': '12:00', - 'duration': '01:00', - 'locations': [str(room.pk)], - }) - self.assertTrue(form.is_valid()) - self.assertNotIn('other_date', form.cleaned_data) - self.assertEqual(form.cleaned_data['days'], [date(2021, 11, 17)]) - - # other_date given, no day is selected - form = TimeSlotCreateForm( - self.meeting, - data={ - 'name': 'time slot', - 'type': 'regular', - 'time': '12:00', - 'duration': '01:00', - 'locations': [str(room.pk)], - 'other_date': '2021-11-15', - }) - self.assertTrue(form.is_valid()) - self.assertNotIn('other_date', form.cleaned_data) - self.assertEqual(form.cleaned_data['days'], [date(2021, 11, 15)]) - - # day is selected and other_date is given - form = TimeSlotCreateForm( - self.meeting, - data={ - 'name': 'time slot', - 'type': 'regular', - 'days': ['738111'], # date(2021,11,17).toordinal() - 'time': '12:00', - 'duration': '01:00', - 'locations': [str(room.pk)], - 'other_date': '2021-11-15', - }) - self.assertTrue(form.is_valid()) - self.assertNotIn('other_date', form.cleaned_data) - self.assertCountEqual(form.cleaned_data['days'], [date(2021, 11, 17), date(2021, 11, 15)]) - - # invalid other_date, no day selected - form = TimeSlotCreateForm( - self.meeting, - data={ - 'name': 'time slot', - 'type': 'regular', - 'time': '12:00', - 'duration': '01:00', - 'locations': [str(room.pk)], - 'other_date': 'invalid', - }) - self.assertFalse(form.is_valid()) - - # invalid other_date, day selected - form = TimeSlotCreateForm( - self.meeting, - data={ - 'name': 'time slot', - 'type': 'regular', - 'days': ['738111'], # date(2021,11,17).toordinal() - 'time': '12:00', - 'duration': '01:00', - 'locations': [str(room.pk)], - 'other_date': 'invalid', - }) - self.assertFalse(form.is_valid()) - - def test_meeting_days(self): - form = TimeSlotCreateForm(self.meeting) - self.assertEqual( - form.fields['days'].choices, - [ - ('738110', 'Tuesday (2021-11-16)'), - ('738111', 'Wednesday (2021-11-17)'), - ('738112', 'Thursday (2021-11-18)'), - ], + def test_overrides_content_type_application_octet_stream(self): + test_file = SimpleUploadedFile( + name='file.md', + content=b'plain text', + content_type='application/octet-stream', ) - def test_locations(self): - rooms = RoomFactory.create_batch(5, meeting=self.meeting) - form = TimeSlotCreateForm(self.meeting) - self.assertCountEqual(form.fields['locations'].queryset.all(), rooms) + form = FileUploadFormTests.TestClass(files={'file': test_file}) + self.assertTrue(form.is_valid(), 'Test data are valid input') + cleaned_file = form.cleaned_data['file'] + # Test that the test_file is what actually came out of the cleaning process. + # This is not technically required here, but the other tests check that test_file's + # content_type has not been changed. If cleaning does not modify the content_type + # when it succeeds, then those other tests are not actually testing anything. + self.assertEqual(cleaned_file, test_file, 'Cleaning should return the file object that was passed in') + self.assertEqual(cleaned_file.name, 'file.md', 'Uploaded filename should not be changed') + with cleaned_file.open('rb') as f: + self.assertEqual(f.read(), b'plain text', 'Uploaded file contents should not be changed') + self.assertEqual(cleaned_file.content_type, 'text/markdown', 'Content-Type should be overridden') - -class DurationChoiceFieldTests(TestCase): - def test_choices_default(self): - f = DurationChoiceField() - self.assertEqual(f.choices, [('', '--Please select'), ('3600', '1 hour'), ('7200', '2 hours')]) - - def test_choices(self): - f = DurationChoiceField([60, 1800, 3600, 5400, 7260, 7261]) - self.assertEqual( - f.choices, - [ - ('', '--Please select'), - ('60', '1 minute'), - ('1800', '30 minutes'), - ('3600', '1 hour'), - ('5400', '1 hour 30 minutes'), - ('7260', '2 hours 1 minute'), - ('7261', '2 hours 1 minute'), - ] + def test_overrides_only_application_octet_stream(self): + test_file = SimpleUploadedFile( + name='file.md', + content=b'plain text', + content_type='application/json' ) - def test_bound_value(self): - class Form(forms.Form): - f = DurationChoiceField() - form = Form(data={'f': '3600'}) - self.assertTrue(form.is_valid()) - self.assertEqual(form.cleaned_data['f'], timedelta(hours=1)) - form = Form(data={'f': '7200'}) - self.assertTrue(form.is_valid()) - self.assertEqual(form.cleaned_data['f'], timedelta(hours=2)) - self.assertFalse(Form(data={'f': '3601'}).is_valid()) - self.assertFalse(Form(data={'f': ''}).is_valid()) - self.assertFalse(Form(data={'f': 'bob'}).is_valid()) + form = FileUploadFormTests.TestClass(files={'file': test_file}) + self.assertFalse(form.is_valid(), 'Test data are invalid input') + self.assertEqual(test_file.name, 'file.md', 'Uploaded filename should not be changed') + self.assertEqual(test_file.content_type, 'application/json', 'Uploaded Content-Type should not be changed') - -class SessionDetailsFormTests(TestCase): - def setUp(self): - super().setUp() - self.meeting = MeetingFactory(type_id='ietf', populate_schedule=False) - self.group = GroupFactory() - - def test_initial_purpose(self): - """First session purpose for group should be default""" - # change the session_purposes GroupFeature to check that it's being used - self.group.features.session_purposes = ['coding', 'admin', 'closed_meeting'] - self.group.features.save() - self.assertEqual(SessionDetailsForm(group=self.group).initial['purpose'], 'coding') - self.group.features.session_purposes = ['admin', 'coding', 'closed_meeting'] - self.group.features.save() - self.assertEqual(SessionDetailsForm(group=self.group).initial['purpose'], 'admin') - - def test_session_purposes(self): - # change the session_purposes GroupFeature to check that it's being used - self.group.features.session_purposes = ['coding', 'admin', 'closed_meeting'] - self.group.features.save() - self.assertCountEqual( - SessionDetailsForm(group=self.group).fields['purpose'].queryset.values_list('slug', flat=True), - ['coding', 'admin', 'closed_meeting'], - ) - self.group.features.session_purposes = ['admin', 'closed_meeting'] - self.group.features.save() - self.assertCountEqual( - SessionDetailsForm(group=self.group).fields['purpose'].queryset.values_list('slug', flat=True), - ['admin', 'closed_meeting'], + def test_overrides_only_requested_extensions_when_valid_ext(self): + test_file = SimpleUploadedFile( + name='file.txt', + content=b'plain text', + content_type='application/octet-stream', ) - def test_allowed_types(self): - """Correct map from SessionPurposeName to allowed TimeSlotTypeName should be sent to JS""" - # change the allowed map to a known and non-standard arrangement - SessionPurposeName.objects.filter(slug='regular').update(timeslot_types=['other']) - SessionPurposeName.objects.filter(slug='admin').update(timeslot_types=['break', 'regular']) - SessionPurposeName.objects.exclude(slug__in=['regular', 'admin']).update(timeslot_types=[]) - # check that the map we just installed is actually passed along to the JS through a widget attr - allowed = json.loads(SessionDetailsForm(group=self.group).fields['type'].widget.attrs['data-allowed-options']) - self.assertEqual(allowed['regular'], ['other']) - self.assertEqual(allowed['admin'], ['break', 'regular']) - for purpose in SessionPurposeName.objects.exclude(slug__in=['regular', 'admin']): - self.assertEqual(allowed[purpose.slug], []) + form = FileUploadFormTests.TestClass(files={'file': test_file}) + self.assertFalse(form.is_valid(), 'Test data are invalid input') + self.assertEqual(test_file.name, 'file.txt', 'Uploaded filename should not be changed') + self.assertEqual(test_file.content_type, 'application/octet-stream', 'Uploaded Content-Type should not be changed') - def test_duration_options(self): - self.assertTrue(self.group.features.acts_like_wg) - self.assertEqual( - SessionDetailsForm(group=self.group).fields['requested_duration'].choices, - [('', '--Please select'), ('3600', '1 hour'), ('7200', '2 hours')], - ) - self.group.features.acts_like_wg = False - self.group.features.save() - self.assertEqual( - SessionDetailsForm(group=self.group).fields['requested_duration'].choices, - [('', '--Please select'), ('1800', '30 minutes'), - ('3600', '1 hour'), ('5400', '1 hour 30 minutes'), - ('7200', '2 hours'), ('9000', '2 hours 30 minutes'), - ('10800', '3 hours'), ('12600', '3 hours 30 minutes'), - ('14400', '4 hours')], + def test_overrides_only_requested_extensions_when_invalid_ext(self): + test_file = SimpleUploadedFile( + name='file.exe', + content=b'plain text', + content_type='application/octet-stream' ) - def test_on_agenda(self): - # new session gets its purpose's on_agenda value when True - self.assertTrue(SessionPurposeName.objects.get(slug='regular').on_agenda) - form = SessionDetailsForm(group=self.group, data={ - 'name': 'blah', - 'purpose': 'regular', - 'type': 'regular', - 'requested_duration': '3600', - }) - self.assertTrue(form.is_valid()) - self.assertTrue(form.cleaned_data['on_agenda']) - - # new session gets its purpose's on_agenda value when False - SessionPurposeName.objects.filter(slug='regular').update(on_agenda=False) - form = SessionDetailsForm(group=self.group, data={ - 'name': 'blah', - 'purpose': 'regular', - 'type': 'regular', - 'requested_duration': '3600', - }) - self.assertTrue(form.is_valid()) - self.assertFalse(form.cleaned_data['on_agenda']) - - # updated session keeps its on_agenda value, even if it differs from its purpose - session = SessionFactory(meeting=self.meeting, add_to_schedule=False, on_agenda=True) - form = SessionDetailsForm( - group=self.group, - instance=session, - data={ - 'name': 'blah', - 'purpose': 'regular', - 'type': 'regular', - 'requested_duration': '3600', - }, - ) - self.assertTrue(form.is_valid()) - self.assertTrue(form.cleaned_data['on_agenda']) - - # session gets purpose's on_agenda value if its purpose changes (changing the - # purpose away from 'regular' so we can use the 'wg' type group that only allows - # regular sessions) - session.purpose_id = 'admin' - session.save() - form = SessionDetailsForm( - group=self.group, - instance=session, - data={ - 'name': 'blah', - 'purpose': 'regular', - 'type': 'regular', - 'requested_duration': '3600', - }, - ) - self.assertTrue(form.is_valid()) - self.assertFalse(form.cleaned_data['on_agenda']) - -class SessionEditFormTests(TestCase): - def test_rejects_group_mismatch(self): - session = SessionFactory(meeting__type_id='ietf', meeting__populate_schedule=False, add_to_schedule=False) - other_group = GroupFactory() - with self.assertRaisesMessage(ValueError, 'Session group does not match group keyword'): - SessionEditForm(instance=session, group=other_group) + form = FileUploadFormTests.TestClass(files={'file': test_file}) + self.assertFalse(form.is_valid(), 'Test data are invalid input') + self.assertEqual(test_file.name, 'file.exe', 'Uploaded filename should not be changed') + self.assertEqual(test_file.content_type, 'application/octet-stream', 'Uploaded Content-Type should not be changed') -class SessionDetailsInlineFormset(TestCase): - def setUp(self): - super().setUp() - self.meeting = MeetingFactory(type_id='ietf', populate_schedule=False) - self.group = GroupFactory() +class ApplyToAllFileUploadFormTests(TestCase): + class TestClass(ApplyToAllFileUploadForm): + doc_type = 'minutes' - def test_initial_sessions(self): - """Sessions for the correct meeting and group should be included""" - sessions = SessionFactory.create_batch(2, meeting=self.meeting, group=self.group, add_to_schedule=False) - SessionFactory(meeting=self.meeting, add_to_schedule=False) # should be ignored - SessionFactory(group=self.group, add_to_schedule=False) # should be ignored - formset_class = sessiondetailsformset_factory() - formset = formset_class(group=self.group, meeting=self.meeting) - self.assertCountEqual(formset.queryset.all(), sessions) + def test_has_apply_to_all_field_by_default(self): + form = ApplyToAllFileUploadFormTests.TestClass(show_apply_to_all_checkbox=True) + self.assertIn('apply_to_all', form.fields) - def test_forms_created_with_group_kwarg(self): - class MockFormClass(SessionDetailsForm): - """Mock class to track the group that was passed to the init method""" - def __init__(self, group, *args, **kwargs): - self.init_group_argument = group - super().__init__(group, *args, **kwargs) - - with patch('ietf.meeting.forms.SessionDetailsForm', MockFormClass): - formset_class = sessiondetailsformset_factory() - formset = formset_class(meeting=self.meeting, group=self.group) - str(formset) # triggers instantiation of forms - self.assertGreaterEqual(len(formset), 1) - for form in formset: - self.assertEqual(form.init_group_argument, self.group) - - def test_add_instance(self): - session = SessionFactory(meeting=self.meeting, group=self.group, add_to_schedule=False) - formset_class = sessiondetailsformset_factory() - formset = formset_class(group=self.group, meeting=self.meeting, data={ - 'session_set-TOTAL_FORMS': '2', - 'session_set-INITIAL_FORMS': '1', - 'session_set-0-id': str(session.pk), - 'session_set-0-name': 'existing', - 'session_set-0-purpose': 'regular', - 'session_set-0-type': 'regular', - 'session_set-0-requested_duration': '3600', - 'session_set-1-name': 'new', - 'session_set-1-purpose': 'regular', - 'session_set-1-type': 'regular', - 'session_set-1-requested_duration': '3600', - }) - formset.save() - # make sure session created - self.assertEqual(self.meeting.session_set.count(), 2) - self.assertIn(session, self.meeting.session_set.all()) - self.assertEqual(len(formset.new_objects), 1) - self.assertEqual(formset.new_objects[0].name, 'new') - self.assertEqual(formset.new_objects[0].meeting, self.meeting) - self.assertEqual(formset.new_objects[0].group, self.group) + def test_no_show_apply_to_all_field(self): + form = ApplyToAllFileUploadFormTests.TestClass(show_apply_to_all_checkbox=False) + self.assertNotIn('apply_to_all', form.fields) diff --git a/ietf/meeting/tests_schedule_forms.py b/ietf/meeting/tests_schedule_forms.py new file mode 100644 index 000000000..416498235 --- /dev/null +++ b/ietf/meeting/tests_schedule_forms.py @@ -0,0 +1,476 @@ +# Copyright The IETF Trust 2021, All Rights Reserved +# -*- coding: utf-8 -*- + +import json + +from datetime import date, timedelta +from unittest.mock import patch + +from django import forms + +import debug # pyflakes: ignore +from ietf.group.factories import GroupFactory + +from ietf.meeting.factories import MeetingFactory, TimeSlotFactory, RoomFactory, SessionFactory +from ietf.meeting.forms import (CsvModelPkInput, CustomDurationField, SwapTimeslotsForm, duration_string, + TimeSlotDurationField, TimeSlotEditForm, TimeSlotCreateForm, DurationChoiceField, + SessionDetailsForm, sessiondetailsformset_factory, SessionEditForm) +from ietf.name.models import SessionPurposeName +from ietf.utils.test_utils import TestCase + + +class CsvModelPkInputTests(TestCase): + widget = CsvModelPkInput() + + def test_render_none(self): + result = self.widget.render('csv_model', value=None) + self.assertHTMLEqual(result, '') + + def test_render_value(self): + result = self.widget.render('csv_model', value=[1, 2, 3]) + self.assertHTMLEqual(result, '') + + def test_value_from_datadict(self): + result = self.widget.value_from_datadict({'csv_model': '11,23,47'}, {}, 'csv_model') + self.assertEqual(result, ['11', '23', '47']) + + +class SwapTimeslotsFormTests(TestCase): + def setUp(self): + super().setUp() + self.meeting = MeetingFactory(type_id='ietf', populate_schedule=False) + self.timeslots = TimeSlotFactory.create_batch(2, meeting=self.meeting) + self.other_meeting_timeslot = TimeSlotFactory() + + def test_valid(self): + form = SwapTimeslotsForm( + meeting=self.meeting, + data={ + 'origin_timeslot': str(self.timeslots[0].pk), + 'target_timeslot': str(self.timeslots[1].pk), + 'rooms': ','.join(str(rm.pk) for rm in self.meeting.room_set.all()), + } + ) + self.assertTrue(form.is_valid()) + + def test_invalid(self): + # the magic numbers are (very likely) non-existent pks + form = SwapTimeslotsForm( + meeting=self.meeting, + data={ + 'origin_timeslot': '25', + 'target_timeslot': str(self.timeslots[1].pk), + 'rooms': ','.join(str(rm.pk) for rm in self.meeting.room_set.all()), + } + ) + self.assertFalse(form.is_valid()) + form = SwapTimeslotsForm( + meeting=self.meeting, + data={ + 'origin_timeslot': str(self.timeslots[0].pk), + 'target_timeslot': str(self.other_meeting_timeslot.pk), + 'rooms': ','.join(str(rm.pk) for rm in self.meeting.room_set.all()), + } + ) + self.assertFalse(form.is_valid()) + form = SwapTimeslotsForm( + meeting=self.meeting, + data={ + 'origin_timeslot': str(self.timeslots[0].pk), + 'target_timeslot': str(self.timeslots[1].pk), + 'rooms': '1034', + } + ) + self.assertFalse(form.is_valid()) + + +class CustomDurationFieldTests(TestCase): + def test_duration_string(self): + self.assertEqual(duration_string(timedelta(hours=3, minutes=17)), '03:17') + self.assertEqual(duration_string(timedelta(hours=3, minutes=17, seconds=43)), '03:17') + self.assertEqual(duration_string(timedelta(days=1, hours=3, minutes=17, seconds=43)), '1 03:17') + self.assertEqual(duration_string(timedelta(hours=3, minutes=17, seconds=43, microseconds=37438)), '03:17') + + def _render_field(self, field): + """Helper to render a form containing a field""" + + class Form(forms.Form): + f = field + + return str(Form()['f']) + + @patch('ietf.meeting.forms.duration_string', return_value='12:34') + def test_render(self, mock_duration_string): + self.assertHTMLEqual( + self._render_field(CustomDurationField()), + '' + ) + self.assertHTMLEqual( + self._render_field(CustomDurationField(initial=timedelta(hours=1))), + '', + 'Rendered value should come from duration_string when initial value is a timedelta' + ) + self.assertHTMLEqual( + self._render_field(CustomDurationField(initial="01:02")), + '', + 'Rendered value should come from initial when it is not a timedelta' + ) + + +class TimeSlotDurationFieldTests(TestCase): + def test_validation(self): + field = TimeSlotDurationField() + with self.assertRaises(forms.ValidationError): + field.clean('-01:00') + with self.assertRaises(forms.ValidationError): + field.clean('12:01') + self.assertEqual(field.clean('00:00'), timedelta(seconds=0)) + self.assertEqual(field.clean('01:00'), timedelta(hours=1)) + self.assertEqual(field.clean('12:00'), timedelta(hours=12)) + + +class TimeSlotEditFormTests(TestCase): + def test_location_options(self): + meeting = MeetingFactory(type_id='ietf', populate_schedule=False) + rooms = [ + RoomFactory(meeting=meeting, capacity=3), + RoomFactory(meeting=meeting, capacity=123), + ] + ts = TimeSlotFactory(meeting=meeting) + rendered = str(TimeSlotEditForm(instance=ts)['location']) + # noinspection PyTypeChecker + self.assertInHTML( + f'', + rendered, + ) + for room in rooms: + # noinspection PyTypeChecker + self.assertInHTML( + f'', + rendered, + ) + + +class TimeSlotCreateFormTests(TestCase): + def setUp(self): + super().setUp() + self.meeting = MeetingFactory(type_id='ietf', date=date(2021, 11, 16), days=3, populate_schedule=False) + + def test_other_date(self): + room = RoomFactory(meeting=self.meeting) + + # no other_date, no day selected + form = TimeSlotCreateForm( + self.meeting, + data={ + 'name': 'time slot', + 'type': 'regular', + 'time': '12:00', + 'duration': '01:00', + 'locations': [str(room.pk)], + }) + self.assertFalse(form.is_valid()) + + # no other_date, day is selected + form = TimeSlotCreateForm( + self.meeting, + data={ + 'name': 'time slot', + 'type': 'regular', + 'days': ['738111'], # date(2021,11,17).toordinal() + 'time': '12:00', + 'duration': '01:00', + 'locations': [str(room.pk)], + }) + self.assertTrue(form.is_valid()) + self.assertNotIn('other_date', form.cleaned_data) + self.assertEqual(form.cleaned_data['days'], [date(2021, 11, 17)]) + + # other_date given, no day is selected + form = TimeSlotCreateForm( + self.meeting, + data={ + 'name': 'time slot', + 'type': 'regular', + 'time': '12:00', + 'duration': '01:00', + 'locations': [str(room.pk)], + 'other_date': '2021-11-15', + }) + self.assertTrue(form.is_valid()) + self.assertNotIn('other_date', form.cleaned_data) + self.assertEqual(form.cleaned_data['days'], [date(2021, 11, 15)]) + + # day is selected and other_date is given + form = TimeSlotCreateForm( + self.meeting, + data={ + 'name': 'time slot', + 'type': 'regular', + 'days': ['738111'], # date(2021,11,17).toordinal() + 'time': '12:00', + 'duration': '01:00', + 'locations': [str(room.pk)], + 'other_date': '2021-11-15', + }) + self.assertTrue(form.is_valid()) + self.assertNotIn('other_date', form.cleaned_data) + self.assertCountEqual(form.cleaned_data['days'], [date(2021, 11, 17), date(2021, 11, 15)]) + + # invalid other_date, no day selected + form = TimeSlotCreateForm( + self.meeting, + data={ + 'name': 'time slot', + 'type': 'regular', + 'time': '12:00', + 'duration': '01:00', + 'locations': [str(room.pk)], + 'other_date': 'invalid', + }) + self.assertFalse(form.is_valid()) + + # invalid other_date, day selected + form = TimeSlotCreateForm( + self.meeting, + data={ + 'name': 'time slot', + 'type': 'regular', + 'days': ['738111'], # date(2021,11,17).toordinal() + 'time': '12:00', + 'duration': '01:00', + 'locations': [str(room.pk)], + 'other_date': 'invalid', + }) + self.assertFalse(form.is_valid()) + + def test_meeting_days(self): + form = TimeSlotCreateForm(self.meeting) + self.assertEqual( + form.fields['days'].choices, + [ + ('738110', 'Tuesday (2021-11-16)'), + ('738111', 'Wednesday (2021-11-17)'), + ('738112', 'Thursday (2021-11-18)'), + ], + ) + + def test_locations(self): + rooms = RoomFactory.create_batch(5, meeting=self.meeting) + form = TimeSlotCreateForm(self.meeting) + self.assertCountEqual(form.fields['locations'].queryset.all(), rooms) + + +class DurationChoiceFieldTests(TestCase): + def test_choices_default(self): + f = DurationChoiceField() + self.assertEqual(f.choices, [('', '--Please select'), ('3600', '1 hour'), ('7200', '2 hours')]) + + def test_choices(self): + f = DurationChoiceField([60, 1800, 3600, 5400, 7260, 7261]) + self.assertEqual( + f.choices, + [ + ('', '--Please select'), + ('60', '1 minute'), + ('1800', '30 minutes'), + ('3600', '1 hour'), + ('5400', '1 hour 30 minutes'), + ('7260', '2 hours 1 minute'), + ('7261', '2 hours 1 minute'), + ] + ) + + def test_bound_value(self): + class Form(forms.Form): + f = DurationChoiceField() + form = Form(data={'f': '3600'}) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['f'], timedelta(hours=1)) + form = Form(data={'f': '7200'}) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['f'], timedelta(hours=2)) + self.assertFalse(Form(data={'f': '3601'}).is_valid()) + self.assertFalse(Form(data={'f': ''}).is_valid()) + self.assertFalse(Form(data={'f': 'bob'}).is_valid()) + + +class SessionDetailsFormTests(TestCase): + def setUp(self): + super().setUp() + self.meeting = MeetingFactory(type_id='ietf', populate_schedule=False) + self.group = GroupFactory() + + def test_initial_purpose(self): + """First session purpose for group should be default""" + # change the session_purposes GroupFeature to check that it's being used + self.group.features.session_purposes = ['coding', 'admin', 'closed_meeting'] + self.group.features.save() + self.assertEqual(SessionDetailsForm(group=self.group).initial['purpose'], 'coding') + self.group.features.session_purposes = ['admin', 'coding', 'closed_meeting'] + self.group.features.save() + self.assertEqual(SessionDetailsForm(group=self.group).initial['purpose'], 'admin') + + def test_session_purposes(self): + # change the session_purposes GroupFeature to check that it's being used + self.group.features.session_purposes = ['coding', 'admin', 'closed_meeting'] + self.group.features.save() + self.assertCountEqual( + SessionDetailsForm(group=self.group).fields['purpose'].queryset.values_list('slug', flat=True), + ['coding', 'admin', 'closed_meeting'], + ) + self.group.features.session_purposes = ['admin', 'closed_meeting'] + self.group.features.save() + self.assertCountEqual( + SessionDetailsForm(group=self.group).fields['purpose'].queryset.values_list('slug', flat=True), + ['admin', 'closed_meeting'], + ) + + def test_allowed_types(self): + """Correct map from SessionPurposeName to allowed TimeSlotTypeName should be sent to JS""" + # change the allowed map to a known and non-standard arrangement + SessionPurposeName.objects.filter(slug='regular').update(timeslot_types=['other']) + SessionPurposeName.objects.filter(slug='admin').update(timeslot_types=['break', 'regular']) + SessionPurposeName.objects.exclude(slug__in=['regular', 'admin']).update(timeslot_types=[]) + # check that the map we just installed is actually passed along to the JS through a widget attr + allowed = json.loads(SessionDetailsForm(group=self.group).fields['type'].widget.attrs['data-allowed-options']) + self.assertEqual(allowed['regular'], ['other']) + self.assertEqual(allowed['admin'], ['break', 'regular']) + for purpose in SessionPurposeName.objects.exclude(slug__in=['regular', 'admin']): + self.assertEqual(allowed[purpose.slug], []) + + def test_duration_options(self): + self.assertTrue(self.group.features.acts_like_wg) + self.assertEqual( + SessionDetailsForm(group=self.group).fields['requested_duration'].choices, + [('', '--Please select'), ('3600', '1 hour'), ('7200', '2 hours')], + ) + self.group.features.acts_like_wg = False + self.group.features.save() + self.assertEqual( + SessionDetailsForm(group=self.group).fields['requested_duration'].choices, + [('', '--Please select'), ('1800', '30 minutes'), + ('3600', '1 hour'), ('5400', '1 hour 30 minutes'), + ('7200', '2 hours'), ('9000', '2 hours 30 minutes'), + ('10800', '3 hours'), ('12600', '3 hours 30 minutes'), + ('14400', '4 hours')], + ) + + def test_on_agenda(self): + # new session gets its purpose's on_agenda value when True + self.assertTrue(SessionPurposeName.objects.get(slug='regular').on_agenda) + form = SessionDetailsForm(group=self.group, data={ + 'name': 'blah', + 'purpose': 'regular', + 'type': 'regular', + 'requested_duration': '3600', + }) + self.assertTrue(form.is_valid()) + self.assertTrue(form.cleaned_data['on_agenda']) + + # new session gets its purpose's on_agenda value when False + SessionPurposeName.objects.filter(slug='regular').update(on_agenda=False) + form = SessionDetailsForm(group=self.group, data={ + 'name': 'blah', + 'purpose': 'regular', + 'type': 'regular', + 'requested_duration': '3600', + }) + self.assertTrue(form.is_valid()) + self.assertFalse(form.cleaned_data['on_agenda']) + + # updated session keeps its on_agenda value, even if it differs from its purpose + session = SessionFactory(meeting=self.meeting, add_to_schedule=False, on_agenda=True) + form = SessionDetailsForm( + group=self.group, + instance=session, + data={ + 'name': 'blah', + 'purpose': 'regular', + 'type': 'regular', + 'requested_duration': '3600', + }, + ) + self.assertTrue(form.is_valid()) + self.assertTrue(form.cleaned_data['on_agenda']) + + # session gets purpose's on_agenda value if its purpose changes (changing the + # purpose away from 'regular' so we can use the 'wg' type group that only allows + # regular sessions) + session.purpose_id = 'admin' + session.save() + form = SessionDetailsForm( + group=self.group, + instance=session, + data={ + 'name': 'blah', + 'purpose': 'regular', + 'type': 'regular', + 'requested_duration': '3600', + }, + ) + self.assertTrue(form.is_valid()) + self.assertFalse(form.cleaned_data['on_agenda']) + +class SessionEditFormTests(TestCase): + def test_rejects_group_mismatch(self): + session = SessionFactory(meeting__type_id='ietf', meeting__populate_schedule=False, add_to_schedule=False) + other_group = GroupFactory() + with self.assertRaisesMessage(ValueError, 'Session group does not match group keyword'): + SessionEditForm(instance=session, group=other_group) + + +class SessionDetailsInlineFormset(TestCase): + def setUp(self): + super().setUp() + self.meeting = MeetingFactory(type_id='ietf', populate_schedule=False) + self.group = GroupFactory() + + def test_initial_sessions(self): + """Sessions for the correct meeting and group should be included""" + sessions = SessionFactory.create_batch(2, meeting=self.meeting, group=self.group, add_to_schedule=False) + SessionFactory(meeting=self.meeting, add_to_schedule=False) # should be ignored + SessionFactory(group=self.group, add_to_schedule=False) # should be ignored + formset_class = sessiondetailsformset_factory() + formset = formset_class(group=self.group, meeting=self.meeting) + self.assertCountEqual(formset.queryset.all(), sessions) + + def test_forms_created_with_group_kwarg(self): + class MockFormClass(SessionDetailsForm): + """Mock class to track the group that was passed to the init method""" + def __init__(self, group, *args, **kwargs): + self.init_group_argument = group + super().__init__(group, *args, **kwargs) + + with patch('ietf.meeting.forms.SessionDetailsForm', MockFormClass): + formset_class = sessiondetailsformset_factory() + formset = formset_class(meeting=self.meeting, group=self.group) + str(formset) # triggers instantiation of forms + self.assertGreaterEqual(len(formset), 1) + for form in formset: + self.assertEqual(form.init_group_argument, self.group) + + def test_add_instance(self): + session = SessionFactory(meeting=self.meeting, group=self.group, add_to_schedule=False) + formset_class = sessiondetailsformset_factory() + formset = formset_class(group=self.group, meeting=self.meeting, data={ + 'session_set-TOTAL_FORMS': '2', + 'session_set-INITIAL_FORMS': '1', + 'session_set-0-id': str(session.pk), + 'session_set-0-name': 'existing', + 'session_set-0-purpose': 'regular', + 'session_set-0-type': 'regular', + 'session_set-0-requested_duration': '3600', + 'session_set-1-name': 'new', + 'session_set-1-purpose': 'regular', + 'session_set-1-type': 'regular', + 'session_set-1-requested_duration': '3600', + }) + formset.save() + # make sure session created + self.assertEqual(self.meeting.session_set.count(), 2) + self.assertIn(session, self.meeting.session_set.all()) + self.assertEqual(len(formset.new_objects), 1) + self.assertEqual(formset.new_objects[0].name, 'new') + self.assertEqual(formset.new_objects[0].meeting, self.meeting) + self.assertEqual(formset.new_objects[0].group, self.group) diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py index 35a09be2d..6ef70c096 100644 --- a/ietf/meeting/views.py +++ b/ietf/meeting/views.py @@ -99,7 +99,8 @@ from ietf.utils.response import permission_denied from ietf.utils.text import xslugify from .forms import (InterimMeetingModelForm, InterimAnnounceForm, InterimSessionModelForm, - InterimCancelForm, InterimSessionInlineFormSet, FileUploadForm, RequestMinutesForm,) + InterimCancelForm, InterimSessionInlineFormSet, RequestMinutesForm, + UploadAgendaForm, UploadBlueSheetForm, UploadMinutesForm, UploadSlidesForm) def get_interim_menu_entries(request): @@ -2341,13 +2342,6 @@ def add_session_drafts(request, session_id, num): }) -class UploadBlueSheetForm(FileUploadForm): - - def __init__(self, *args, **kwargs): - kwargs['doc_type'] = 'bluesheets' - super(UploadBlueSheetForm, self).__init__(*args, **kwargs ) - - def upload_session_bluesheets(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) @@ -2435,15 +2429,6 @@ def save_bluesheet(request, session, file, encoding='utf-8'): doc.save_with_history([e]) return save_error -class UploadMinutesForm(FileUploadForm): - apply_to_all = forms.BooleanField(label='Apply to all group sessions at this meeting',initial=True,required=False) - - def __init__(self, show_apply_to_all_checkbox, *args, **kwargs): - kwargs['doc_type'] = 'minutes' - super(UploadMinutesForm, self).__init__(*args, **kwargs ) - if not show_apply_to_all_checkbox: - self.fields.pop('apply_to_all') - def upload_session_minutes(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure @@ -2536,15 +2521,6 @@ def upload_session_minutes(request, session_id, num): }) -class UploadAgendaForm(FileUploadForm): - apply_to_all = forms.BooleanField(label='Apply to all group sessions at this meeting',initial=True,required=False) - - def __init__(self, show_apply_to_all_checkbox, *args, **kwargs): - kwargs['doc_type'] = 'agenda' - super(UploadAgendaForm, self).__init__(*args, **kwargs ) - if not show_apply_to_all_checkbox: - self.fields.pop('apply_to_all') - def upload_session_agenda(request, session_id, num): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) @@ -2639,27 +2615,6 @@ def upload_session_agenda(request, session_id, num): }) -class UploadSlidesForm(FileUploadForm): - title = forms.CharField(max_length=255) - apply_to_all = forms.BooleanField(label='Apply to all group sessions at this meeting',initial=False,required=False) - - def __init__(self, session, show_apply_to_all_checkbox, *args, **kwargs): - self.session = session - kwargs['doc_type'] = 'slides' - super(UploadSlidesForm, self).__init__(*args, **kwargs ) - if not show_apply_to_all_checkbox: - self.fields.pop('apply_to_all') - - def clean_title(self): - title = self.cleaned_data['title'] - # The current tables only handles Unicode BMP: - if ord(max(title)) > 0xffff: - raise forms.ValidationError("The title contains characters outside the Unicode BMP, which is not currently supported") - if self.session.meeting.type_id=='interim': - if re.search(r'-\d{2}$', title): - raise forms.ValidationError("Interim slides currently may not have a title that ends with something that looks like a revision number (-nn)") - return title - def upload_session_slides(request, session_id, num, name): # num is redundant, but we're dragging it along an artifact of where we are in the current URL structure session = get_object_or_404(Session,pk=session_id) diff --git a/ietf/meeting/views_proceedings.py b/ietf/meeting/views_proceedings.py index 9b2bd276b..cfb38e5ab 100644 --- a/ietf/meeting/views_proceedings.py +++ b/ietf/meeting/views_proceedings.py @@ -18,6 +18,8 @@ from ietf.secr.proceedings.utils import handle_upload_file from ietf.utils.text import xslugify class UploadProceedingsMaterialForm(FileUploadForm): + doc_type = 'procmaterials' + use_url = forms.BooleanField( required=False, label='Use an external URL instead of uploading a document', @@ -34,7 +36,7 @@ class UploadProceedingsMaterialForm(FileUploadForm): ) def __init__(self, *args, **kwargs): - super().__init__(doc_type='procmaterials', *args, **kwargs) + super().__init__(*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), diff --git a/ietf/settings.py b/ietf/settings.py index f836ceb59..a6c4c8188 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -949,6 +949,12 @@ MEETING_VALID_MIME_TYPE_EXTENSIONS = { 'application/pdf': ['.pdf'], } +# Files uploaded with Content-Type application/octet-stream and an extension in this map will +# be treated as if they had been uploaded with the mapped Content-Type value. +MEETING_APPLICATION_OCTET_STREAM_OVERRIDES = { + '.md': 'text/markdown', +} + MEETING_VALID_UPLOAD_MIME_FOR_OBSERVED_MIME = { 'text/plain': ['text/plain', 'text/markdown', 'text/x-markdown', ], 'text/html': ['text/html', ],