datatracker/ietf/utils/fields.py
Jennifer Richards bcd37edfcd Merged from session_purpose_dev
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 commit 1054f90873

Note: SVN reference [19424] has been migrated to Git commit 5318081608

Note: SVN reference [19427] has been migrated to Git commit 173e438aee

Note: SVN reference [19430] has been migrated to Git commit 7a2530a0a6

Note: SVN reference [19433] has been migrated to Git commit 3be50d6e39

Note: SVN reference [19434] has been migrated to Git commit 3e3d681e5f

Note: SVN reference [19438] has been migrated to Git commit b6ac3d4b1d

Note: SVN reference [19439] has been migrated to Git commit 446ac7a47e

Note: SVN reference [19449] has been migrated to Git commit 5cbe402036

Note: SVN reference [19549] has been migrated to Git commit 3dfce7b850

Note: SVN reference [19550] has been migrated to Git commit 7b35c09c40

Note: SVN reference [19551] has been migrated to Git commit d7f20342b6

Note: SVN reference [19556] has been migrated to Git commit 2b1864f5a0
2021-11-09 01:35:25 +00:00

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