wcs/wcs/qommon/form.py

3609 lines
124 KiB
Python

# w.c.s. - web application for online forms
# Copyright (C) 2005-2010 Entr'ouvert
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <http://www.gnu.org/licenses/>.
import base64
import collections
import copy
import datetime
import fnmatch
import hashlib
import html
import io
import itertools
import json
import keyword
import mimetypes
import os
import random
import re
import sys
import tempfile
import time
from functools import partial
import dns
import dns.exception
import dns.resolver
import emoji
from bleach import Cleaner
from bleach.linkifier import LinkifyFilter
from PIL import Image
try:
import magic
except ImportError:
magic = None
import quixote
import quixote.form.widget
from django.conf import settings
from django.utils.encoding import force_bytes, force_str
from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from quixote import get_publisher, get_request, get_response, get_session
from quixote.form import CheckboxWidget as QuixoteCheckboxWidget
from quixote.form import FileWidget
from quixote.form import Form as QuixoteForm
from quixote.form import HiddenWidget, IntWidget, MultipleSelectWidget, PasswordWidget, SelectWidget
from quixote.form import StringWidget as QuixoteStringWidget
from quixote.form import TextWidget as QuixoteTextWidget
from quixote.form import Widget
from quixote.html import TemplateIO, htmlescape, htmltag, htmltext, stringify
from wcs.conditions import Condition, ValidationError
from ..portfolio import has_portfolio
from . import _, force_str, misc, ngettext
from .humantime import humanduration2seconds, seconds2humanduration, timewords
from .misc import HAS_PDFTOPPM, json_loads, strftime
from .publisher import get_cfg
from .template import Template, TemplateError
from .template import render as render_template
from .template_utils import render_block_to_string
from .upload_storage import PicklableUpload # noqa pylint: disable=unused-import
from .upload_storage import UploadStorageError, get_storage_object
Widget.REQUIRED_ERROR = _('required field')
get_error_orig = Widget.get_error
def is_prefilled(self):
if hasattr(self, 'prefilled'):
return self.prefilled
else:
return False
def render_title(self, title):
if title:
title = get_publisher().translate(title)
if self.required:
title += htmltext('<span title="%s" class="required">*</span>') % _('This field is required.')
attrs = {
'class': 'field--label',
'id': 'form_label_%s' % self.get_name_for_id(),
}
if not getattr(self, 'has_inside_labels', False):
attrs['for'] = 'form_%s' % self.get_name_for_id()
label = htmltag('label', **attrs)
return htmltext('<div class="title">') + label + htmltext('%s</label></div>') % title
else:
return ''
def render_hint(self, hint):
if not hint:
return ''
return htmltext('<div id="form_hint_%s" class="hint"><p>%s</p></div>') % (self.get_name_for_id(), hint)
def render_error(self, error):
if not error:
return ''
return htmltext('<div id="form_error_%s" class="error"><p>%s</p></div>') % (self.get_name_for_id(), error)
def get_template_names(widget):
template_names = []
widget_template_name = getattr(widget, 'template_name', None)
for extra_css_class in (getattr(widget, 'extra_css_class', '') or '').split():
if not extra_css_class.startswith('template-'):
continue
template_name = extra_css_class.split('-', 1)[1]
# full template
template_names.append('qommon/forms/widgets/%s.html' % template_name)
if widget_template_name:
# widget specific variation
template_names.append(widget_template_name.replace('.html', '--%s.html' % template_name))
if widget_template_name:
template_names.append(widget_template_name)
template_names.append('qommon/forms/widget.html')
return template_names
def render(self):
# quixote/form/widget.py, Widget::render
def safe(text):
return mark_safe(str(htmlescape(text)))
if hasattr(self, 'add_media'):
self.add_media()
self.class_name = self.__class__.__name__
self.rendered_title = lambda: safe(self.render_title(self.get_title()))
self.rendered_error = lambda: safe(self.render_error(self.get_error()))
self.rendered_hint = lambda: safe(self.render_hint(self.get_hint()))
if get_publisher():
context = get_publisher().substitutions.get_context_variables(mode='lazy')
else:
context = {}
context['widget'] = self
hint = self.get_hint()
if hint:
self.attrs['aria-describedby'] = 'form_hint_%s' % self.get_name_for_id()
error = self.get_error()
if error:
self.attrs['aria-invalid'] = 'true'
if 'aria-describedby' not in self.attrs:
self.attrs['aria-describedby'] = 'form_error_%s' % self.get_name_for_id()
else:
self.attrs['aria-describedby'] += ' form_error_%s' % self.get_name_for_id()
template_names = get_template_names(self)
return htmltext(render_template(template_names, context))
def render_widget_content(self):
# widget content (without label, hint, etc.) is reused on status page;
# render the appropriate block.
self.add_media()
template_names = get_template_names(self)
context = {'widget': self}
return htmltext(force_str(render_block_to_string(template_names, 'widget-content', context)))
def widget_get_name_for_id(self):
return self.name.replace('$', '__')
Widget.render = render
Widget.cleanup = None
Widget.render_error = render_error
Widget.render_hint = render_hint
Widget.render_title = render_title
Widget.is_prefilled = is_prefilled
Widget.render_widget_content = render_widget_content
Widget.get_name_for_id = widget_get_name_for_id
def file_render_content(self):
# remove trailing __file for identifier
attrs = {'id': 'form_' + self.get_name_for_id().rsplit('__', 1)[0]}
if self.required:
attrs['aria-required'] = 'true'
if self.attrs:
attrs.update(self.attrs)
return htmltag("input", xml_end=True, type=self.HTML_TYPE, name=self.name, value=self.value, **attrs)
FileWidget.render_content = file_render_content
class SubmitWidget(quixote.form.widget.SubmitWidget):
def __init__(self, *args, **kwargs):
self.extra_css_class = kwargs.pop('extra_css_class', None)
super().__init__(*args, **kwargs)
def render_content(self):
if self.name in ('cancel', 'previous', 'save-draft'):
self.attrs['formnovalidate'] = 'formnovalidate'
label = self.label or ''
if label and 'aria-label' not in self.attrs:
cleaned_label = emoji.replace_emoji(label, replace='').strip()
if cleaned_label and cleaned_label != label:
self.attrs['aria-label'] = cleaned_label
return (
htmltag('button', name=self.name, value=htmlescape(label), **self.attrs)
+ str(label)
+ htmltext('</button>')
)
class RadiobuttonsWidget(quixote.form.RadiobuttonsWidget):
template_name = 'qommon/forms/widgets/radiobuttons.html'
has_inside_labels = True
a11y_labelledby = True
a11y_role = 'group'
content_extra_attributes = {'role': 'radiogroup'}
def __init__(self, name, value=None, **kwargs):
self.extra_css_class = kwargs.pop('extra_css_class', None)
self.options_with_attributes = kwargs.pop('options_with_attributes', None)
super().__init__(name, value=value, **kwargs)
def get_options(self):
options = self.options_with_attributes or self.options
for option in options:
object, description, key = option[:3]
yield {
'value': key,
'label': description,
'disabled': bool(self.options_with_attributes and option[-1].get('disabled')),
'selected': self.is_selected(object),
}
def get_selection_error_text(*args):
return _('invalid value selected')
def set_value(self, value):
self.value = value
SelectWidget.SELECTION_ERROR = property(get_selection_error_text)
SelectWidget.set_value = set_value
def transfer_form_value(self, request):
# transfer form value (set in constructor, or using set_value) to
# request.form{}
request.form[self.name] = self.value
Widget.transfer_form_value = transfer_form_value
class Form(QuixoteForm):
TOKEN_NOTICE = _(
"The form you have submitted is invalid. Most "
"likely it has been successfully submitted once "
"already. Please review the form data "
"and submit the form again."
)
ERROR_NOTICE = _("There were errors processing your form. See below for details.")
info = None
captcha = None
advanced_label = '+'
def __init__(self, *args, **kwargs):
self.advanced_label = kwargs.pop('advanced_label', self.advanced_label)
self.use_tabs = kwargs.pop('use_tabs', False)
QuixoteForm.__init__(self, *args, **kwargs)
self.attrs['novalidate'] = 'novalidate'
self.global_error_messages = None
def add_captcha(self, hint=None):
if not self.captcha and not (get_session().won_captcha or get_session().user):
self.captcha = CaptchaWidget('captcha', hint=hint)
def add_submit(self, name, value=None, **kwargs):
return self.add(SubmitWidget, name, value, **kwargs)
def add(self, widget_class, name, *args, **kwargs):
if kwargs and 'render_br' not in kwargs:
kwargs['render_br'] = False
advanced = False
if kwargs and kwargs.get('advanced', False):
advanced = True
del kwargs['advanced']
default_value = kwargs.pop('default_value', Ellipsis)
if self.use_tabs:
tab = kwargs.pop('tab', None)
QuixoteForm.add(self, widget_class, name, *args, **kwargs)
widget = self._names[name]
if 'id' not in kwargs and 'id' in widget.attrs:
# don't let quixote3 assign a default id to widgets
del widget.attrs['id']
widget.advanced = advanced
if default_value is not Ellipsis:
widget.is_not_default = getattr(widget, 'value', None) not in (None, default_value)
else:
widget.is_not_default = bool(getattr(widget, 'value', None))
if self.use_tabs:
if tab:
widget.tab = tab
elif advanced:
widget.tab = ('advanced', _('Advanced'))
else:
widget.tab = ('general', _('General'))
return widget
def set_error(self, name, error):
super().set_error(name, force_str(error))
def remove(self, name):
widget = self._names.get(name)
if widget:
del self._names[name]
self.widgets.remove(widget)
def get_all_widgets(self):
l = QuixoteForm.get_all_widgets(self)
if self.captcha:
l.append(self.captcha)
return l
def add_global_errors(self, error_messages):
self.global_error_messages = error_messages
def _get_default_action(self):
if get_request().get_header('x-popup') == 'true':
# do not leave action empty for popups, as they get embedded into
# another URI
return get_request().get_path()
return QuixoteForm._get_default_action(self)
def render_button(self, button):
r = TemplateIO(html=True)
classnames = '%s widget %s-button %s' % (
button.__class__.__name__,
button.name,
getattr(button, 'extra_css_class', None) or '',
)
r += htmltext('<div class="%s">') % classnames
r += htmltext('<div class="content">')
r += button.render_content()
r += htmltext('</div>')
r += htmltext('</div>')
r += htmltext('\n')
return r.getvalue()
def get_initial_tab_index(self):
if hasattr(self, 'initial_tab'):
return self.tabs.index(self.initial_tab)
return 0
def _render_start(self):
r = TemplateIO(html=True)
if self.use_tabs:
r += htmltext('<div class="pk-tabs form-with-tabs">')
self.tabs = []
for widget in self.widgets:
if widget.tab not in self.tabs:
self.tabs.append(widget.tab)
self.tabs.sort(key=lambda x: bool(x[0] == 'advanced')) # make "advanced" last tab
r += htmltext('<div class="pk-tabs--tab-list" role="tablist">')
initial_tab_index = self.get_initial_tab_index()
for i, tab in enumerate(self.tabs):
tab_slug, tab_label = tab
attrs = {
'role': 'tab',
'aria-selected': 'true' if i == initial_tab_index else 'false',
'aria-controls': 'panel-%s' % tab_slug,
'id': 'tab-%s' % tab_slug,
'tabindex': '0' if i == initial_tab_index else '-1',
}
if any(getattr(x, 'is_not_default', False) for x in self.widgets if x.tab == tab):
attrs['class'] = 'pk-tabs--button-marker'
r += htmltag('button', **attrs) + str(tab_label) + htmltext('</button>')
r += htmltext('</div>')
r += htmltext('<div class="pk-tabs--container">')
r += super()._render_start()
return r.getvalue()
def _render_finish(self):
r = super()._render_finish()
if self.use_tabs:
r += htmltext('</div>')
r += htmltext('</div>')
return r
def _render_submit_widgets(self):
r = TemplateIO(html=True)
if self.submit_widgets:
r += htmltext('<div class="buttons submit">')
for widget in self.submit_widgets:
r += self.render_button(widget)
r += htmltext('</div>')
return r.getvalue()
def _render_error_notice(self):
errors = []
classnames = ['errornotice']
if self.has_errors():
errors.append(QuixoteForm._render_error_notice(self))
if self.global_error_messages:
errors.extend(self.global_error_messages)
classnames.append('global-errors')
t = TemplateIO(html=True)
t += htmltext('<div class="%s" role="status">' % ' '.join(classnames))
for error in errors:
if isinstance(error, htmltext):
t += error
else:
t += htmltext('<p>%s</p>') % error
t += htmltext('</div>')
return t.getvalue()
def _render_body(self):
r = TemplateIO(html=True)
if self.has_errors() or self.global_error_messages:
r += self._render_error_notice()
r += self._render_widgets()
if self.captcha:
r += self.captcha.render()
r += self._render_submit_widgets()
return r.getvalue()
def _render_widgets(self):
r = TemplateIO(html=True)
if self.use_tabs:
initial_tab_index = self.get_initial_tab_index()
for i, tab in enumerate(self.tabs):
tab_slug = tab[0]
tab_attrs = {
'id': 'panel-%s' % tab_slug,
'role': 'tabpanel',
'tabindex': '0' if i == initial_tab_index else '-1',
'data-tab-slug': tab_slug,
'aria-labelledby': 'tab-%s' % tab_slug,
}
if i != initial_tab_index:
tab_attrs['hidden'] = 'hidden'
widgets = [x for x in self.widgets if x.tab == tab]
if widgets:
r += htmltag('div', **tab_attrs)
for widget in widgets:
r += widget.render()
r += htmltext('</div>')
return r.getvalue()
advanced_widgets = []
for widget in self.widgets:
if not hasattr(widget, 'advanced') or not widget.advanced:
r += widget.render()
else:
advanced_widgets.append(widget)
if advanced_widgets:
get_response().add_javascript(['jquery.js', 'qommon.forms.js'])
r += htmltext('<fieldset class="form-plus">')
r += htmltext('<legend>%s</legend>') % self.advanced_label
for widget in advanced_widgets:
r += widget.render()
r += htmltext('</fieldset>')
return r.getvalue()
def add_media(self):
for widget in self.get_all_widgets():
if hasattr(widget, 'add_media'):
widget.add_media()
class HtmlWidget:
error = None
name = None
def __init__(self, string, title=None, *args, **kwargs):
self.attrs = {}
self.string = string
self.title = title
def render(self):
return self.render_content()
def render_content(self):
content = self.title or self.string or ''
if getattr(self, 'is_hidden', False):
content = htmltext(str(content).replace('>', ' style="display: none">', 1))
return htmltext(content)
def has_error(self, request=None):
return False
def parse(self, *args):
pass
def clear_error(self, request=None):
pass
def transfer_form_value(self, request):
pass
class CommentWidget(Widget):
template_name = 'qommon/forms/widgets/comment.html'
def __init__(self, content, extra_css_class):
super().__init__(name='')
self.content = content
self.extra_css_class = extra_css_class
def has_error(self, request):
return False
def parse(self, *args, **kwargs):
pass
def clear_error(self, request=None):
pass
class CompositeWidget(quixote.form.CompositeWidget):
content_extra_attributes = {'role': 'group'}
def add_hidden(self, name, value=None, **kwargs):
self.add(HiddenWidget, name, value, **kwargs)
def transfer_form_value(self, request):
for widget in self.get_widgets():
widget.transfer_form_value(request)
def render_as_thead(self):
r = TemplateIO(html=True)
r += htmltext('<tr>\n')
for widget in self.get_widgets():
r += htmltext('<th scope="col">')
r += str(widget.get_title())
r += htmltext('</th>')
r += htmltext('</tr>\n')
return r.getvalue()
def render_content_as_tr(self):
r = TemplateIO(html=True)
r += htmltext('<tr>\n')
for widget in self.get_widgets():
extra_attributes = ''
classnames = '%s widget' % widget.__class__.__name__
if hasattr(widget, 'extra_css_class') and widget.extra_css_class:
classnames += ' ' + widget.extra_css_class
if hasattr(widget, 'content_extra_attributes'):
extra_attributes = ' '.join(['%s=%s' % x for x in widget.content_extra_attributes.items()])
r += htmltext('<td><div class="%s"><div class="content" %s>' % (classnames, extra_attributes))
r += widget.render_content()
r += widget.render_error(widget.get_error())
r += htmltext('</div></div></td>')
r += htmltext('</tr>\n')
return r.getvalue()
class StringWidget(QuixoteStringWidget):
def __init__(self, name, *args, **kwargs):
if 'readonly' in kwargs and not kwargs.get('readonly'):
del kwargs['readonly']
elif 'readonly' in kwargs:
kwargs['readonly'] = 'readonly'
self.maxlength = kwargs.get('maxlength', None)
self.validation_function = kwargs.pop('validation_function', None)
super().__init__(name, *args, **kwargs)
def _parse(self, request):
QuixoteStringWidget._parse(self, request)
if self.value:
self.value = self.value.strip()
if self.maxlength and len(self.value) > self.maxlength:
self.error = _('Too long, value must be at most %d characters.') % self.maxlength
elif self.validation_function:
try:
self.validation_function(self.value)
except ValueError as e:
self.error = str(e)
def render_content(self):
attrs = {'id': 'form_' + self.get_name_for_id()}
if self.required:
attrs['aria-required'] = 'true'
if getattr(self, 'prefill_attributes', None) and 'autocomplete' in self.prefill_attributes:
attrs['autocomplete'] = self.prefill_attributes['autocomplete']
if self.attrs:
attrs.update(self.attrs)
if getattr(self, 'inputmode', None):
attrs['inputmode'] = self.inputmode
return htmltag("input", xml_end=True, type=self.HTML_TYPE, name=self.name, value=self.value, **attrs)
class DurationWidget(StringWidget):
def __init__(self, name, value=None, **kwargs):
if value:
value = seconds2humanduration(int(value))
if 'hint' in kwargs:
kwargs['hint'] = str(kwargs['hint']) + htmltext('<br>')
else:
kwargs['hint'] = ''
kwargs['hint'] += htmltext(_('Usable units of time: %s.')) % ', '.join(timewords())
super().__init__(name, value=value, **kwargs)
def parse(self, request=None):
value = super().parse(request)
return str(humanduration2seconds(self.value)) if value else None
class TextWidget(QuixoteTextWidget):
prefill_attributes = None
def __init__(self, name, *args, **kwargs):
self.validation_function = kwargs.pop('validation_function', None)
super().__init__(name, *args, **kwargs)
def add_media(self):
if self.prefill_attributes and 'geolocation' in self.prefill_attributes:
get_response().add_javascript(['qommon.geolocation.js'])
def _parse(self, request, use_validation_function=True):
QuixoteTextWidget._parse(self, request)
if self.value is not None:
try:
maxlength = int(self.attrs.get('maxlength', 0))
except (TypeError, ValueError):
maxlength = 0
if maxlength:
if len(self.value) > maxlength:
self.error = _('too many characters (limit is %d)') % maxlength
if use_validation_function and self.validation_function:
try:
self.validation_function(self.value)
except ValueError as e:
self.error = str(e)
def render_content(self):
attrs = {'id': 'form_' + self.get_name_for_id()}
if self.required:
attrs['aria-required'] = 'true'
if self.attrs:
attrs.update(self.attrs)
if not attrs.get('cols'):
attrs['cols'] = 72
if not attrs.get('rows'):
attrs['rows'] = 5
if attrs.get('readonly') and not self.value:
attrs['rows'] = 1
return (
htmltag("textarea", name=self.name, **attrs)
+ htmlescape(self.value or "")
+ htmltext("</textarea>")
)
class CheckboxWidget(QuixoteCheckboxWidget):
"""
Widget just like CheckboxWidget but with an effective support for the
required attribute, if required the checkbox will have to be checked.
"""
template_name = 'qommon/forms/widgets/checkbox.html'
def _parse(self, request):
self.value = self.name in request.form and not request.form[self.name] in (False, '', 'False')
if self.required and not self.value:
self.set_error(self.REQUIRED_ERROR)
def set_value(self, value):
if value in (None, False, '', 'False'):
self.value = False
else:
self.value = True
def render_content(self, standalone=True):
attrs = {'id': 'form_' + self.get_name_for_id()}
if self.required:
attrs['aria-required'] = 'true'
inline_title = self.attrs.pop('inline_title', '')
if self.attrs:
attrs.update(self.attrs)
if attrs.pop('readonly', None) and not attrs.get('disabled'):
# hack to restore value on click
attrs['onclick'] = 'this.checked = !this.checked;'
checkbox = htmltag(
"input",
xml_end=True,
type="checkbox",
name=self.name,
value="yes",
checked=self.value and "checked" or None,
**attrs,
)
if standalone:
data_attrs = ' '.join('%s="%s"' % x for x in attrs.items() if x[0].startswith('data-'))
# more elaborate markup so standalone checkboxes can be applied a
# custom style.
return (
htmltext('<label %s>%s<span>' % (data_attrs, checkbox))
+ str(inline_title)
+ htmltext('</span></label>')
)
return checkbox
class UploadedFile:
def __init__(self, directory, filename, upload):
self.directory = directory
self.base_filename = upload.base_filename
self.content_type = upload.content_type
# Find a good filename
if filename:
self.filename = filename
elif self.base_filename:
self.filename = self.base_filename
else:
t = datetime.datetime.now().isoformat()
fd = tempfile.mkstemp(prefix=t, suffix='.upload', dir=self.dir_path())[0]
os.close(fd)
self.filename = os.path.basename(filename)
if upload.fp:
self.set_content(upload.fp.read())
def set_content(self, content):
self.size = len(content)
file_path = self.build_file_path()
if not os.path.exists(self.dir_path()):
os.mkdir(self.dir_path())
with open(file_path, 'wb') as f:
f.write(content)
def dir_path(self):
return os.path.join(get_publisher().app_dir, self.directory)
def build_file_path(self):
return os.path.join(get_publisher().app_dir, self.directory, self.filename)
def get_file(self):
return open(self.build_file_path(), 'rb') # pylint: disable=consider-using-with
def get_content(self):
return self.get_file().read()
def build_response(self):
response = get_response()
response.content_type = self.content_type
response.set_header('content-disposition', 'attachment; filename="%s"' % self.base_filename)
return self.get_file().read()
class FileWithPreviewWidget(CompositeWidget):
"""Widget that proposes a File Upload widget but that stores the file
ondisk so it has a "readonly" mode where the filename is shown."""
template_name = 'qommon/forms/widgets/file.html'
extra_css_class = 'file-upload-widget'
max_file_size = None
file_type = None
max_file_size_bytes = None # will be filled automatically
get_value_from_token = True
def __init__(self, name, value=None, **kwargs):
self.storage = kwargs.pop('storage', None)
try:
self.is_remote_storage = get_storage_object(self.storage).has_redirect_url(None)
except UploadStorageError:
# broken, consider it as remote as files are certainly not accessible.
self.is_remote_storage = True
CompositeWidget.__init__(self, name, value, **kwargs)
self.value = value
self.readonly = kwargs.get('readonly')
self.max_file_size = kwargs.pop('max_file_size', None)
self.automatic_image_resize = kwargs.pop('automatic_image_resize', False)
self.allow_portfolio_picking = has_portfolio() and kwargs.pop('allow_portfolio_picking', True)
if self.max_file_size:
self.max_file_size_bytes = FileSizeWidget.parse_file_size(self.max_file_size)
self.add(StringWidget, 'token')
self.get_widget('token').is_hidden = True
if not self.readonly:
attrs = {'data-url': get_publisher().get_root_url() + 'tmp-upload'}
if self.storage:
attrs['data-url'] += '?storage=%s' % self.storage
self.file_type = kwargs.pop('file_type', None)
if self.file_type:
attrs['accept'] = ','.join(self.file_type)
if self.max_file_size_bytes:
# this could be used for client size validation of file size
attrs['data-max-file-size'] = str(self.max_file_size_bytes)
attrs['data-max-file-size-human'] = str(self.max_file_size)
self.add(FileWidget, 'file', render_br=False, attrs=attrs)
if value:
self.set_value(value)
def is_image(self):
if getattr(self, 'file_type', None):
return all(x.startswith('image/') for x in getattr(self, 'file_type', None) or [])
return False
def set_value(self, value):
try:
self.value = value
if self.value:
if not hasattr(self.value, 'token') or not get_session().get_tempfile(self.value.token):
# it has no token, or its token is not in the session; this may be
# because the file value has not been created when filling a form,
# or because it was restored from a draft created from an expired
# session. Either way, create and use a new token.
self.value.token = (
get_session().add_tempfile(self.value, storage=self.storage).get('token')
)
self.get_widget('token').set_value(self.value.token)
except Exception as e:
if getattr(self, 'field', None):
get_publisher().record_error(
_('Failed to set value on field "%s"') % self.field.label,
formdef=getattr(self, 'formdef', None),
exception=e,
)
self.value = None
def add_media(self):
get_response().add_javascript(['qommon.fileupload.js'])
if not self.readonly and get_request().user and self.allow_portfolio_picking:
get_response().add_javascript(['../../i18n.js', 'fargo.js'])
def tempfile(self):
return get_session().get_tempfile(self.get('token')) or {}
def has_tempfile_image(self):
temp = self.tempfile()
if not temp:
return False
if not temp.get('size'): # empty or RemoteOpaque file
return False
filetype = (mimetypes.guess_type(temp.get('orig_filename', '')) or [''])[0]
if not filetype:
return False
if misc.is_svg_filetype(filetype):
return True
if filetype == 'application/pdf':
return HAS_PDFTOPPM
if not filetype.startswith('image/'):
return False
image_content = get_session().get_tempfile_content(self.get('token'))
try:
Image.open(image_content.fp)
except Exception:
return False
return True
def set_value_from_token(self, request):
self.value = None
if self.get('token'):
token = self.get('token')
elif self.get('file'):
try:
token = get_session().add_tempfile(self.get('file'), storage=self.storage)['token']
except UploadStorageError:
self.error = _('failed to store file (system error)')
return
request.form[self.get_widget('token').get_name()] = token
else:
token = None
session = get_session()
if token:
self.value = session.get_tempfile_content(token)
def _parse(self, request):
if self.get_value_from_token:
self.set_value_from_token(request)
if self.value is None:
# there's no file, the other checks are irrelevant.
return
if self.storage and self.storage != self.storage:
self.error = _('unknown storage system (system error)')
return
# Don't trust the browser supplied MIME type, update the Upload object
# with a MIME type created with magic (or based on the extension if the
# module is missing).
#
# This also helps people uploading PDF files that were downloaded from
# sites setting a wrong MIME type (like application/force-download) for
# various reasons.
if isinstance(self.value.fp, io.BufferedRandom):
# internally recreated file, trust supplied MIME type
filetype = self.value.content_type
elif magic and self.value.fp:
mime = magic.Magic(mime=True)
filetype = mime.from_file(self.value.fp.name)
if filetype in ('application/octet-stream', 'text/plain'):
# second-guess libmagic as we want to accept PDF files
# with some garbage at start.
with open(self.value.fp.name, 'rb') as fd:
first_bytes = fd.read(1024)
if b'%PDF' in first_bytes:
filetype = 'application/pdf'
else:
filetype = getattr(self.value, 'storage_attrs', {}).get('content_type')
if not filetype:
filetype = mimetypes.guess_type(self.value.base_filename)[0]
if not filetype:
filetype = 'application/octet-stream'
self.value.content_type = filetype
if self.max_file_size and hasattr(self.value, 'file_size'):
# validate file size
if self.value.file_size > self.max_file_size_bytes:
self.error = _('over file size limit (%s)') % self.max_file_size
return
if self.file_type:
# validate file type
accepted_file_types = []
for file_type in self.file_type:
accepted_file_types.extend(file_type.split(','))
valid_file_type = False
for accepted_file_type in accepted_file_types:
# fnmatch is used to handle generic mimetypes, like
# image/*
if fnmatch.fnmatch(self.value.content_type, accepted_file_type):
valid_file_type = True
break
if not valid_file_type:
self.error = _('invalid file type')
blacklisted_file_types = get_publisher().get_site_option('blacklisted-file-types')
if blacklisted_file_types:
blacklisted_file_types = [x.strip() for x in blacklisted_file_types.split(',')]
else:
blacklisted_file_types = [
'.exe',
'.bat',
'.com',
'.pif',
'.php',
'.js',
'application/x-ms-dos-executable',
'text/x-php',
]
if (
os.path.splitext(self.value.base_filename)[-1].lower() in blacklisted_file_types
or filetype in blacklisted_file_types
):
self.error = _('forbidden file type')
class EmailWidget(StringWidget):
HTML_TYPE = 'email'
user_part_re = re.compile(
r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*\Z" # dot-atom
r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-\011\013\014\016-\177])*"\Z)', # quoted-string
re.IGNORECASE,
)
def __init__(self, *args, **kwargs):
StringWidget.__init__(self, *args, **kwargs)
if 'size' not in kwargs:
self.attrs['size'] = '35'
def add_media(self):
get_response().add_javascript(['jquery.js', '../../i18n.js', 'qommon.forms.js'])
get_response().add_javascript_code(
'''
const WCS_WELL_KNOWN_DOMAINS = %s;
const WCS_VALID_KNOWN_DOMAINS = %s;
'''
% (
json.dumps(get_publisher().get_email_well_known_domains()),
json.dumps(get_publisher().get_email_valid_known_domains()),
)
)
def _parse(self, request):
StringWidget._parse(self, request)
if self.value is not None:
# basic tests first
if '@' not in self.value[1:-1]:
self.error = _('must be a valid email address')
return
if self.value[0] != '"' and ' ' in self.value:
self.error = _('must be a valid email address')
return
if self.value[0] != '"' and self.value.count('@') != 1:
self.error = _('must be a valid email address')
return
user_part, domain = self.value.rsplit('@', 1)
if not self.user_part_re.match(user_part):
self.error = _('must be a valid email address')
return
if get_cfg('emails', {}).get('check_domain_with_dns', True):
# testing for domain existence
if [x for x in domain.split('.') if not x]:
# empty parts in domain, ex: @example..net, or
# @.example.net
self.error = _('invalid address domain')
return
domain = force_str(domain, 'utf-8', errors='ignore')
try:
domain = force_str(domain.encode('idna'))
except UnicodeError:
self.error = _('invalid address domain')
return
if domain == 'localhost':
return
try:
dns.resolver.query(force_str(domain), 'MX')
except dns.exception.DNSException:
self.error = _('invalid address domain')
class OptGroup:
def __init__(self, title):
self.title = title
class SingleSelectWidget(quixote.form.widget.SingleSelectWidget):
def __init__(self, *args, **kwargs):
self.autocomplete = bool('data-autocomplete' in kwargs)
super().__init__(*args, **kwargs)
def get_allowed_values(self):
return [item[0] for item in self.options if not isinstance(item[0], OptGroup)]
def set_options(self, options, *args, **kwargs):
# options can be,
# - [objects:any, ...]
# - [(object:any, description:any), ...]
# - [(object:any, description:any, key:any), ...]
# - [(object:any, description:any, key:any, html_attrs:dict), ...]
self.options = [] # for compatibility with existing quixote methods
self.full_options = []
for option in options:
if isinstance(option, (tuple, list)):
if len(option) == 2:
option_tuple = (option[0], option[1], stringify(option[1]))
elif len(option) == 3:
option_tuple = (option[0], option[1], stringify(option[2]))
elif len(option) >= 4:
option_tuple = (option[0], option[1], stringify(option[2]), option[3])
else:
option_tuple = (option, option, stringify(option))
self.full_options.append(option_tuple)
self.full_options = [x + ({},) if len(x) == 3 else x for x in self.full_options]
self.options = [x[:3] for x in self.full_options]
def add_media(self):
if self.autocomplete:
get_response().add_javascript(['select2.js'])
def render_content(self):
attrs = {'id': 'form_' + self.get_name_for_id()}
if self.required:
attrs['aria-required'] = 'true'
if self.attrs:
attrs.update(self.attrs)
tags = [htmltag("select", name=self.name, **attrs)]
opened_optgroup = False
for obj, description, key, attrs in self.full_options:
if isinstance(obj, OptGroup):
if opened_optgroup:
tags.append(htmltext("</optgroup>"))
tags.append(htmltag("optgroup", label=obj.title))
opened_optgroup = True
continue
if self.is_selected(obj):
selected = 'selected'
else:
selected = None
if description is None:
description = ""
r = htmltag("option", value=key, selected=selected, **attrs)
tags.append(r + htmlescape(description) + htmltext('</option>'))
if opened_optgroup:
tags.append(htmltext("</optgroup>"))
tags.append(htmltext("</select>"))
return htmltext("\n").join(tags)
class ValidationCondition(Condition):
def __init__(self, django_condition, value):
super().__init__({'type': 'django', 'value': django_condition})
self.evaluated_value = value
def get_data(self):
data = get_publisher().get_substitution_variables()
data['value'] = self.evaluated_value
return data
class ValidationWidget(CompositeWidget):
validation_methods = collections.OrderedDict(
[
(
'digits',
{
'title': _('Digits'),
'regex': r'\d+',
'error_message': _('Only digits are allowed'),
'html_inputmode': 'numeric',
},
),
(
'phone',
{
'title': _('Phone Number'),
'regex': r'\+?[-\(\)\d\.\s/]+',
'error_message': _('Invalid phone number'),
'html_input_type': 'tel',
'normalize_for_fts': misc.normalize_phone_number_for_fts,
},
),
(
'phone-fr',
{
'title': _('Phone Number (France)'),
'function': 'validate_phone_fr',
'error_message': _('Invalid phone number'),
'html_input_type': 'tel',
'normalize_for_fts': misc.normalize_phone_number_for_fts,
},
),
(
'zipcode-fr',
{
'title': _('Zip Code (France)'),
'regex': r'\d{5}',
'error_message': _('Invalid zip code'),
'html_inputmode': 'numeric',
},
),
(
'siren-fr',
{
'title': _('SIREN Code (France)'),
'function': 'validate_siren',
'error_message': _('Invalid SIREN code'),
'html_inputmode': 'numeric',
'normalize_function': lambda v: v.upper().strip().replace(' ', ''),
},
),
(
'siret-fr',
{
'title': _('SIRET Code (France)'),
'function': 'validate_siret',
'error_message': _('Invalid SIRET code'),
'html_inputmode': 'numeric',
'normalize_function': lambda v: v.upper().strip().replace(' ', ''),
},
),
(
'nir-fr',
{
'title': _('NIR (France)'),
'error_message': _('Invalid NIR'),
'function': 'validate_nir',
'normalize_function': lambda v: v.upper().strip().replace(' ', ''),
},
),
(
'nrn-be',
{
'title': _('National register number (Belgium)'),
'error_message': _('Invalid national register number'),
'function': 'validate_belgian_nrn',
},
),
(
'iban',
{
'title': _('IBAN'),
'function': 'validate_iban',
'error_message': _('Invalid IBAN'),
'normalize_function': lambda v: v.upper().strip().replace(' ', ''),
},
),
(
'time',
{
'title': _('Time'),
'regex': r'([01]?[0-9]|2[0-3]):[0-5][0-9]',
'error_message': _('Invalid time'),
'html_input_type': 'time',
},
),
('regex', {'title': _('Regular Expression')}),
('django', {'title': _('Django Condition')}),
]
)
def __init__(self, name, value=None, **kwargs):
super().__init__(name, value=value, **kwargs)
if not value:
value = {}
options = [(None, _('None'), '')] + [(x, y['title'], x) for x, y in self.validation_methods.items()]
self.add(
SingleSelectWidget,
'type',
options=options,
value=value.get('type'),
attrs={'data-dynamic-display-parent': 'true'},
)
self.parse()
if not self.value:
self.value = {}
self.add(
RegexStringWidget,
'value_regex',
size=80,
value=value.get('value') if value.get('type') == 'regex' else None,
attrs={
'data-dynamic-display-child-of': 'validation$type',
'data-dynamic-display-value': 'regex',
},
)
self.add(
DjangoConditionWidget,
'value_django',
size=80,
value=value.get('value') if value.get('type') == 'django' else None,
attrs={
'data-dynamic-display-child-of': 'validation$type',
'data-dynamic-display-value': 'django',
},
)
self.add(
StringWidget,
'error_message',
size=80,
value=value.get('error_message') if value.get('type') else None,
title=_('Custom error message'),
hint=_(
'This message will be be displayed if validation fails. '
'An empty value will give the default error message.'
),
attrs={
'data-dynamic-display-child-of': 'validation$type',
'data-dynamic-display-value-in': '|'.join([x[2] for x in options if x[2]]),
},
)
self._parsed = False
def _parse(self, request):
values = {}
type_ = self.get('type')
if type_:
values['type'] = type_
value = self.get('value_%s' % type_)
if value:
values['value'] = value
error_message = self.get('error_message')
if error_message:
default_error_message = self.validation_methods[type_].get('error_message')
if error_message != default_error_message:
values['error_message'] = error_message
self.value = values or None
def render_content(self):
r = TemplateIO(html=True)
inlines = ['type', 'value_regex', 'value_django']
for name in inlines:
widget = self.get_widget(name)
r += widget.render_error(widget.get_error())
for name in inlines:
widget = self.get_widget(name)
r += widget.render_content()
widget = self.get_widget('error_message')
r += widget.render()
error_messages = {
x: str(y.get('error_message'))
for x, y in self.validation_methods.items()
if y.get('error_message')
}
r += htmltext(
'<script id="validation-error-messages" type="application/json">%s</script>'
% json.dumps(error_messages)
)
return r.getvalue()
@classmethod
def get_validation_function(cls, validation):
pattern = cls.get_validation_pattern(validation)
if pattern:
def regex_validation(value):
return bool(re.match(r'^(?:%s)$' % pattern, value))
return regex_validation
if validation['type'] == 'django' and validation.get('value'):
def django_validation(value):
condition = ValidationCondition(validation['value'], value=value)
return condition.evaluate()
return django_validation
validation_method = cls.validation_methods.get(validation['type'])
if validation_method and 'function' in validation_method:
return getattr(misc, validation_method['function'])
@classmethod
def get_validation_error_message(cls, validation):
if validation.get('error_message'):
return get_publisher().translate(validation.get('error_message'))
validation_method = cls.validation_methods.get(validation['type'])
if validation_method and 'error_message' in validation_method:
return validation_method['error_message']
@classmethod
def get_validation_pattern(cls, validation):
validation_method = cls.validation_methods.get(validation['type'])
if validation_method and validation_method.get('regex'):
return validation_method.get('regex')
if validation['type'] == 'regex':
return validation.get('value')
return None
@classmethod
def get_normalize_function(cls, validation):
validation_method = cls.validation_methods.get(validation['type'])
if validation_method and validation_method.get('normalize_function'):
return validation_method['normalize_function']
return lambda x: x # identity
@classmethod
def get_html_input_type(cls, validation):
validation_method = cls.validation_methods.get(validation['type'])
if validation_method and validation_method.get('html_input_type'):
return validation_method.get('html_input_type')
return 'text'
@classmethod
def get_html_inputmode(cls, validation):
validation_method = cls.validation_methods.get(validation['type'])
if validation_method and validation_method.get('html_inputmode'):
return validation_method.get('html_inputmode')
class WcsExtraStringWidget(StringWidget):
field = None
prefill = False
prefill_attributes = None
validation_function = None
validation_function_error_message = None
def add_media(self):
if self.prefill_attributes and 'geolocation' in self.prefill_attributes:
get_response().add_javascript(['qommon.geolocation.js'])
def render_content(self):
if self.field and self.field.validation:
self.HTML_TYPE = ValidationWidget.get_html_input_type(self.field.validation)
self.inputmode = ValidationWidget.get_html_inputmode(self.field.validation)
return super().render_content()
def _parse(self, request):
StringWidget._parse(self, request)
if self.field and self.field.validation and self.value is not None:
self.validation_function = ValidationWidget.get_validation_function(self.field.validation)
self.validation_function_error_message = ValidationWidget.get_validation_error_message(
self.field.validation
)
normalized_value = self.value
if self.field and self.value and self.field.validation:
normalize = ValidationWidget.get_normalize_function(self.field.validation)
normalized_value = normalize(self.value)
if self.value and self.validation_function and not self.validation_function(normalized_value):
self.error = self.validation_function_error_message or _('invalid value')
if self.field and self.value and not self.error and self.field.validation:
self.value = normalized_value
class DateWidget(StringWidget):
'''StringWidget which checks the value entered is a correct date'''
template_name = 'qommon/forms/widgets/date.html'
minimum_date = None
maximum_date = None
content_extra_css_class = 'date'
def __init__(self, name, value=None, **kwargs):
minimum_date = kwargs.pop('minimum_date', None)
if minimum_date:
self.minimum_date = misc.get_as_datetime(minimum_date)
maximum_date = kwargs.pop('maximum_date', None)
if maximum_date:
self.maximum_date = misc.get_as_datetime(maximum_date)
if kwargs.pop('minimum_is_future', False):
if kwargs.get('date_can_be_today'):
self.minimum_date = datetime.date.today()
else:
self.minimum_date = datetime.datetime.today() + datetime.timedelta(1)
if kwargs.pop('date_in_the_past', False):
if kwargs.get('date_can_be_today'):
self.maximum_date = datetime.date.today()
else:
self.maximum_date = datetime.datetime.today() - datetime.timedelta(1)
if 'date_can_be_today' in kwargs:
del kwargs['date_can_be_today']
if isinstance(value, (datetime.date, datetime.datetime)):
value = value.strftime(misc.date_format())
StringWidget.__init__(self, name, value=value, **kwargs)
self.attrs['size'] = '12'
self.attrs['maxlength'] = '10'
def transfer_form_value(self, request):
if isinstance(self.value, time.struct_time):
request.form[self.name] = strftime(misc.date_format(), self.value)
else:
super().transfer_form_value(request)
def parse(self, request=None):
StringWidget.parse(self, request=request)
return self.value
@classmethod
def get_format_string(cls):
return misc.date_format()
def _parse(self, request):
StringWidget._parse(self, request)
if self.value is not None:
try:
value = misc.get_as_datetime(self.value).timetuple()
self.value = strftime(self.get_format_string(), value)
except ValueError:
self.error = _('invalid date')
self.value = None
return
if value[0] < 1500 or value[0] > 2099:
self.error = _('invalid date')
self.value = None
elif self.minimum_date and value[:3] < self.minimum_date.timetuple()[:3]:
self.error = _('invalid date: date must be on or after %s') % strftime(
misc.date_format(), self.minimum_date
)
elif self.maximum_date and value[:3] > self.maximum_date.timetuple()[:3]:
self.error = _('invalid date; date must be on or before %s') % strftime(
misc.date_format(), self.maximum_date
)
def add_media(self):
self.prepare_javascript()
@classmethod
def prepare_javascript(cls):
get_response().add_javascript(['jquery.js', 'bootstrap-datetimepicker.js', 'qommon.forms.js'])
current_language = get_request().language
if current_language != 'en' and current_language in (get_publisher().supported_languages or []):
get_response().add_javascript(['bootstrap-datetimepicker.%s.js' % current_language])
get_response().add_css_include('datetimepicker.css')
def date_format(self):
return (
self.get_format_string()
.replace('%Y', 'yyyy')
.replace('%m', 'mm')
.replace('%d', 'dd')
.replace('%H', 'hh')
.replace('%M', 'ii')
.replace('%S', 'ss')
)
def start_date(self):
return (
self.date_format()
.replace('yyyy', '%04d' % self.minimum_date.year)
.replace('mm', '%02d' % self.minimum_date.month)
.replace('dd', '%02d' % self.minimum_date.day)
.replace('hh', '00')
.replace('ii', '00')
.replace('ss', '00')
)
def end_date(self):
return (
self.date_format()
.replace('yyyy', '%04d' % self.maximum_date.year)
.replace('mm', '%02d' % self.maximum_date.month)
.replace('dd', '%02d' % self.maximum_date.day)
.replace('hh', '00')
.replace('ii', '00')
.replace('ss', '00')
)
class TimeWidget(DateWidget):
template_name = 'qommon/forms/widgets/time.html'
def _parse(self, request):
StringWidget._parse(self, request)
if self.value is not None:
try:
value = datetime.datetime.strptime(self.value, self.get_format_string())
self.value = strftime(self.get_format_string(), value)
except ValueError:
self.error = _('invalid time')
self.value = None
return
@classmethod
def get_format_string(cls):
return '%H:%M'
class DateTimeWidget(CompositeWidget):
def __init__(self, name, value=None, **kwargs):
super().__init__(name, value=value, **kwargs)
date_value = None
time_value = None
if value:
date_value = misc.get_as_datetime(value).strftime(DateWidget.get_format_string())
time_value = misc.get_as_datetime(value).strftime(TimeWidget.get_format_string())
self.add(DateWidget, 'date', value=date_value, render_br=False)
self.add(TimeWidget, 'time', value=time_value, render_br=False)
def render_content(self):
r = TemplateIO(html=True)
for widget in self.get_widgets():
r += widget.render_widget_content()
return r.getvalue()
def _parse(self, request):
date = self.get('date')
time = self.get('time')
if not date and not time:
self.value = None
return self.value
time = time or '00:00'
try:
misc.get_as_datetime('%s %s' % (date, time))
except ValueError:
self.error = _('invalid value')
self.value = '%s %s' % (date, time)
return self.value
class RegexStringWidget(StringWidget):
'''StringWidget which checks the value entered is a correct regex'''
def _parse(self, request):
StringWidget._parse(self, request)
if self.value is not None:
try:
re.compile(self.value)
except Exception:
self.error = _('invalid regular expression')
self.value = None
class CheckboxesWidget(Widget):
readonly = False
template_name = 'qommon/forms/widgets/checkboxes.html'
a11y_role = 'group'
a11y_labelledby = True
has_inside_labels = True
def __init__(self, name, value=None, options=None, **kwargs):
self.options = options
self.options_with_attributes = kwargs.pop('options_with_attributes', None)
self.inline = kwargs.pop('inline', True)
self.min_choices = int(kwargs.pop('min_choices', 0) or 0)
self.max_choices = int(kwargs.pop('max_choices', 0) or 0)
if 'readonly' in kwargs:
del kwargs['readonly']
self.readonly = True
super().__init__(name, value, **kwargs)
def is_selected(self, value):
return bool(self.value and value in self.value)
def get_options(self):
options = self.options_with_attributes or self.options
for i, option in enumerate(options):
if len(option) == 2:
obj, description, key = (option[0], option[1], str(i))
else:
obj, description, key = option[:3]
yield {
'name': self.name + '$element%s' % key,
'value': obj,
'label': description,
'disabled': bool(self.options_with_attributes and option[-1].get('disabled')),
'selected': self.is_selected(obj),
'options': option[-1] if self.options_with_attributes else None,
}
def _parse(self, request):
if self.readonly:
return
values = []
for option in self.get_options():
if option.get('disabled'):
continue
name = option['name']
if name in request.form and not request.form[name] in (False, '', 'False'):
values.append(option['value'])
self.value = values
if self.required and not self.value:
self.set_error(self.REQUIRED_ERROR)
if self.value and self.min_choices and len(self.value) < self.min_choices:
self.set_error(_('You must select at least %d answers.') % self.min_choices)
if self.value and self.max_choices and len(self.value) > self.max_choices:
self.set_error(_('You must select at most %d answers.') % self.max_choices)
def set_value(self, value):
self.value = value
self._parsed = True
def transfer_form_value(self, request):
for v in self.value or []:
for option in self.get_options():
if option['value'] == v:
request.form[option['name']] = True
class ValidatedStringWidget(StringWidget):
'''StringWidget which checks the value entered is correct according to a regex'''
regex = None
def __init__(self, *args, **kwargs):
if 'regex' in kwargs:
self.regex = kwargs.pop('regex')
super().__init__(*args, **kwargs)
def _parse(self, request):
StringWidget._parse(self, request)
if self.regex and self.value is not None:
match = re.match(self.regex, self.value)
if not match or not match.group() == self.value:
self.error = _('wrong format')
class UrlWidget(ValidatedStringWidget):
'''StringWidget which checks the value entered is a correct url starting with http or https'''
regex = r'^https?://.+'
def _parse(self, request):
ValidatedStringWidget._parse(self, request)
if self.error:
self.error = _('must start with http:// or https:// and have a domain name')
class VarnameWidget(ValidatedStringWidget):
"""StringWidget which checks the value entered is a syntactically correct
variable name."""
regex = r'^[a-zA-Z][a-zA-Z0-9_]*'
def _parse(self, request):
ValidatedStringWidget._parse(self, request)
if self.error:
self.error = _('must only consist of letters, numbers, or underscore')
# forbid id/text to be used as identifier, as they would clash against
# "native" id/text keys in datasources; forbid "status" to avoid status
# filtering being diverted to a form field.
# And forbid all reserved Python keywords so varnames can be used in
# dotted expressions (form.var.plop).
if self.value in ('id', 'text', 'status') + tuple(keyword.kwlist):
self.error = _('this value is reserved for internal use.')
class SlugWidget(ValidatedStringWidget):
def __init__(self, name, value=None, **kwargs):
if 'title' not in kwargs:
kwargs['title'] = _('Identifier')
if 'required' not in kwargs:
kwargs['required'] = True
if 'size' not in kwargs:
kwargs['size'] = 50
self.had_uppercase_value = bool(value and value != value.lower())
super().__init__(name, value=value, **kwargs)
@property
def regex(self):
if self.had_uppercase_value:
# do not break existing values using uppercase letters
return r'^[a-zA-Z][a-zA-Z0-9_-]*'
return r'^[a-z][a-z0-9_-]*'
def _parse(self, request):
super()._parse(request)
if self.error:
self.error = _('wrong format: must only consist of letters, numbers, dashes, or underscores')
class FileSizeWidget(ValidatedStringWidget):
"""StringWidget which checks the value entered is a syntactically correct
file size."""
regex = r'^\s*([\d]+)\s*([MKk]i?)?[oB]?\s*$'
def __init__(self, *args, **kwargs):
hint = kwargs.pop('hint', _('Accepted units: MB (megabytes), kB (kilobytes), for example: 3 MB'))
ValidatedStringWidget.__init__(self, *args, hint=hint, **kwargs)
@classmethod
def parse_file_size(cls, value):
try:
value, unit = re.search(cls.regex, value).groups()
except AttributeError: # None has no .groups()
raise ValueError()
coeffs = {
'Mi': 2**20,
'Ki': 2**10,
'ki': 2**10,
'M': 10**6,
'K': 10**3,
'k': 10**3,
None: 1,
}
return int(value) * coeffs.get(unit)
def _parse(self, request):
ValidatedStringWidget._parse(self, request)
if self.error:
self.error = _('invalid file size')
class CaptchaWidget(CompositeWidget):
def __init__(self, name, value=None, mode='arithmetic-simple', *args, **kwargs):
CompositeWidget.__init__(self, name, value, **kwargs)
self.render_br = False
if value:
token = value
else:
token = get_session().create_captcha_token()
hidden_input = htmltext('<input type="hidden" name="%s$token" value="%s"></input>')
self.add(HtmlWidget, 'token', title=hidden_input % (self.name, token['token']))
# create question, and fill token['answer']
if mode in ('arithmetic', 'arithmetic-simple'):
a, b = random.randint(2, 9), random.randint(2, 9)
while b == a:
# don't get twice the same number
b = random.randint(2, 9)
if mode == 'arithmetic-simple':
operator = random.choice([_('plus'), _('minus')])
else:
operator = random.choice([_('times'), _('plus'), _('minus')])
if operator == _('times'):
answer = a * b
elif operator == _('plus'):
answer = a + b
elif operator == _('minus'):
if b > a:
a, b = b, a
answer = a - b
self.question = _('What is the result of %(a)d %(op)s %(b)d?') % {
'a': a,
'b': b,
'op': operator,
}
self.hint = kwargs.get('hint')
if self.hint is None:
self.hint = _('Please answer this simple mathematical question as proof you are not a bot.')
self.add(StringWidget, 'q', required=True, attrs={'required': 'required'})
token['answer'] = str(answer)
def _parse(self, request):
v = {'answer': self.get('q'), 'token': request.form.get('%s$token' % self.name)}
token = get_session().get_captcha_token(v['token'])
if v['answer'] and token and token['answer'] == v['answer'].strip():
get_session().won_captcha = True
self.value = v
elif v['answer']:
self.error = _('wrong answer')
def get_title(self):
return self.question
def render_content(self):
r = TemplateIO(html=True)
for widget in self.get_widgets():
r += widget.render_content()
return r.getvalue()
class WidgetList(quixote.form.widget.WidgetList):
always_include_add_button = False
def __init__(
self,
name,
value=None,
element_type=StringWidget,
element_kwargs=None,
add_element_label="Add row",
default_items_count=None,
max_items=None,
**kwargs,
):
if add_element_label == 'Add row':
add_element_label = str(_('Add row'))
self.extra_css_class = kwargs.pop('extra_css_class', None)
CompositeWidget.__init__(self, name, value=value, **kwargs)
self.element_type = element_type
self.element_kwargs = element_kwargs or {}
self.element_names = []
self.default_items_count = default_items_count or 1
self.max_items = max_items
# Add element widgets for initial value
if value is not None:
for element_value in value:
self.add_element(value=element_value)
if not self.element_names:
# Add at least an element widget
self.add_element()
if not kwargs.get('readonly'):
# add element widgets to match submitted list
prefix = '%s$element' % self.name
if get_request().form:
known_prefixes = {
x.split('$', 2)[1] for x in get_request().form.keys() if x.startswith(prefix)
}
for dummy in range(len(known_prefixes) - len(self.element_names)):
self.add_element()
# Add submit to add more element widgets
current_len = len(self.element_names)
if self.always_include_add_button or (not max_items) or current_len < max_items:
self.add(
SubmitWidget,
'add_element',
value=add_element_label,
render_br=False,
extra_css_class='list-add',
)
if self.get('add_element') and (not max_items or current_len < max_items):
# add an empty row
self.add_element()
# Add elements until default_items_count
while len(self.element_names) < self.default_items_count:
self.add_element()
def add_element(self, value=None, element_name=None):
if element_name:
name = element_name
else:
name = 'element%d' % len(self.element_names)
self.add(self.element_type, name, value=value, index=len(self.element_names), **self.element_kwargs)
self.element_names.append(name)
def add_media(self):
get_response().add_javascript(['jquery.js', 'jquery-ui.js', 'widget_list.js'])
def transfer_form_value(self, request):
for widget in self.get_widgets():
widget.transfer_form_value(request)
def _parse(self, request):
super()._parse(request)
if self.max_items and self.value and len(self.value) > self.max_items:
self.set_error(_('Too many elements (maximum: %s)') % self.max_items)
def set_value(self, value):
for dummy in range(len(value) - len(self.element_names)):
self.add_element()
for element_name, subvalue in zip(self.element_names, value):
self.get_widget(element_name).set_value(subvalue)
def render(self):
return render(self)
def render_content(self):
r = TemplateIO(html=True)
add_element_widget = self.get_widget('add_element')
clear_errors = False
if self.value is None and self.required:
# if there's no value and it's marked required, there won't be
# values in subwidgets either, clear them instead of filling the
# screen with "required field" messages.
clear_errors = True
count = 0
for widget in self.get_widgets():
if widget is add_element_widget:
continue
if clear_errors:
widget.clear_error()
r += widget.render()
count += 1
if add_element_widget:
if self.max_items and count >= self.max_items:
add_element_widget.is_hidden = True
r += add_element_widget.render()
return r.getvalue()
class WidgetListOfRoles(WidgetList):
def __init__(self, name, value=None, roles=None, **kwargs):
self.first_element_empty_label = kwargs.pop('first_element_empty_label', '---')
super().__init__(
name,
value=value,
element_type=SingleSelectWidget,
element_kwargs={
'render_br': False,
'options': [(None, '---', None)] + roles or [],
},
**kwargs,
)
def get_widgets(self):
seen = False
for widget in super().get_widgets():
# change first option (empty value) of first element to use a specific label
if not seen and isinstance(widget, SingleSelectWidget):
widget.full_options = widget.full_options[:]
widget.full_options[0] = list(widget.full_options[0])
widget.full_options[0][1] = self.first_element_empty_label
seen = True
yield widget
class WidgetDict(quixote.form.widget.WidgetDict):
# Fix the title and hint setting
# FIXME: to be fixed in Quixote upstream : title and hint parameters should be removed
def __init__(
self,
name,
value=None,
title='',
hint='',
element_key_type=StringWidget,
element_value_type=StringWidget,
element_key_kwargs=None,
element_value_kwargs=None,
add_element_label='Add row',
allow_empty_values=False,
value_for_empty_value=None,
**kwargs,
):
# noqa pylint: disable=too-many-arguments
if add_element_label == 'Add row':
add_element_label = str(_('Add row'))
quixote.form.widget.WidgetDict.__init__(
self,
name,
value,
element_key_type=element_key_type,
element_value_type=element_value_type,
element_key_kwargs=element_key_kwargs or {},
element_value_kwargs=element_value_kwargs or {},
add_element_label=add_element_label,
**kwargs,
)
if title:
self.title = title
if hint:
self.hint = hint
self.allow_empty_values = allow_empty_values
self.value_for_empty_value = value_for_empty_value
def render_content(self):
r = TemplateIO(html=True)
lines = []
for name in self.element_names:
if name in ('add_element', 'added_elements'):
continue
key_widget = self.get_widget(name + 'key')
value_widget = self.get_widget(name + 'value')
lines.append({'key': key_widget, 'value': value_widget})
def sort_key(line):
if not isinstance(line['key'], StringWidget) or not line['key'].value:
return (1, None) # empty keys always at the end
return (0, line['key'].value)
lines.sort(key=sort_key)
for line in lines:
r += htmltext(
'<div class="dict-key">%s</div>'
'<div class="dict-separator">: </div>'
'<div class="dict-value">%s</div>'
) % (line['key'].render(), line['value'].render())
r += htmltext('\n')
add_element_widget = self.get_widget('add_element')
add_element_widget.render_br = False
add_element_widget.extra_css_class = 'list-add'
r += add_element_widget.render()
r += self.get_widget('added_elements').render()
return r.getvalue()
def _parse(self, request):
values = {}
for name in self.element_names:
key = self.get(name + 'key')
value = self.get(name + 'value')
if key and value:
values[key] = value
elif key and self.allow_empty_values:
values[key] = self.value_for_empty_value
self.value = values or None
class WysiwygTextWidget(TextWidget):
ALL_TAGS = [
'a',
'abbr',
'acronym',
'address',
'area',
'article',
'aside',
'audio',
'b',
'big',
'blockquote',
'br',
'button',
'canvas',
'caption',
'center',
'cite',
'code',
'col',
'colgroup',
'command',
'datagrid',
'datalist',
'dd',
'del',
'details',
'dfn',
'dialog',
'dir',
'div',
'dl',
'dt',
'em',
'event-source',
'fieldset',
'figcaption',
'figure',
'font',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hr',
'i',
'img',
'input',
'ins',
'kbd',
'keygen',
'label',
'legend',
'li',
'm',
'map',
'menu',
'meter',
'multicol',
'nav',
'nextid',
'noscript',
'ol',
'optgroup',
'option',
'output',
'p',
'pre',
'progress',
'q',
's',
'samp',
'script',
'section',
'select',
'small',
'sound',
'source',
'spacer',
'span',
'strike',
'strong',
'style',
'sub',
'sup',
'table',
'tbody',
'td',
'textarea',
'tfoot',
'th',
'thead',
'time',
'tr',
'tt',
'u',
'ul',
'var',
'video',
]
ALL_ATTRS = [
'abbr',
'accept',
'accept-charset',
'accesskey',
'action',
'align',
'alt',
'autocomplete',
'autofocus',
'axis',
'background',
'balance',
'bgcolor',
'bgproperties',
'border',
'bordercolor',
'bordercolordark',
'bordercolorlight',
'bottompadding',
'cellpadding',
'cellspacing',
'ch',
'challenge',
'char',
'charoff',
'charset',
'checked',
'choff',
'cite',
'class',
'clear',
'color',
'cols',
'colspan',
'compact',
'contenteditable',
'controls',
'coords',
'data',
'datafld',
'datapagesize',
'datasrc',
'datetime',
'default',
'delay',
'dir',
'disabled',
'draggable',
'dynsrc',
'enctype',
'end',
'face',
'for',
'form',
'frame',
'galleryimg',
'gutter',
'headers',
'height',
'hidden',
'hidefocus',
'high',
'href',
'hreflang',
'hspace',
'icon',
'id',
'inputmode',
'ismap',
'keytype',
'label',
'lang',
'leftspacing',
'list',
'longdesc',
'loop',
'loopcount',
'loopend',
'loopstart',
'low',
'lowsrc',
'max',
'maxlength',
'media',
'method',
'min',
'multiple',
'name',
'nohref',
'noshade',
'nowrap',
'open',
'optimum',
'pattern',
'ping',
'point-size',
'poster',
'pqg',
'preload',
'prompt',
'radiogroup',
'readonly',
'rel',
'repeat-max',
'repeat-min',
'replace',
'required',
'rev',
'rightspacing',
'rows',
'rowspan',
'rules',
'scope',
'selected',
'shape',
'size',
'span',
'src',
'start',
'step',
'style',
'summary',
'suppress',
'tabindex',
'target',
'template',
'title',
'toppadding',
'type',
'unselectable',
'urn',
'usemap',
'valign',
'value',
'variable',
'volume',
'vrml',
'vspace',
'width',
'wrap',
'xml:lang',
]
ALL_STYLES = [
'azimuth',
'background-color',
'border-bottom-color',
'border-bottom-left-radius',
'border-bottom-right-radius',
'border-collapse',
'border-color',
'border-left-color',
'border-radius',
'border-right-color',
'border-top-color',
'border-top-left-radius',
'border-top-right-radius',
'clear',
'color',
'cursor',
'direction',
'display',
'elevation',
'float',
'font',
'font-family',
'font-size',
'font-style',
'font-variant',
'font-weight',
'height',
'letter-spacing',
'line-height',
'margin',
'margin-bottom',
'margin-left',
'margin-right',
'margin-top',
'overflow',
'padding',
'padding-bottom',
'padding-left',
'padding-right',
'padding-top',
'pause',
'pause-after',
'pause-before',
'pitch',
'pitch-range',
'richness',
'speak',
'speak-header',
'speak-numeral',
'speak-punctuation',
'speech-rate',
'stress',
'text-align',
'text-decoration',
'text-indent',
'unicode-bidi',
'vertical-align',
'voice-family',
'volume',
'white-space',
'width',
]
def _parse(self, request):
TextWidget._parse(self, request, use_validation_function=False)
if self.value:
cleaner = Cleaner(
tags=self.ALL_TAGS,
attributes=self.ALL_ATTRS,
styles=self.ALL_STYLES,
strip=True,
strip_comments=False,
filters=[partial(LinkifyFilter, skip_tags=['pre'], parse_email=False)],
)
self.value = cleaner.clean(self.value)
if self.value.startswith('<br />'):
self.value = self.value[6:]
if self.value.endswith('<br />'):
self.value = self.value[:-6]
if not strip_tags(self.value).strip() and not '<img' in self.value:
self.value = ''
# unescape Django template tags
charset = get_publisher().site_charset
def unquote_django(matchobj):
return force_str(html.unescape(force_str(matchobj.group(0), charset)))
self.value = re.sub('{[{%](.*?)[%}]}', unquote_django, self.value)
if self.validation_function:
try:
self.validation_function(self.value)
except ValueError as e:
self.error = str(e)
if self.value == '':
self.value = None
def add_media(self):
get_response().add_javascript(['qommon.wysiwyg.js'])
def render_content(self):
from ckeditor.widgets import DEFAULT_CONFIG as CKEDITOR_DEFAULT_CONFIG
attrs = self.attrs.copy()
config = copy.deepcopy(CKEDITOR_DEFAULT_CONFIG)
config.update(settings.CKEDITOR_CONFIGS['default'])
attrs['data-config'] = json.dumps(config)
return (
htmltag('textarea', name=self.name, **attrs)
+ htmlescape(self.value or '')
+ htmltext("</textarea>")
)
class MiniRichTextWidget(WysiwygTextWidget):
template_name = 'qommon/forms/widgets/mini-rich-text.html'
ALL_TAGS = ['p', 'b', 'strong', 'i', 'em', 'br', 'a']
ALL_ATTRS = ['href']
ALL_STYLES = []
EDITION_MODE = 'basic'
def add_media(self):
get_response().add_css_include('../xstatic/css/godo.css')
class RichTextWidget(MiniRichTextWidget):
ALL_TAGS = ['p', 'b', 'strong', 'i', 'em', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'li', 'a']
EDITION_MODE = 'full'
class TableWidget(CompositeWidget):
readonly = False
def __init__(self, name, value=None, rows=None, columns=None, **kwargs):
CompositeWidget.__init__(self, name, value, **kwargs)
self.rows = rows
self.columns = columns
if 'title' in kwargs:
del kwargs['title']
if kwargs.get('readonly'):
self.readonly = True
for i in range(len(rows)):
for j in range(len(columns)):
widget = self.add_widget(kwargs, i, j)
widget = self.get_widget('c-%s-%s' % (i, j))
if value and self.readonly:
widget.set_value(value[i][j])
widget.transfer_form_value(get_request())
def add_widget(self, kwargs, i, j):
widget_kwargs = {}
if kwargs.get('readonly'):
widget_kwargs['readonly'] = 'readonly'
return self.add(StringWidget, 'c-%s-%s' % (i, j), **widget_kwargs)
def render_content(self):
r = TemplateIO(html=True)
attrs = copy.copy(self.attrs)
attrs['aria-labelledby'] = 'form_label_%s' % self.get_name_for_id()
r += htmltag('table', **attrs)
r += htmltext('<thead><tr><td></td>')
for column in self.columns:
r += htmltext('<th scope="col"><span>%s</span></th>') % column
r += htmltext('</tr></thead><tbody>')
for i, row in enumerate(self.rows):
r += htmltext('<tr><th scope="row">%s</th>') % row
for j, column in enumerate(self.columns):
widget = self.get_widget('c-%s-%s' % (i, j))
r += htmltext('<td>')
r += widget.render_content()
r += htmltext('</td>')
r += htmltext('</tr>')
r += htmltext('</tbody></table>')
return r.getvalue()
def parse(self, request=None):
CompositeWidget.parse(self, request=request)
if request is None:
request = get_request()
if (request.form or request.get_method() == 'POST') and self.required:
if not self.value:
self.set_error(self.REQUIRED_ERROR)
else:
for row in self.value:
for column in row:
if column:
break
else:
continue
break
else:
self.set_error(self.REQUIRED_ERROR)
return self.value
def _parse(self, request):
if self.readonly:
return
table = []
for i in range(len(self.rows)):
row = []
for j in range(len(self.columns)):
widget = self.get_widget('c-%s-%s' % (i, j))
row.append(widget.parse())
table.append(row)
self.value = table
def set_value(self, value):
self.value = value
if not value:
return
for i in range(len(self.rows)):
for j in range(len(self.columns)):
widget = self.get_widget('c-%s-%s' % (i, j))
try:
widget.set_value(value[i][j])
except IndexError:
pass
class SingleSelectTableWidget(TableWidget):
def add_widget(self, kwargs, i, j):
widget_kwargs = {'options': kwargs.get('options')}
if kwargs.get('readonly'):
widget_kwargs['readonly'] = 'readonly'
return self.add(SingleSelectWidget, 'c-%s-%s' % (i, j), **widget_kwargs)
class CheckboxesTableWidget(TableWidget):
def add_widget(self, kwargs, i, j):
widget_kwargs = {'options': kwargs.get('options')}
if kwargs.get('readonly'):
widget_kwargs['readonly'] = 'readonly'
return self.add(CheckboxWidget, 'c-%s-%s' % (i, j), **widget_kwargs)
class SingleSelectHintWidget(SingleSelectWidget):
template_name = 'qommon/forms/widgets/select.html'
readonly = None
def __init__(self, name, value=None, **kwargs):
self.options_with_attributes = kwargs.pop('options_with_attributes', None)
self.select2 = kwargs.pop('select2', None)
if 'template-name' in kwargs:
self.template_name = kwargs.pop('template-name')
super().__init__(name, value=value, **kwargs)
def add_media(self):
if self.select2:
get_response().add_javascript(['select2.js'])
def separate_hint(self):
return self.hint and len(self.hint) > 80
def get_options(self):
if self.options_with_attributes:
options = self.options_with_attributes[:]
else:
options = self.options[:]
if options[0][0] is None:
options = self.options[1:]
for option in options:
object, description, key = option[:3]
html_attrs = {}
html_attrs['value'] = key
if self.is_selected(object):
html_attrs['selected'] = 'selected'
elif self.readonly and self.value:
# if readonly only include the selected option
continue
if self.options_with_attributes and option[-1].get('disabled'):
html_attrs['disabled'] = 'disabled'
if description is None:
description = ''
yield {
'description': description,
'attrs': html_attrs,
'options': option[-1] if self.options_with_attributes else None,
}
def has_valid_options(self):
# helper function for templates, return True if there's at least a
# valid option.
for option in self.get_options():
if not option['attrs'].get('disabled'):
return True
return False
def get_hint(self):
if self.separate_hint():
return SingleSelectWidget.get_hint(self)
return None
class MultiSelectWidget(MultipleSelectWidget):
template_name = 'qommon/forms/widgets/multiselect.html'
def __init__(self, name, value=None, **kwargs):
self.options_with_attributes = kwargs.pop('options_with_attributes', None)
self.readonly = bool(kwargs.pop('readonly', False))
self.min_choices = int(kwargs.pop('min_choices', 0) or 0)
self.max_choices = int(kwargs.pop('max_choices', 0) or 0)
try:
super().__init__(name, value=value, **kwargs)
except ValueError:
# ignore ValueError quixote will raise when options are empty.
if kwargs.get('options'):
raise
def add_media(self):
if not self.readonly:
get_response().add_javascript(['select2.js'])
def get_options(self):
options = self.options_with_attributes or self.options
for option in options:
object, description, key = option[:3]
yield {
'value': key,
'label': description,
'disabled': bool(self.options_with_attributes and option[-1].get('disabled')),
'selected': self.is_selected(object),
}
def get_selected_options_labels(self):
return list(x.get('label') for x in self.get_options() if x.get('selected'))
def transfer_form_value(self, request):
request.form[self.name + '[]'] = self.value
def _parse(self, request):
orig_name, self.name = self.name, self.name + '[]'
try:
super()._parse(request)
finally:
self.name = orig_name
if self.value and self.min_choices and len(self.value) < self.min_choices:
self.set_error(_('You must select at least %d choices.') % self.min_choices)
if self.value and self.max_choices and len(self.value) > self.max_choices:
self.set_error(_('You must select at most %d choices.') % self.max_choices)
class WidgetListAsTable(WidgetList):
def render_content(self):
r = TemplateIO(html=True)
add_element_widget = self.get_widget('add_element')
if add_element_widget:
add_element_widget.render_br = False
add_element_widget.extra_css_class = 'list-add'
for widget in self.get_widgets():
if widget is add_element_widget:
continue
if not hasattr(widget, 'render_content_as_tr'):
r += widget.render()
attrs = copy.copy(self.attrs)
attrs['aria-labelledby'] = 'form_label_%s' % self.get_name_for_id()
r += htmltag('table', **attrs)
r += htmltext('<thead>')
r += self.get_widgets()[0].render_as_thead()
r += htmltext('</thead>')
r += htmltext('<tbody>')
for widget in self.get_widgets():
if widget is add_element_widget:
continue
if hasattr(widget, 'render_content_as_tr'):
r += widget.render_content_as_tr()
r += htmltext('</tbody>')
r += htmltext('</table>')
if add_element_widget and not self.readonly:
r += add_element_widget.render()
return r.getvalue()
def render(self):
return render(self)
class TableRowWidget(CompositeWidget):
def __init__(self, name, value=None, **kwargs):
CompositeWidget.__init__(self, name, value, **kwargs)
for i, column in enumerate(self.columns):
self.add(StringWidget, name='col%s' % i, title=column, **kwargs)
class TableListRowsWidget(WidgetListAsTable):
readonly = False
def create_row_class(self, columns):
class klass(TableRowWidget):
pass
setattr(klass, 'columns', columns)
return klass
def add_element(self, value=None):
name = "element%d" % len(self.element_names)
self.add(self.table_row_class, name, value=value, **self.widget_kwargs)
self.element_names.append(name)
def __init__(self, name, value=None, columns=None, min_rows=5, **kwargs):
self.table_row_class = self.create_row_class(columns)
self.widget_kwargs = {}
if 'readonly' in kwargs:
del kwargs['readonly']
self.readonly = True
self.widget_kwargs['readonly'] = 'readonly'
WidgetListAsTable.__init__(
self, name, value, element_type=self.table_row_class, element_kwargs=self.widget_kwargs, **kwargs
)
self.columns = columns
while len(self.element_names) < min_rows:
self.add_element()
self.set_value(value)
def parse(self, request=None):
WidgetListAsTable.parse(self, request=request)
if request is None:
request = get_request()
add_element_pushed = self.get_widget('add_element').parse()
if (request.form or request.get_method() == 'POST') and self.required:
if not self.value and not add_element_pushed:
self.set_error(self.REQUIRED_ERROR)
for row in self.value or []:
for column in row:
if column:
break
else:
continue
break
else:
if not add_element_pushed:
self.set_error(self.REQUIRED_ERROR)
return self.value
def _parse(self, request):
if self.readonly:
return
table = []
for row_name in self.element_names:
row = []
row_widget = self.get_widget(row_name)
notnull = False
for j in range(len(self.columns)):
widget = row_widget.get_widget('col%s' % j)
row.append(widget.parse())
if row[-1]:
notnull = True
if notnull:
table.append(row)
self.value = table
def set_value(self, value):
self.value = value
if not value:
return
while len(self.element_names) < len(value):
self.add_element()
for i, row_name in enumerate(self.element_names):
widget_row = self.get_widget(row_name)
for j in range(len(self.columns)):
widget = widget_row.get_widget('col%s' % j)
try:
widget.set_value(value[i][j])
widget.transfer_form_value(get_request())
except IndexError:
pass
class RankedItemsWidget(CompositeWidget):
readonly = False
has_inside_labels = True
def __init__(self, name, value=None, elements=None, **kwargs):
CompositeWidget.__init__(self, name, value, **kwargs)
self.element_names = {}
if 'title' in kwargs:
del kwargs['title']
if 'readonly' in kwargs:
if kwargs['readonly']:
self.readonly = True
del kwargs['readonly']
if 'required' in kwargs:
if kwargs['required']:
self.required = True
del kwargs['required']
self.randomize_items = False
if 'randomize_items' in kwargs:
if kwargs['randomize_items']:
self.randomize_items = True
del kwargs['randomize_items']
for v in elements:
if isinstance(v, tuple):
title = v[1]
key = v[0]
if isinstance(key, int):
name = 'element%d' % v[0]
elif type(key) in (str, htmltext):
name = str('element%s' % v[0])
key = str(key)
else:
raise NotImplementedError()
else:
title = v
key = v
name = 'element%d' % len(self.element_names.keys())
if value:
position = value.get(key)
else:
position = None
self.add(IntWidget, name, title=title, value=position, size=5, required=False, **kwargs)
self.element_names[name] = key
if self.randomize_items:
random.shuffle(self.widgets)
if self.readonly:
self.widgets.sort(key=lambda x: x.value or sys.maxsize)
if value:
# in readonly mode, we mark all fields as already parsed, to
# avoid getting them reinitialized on a has_error() call
for name in self.element_names:
self.get_widget(name)._parsed = True
self._parsed = True
def _parse(self, request):
values = {}
for key, val in self.element_names.items():
value = self.get(key)
if value is not None:
values[val] = value
if value is not None and not isinstance(value, int):
self.get_widget(key).set_error(IntWidget.TYPE_ERROR)
self.value = values or None
def set_value(self, value):
self.value = value
if value:
for key, val in self.element_names.items():
self.get_widget(key).set_value(self.value.get(val))
def render_content(self):
r = TemplateIO(html=True)
r += htmltext('<ul>')
for widget in self.get_widgets():
if widget.has_error():
r += htmltext('<li class="error"><label>')
else:
r += htmltext('<li><label>')
if self.readonly:
widget.attrs['disabled'] = 'disabled'
if widget.value:
r += htmltext('<input type="hidden" name="%s" value="%s" >') % (widget.name, widget.value)
widget.name = widget.name + 'xx'
r += widget.render_content()
r += widget.title
r += htmltext('</label>')
r += htmltext('</li>')
r += htmltext('</ul>')
return r.getvalue()
class JsonpSingleSelectWidget(Widget):
template_name = 'qommon/forms/widgets/select_jsonp.html'
def __init__(self, name, value=None, url=None, add_related_url=None, edit_related=False, **kwargs):
super().__init__(name, value=value, **kwargs)
self.url = url
self.add_related_url = add_related_url
self.edit_related = edit_related
def add_media(self):
get_response().add_javascript(['select2.js'])
def get_display_value(self):
if self.value is None:
value = None
else:
value = htmlescape(self.value)
if not value:
return None
key = '%s_%s' % (self.url, value)
if not get_session().jsonp_display_values:
get_session().jsonp_display_values = {}
if key not in get_session().jsonp_display_values:
# get display value from data source; if it works it will be put in
# jsonp_display_values as a side effect.
field = getattr(self, 'field', None)
if field:
field.get_display_value(self.value)
return get_session().jsonp_display_values.get(key)
def get_edit_related_url(self):
if not self.edit_related:
return
if self.value is None:
value = None
else:
value = htmlescape(self.value)
if not value:
return None
field = getattr(self, 'field', None)
if not field:
return
carddef = field.get_carddef()
if not carddef:
return
try:
carddata = carddef.data_class().get(value)
except KeyError:
return
return carddata.get_edit_related_url()
def get_select2_url(self):
if Template.is_template_string(self.url):
vars = get_publisher().substitutions.get_context_variables(mode='lazy')
# skip variables that were not set (None)
vars = {x: y for x, y in vars.items() if y is not None}
url = misc.get_variadic_url(self.url, vars, encode_query=False)
else:
url = self.url
return url
def parse(self, request=None):
if request and request.form.get(self.name) and request.form.get(self.name + '_display'):
# store text value associated to the jsonp value
if not get_session().jsonp_display_values:
get_session().jsonp_display_values = {}
value = request.form.get(self.name)
display_value = request.form.get(self.name + '_display')
get_session().jsonp_display_values['%s_%s' % (self.url, value)] = display_value
return Widget.parse(self, request=request)
class AutocompleteStringWidget(WcsExtraStringWidget):
url = None
def __init__(self, *args, **kwargs):
self.url = kwargs.pop('url', None)
WcsExtraStringWidget.__init__(self, *args, **kwargs)
def add_media(self):
super().add_media()
get_response().add_javascript(['jquery.js', 'jquery-ui.js'])
def render_content(self):
if Template.is_template_string(self.url):
vars = get_publisher().substitutions.get_context_variables(mode='lazy')
# skip variables that were not set (None)
vars = {x: y for x, y in vars.items() if y is not None}
url = misc.get_variadic_url(self.url, vars, encode_query=False)
else:
url = self.url
r = TemplateIO(html=True)
r += WcsExtraStringWidget.render_content(self)
if not url:
# there's no autocomplete URL, get out now.
return r.getvalue()
data_type = 'json' if url.startswith('/api/autocomplete/') else 'jsonp'
r += htmltext(
"""
<script id="script_%(id)s">
$(function() {
$("#form_%(id)s").autocomplete({
source: function( request, response ) {
$.ajax({
url: $("#form_%(id)s").data('uiAutocomplete').options.url,
dataType: "%(data_type)s",
data: {
q: request.term
},
success: function( data ) {
response( $.map(data.data, function(item) {
return {label: item.text, value: item.label};
}));
}
});
},
minLength: 2,
open: function() {
$(this).removeClass("ui-corner-all").addClass("ui-corner-top");
},
close: function() {
$(this).removeClass("ui-corner-top").addClass("ui-corner-all");
}
});
"""
% {'id': self.get_name_for_id(), 'data_type': data_type}
)
if '[var_' not in url:
r += htmltext(
"""
$("#form_%(id)s").data('uiAutocomplete').options.url = '%(url)s';
"""
% {'id': self.get_name_for_id(), 'url': url}
)
if '[var_' in url:
# if this is a parametric url, store template url and hook to the
# appropriate onchange event to give the url to autocomplete
r += htmltext(
"""
$("#form_%(id)s").data('uiAutocomplete').options.wcs_base_url = '%(url)s';
"""
% {'id': self.get_name_for_id(), 'url': url}
)
variables = re.findall(r'\[(var_.+?)\]', url)
r += htmltext(
"""
function url_replace_%(id)s() {
var url = $("#form_%(id)s").data('uiAutocomplete').options.wcs_base_url;"""
% {'id': self.get_name_for_id()}
)
for variable in variables:
r += htmltext(
"""
selector = '#' + $('#%(variable)s').data('valuecontainerid');
url = url.replace('[%(variable)s]', $(selector).val() || '');"""
% {'variable': variable}
)
r += htmltext(
"""
$("#form_%(id)s").data('uiAutocomplete').options.url = url;
if ($("form_%(id)s").val() != $("form_%(id)s").attr('value'))
$("#form_%(id)s").val('');
}
"""
% {'id': self.get_name_for_id()}
)
for variable in variables:
r += htmltext(
"""
$('#%(variable)s').change(url_replace_%(id)s);
$('#%(variable)s').change();
"""
% {'id': self.get_name_for_id(), 'variable': variable}
)
r += htmltext(
"""
});
</script>"""
)
return r.getvalue()
class ColourWidget(SingleSelectWidget):
def __init__(self, *args, **kwargs):
colours = ['%s%s%s' % x for x in itertools.product(('00', '66', '99', 'FF'), repeat=3)]
SelectWidget.__init__(self, options=colours, *args, **kwargs)
self.attrs['class'] = 'colour-picker'
def add_media(self):
get_response().add_javascript(['jquery.js', 'jquery.colourpicker.js'])
def _parse(self, request):
SingleSelectWidget._parse(self, request)
if self.value is not None:
self.value = self.value.upper()
class PasswordEntryWidget(CompositeWidget):
min_length = 0
max_length = 0
count_uppercase = 0
count_lowercase = 0
count_digit = 0
count_special = 0
confirmation = True
def __init__(self, name, value=None, **kwargs):
# hint will be displayed with pwd1 widget
hint = kwargs.pop('hint', None)
CompositeWidget.__init__(self, name, value, **kwargs)
self.min_length = kwargs.get('min_length', 0)
self.max_length = kwargs.get('max_length', 0)
self.count_uppercase = kwargs.get('count_uppercase', 0)
self.count_lowercase = kwargs.get('count_lowercase', 0)
self.count_digit = kwargs.get('count_digit', 0)
self.count_special = kwargs.get('count_special', 0)
self.confirmation = kwargs.get('confirmation', True)
confirmation_title = kwargs.get('confirmation_title') or _('Confirmation')
self.strength_indicator = kwargs.get('strength_indicator', True)
self.formats = kwargs.get('formats', ['sha1'])
if not self.attrs.get('readonly'):
self.add(
PasswordWidget,
name='pwd1',
title='',
value='',
required=kwargs.get('required', False),
autocomplete='off',
hint=hint,
attrs={'id': 'form_' + self.get_name_for_id()},
)
if self.confirmation:
self.add(
PasswordWidget,
name='pwd2',
title=confirmation_title,
required=kwargs.get('required', False),
autocomplete='off',
)
else:
encoded_value = force_str(base64.encodebytes(force_bytes(json.dumps(value))))
if value:
fake_value = '*' * 8
else:
fake_value = ''
self.add(
HtmlWidget,
'hashed',
title=htmltext(
'<input value="%s" readonly="readonly">'
'<input type="hidden" name="%s$encoded" value="%s"></input>'
% (fake_value, self.name, encoded_value)
),
)
def add_media(self):
get_response().add_javascript(['jquery.js', 'jquery.passstrength.js'])
def render_content(self):
if self.attrs.get('readonly') or not self.strength_indicator:
return CompositeWidget.render_content(self)
r = TemplateIO(html=True)
r += CompositeWidget.render_content(self)
ctx = {
'form_id': self.get_name_for_id(),
'min_length': self.min_length,
'veryweak': _('Very weak'),
'weak': _('Weak'),
'moderate': _('Moderate'),
'good': _('Good'),
'strong': _('Strong'),
'verystrong': _('Very strong'),
'password_strength': _('Password strength:'),
'tooshort': _('Too short'),
}
r += (
htmltext(
'''<script>
$(function() {
$('input[id="form_%(form_id)s"]').passStrengthify({
levels: ["%(veryweak)s", "%(veryweak)s", "%(weak)s", "%(weak)s", "%(moderate)s", "%(good)s", "%(strong)s", "%(verystrong)s"],
minimum: %(min_length)s,
labels: {
passwordStrength: "%(password_strength)s",
tooShort: "%(tooshort)s"
}
});
});
</script>'''
)
% ctx
)
return r.getvalue()
def _parse(self, request):
CompositeWidget._parse(self, request)
if request.form.get('%s$encoded' % self.name):
self.value = json_loads(
base64.decodebytes(force_bytes(request.form.get('%s$encoded' % self.name)))
)
return
pwd1 = self.get('pwd1') or ''
if not self.get_widget('pwd1'):
# we are in read-only mode, stop here.
return
set_errors = []
min_len = self.min_length
if len(pwd1) < min_len:
set_errors.append(_('Password is too short. It must be at least %d characters.') % min_len)
max_len = self.max_length
if max_len and len(pwd1) > max_len:
set_errors.append(_('Password is too long. It must be at most %d characters.') % max_len)
count = self.count_uppercase
if len([x for x in pwd1 if x.isupper()]) < count:
set_errors.append(
ngettext(
'Password must contain an uppercase character.',
'Password must contain at least %(count)d uppercase characters.',
count,
)
% {'count': count}
)
count = self.count_lowercase
if len([x for x in pwd1 if x.islower()]) < count:
set_errors.append(
ngettext(
'Password must contain a lowercase character.',
'Password must contain at least %(count)d lowercase characters.',
count,
)
% {'count': count}
)
count = self.count_digit
if len([x for x in pwd1 if x.isdigit()]) < self.count_digit:
set_errors.append(
ngettext(
'Password must contain a digit.',
'Password must contain at least %(count)d digits.',
count,
)
% {'count': count}
)
count = self.count_special
if len([x for x in pwd1 if not x.isalnum()]) < count:
set_errors.append(
ngettext(
'Password must contain a special character.',
'Password must contain at least %(count)d special characters.',
count,
)
% {'count': count}
)
if self.confirmation:
pwd2 = self.get('pwd2') or ''
if pwd1 != pwd2:
self.get_widget('pwd2').set_error(_('Passwords do not match'))
pwd1 = None
if set_errors:
self.get_widget('pwd1').set_error(' '.join(set_errors))
pwd1 = None
PASSWORD_FORMATS = {
'cleartext': force_str,
'md5': lambda x: force_str(hashlib.md5(force_bytes(x)).hexdigest()),
'sha1': lambda x: force_str(hashlib.sha1(force_bytes(x)).hexdigest()),
}
if pwd1:
self.value = {}
for fmt in self.formats:
self.value[fmt] = PASSWORD_FORMATS[fmt](pwd1)
else:
self.value = None
class MapWidget(CompositeWidget):
template_name = 'qommon/forms/widgets/map.html'
def __init__(self, name, value=None, **kwargs):
CompositeWidget.__init__(self, name, value, **kwargs)
self.add(HiddenWidget, 'latlng', value=value)
self.readonly = kwargs.pop('readonly', False)
self.map_attributes = {}
self.map_attributes.update(get_publisher().get_map_attributes())
self.sync_map_and_address_fields = get_publisher().has_site_option(
'sync-map-and-address-fields', default=True
)
if kwargs.get('initial_zoom') is None:
kwargs['initial_zoom'] = get_publisher().get_default_zoom_level()
for attribute in ('initial_zoom', 'min_zoom', 'max_zoom', 'init_with_geoloc'):
if attribute in kwargs:
self.map_attributes['data-' + attribute] = kwargs.pop(attribute)
if kwargs.get('default_position'):
self.map_attributes['data-def-lat'] = kwargs['default_position'].split(';')[0]
self.map_attributes['data-def-lng'] = kwargs['default_position'].split(';')[1]
def initial_position(self):
if self.value and ';' in self.value:
return {'lat': self.value.split(';')[0], 'lng': self.value.split(';')[1]}
return None
def add_media(self):
get_response().add_javascript(['qommon.map.js', 'leaflet-search.js'])
def _parse(self, request):
CompositeWidget._parse(self, request)
self.value = self.get('latlng')
if self.value:
try:
lat, lon = self.value.split(';')
except ValueError:
self.set_error(_('Invalid value'))
else:
lat_lon = misc.normalize_geolocation({'lat': lat, 'lon': lon})
self.value = '%s;%s' % (lat_lon['lat'], lat_lon['lon']) if lat_lon else None
def set_value(self, value):
super().set_value(value)
self.get_widget('latlng').set_value(value)
class MapMarkerSelectionWidget(MapWidget):
template_name = 'qommon/forms/widgets/map-marker-selection.html'
def __init__(self, name, value=None, **kwargs):
CompositeWidget.__init__(self, name, value, **kwargs)
self.add(HiddenWidget, 'marker_id', value=value)
self.readonly = kwargs.pop('readonly', False)
self.map_attributes = {}
self.map_attributes.update(get_publisher().get_map_attributes())
self.sync_map_and_address_fields = get_publisher().has_site_option(
'sync-map-and-address-fields', default=True
)
for attribute in ('initial_zoom', 'min_zoom', 'max_zoom', 'init_with_geoloc'):
if attribute in kwargs:
self.map_attributes['data-' + attribute] = kwargs.pop(attribute)
from wcs import data_sources
data_source = data_sources.get_object(kwargs['data_source'])
self.geojson_markers_url = data_source.get_geojson_url() if data_source else ''
def initial_position(self):
return None
def _parse(self, request):
CompositeWidget._parse(self, request)
self.value = self.get('marker_id')
def set_value(self, value):
CompositeWidget.set_value(self, value)
class HiddenErrorWidget(HiddenWidget):
def set_error(self, error):
Widget.set_error(self, error)
class SingleSelectWidgetWithOther(CompositeWidget):
def __init__(self, name, value=None, **kwargs):
CompositeWidget.__init__(self, name, value=value, **kwargs)
if 'title' in kwargs:
del kwargs['title']
options = kwargs.get('options')[:]
if len(options[0]) == 1:
options = [(x[0], x[0], x[0]) for x in options]
elif len(options[0]) == 2:
options = [(x[0], x[1], x[0]) for x in options]
options.append(('__other', kwargs.pop('other_label', _('Other:')), '__other'))
kwargs['options'] = options
if value is None or value in [x[0] for x in options]:
choice_value = value
other_value = None
else:
choice_value = '__other'
other_value = value
self.add(SingleSelectWidget, 'choice', value=choice_value, **kwargs)
self.add(StringWidget, 'other', value=other_value, size=35)
def _parse(self, request):
self.value = self.get('choice')
if self.value == '__other':
self.value = self.get('other')
class ComputedExpressionWidget(CompositeWidget):
"""Widget that checks the entered value is a correct workflow
expression."""
def __init__(self, name, value=None, *args, **kwargs):
if not value:
value = {}
else:
from wcs.workflows import WorkflowStatusItem
value = WorkflowStatusItem.get_expression(value)
if value.get('type') == 'text':
value['type'] = 'template'
value_placeholder = kwargs.pop('value_placeholder', None)
self.allow_python = kwargs.pop('allow_python', True)
CompositeWidget.__init__(self, name, value, **kwargs)
self.add(
StringWidget,
'value_template',
size=80,
value=value.get('value') if value.get('type') == 'template' else None,
placeholder=value_placeholder,
)
self.has_python = False
if self.allow_python and not get_publisher().has_site_option('disable-python-expressions'):
self.has_python = True
self.add(
StringWidget,
'value_python',
size=80,
value=value.get('value') if value.get('type') == 'python' else None,
placeholder=value_placeholder,
)
self.add(
SingleSelectWidget,
'type',
options=[
('template', _('Template'), 'template'),
('python', _('Python Expression (deprecated)'), 'python'),
],
value=value.get('type'),
attrs={'data-dynamic-display-parent': 'true'},
)
def render_content(self):
ctx = {
'name': self.name,
'template_label': _('Template'),
'value_template': self.get_widget('value_template').render_content(),
}
if not self.has_python:
return (
htmltext(
'''\
<style>span[data-name="%(name)s"].template::after { content: "%(template_label)s"; }</style>
<span class="template only" data-name="%(name)s">%(value_template)s</span>'''
)
% ctx
)
ctx.update(
{
'python_label': _('Python'),
'type': self.get_widget('type').render_content(),
'value_python': self.get_widget('value_python').render_content(),
}
)
return (
htmltext(
'''
<style>
span[data-name="%(name)s"].template::after { content: "%(template_label)s"; }
span[data-name="%(name)s"].python::after { content: "%(python_label)s"; }
</style>
<span class="template"
data-name="%(name)s"
data-dynamic-display-value="template"
data-dynamic-display-child-of="%(name)s$type"
>%(value_template)s</span
><span class="python"
data-name="%(name)s"
data-dynamic-display-value="python"
data-dynamic-display-child-of="%(name)s$type"
>%(value_python)s</span
>%(type)s'''
)
% ctx
)
@classmethod
def validate_template(cls, template):
try:
Template(template, raises=True)
except TemplateError as e:
raise ValidationError('%s' % e)
@classmethod
def validate(cls, expression, allow_python=True):
if not expression:
return
from wcs.workflows import WorkflowStatusItem
expression = WorkflowStatusItem.get_expression(expression, allow_python=allow_python)
if expression['type'] == 'python':
if '{{' in expression['value']:
raise ValidationError(_('invalid usage, Python expression cannot contain {{'))
try:
compile(expression['value'], '<string>', 'eval')
except SyntaxError as e:
raise ValidationError(_('syntax error in Python expression: %s') % e)
else:
cls.validate_template(expression['value'])
def _parse(self, request):
self.value = None
if self.has_python:
if not self.get('type'):
return
value_type = self.get('type')
else:
value_type = 'template'
value_content = self.get('value_%s' % value_type)
if value_type == 'python' and value_content:
self.value = '=' + (value_content or '')
else:
self.value = value_content
if self.value:
try:
self.validate(self.value, allow_python=self.allow_python)
except ValidationError as e:
self.set_error(str(e))
class ConditionWidget(CompositeWidget):
def __init__(self, name, value=None, **kwargs):
CompositeWidget.__init__(self, name, value, **kwargs)
if not value:
value = {}
self.has_python = False
self.add(
StringWidget,
'value_django',
size=80,
value=value.get('value') if value.get('type') == 'django' else None,
)
if kwargs.get('allow_python', True) and not get_publisher().has_site_option(
'disable-python-expressions'
):
self.has_python = True
self.add(
StringWidget,
'value_python',
size=80,
value=value.get('value') if value.get('type') == 'python' else None,
)
self.add(
SingleSelectWidget,
'type',
options=[
('django', _('Django Expression'), 'django'),
('python', _('Python Expression (deprecated)'), 'python'),
],
value=value.get('type'),
attrs={'data-dynamic-display-parent': 'true'},
)
@property
def content_extra_attributes(self):
validation_url = get_publisher().get_root_url() + 'api/validate-condition'
return {'data-validation-url': validation_url}
def _parse(self, request):
self.value = None
if self.has_python:
if not self.get('type') or self.get('type') == 'none':
return
self.value = {'type': self.get('type')}
else:
self.value = {'type': 'django'}
self.value['value'] = self.get('value_%s' % self.value['type'])
if self.value['value']:
try:
Condition(self.value).validate()
except ValidationError as e:
self.set_error(str(e))
else:
self.value = None
def render_content(self):
ctx = {
'name': self.name,
'value_django': self.get_widget('value_django').render_content(),
}
if not self.has_python:
return htmltext('<span class="django only">%(value_django)s</span>') % ctx
ctx.update(
{
'value_python': self.get_widget('value_python').render_content(),
'type': self.get_widget('type').render_content(),
}
)
return (
htmltext(
'''
<span class="django"
data-dynamic-display-value="django"
data-dynamic-display-child-of="%(name)s$type"
>%(value_django)s</span
><span class="python"
data-dynamic-display-value="python"
data-dynamic-display-child-of="%(name)s$type"
>%(value_python)s</span
>%(type)s'''
)
% ctx
)
class DjangoConditionWidget(StringWidget):
def _parse(self, request):
super()._parse(request)
if self.value:
try:
Condition({'type': 'django', 'value': self.value}).validate()
except ValidationError as e:
self.set_error(str(e))