datatracker/ietf/meeting/forms.py
Jennifer Richards 08e953995a
feat: better reject null characters in forms (#7472)
* feat: subclass ModelMultipleChoiceField to reject nuls

* refactor: Use custom ModelMultipleChoiceField

* fix: handle value=None
2024-05-28 10:34:55 -05:00

841 lines
34 KiB
Python

# Copyright The IETF Trust 2016-2023, All Rights Reserved
# -*- coding: utf-8 -*-
import io
import os
import datetime
import json
import re
from pathlib import Path
from django import forms
from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
from django.forms import BaseInlineFormSet
from django.utils.functional import cached_property
import debug # pyflakes:ignore
from ietf.doc.models import Document, State, NewRevisionDocEvent
from ietf.group.models import Group
from ietf.group.utils import groups_managed_by
from ietf.meeting.models import Session, Meeting, Schedule, countries, timezones, TimeSlot, Room
from ietf.meeting.helpers import get_next_interim_number, make_materials_directories
from ietf.meeting.helpers import is_interim_meeting_approved, get_next_agenda_name
from ietf.message.models import Message
from ietf.name.models import TimeSlotTypeName, SessionPurposeName
from ietf.person.models import Person
from ietf.utils.fields import (
DatepickerDateField,
DatepickerSplitDateTimeWidget,
DurationField,
ModelMultipleChoiceField,
MultiEmailField,
)
from ietf.utils.validators import ( validate_file_size, validate_mime_type,
validate_file_extension, validate_no_html_frame)
# need to insert empty option for use in ChoiceField
# countries.insert(0, ('', '-'*9 ))
countries.insert(0, ('', '-' * 9))
timezones.insert(0, ('', '-' * 9))
# -------------------------------------------------
# Helpers
# -------------------------------------------------
class GroupModelChoiceField(forms.ModelChoiceField):
'''
Custom ModelChoiceField, changes the label to a more readable format
'''
def label_from_instance(self, obj):
return obj.acronym
class CustomDurationField(DurationField):
"""Custom DurationField to display as HH:MM (no seconds)"""
widget = forms.TextInput(dict(placeholder='HH:MM'))
def prepare_value(self, value):
if isinstance(value, datetime.timedelta):
return duration_string(value)
return value
def duration_string(duration):
'''Custom duration_string to return HH:MM (no seconds)'''
days = duration.days
seconds = duration.seconds
minutes = seconds // 60
hours = minutes // 60
minutes = minutes % 60
string = '{:02d}:{:02d}'.format(hours, minutes)
if days:
string = '{} '.format(days) + string
return string
# -------------------------------------------------
# Forms
# -------------------------------------------------
class InterimSessionInlineFormSet(BaseInlineFormSet):
def __init__(self, *args, **kwargs):
super(InterimSessionInlineFormSet, self).__init__(*args, **kwargs)
if 'data' in kwargs:
self.meeting_type = kwargs['data']['meeting_type']
def clean(self):
'''Custom clean method to verify dates are consecutive for multi-day meetings'''
super(InterimSessionInlineFormSet, self).clean()
if self.meeting_type == 'multi-day':
dates = []
for form in self.forms:
date = form.cleaned_data.get('date')
if date:
dates.append(date)
if len(dates) < 2:
return
dates.sort()
last_date = dates[0]
for date in dates[1:]:
if date - last_date != datetime.timedelta(days=1):
raise forms.ValidationError('For Multi-Day meetings, days must be consecutive')
last_date = date
self.days = len(dates)
return # formset doesn't have cleaned_data
class InterimMeetingModelForm(forms.ModelForm):
group = GroupModelChoiceField(
queryset=Group.objects.with_meetings().filter(
state__in=('active', 'proposed', 'bof')
).order_by('acronym'),
required=False,
empty_label="Click to select",
)
group.widget.attrs['data-max-entries'] = 1
group.widget.attrs['data-minimum-input-length'] = 0
in_person = forms.BooleanField(required=False)
meeting_type = forms.ChoiceField(
choices=(
("single", "Single"),
("multi-day", "Multi-Day"),
('series', 'Series')
),
required=False,
initial='single',
widget=forms.RadioSelect,
help_text='''
Use <b>Multi-Day</b> for a single meeting that spans more than one contiguous
workday. Do not use Multi-Day for a series of separate meetings (such as
periodic interim calls). Use Series instead.
Use <b>Series</b> for a series of separate meetings, such as periodic interim calls.
Use Multi-Day for a single meeting that spans more than one contiguous
workday.''',
)
approved = forms.BooleanField(required=False)
city = forms.CharField(max_length=255, required=False)
city.widget.attrs['placeholder'] = "City"
country = forms.ChoiceField(choices=countries, required=False)
country.widget.attrs['class'] = "select2-field"
country.widget.attrs['data-max-entries'] = 1
country.widget.attrs['data-placeholder'] = "Country"
country.widget.attrs['data-minimum-input-length'] = 0
time_zone = forms.ChoiceField(choices=timezones)
time_zone.widget.attrs['class'] = "select2-field"
time_zone.widget.attrs['data-max-entries'] = 1
time_zone.widget.attrs['data-minimum-input-length'] = 0
class Meta:
model = Meeting
fields = ('group', 'in_person', 'meeting_type', 'approved', 'city', 'country', 'time_zone')
def __init__(self, request, *args, **kwargs):
super(InterimMeetingModelForm, self).__init__(*args, **kwargs)
self.user = request.user
self.person = self.user.person
self.is_edit = bool(self.instance.pk)
self.fields['group'].widget.attrs['class'] = "select2-field"
self.fields['time_zone'].initial = 'UTC'
self.fields['approved'].initial = True
self.set_group_options()
if self.is_edit:
self.fields['group'].initial = self.instance.session_set.first().group
self.fields['group'].widget.attrs['disabled'] = True
if self.instance.city or self.instance.country:
self.fields['in_person'].initial = True
if is_interim_meeting_approved(self.instance):
self.fields['approved'].initial = True
else:
self.fields['approved'].initial = False
self.fields['approved'].widget.attrs['disabled'] = True
def clean(self):
super(InterimMeetingModelForm, self).clean()
cleaned_data = self.cleaned_data
if not cleaned_data.get('group'):
raise forms.ValidationError("You must select a group")
return self.cleaned_data
def is_virtual(self):
if not self.is_bound or self.data.get('in_person'):
return False
else:
return True
def set_group_options(self):
"""Set group options based on user accessing the form"""
queryset = groups_managed_by(
self.user,
Group.objects.with_meetings(),
).filter(
state_id__in=['active', 'proposed', 'bof']
).order_by('acronym')
self.fields['group'].queryset = queryset
# if there's only one possibility make it the default
if len(queryset) == 1:
self.fields['group'].initial = queryset[0]
def save(self, *args, **kwargs):
'''Save must handle fields not included in the form: date,number,type_id'''
date = kwargs.pop('date')
group = self.cleaned_data.get('group')
meeting = super(InterimMeetingModelForm, self).save(commit=False)
if not meeting.type_id:
meeting.type_id = 'interim'
if not meeting.number:
meeting.number = get_next_interim_number(group.acronym, date)
meeting.date = date
meeting.days = 1
if kwargs.get('commit', True):
# create schedule with meeting
meeting.save() # pre-save so we have meeting.pk for schedule
if not meeting.schedule:
meeting.schedule = Schedule.objects.create(
meeting=meeting,
owner=Person.objects.get(name='(System)'))
meeting.save() # save with schedule
# create directories
make_materials_directories(meeting)
return meeting
class InterimSessionModelForm(forms.ModelForm):
date = DatepickerDateField(date_format="yyyy-mm-dd", picker_settings={"autoclose": "1"}, label='Date', required=False)
time = forms.TimeField(widget=forms.TimeInput(format='%H:%M'), required=True, help_text="Start time in meeting time zone")
time.widget.attrs['placeholder'] = "HH:MM"
requested_duration = CustomDurationField(required=True)
end_time = forms.TimeField(required=False, help_text="End time in meeting time zone")
end_time.widget.attrs['placeholder'] = "HH:MM"
remote_participation = forms.ChoiceField(choices=(), required=False)
remote_instructions = forms.CharField(
max_length=1024,
required=False,
help_text='''
For virtual interims, a conference link <b>should be provided in the original request</b> in all but the most unusual circumstances.
Otherwise, "Remote participation is not supported" or "Remote participation information will be obtained at the time of approval" are acceptable values.
See <a href="https://www.ietf.org/forms/wg-webex-account-request/">here</a> for more on remote participation support.
''',
)
agenda = forms.CharField(required=False, widget=forms.Textarea, strip=False)
agenda.widget.attrs['placeholder'] = "Paste agenda here"
agenda_note = forms.CharField(max_length=255, required=False, label=" Additional information")
class Meta:
model = Session
fields = ('date', 'time', 'requested_duration', 'end_time',
'remote_instructions', 'agenda', 'agenda_note')
def __init__(self, *args, **kwargs):
if 'user' in kwargs:
self.user = kwargs.pop('user')
if 'group' in kwargs:
self.group = kwargs.pop('group')
if 'requires_approval' in kwargs:
self.requires_approval = kwargs.pop('requires_approval')
super(InterimSessionModelForm, self).__init__(*args, **kwargs)
self.is_edit = bool(self.instance.pk)
# setup fields that aren't intrinsic to the Session object
if self.is_edit:
self.initial['date'] = self.instance.official_timeslotassignment().timeslot.local_start_time().date()
self.initial['time'] = self.instance.official_timeslotassignment().timeslot.local_start_time().time()
if self.instance.agenda():
doc = self.instance.agenda()
content = doc.text_or_error()
self.initial['agenda'] = content
# set up remote participation choices
choices = []
if hasattr(settings, 'MEETECHO_API_CONFIG'):
choices.append(('meetecho', 'Automatically create Meetecho conference'))
choices.append(('manual', 'Manually specify remote instructions...'))
self.fields['remote_participation'].choices = choices
# put remote_participation ahead of remote_instructions
field_order = [field for field in self.fields if field != 'remote_participation']
field_order.insert(field_order.index('remote_instructions'), 'remote_participation')
self.order_fields(field_order)
def clean_date(self):
'''Date field validator. We can't use required on the input because
it is a datepicker widget'''
date = self.cleaned_data.get('date')
if not date:
raise forms.ValidationError('Required field')
return date
def clean_requested_duration(self):
min_minutes = settings.INTERIM_SESSION_MINIMUM_MINUTES
max_minutes = settings.INTERIM_SESSION_MAXIMUM_MINUTES
duration = self.cleaned_data.get('requested_duration')
if not duration or duration < datetime.timedelta(minutes=min_minutes) or duration > datetime.timedelta(minutes=max_minutes):
raise forms.ValidationError('Provide a duration, %s-%smin.' % (min_minutes, max_minutes))
return duration
def clean(self):
if self.cleaned_data.get('remote_participation', None) == 'meetecho':
self.cleaned_data['remote_instructions'] = '' # blank this out if we're creating a Meetecho conference
elif not self.cleaned_data['remote_instructions']:
self.add_error('remote_instructions', 'This field is required')
return self.cleaned_data
# Override to ignore the non-model 'remote_participation' field when computing has_changed()
@cached_property
def changed_data(self):
data = super().changed_data
if 'remote_participation' in data:
data.remove('remote_participation')
return data
def save(self, *args, **kwargs):
"""NOTE: as the baseform of an inlineformset self.save(commit=True)
never gets called"""
session = super(InterimSessionModelForm, self).save(commit=False)
session.group = self.group
session.type_id = 'regular'
session.purpose_id = 'regular'
if kwargs.get('commit', True) is True:
super(InterimSessionModelForm, self).save(commit=True)
return session
def save_agenda(self):
if self.instance.agenda():
doc = self.instance.agenda()
doc.rev = str(int(doc.rev) + 1).zfill(2)
doc.uploaded_filename = doc.filename_with_rev()
e = NewRevisionDocEvent.objects.create(
type='new_revision',
by=self.user.person,
doc=doc,
rev=doc.rev,
desc='New revision available')
doc.save_with_history([e])
else:
filename = get_next_agenda_name(meeting=self.instance.meeting)
doc = Document.objects.create(
type_id='agenda',
group=self.group,
name=filename,
rev='00',
# FIXME: if these are always computed, they shouldn't be in uploaded_filename - just compute them when needed
# FIXME: What about agendas in html or markdown format?
uploaded_filename='{}-00.txt'.format(filename))
doc.set_state(State.objects.get(type__slug=doc.type.slug, slug='active'))
self.instance.presentations.create(document=doc, rev=doc.rev)
NewRevisionDocEvent.objects.create(
type='new_revision',
by=self.user.person,
doc=doc,
rev=doc.rev,
desc='New revision available')
# write file
path = os.path.join(self.instance.meeting.get_materials_path(), 'agenda', doc.filename_with_rev())
directory = os.path.dirname(path)
if not os.path.exists(directory):
os.makedirs(directory)
with io.open(path, "w", encoding='utf-8') as file:
file.write(self.cleaned_data['agenda'])
class InterimAnnounceForm(forms.ModelForm):
class Meta:
model = Message
fields = ('to', 'cc', 'frm', 'subject', 'body')
def __init__(self, *args, **kwargs):
super(InterimAnnounceForm, self).__init__(*args, **kwargs)
self.fields['frm'].label='From'
self.fields['frm'].widget.attrs['readonly'] = True
self.fields['to'].widget.attrs['readonly'] = True
def save(self, *args, **kwargs):
user = kwargs.pop('user')
message = super(InterimAnnounceForm, self).save(commit=False)
message.by = user.person
message.save()
return message
class InterimCancelForm(forms.Form):
group = forms.CharField(max_length=255, required=False)
date = forms.DateField(required=False)
# max_length must match Session.agenda_note
comments = forms.CharField(max_length=512, required=False, widget=forms.Textarea(attrs={'placeholder': 'enter optional comments here'}), strip=False)
def __init__(self, *args, **kwargs):
super(InterimCancelForm, self).__init__(*args, **kwargs)
self.fields['group'].widget.attrs['disabled'] = True
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):
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":
label += 'Did you remember to put in slide numbers? '
if self.mime_types:
label += 'Note that you can only upload files with these formats: %s.' % (', '.join(self.mime_types, ))
self.fields['file'].label=label
def clean_file(self):
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 = {}
self.file_encoding[file.name] = encoding or None
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))
# 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 (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 UploadNarrativeMinutesForm(ApplyToAllFileUploadForm):
doc_type = 'narrativeminutes'
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 ImportMinutesForm(forms.Form):
markdown_text = forms.CharField(strip=False, widget=forms.HiddenInput)
class RequestMinutesForm(forms.Form):
to = MultiEmailField()
cc = MultiEmailField(required=False)
subject = forms.CharField()
body = forms.CharField(widget=forms.Textarea,strip=False)
class SwapDaysForm(forms.Form):
source_day = forms.DateField(required=True)
target_day = forms.DateField(required=True)
class CsvModelPkInput(forms.TextInput):
"""Text input that expects a CSV list of PKs of a model instances"""
def format_value(self, value):
"""Convert value to contents of input text widget
Value is a list of pks, or None
"""
return '' if value is None else ','.join(str(v) for v in value)
def value_from_datadict(self, data, files, name):
"""Convert data back to list of PKs"""
value = super(CsvModelPkInput, self).value_from_datadict(data, files, name)
return value.split(',')
class SwapTimeslotsForm(forms.Form):
"""Timeslot swap form
Interface uses timeslot instances rather than time/duration to simplify handling in
the JavaScript. This might make more sense with a DateTimeField and DurationField for
origin/target. Instead, grabs time and duration from a TimeSlot.
This is not likely to be practical as a rendered form. Current use is to validate
data from an ad hoc form. In an ideal world, this would be refactored to use a complex
custom widget, but unless it proves to be reused that would be a poor investment of time.
"""
origin_timeslot = forms.ModelChoiceField(
required=True,
queryset=TimeSlot.objects.none(), # default to none, fill in when we have a meeting
widget=forms.TextInput,
)
target_timeslot = forms.ModelChoiceField(
required=True,
queryset=TimeSlot.objects.none(), # default to none, fill in when we have a meeting
widget=forms.TextInput,
)
rooms = ModelMultipleChoiceField(
required=True,
queryset=Room.objects.none(), # default to none, fill in when we have a meeting
widget=CsvModelPkInput,
)
def __init__(self, meeting, *args, **kwargs):
super(SwapTimeslotsForm, self).__init__(*args, **kwargs)
self.meeting = meeting
self.fields['origin_timeslot'].queryset = meeting.timeslot_set.all()
self.fields['target_timeslot'].queryset = meeting.timeslot_set.all()
self.fields['rooms'].queryset = meeting.room_set.all()
class TimeSlotDurationField(CustomDurationField):
"""Duration field for TimeSlot edit / create forms"""
default_validators=[
validators.MinValueValidator(datetime.timedelta(seconds=0)),
validators.MaxValueValidator(datetime.timedelta(hours=12)),
]
def __init__(self, **kwargs):
kwargs.setdefault('help_text', 'Duration of timeslot in hours and minutes')
super().__init__(**kwargs)
class TimeSlotEditForm(forms.ModelForm):
class Meta:
model = TimeSlot
fields = ('name', 'type', 'time', 'duration', 'show_location', 'location')
field_classes = dict(
time=forms.SplitDateTimeField,
duration=TimeSlotDurationField
)
widgets = dict(
time=DatepickerSplitDateTimeWidget,
)
def __init__(self, *args, **kwargs):
super(TimeSlotEditForm, self).__init__(*args, **kwargs)
self.fields['location'].queryset = self.instance.meeting.room_set.all()
class TimeSlotCreateForm(forms.Form):
name = forms.CharField(max_length=255)
type = forms.ModelChoiceField(queryset=TimeSlotTypeName.objects.all(), initial='regular')
days = forms.TypedMultipleChoiceField(
label='Meeting days',
widget=forms.CheckboxSelectMultiple,
coerce=lambda s: datetime.date.fromordinal(int(s)),
empty_value=None,
required=False
)
other_date = DatepickerDateField(
required=False,
help_text='Optional date outside the official meeting dates',
date_format="yyyy-mm-dd",
picker_settings={"autoclose": "1"},
)
time = forms.TimeField(
help_text='Time to create timeslot on each selected date',
widget=forms.TimeInput(dict(placeholder='HH:MM'))
)
duration = TimeSlotDurationField()
show_location = forms.BooleanField(required=False, initial=True)
locations = ModelMultipleChoiceField(
queryset=Room.objects.none(),
widget=forms.CheckboxSelectMultiple,
)
def __init__(self, meeting, *args, **kwargs):
super(TimeSlotCreateForm, self).__init__(*args, **kwargs)
meeting_days = [
meeting.date + datetime.timedelta(days=n)
for n in range(meeting.days)
]
# Fill in dynamic field properties
self.fields['days'].choices = self._day_choices(meeting_days)
self.fields['other_date'].widget.attrs['data-date-default-view-date'] = meeting.date
self.fields['other_date'].widget.attrs['data-date-dates-disabled'] = ','.join(
d.isoformat() for d in meeting_days
)
self.fields['locations'].queryset = meeting.room_set.order_by('name')
def clean_other_date(self):
# Because other_date is not required, failed field validation does not automatically
# invalidate the form. It should, otherwise a typo may be silently ignored.
if self.data.get('other_date') and not self.cleaned_data.get('other_date'):
raise ValidationError('Enter a valid date or leave field blank.')
return self.cleaned_data.get('other_date', None)
def clean(self):
# Merge other_date and days fields
try:
other_date = self.cleaned_data.pop('other_date')
except KeyError:
other_date = None
self.cleaned_data['days'] = self.cleaned_data.get('days') or []
if other_date is not None:
self.cleaned_data['days'].append(other_date)
if len(self.cleaned_data['days']) == 0:
self.add_error('days', ValidationError('Please select a day or specify a date'))
@staticmethod
def _day_choices(days):
"""Generates an iterable of value, label pairs for a choice field
Uses toordinal() to represent dates - would prefer to use isoformat(),
but fromisoformat() is not available in python 3.6..
"""
choices = [
(str(day.toordinal()), day.strftime('%A ({})'.format(day.isoformat())))
for day in days
]
return choices
class DurationChoiceField(forms.ChoiceField):
def __init__(self, durations=None, *args, **kwargs):
if durations is None:
durations = (3600, 5400, 7200)
super().__init__(
choices=self._make_choices(durations),
*args, **kwargs,
)
def prepare_value(self, value):
"""Converts incoming value into string used for the option value"""
if value:
return str(int(value.total_seconds())) if isinstance(value, datetime.timedelta) else str(value)
return ''
def to_python(self, value):
if value in self.empty_values or (isinstance(value, str) and not value.isnumeric()):
return None # treat non-numeric values as empty
else:
# noinspection PyTypeChecker
return datetime.timedelta(seconds=round(float(value)))
def valid_value(self, value):
return super().valid_value(self.prepare_value(value))
def _format_duration_choice(self, dur):
seconds = int(dur.total_seconds()) if isinstance(dur, datetime.timedelta) else int(dur)
hours = int(seconds / 3600)
minutes = round((seconds - 3600 * hours) / 60)
hr_str = '{} hour{}'.format(hours, '' if hours == 1 else 's')
min_str = '{} minute{}'.format(minutes, '' if minutes == 1 else 's')
if hours > 0 and minutes > 0:
time_str = ' '.join((hr_str, min_str))
elif hours > 0:
time_str = hr_str
else:
time_str = min_str
return (str(seconds), time_str)
def _make_choices(self, durations):
return (
('','--Please select'),
*[self._format_duration_choice(dur) for dur in durations])
def _set_durations(self, durations):
self.choices = self._make_choices(durations)
durations = property(None, _set_durations)
class SessionDetailsForm(forms.ModelForm):
requested_duration = DurationChoiceField()
def __init__(self, group, *args, **kwargs):
session_purposes = group.features.session_purposes
# Default to the first allowed session_purposes. Do not do this if we have an instance,
# though, because ModelForm will override instance data with initial data if it gets both.
# When we have an instance we want to keep its value.
if 'instance' not in kwargs:
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault(
'purpose',
session_purposes[0] if len(session_purposes) > 0 else None,
)
kwargs['initial'].setdefault('has_onsite_tool', group.features.acts_like_wg)
super().__init__(*args, **kwargs)
self.fields['type'].widget.attrs.update({
'data-allowed-options': json.dumps({
purpose.slug: list(purpose.timeslot_types)
for purpose in SessionPurposeName.objects.all()
}),
})
self.fields['purpose'].queryset = SessionPurposeName.objects.filter(pk__in=session_purposes)
if not group.features.acts_like_wg:
self.fields['requested_duration'].durations = [datetime.timedelta(minutes=m) for m in range(30, 241, 30)]
class Meta:
model = Session
fields = (
'purpose', 'name', 'short', 'type', 'requested_duration',
'on_agenda', 'agenda_note', 'has_onsite_tool', 'chat_room', 'remote_instructions',
'attendees', 'comments',
)
labels = {'requested_duration': 'Length'}
def clean(self):
super().clean()
# Fill in on_agenda. If this is a new instance or we have changed its purpose, then use
# the on_agenda value for the purpose. Otherwise, keep the value of an existing instance (if any)
# or leave it blank.
if 'purpose' in self.cleaned_data and (
self.instance.pk is None or (self.instance.purpose != self.cleaned_data['purpose'])
):
self.cleaned_data['on_agenda'] = self.cleaned_data['purpose'].on_agenda
elif self.instance.pk is not None:
self.cleaned_data['on_agenda'] = self.instance.on_agenda
return self.cleaned_data
class Media:
js = ('ietf/js/session_details_form.js',)
class SessionEditForm(SessionDetailsForm):
"""Form to edit an existing session"""
def __init__(self, instance, *args, **kwargs):
kw_group = kwargs.pop('group', None)
if kw_group is not None and kw_group != instance.group:
raise ValueError('Session group does not match group keyword')
super().__init__(instance=instance, group=instance.group, *args, **kwargs)
class SessionCancelForm(forms.Form):
confirmed = forms.BooleanField(
label='Cancel session?',
help_text='Confirm that you want to cancel this session.',
)
class SessionDetailsInlineFormSet(forms.BaseInlineFormSet):
def __init__(self, group, meeting, queryset=None, *args, **kwargs):
self._meeting = meeting
# Restrict sessions to the meeting and group. The instance
# property handles one of these for free.
kwargs['instance'] = group
if queryset is None:
queryset = Session._default_manager
if self._meeting.pk is not None:
queryset = queryset.filter(meeting=self._meeting)
else:
queryset = queryset.none()
kwargs['queryset'] = queryset.not_deleted()
kwargs.setdefault('form_kwargs', {})
kwargs['form_kwargs'].update({'group': group})
super().__init__(*args, **kwargs)
def save_new(self, form, commit=True):
form.instance.meeting = self._meeting
return super().save_new(form, commit)
@property
def forms_to_keep(self):
"""Get the not-deleted forms"""
return [f for f in self.forms if f not in self.deleted_forms]
def sessiondetailsformset_factory(min_num=1, max_num=3):
return forms.inlineformset_factory(
Group,
Session,
formset=SessionDetailsInlineFormSet,
form=SessionDetailsForm,
can_delete=True,
can_order=False,
min_num=min_num,
max_num=max_num,
extra=max_num, # only creates up to max_num total
)