Merged in [19744] from jennifer@painless-security.com:

Treat application/octet-stream as text/markdown for '.md' materials uploads. Refactor FileUploadForm hierarchy to reduce boilerplate. Fixes #3163.
 - Legacy-Id: 19746
Note: SVN reference [19744] has been migrated to Git commit b04254a293
This commit is contained in:
Robert Sparks 2021-12-02 21:31:06 +00:00
commit 640855f786
6 changed files with 648 additions and 507 deletions

View file

@ -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)

View file

@ -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()
def test_render_none(self):
result = self.widget.render('csv_model', value=None)
self.assertHTMLEqual(result, '<input type="text" name="csv_model" value="">')
def test_render_value(self):
result = self.widget.render('csv_model', value=[1, 2, 3])
self.assertHTMLEqual(result, '<input type="text" name="csv_model" value="1,2,3">')
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()),
}
@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']},
)
self.assertTrue(form.is_valid())
class FileUploadFormTests(TestCase):
class TestClass(FileUploadForm):
doc_type = 'minutes'
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()),
'<input id="id_f" name="f" type="text" placeholder="HH:MM" required>'
)
self.assertHTMLEqual(
self._render_field(CustomDurationField(initial=timedelta(hours=1))),
'<input id="id_f" name="f" type="text" placeholder="HH:MM" required value="12:34">',
'Rendered value should come from duration_string when initial value is a timedelta'
)
self.assertHTMLEqual(
self._render_field(CustomDurationField(initial="01:02")),
'<input id="id_f" name="f" type="text" placeholder="HH:MM" required value="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'<option value="{ts.location.pk}" selected>{ts.location.name} size: None</option>',
rendered,
)
for room in rooms:
# noinspection PyTypeChecker
self.assertInHTML(
f'<option value="{room.pk}">{room.name} size: {room.capacity}</option>',
rendered,
def test_overrides_content_type_application_octet_stream(self):
test_file = SimpleUploadedFile(
name='file.md',
content=b'plain text',
content_type='application/octet-stream',
)
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 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_only_application_octet_stream(self):
test_file = SimpleUploadedFile(
name='file.md',
content=b'plain text',
content_type='application/json'
)
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.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 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_requested_extensions_when_valid_ext(self):
test_file = SimpleUploadedFile(
name='file.txt',
content=b'plain text',
content_type='application/octet-stream',
)
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.txt', 'Uploaded filename should not be changed')
self.assertEqual(test_file.content_type, 'application/octet-stream', '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_invalid_ext(self):
test_file = SimpleUploadedFile(
name='file.exe',
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], [])
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)
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)

View file

@ -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, '<input type="text" name="csv_model" value="">')
def test_render_value(self):
result = self.widget.render('csv_model', value=[1, 2, 3])
self.assertHTMLEqual(result, '<input type="text" name="csv_model" value="1,2,3">')
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()),
'<input id="id_f" name="f" type="text" placeholder="HH:MM" required>'
)
self.assertHTMLEqual(
self._render_field(CustomDurationField(initial=timedelta(hours=1))),
'<input id="id_f" name="f" type="text" placeholder="HH:MM" required value="12:34">',
'Rendered value should come from duration_string when initial value is a timedelta'
)
self.assertHTMLEqual(
self._render_field(CustomDurationField(initial="01:02")),
'<input id="id_f" name="f" type="text" placeholder="HH:MM" required value="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'<option value="{ts.location.pk}" selected>{ts.location.name} size: None</option>',
rendered,
)
for room in rooms:
# noinspection PyTypeChecker
self.assertInHTML(
f'<option value="{room.pk}">{room.name} size: {room.capacity}</option>',
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)

View file

@ -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)

View file

@ -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),

View file

@ -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', ],