Create dev branch for session purpose work (from revision [19414]) Snapshot of dev work to add session purpose annotation (from revision [19415]) Allow non-WG-like groups to request additional sessions/durations and bypass approval (from revision [19424]) Add 'closed' session purpose, assign purposes for nomcom groups, and update schedule editor to enforce timeslot type and allow blurring sessions by purpose (from revision [19427]) Add management command to set up timeslots/sessions for testing/demoing 'purpose' field (from revision [19430]) Update session purposes and group type -> purpose map to match notes page, change 'session' purpose to 'regular' (from revision [19433]) Redirect edit_schedule urls to edit_meeting_schedule view (from revision [19434]) Allow hiding/blurring sessions and timeslots based on TimeSlotType in the schedule editor (from revision [19438]) Disable session purpose/timeslot type hiding on schedule editor when only 0 or 1 options (from revision [19439]) Improvements to the timeslot and schedule editors (move new toggles to modals, handle overflowing session names, fix timeslot editor scrolling, add buttons to quickly create single timeslot, accept trailing slash on edit URL) (from revision [19449]) Update purpose/types after discussions, add on_agenda Session field, prevent session requests for groups with no allowed purpose, handle addition fields in session request, fix editing session requests, add session edit form/access from schedule editor, eliminate TimeSlotTypeName "private" field, add server-side timeslot type filtering to schedule editor (from revision [19549]) Eliminate the officehours timeslot type, update/renumber migrations, mark offagenda/reserved TimeSlotTypeNames as not used, add a 'none' SessionPurposeName and disallow null, update agenda filter keywords/filter helpers, fix broken tests and general debugging (from revision [19550]) Tear out old meeting schedule editor and related code (from revision [19551]) Fix merge errors in preceding commits (from revision [19556]) - Legacy-Id: 19570 Note: SVN reference [19415] has been migrated to Git commit1054f90873
Note: SVN reference [19424] has been migrated to Git commit5318081608
Note: SVN reference [19427] has been migrated to Git commit173e438aee
Note: SVN reference [19430] has been migrated to Git commit7a2530a0a6
Note: SVN reference [19433] has been migrated to Git commit3be50d6e39
Note: SVN reference [19434] has been migrated to Git commit3e3d681e5f
Note: SVN reference [19438] has been migrated to Git commitb6ac3d4b1d
Note: SVN reference [19439] has been migrated to Git commit446ac7a47e
Note: SVN reference [19449] has been migrated to Git commit5cbe402036
Note: SVN reference [19549] has been migrated to Git commit3dfce7b850
Note: SVN reference [19550] has been migrated to Git commit7b35c09c40
Note: SVN reference [19551] has been migrated to Git commitd7f20342b6
Note: SVN reference [19556] has been migrated to Git commit2b1864f5a0
343 lines
12 KiB
Python
343 lines
12 KiB
Python
# Copyright The IETF Trust 2012-2020, All Rights Reserved
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
import datetime
|
|
import json
|
|
import re
|
|
|
|
import jsonfield
|
|
|
|
import debug # pyflakes:ignore
|
|
|
|
from typing import Optional, Type # pyflakes:ignore
|
|
|
|
from django import forms
|
|
from django.db import models # pyflakes:ignore
|
|
from django.core.validators import validate_email
|
|
from django.core.exceptions import ValidationError
|
|
from django.utils.dateparse import parse_duration
|
|
|
|
class MultiEmailField(forms.Field):
|
|
def to_python(self, value):
|
|
"Normalize data to a list of strings."
|
|
|
|
# Return an empty list if no input was given.
|
|
if not value:
|
|
return []
|
|
|
|
if isinstance(value, str):
|
|
values = value.split(',')
|
|
return [ x.strip() for x in values if x.strip() ]
|
|
else:
|
|
return value
|
|
|
|
def validate(self, value):
|
|
"Check if value consists only of valid emails."
|
|
# Use the parent's handling of required fields, etc.
|
|
super(MultiEmailField, self).validate(value)
|
|
|
|
for email in value:
|
|
validate_email(email)
|
|
|
|
def yyyymmdd_to_strftime_format(fmt):
|
|
translation_table = sorted([
|
|
("yyyy", "%Y"),
|
|
("yy", "%y"),
|
|
("mm", "%m"),
|
|
("m", "%-m"),
|
|
("MM", "%B"),
|
|
("M", "%b"),
|
|
("dd", "%d"),
|
|
("d", "%-d"),
|
|
], key=lambda t: len(t[0]), reverse=True)
|
|
|
|
res = ""
|
|
remaining = fmt
|
|
while remaining:
|
|
for pattern, replacement in translation_table:
|
|
if remaining.startswith(pattern):
|
|
res += replacement
|
|
remaining = remaining[len(pattern):]
|
|
break
|
|
else:
|
|
res += remaining[0]
|
|
remaining = remaining[1:]
|
|
return res
|
|
|
|
|
|
class DatepickerMedia:
|
|
"""Media definitions needed for Datepicker widgets"""
|
|
css = dict(all=('bootstrap-datepicker/css/bootstrap-datepicker3.min.css',))
|
|
js = ('bootstrap-datepicker/js/bootstrap-datepicker.min.js',)
|
|
|
|
|
|
class DatepickerDateInput(forms.DateInput):
|
|
"""DateInput that uses the Bootstrap datepicker
|
|
|
|
The format must be in the Bootstrap datepicker format (yyyy-mm-dd, e.g.), not the
|
|
strftime format. The picker_settings argument is a dict of parameters for the datepicker,
|
|
converting their camelCase names to dash-separated lowercase names and omitting the
|
|
'data-date' prefix to the key.
|
|
"""
|
|
Media = DatepickerMedia
|
|
|
|
def __init__(self, attrs=None, date_format=None, picker_settings=None):
|
|
super().__init__(
|
|
attrs,
|
|
yyyymmdd_to_strftime_format(date_format),
|
|
)
|
|
self.attrs.setdefault('data-provide', 'datepicker')
|
|
self.attrs.setdefault('data-date-format', date_format)
|
|
self.attrs.setdefault("data-date-autoclose", "1")
|
|
self.attrs.setdefault('placeholder', date_format)
|
|
if picker_settings is not None:
|
|
for k, v in picker_settings.items():
|
|
self.attrs['data-date-{}'.format(k)] = v
|
|
|
|
|
|
class DatepickerSplitDateTimeWidget(forms.SplitDateTimeWidget):
|
|
"""Split datetime widget using Bootstrap datepicker
|
|
|
|
The format must be in the Bootstrap datepicker format (yyyy-mm-dd, e.g.), not the
|
|
strftime format. The picker_settings argument is a dict of parameters for the datepicker,
|
|
converting their camelCase names to dash-separated lowercase names and omitting the
|
|
'data-date' prefix to the key.
|
|
"""
|
|
Media = DatepickerMedia
|
|
|
|
def __init__(self, *, date_format='yyyy-mm-dd', picker_settings=None, **kwargs):
|
|
date_attrs = kwargs.setdefault('date_attrs', dict())
|
|
date_attrs.setdefault("data-provide", "datepicker")
|
|
date_attrs.setdefault("data-date-format", date_format)
|
|
date_attrs.setdefault("data-date-autoclose", "1")
|
|
date_attrs.setdefault("placeholder", date_format)
|
|
if picker_settings is not None:
|
|
for k, v in picker_settings.items():
|
|
date_attrs['data-date-{}'.format(k)] = v
|
|
super().__init__(date_format=yyyymmdd_to_strftime_format(date_format), **kwargs)
|
|
|
|
|
|
class DatepickerDateField(forms.DateField):
|
|
"""DateField with some glue for triggering JS Bootstrap datepicker"""
|
|
def __init__(self, date_format, picker_settings=None, *args, **kwargs):
|
|
strftime_format = yyyymmdd_to_strftime_format(date_format)
|
|
kwargs["input_formats"] = [strftime_format]
|
|
kwargs["widget"] = DatepickerDateInput(dict(placeholder=date_format), date_format, picker_settings)
|
|
super(DatepickerDateField, self).__init__(*args, **kwargs)
|
|
|
|
|
|
# This accepts any ordered combination of labelled days, hours, minutes, seconds
|
|
ext_duration_re = re.compile(
|
|
r'^'
|
|
r'(?:(?P<days>-?\d+) ?(?:d|days))?'
|
|
r'(?:[, ]*(?P<hours>-?\d+) ?(?:h|hours))?'
|
|
r'(?:[, ]*(?P<minutes>-?\d+) ?(?:m|minutes))?'
|
|
r'(?:[, ]*(?P<seconds>-?\d+) ?(?:s|seconds))?'
|
|
r'$'
|
|
)
|
|
# This requires hours and minutes, and accepts optional X days and :SS
|
|
mix_duration_re = re.compile(
|
|
r'^'
|
|
r'(?:(?P<days>-?\d+) ?(?:d|days)[, ]*)?'
|
|
r'(?:(?P<hours>-?\d+))'
|
|
r'(?::(?P<minutes>-?\d+))'
|
|
r'(?::(?P<seconds>-?\d+))?'
|
|
r'$'
|
|
)
|
|
|
|
def parse_duration_ext(value):
|
|
if value.strip() != '':
|
|
match = ext_duration_re.match(value)
|
|
if not match:
|
|
match = mix_duration_re.match(value)
|
|
if not match:
|
|
return parse_duration(value)
|
|
else:
|
|
kw = match.groupdict()
|
|
kw = {k: float(v) for k, v in kw.items() if v is not None}
|
|
return datetime.timedelta(**kw)
|
|
|
|
class DurationField(forms.DurationField):
|
|
def to_python(self, value):
|
|
if value in self.empty_values:
|
|
return None
|
|
if isinstance(value, datetime.timedelta):
|
|
return value
|
|
value = parse_duration_ext(value)
|
|
if value is None:
|
|
raise ValidationError(self.error_messages['invalid'], code='invalid')
|
|
return value
|
|
|
|
|
|
class SearchableTextInput(forms.TextInput):
|
|
class Media:
|
|
css = {
|
|
'all': (
|
|
'select2/select2.css',
|
|
'select2-bootstrap-css/select2-bootstrap.min.css',
|
|
)
|
|
}
|
|
js = (
|
|
'select2/select2.min.js',
|
|
'ietf/js/select2-field.js',
|
|
)
|
|
|
|
# FIXME: select2 version 4 uses a standard select for the AJAX case -
|
|
# switching to that would allow us to derive from the standard
|
|
# multi-select machinery in Django instead of the manual CharField
|
|
# stuff below
|
|
|
|
class SearchableField(forms.CharField):
|
|
"""Base class for searchable fields
|
|
|
|
The field uses a comma-separated list of primary keys in a CharField element as its
|
|
API with some extra attributes used by the Javascript part.
|
|
|
|
When used in a form, the template rendering that form must include the form's media.
|
|
This is done by putting {{ form.media }} in a header block. If CSS and JS should be
|
|
separated for the template, use {{ form.media.css }} and {{ form.media.js }} instead.
|
|
|
|
To make a usable subclass, you must fill in the model (either as a class-scoped variable
|
|
or in the __init__() method before calling the superclass __init__()) and define
|
|
the make_select2_data() and ajax_url() methods. You likely want to provide a more
|
|
specific default_hint_text as well.
|
|
"""
|
|
widget = SearchableTextInput
|
|
# model = None # must be filled in by subclass
|
|
model = None # type:Optional[Type[models.Model]]
|
|
# max_entries = None # may be overridden in __init__
|
|
max_entries = None # type: Optional[int]
|
|
default_hint_text = 'Type a value to search'
|
|
|
|
def __init__(self, hint_text=None, *args, **kwargs):
|
|
assert self.model is not None
|
|
self.hint_text = hint_text if hint_text is not None else self.default_hint_text
|
|
kwargs["max_length"] = 10000
|
|
# Pop max_entries out of kwargs - this distinguishes passing 'None' from
|
|
# not setting the parameter at all.
|
|
if 'max_entries' in kwargs:
|
|
self.max_entries = kwargs.pop('max_entries')
|
|
|
|
super(SearchableField, self).__init__(*args, **kwargs)
|
|
|
|
self.widget.attrs["class"] = "select2-field form-control"
|
|
self.widget.attrs["data-placeholder"] = self.hint_text
|
|
if self.max_entries is not None:
|
|
self.widget.attrs["data-max-entries"] = self.max_entries
|
|
|
|
def make_select2_data(self, model_instances):
|
|
"""Get select2 data items
|
|
|
|
Should return an array of dicts, each with at least 'id' and 'text' keys.
|
|
"""
|
|
raise NotImplementedError('Must implement make_select2_data')
|
|
|
|
def ajax_url(self):
|
|
"""Get the URL for AJAX searches
|
|
|
|
Doing this in the constructor is difficult because the URL patterns may not have been
|
|
fully constructed there yet.
|
|
"""
|
|
raise NotImplementedError('Must implement ajax_url')
|
|
|
|
def get_model_instances(self, item_ids):
|
|
"""Get model instances corresponding to item identifiers in select2 field value
|
|
|
|
Default implementation expects identifiers to be model pks. Return value is an iterable.
|
|
"""
|
|
return self.model.objects.filter(pk__in=item_ids)
|
|
|
|
def validate_pks(self, pks):
|
|
"""Validate format of PKs
|
|
|
|
Base implementation does nothing, but subclasses may override if desired.
|
|
Should raise a forms.ValidationError in case of a failed validation.
|
|
"""
|
|
pass
|
|
|
|
def describe_failed_pks(self, failed_pks):
|
|
"""Format error message to display when non-existent PKs are referenced"""
|
|
return ('Could not recognize the following {model_name}s: {pks}. '
|
|
'You can only input {model_name}s already registered in the Datatracker.'.format(
|
|
pks=', '.join(failed_pks),
|
|
model_name=self.model.__name__.lower())
|
|
)
|
|
|
|
def parse_select2_value(self, value):
|
|
"""Parse select2 field value into individual item identifiers"""
|
|
return [x.strip() for x in value.split(",") if x.strip()]
|
|
|
|
def prepare_value(self, value):
|
|
if not value:
|
|
value = ""
|
|
if isinstance(value, int):
|
|
value = str(value)
|
|
if isinstance(value, str):
|
|
item_ids = self.parse_select2_value(value)
|
|
value = self.get_model_instances(item_ids)
|
|
if isinstance(value, self.model):
|
|
value = [value]
|
|
|
|
self.widget.attrs["data-pre"] = json.dumps({
|
|
d['id']: d for d in self.make_select2_data(value)
|
|
})
|
|
|
|
# doing this in the constructor is difficult because the URL
|
|
# patterns may not have been fully constructed there yet
|
|
self.widget.attrs["data-ajax-url"] = self.ajax_url()
|
|
|
|
return ",".join(str(o.pk) for o in value)
|
|
|
|
def clean(self, value):
|
|
value = super(SearchableField, self).clean(value)
|
|
pks = self.parse_select2_value(value)
|
|
self.validate_pks(pks)
|
|
|
|
try:
|
|
objs = self.model.objects.filter(pk__in=pks)
|
|
except ValueError as e:
|
|
raise forms.ValidationError('Unexpected field value; {}'.format(e))
|
|
|
|
found_pks = [ str(o.pk) for o in objs ]
|
|
failed_pks = [ x for x in pks if x not in found_pks ]
|
|
if failed_pks:
|
|
raise forms.ValidationError(self.describe_failed_pks(failed_pks))
|
|
|
|
if self.max_entries != None and len(objs) > self.max_entries:
|
|
raise forms.ValidationError('You can select at most {} {}.'.format(
|
|
self.max_entries,
|
|
'entry' if self.max_entries == 1 else 'entries',
|
|
))
|
|
|
|
return objs.first() if self.max_entries == 1 else objs
|
|
|
|
|
|
class IETFJSONField(jsonfield.fields.forms.JSONField):
|
|
def __init__(self, *args, empty_values=jsonfield.fields.forms.JSONField.empty_values,
|
|
accepted_empty_values=None, **kwargs):
|
|
if accepted_empty_values is None:
|
|
accepted_empty_values = []
|
|
self.empty_values = [x
|
|
for x in empty_values
|
|
if x not in accepted_empty_values]
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
class MissingOkImageField(models.ImageField):
|
|
"""Image field that can validate successfully if file goes missing
|
|
|
|
The default ImageField fails even to validate if its back-end file goes
|
|
missing, at least when width_field and height_field are used. This ignores
|
|
the exception that arises. Without this, even deleting a model instance
|
|
through a form fails.
|
|
"""
|
|
def update_dimension_fields(self, *args, **kwargs):
|
|
try:
|
|
super().update_dimension_fields(*args, **kwargs)
|
|
except FileNotFoundError:
|
|
pass # don't do anything if the file has gone missing
|
|
|
|
|