447 lines
16 KiB
Python
447 lines
16 KiB
Python
# w.c.s. - web application for online forms
|
|
# Copyright (C) 2005-2020 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 itertools
|
|
import uuid
|
|
import xml.etree.ElementTree as ET
|
|
from contextlib import contextmanager
|
|
|
|
from quixote import get_publisher, get_request
|
|
from quixote.html import htmltag, htmltext
|
|
|
|
from . import data_sources, fields
|
|
from .categories import BlockCategory
|
|
from .qommon import _, misc
|
|
from .qommon.form import CompositeWidget, WidgetList
|
|
from .qommon.storage import StorableObject
|
|
from .qommon.substitution import CompatibilityNamesDict
|
|
from .qommon.template import Template
|
|
|
|
|
|
class BlockdefImportError(Exception):
|
|
def __init__(self, msg, details=None):
|
|
self.msg = msg
|
|
self.details = details
|
|
|
|
|
|
class BlockDef(StorableObject):
|
|
_names = 'blockdefs'
|
|
_indexes = ['slug']
|
|
backoffice_class = 'wcs.admin.blocks.BlockDirectory'
|
|
xml_root_node = 'block'
|
|
verbose_name = _('Field block')
|
|
verbose_name_plural = _('Field blocks')
|
|
var_prefixes = ['block']
|
|
|
|
name = None
|
|
slug = None
|
|
fields = None
|
|
digest_template = None
|
|
category_id = None
|
|
|
|
SLUG_DASH = '_'
|
|
|
|
# declarations for serialization
|
|
TEXT_ATTRIBUTES = ['name', 'slug', 'digest_template']
|
|
|
|
def __init__(self, name=None, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.name = name
|
|
self.fields = []
|
|
|
|
@property
|
|
def category(self):
|
|
return BlockCategory.get(self.category_id, ignore_errors=True)
|
|
|
|
@category.setter
|
|
def category(self, category):
|
|
if category:
|
|
self.category_id = category.id
|
|
elif self.category_id:
|
|
self.category_id = None
|
|
|
|
def store(self, comment=None, *args, **kwargs):
|
|
from wcs.carddef import CardDef
|
|
from wcs.formdef import FormDef
|
|
|
|
assert not self.is_readonly()
|
|
if self.slug is None:
|
|
# set slug if it's not yet there
|
|
self.slug = self.get_new_slug()
|
|
|
|
super().store(*args, **kwargs)
|
|
if get_publisher().snapshot_class:
|
|
get_publisher().snapshot_class.snap(instance=self, comment=comment)
|
|
|
|
# update relations
|
|
for objdef in itertools.chain(
|
|
FormDef.select(ignore_errors=True, ignore_migration=True),
|
|
CardDef.select(ignore_errors=True, ignore_migration=True),
|
|
):
|
|
for field in objdef.get_all_fields():
|
|
if field.key == 'block' and field.type == 'block:%s' % self.slug:
|
|
objdef.store()
|
|
break
|
|
|
|
def get_new_field_id(self):
|
|
return 'bf%s' % str(uuid.uuid4())
|
|
|
|
def get_admin_url(self):
|
|
base_url = get_publisher().get_backoffice_url()
|
|
return '%s/forms/blocks/%s/' % (base_url, self.id)
|
|
|
|
def get_field_admin_url(self, field):
|
|
return self.get_admin_url() + '%s/' % field.id
|
|
|
|
def get_display_value(self, value):
|
|
if not self.digest_template:
|
|
return self.name
|
|
|
|
from .variables import LazyBlockDataVar
|
|
|
|
context = CompatibilityNamesDict({'block_var': LazyBlockDataVar(self.fields, value)})
|
|
# for backward compatibility it is also possible to use <slug>_var_<whatever>
|
|
context[self.slug.replace('-', '_') + '_var'] = context['block_var']
|
|
return Template(self.digest_template, autoescape=False).render(context)
|
|
|
|
def get_substitution_counter_variables(self, index):
|
|
return CompatibilityNamesDict(
|
|
{
|
|
'block_counter': {
|
|
'index0': index,
|
|
'index': index + 1,
|
|
}
|
|
}
|
|
)
|
|
|
|
def get_dependencies(self):
|
|
yield self.category
|
|
for field in self.fields or []:
|
|
yield from field.get_dependencies()
|
|
|
|
def export_to_xml(self, include_id=False):
|
|
root = ET.Element(self.xml_root_node)
|
|
if include_id and self.id:
|
|
root.attrib['id'] = str(self.id)
|
|
for text_attribute in list(self.TEXT_ATTRIBUTES):
|
|
if not hasattr(self, text_attribute) or not getattr(self, text_attribute):
|
|
continue
|
|
ET.SubElement(root, text_attribute).text = getattr(self, text_attribute)
|
|
|
|
BlockCategory.object_category_xml_export(self, root, include_id=include_id)
|
|
|
|
fields = ET.SubElement(root, 'fields')
|
|
for field in self.fields or []:
|
|
fields.append(field.export_to_xml(charset='utf-8', include_id=True))
|
|
|
|
return root
|
|
|
|
@classmethod
|
|
def import_from_xml(cls, fd, include_id=False, check_datasources=True):
|
|
try:
|
|
tree = ET.parse(fd)
|
|
except Exception:
|
|
raise ValueError()
|
|
blockdef = cls.import_from_xml_tree(tree, include_id=include_id)
|
|
|
|
if blockdef.slug:
|
|
try:
|
|
cls.get_on_index(blockdef.slug, 'slug', ignore_migration=True)
|
|
except KeyError:
|
|
pass
|
|
else:
|
|
blockdef.slug = blockdef.get_new_slug()
|
|
|
|
if check_datasources:
|
|
# check if datasources are defined
|
|
unknown_datasources = set()
|
|
for field in blockdef.fields:
|
|
data_source = getattr(field, 'data_source', None)
|
|
if data_source:
|
|
if isinstance(data_sources.get_object(data_source), data_sources.StubNamedDataSource):
|
|
unknown_datasources.add(data_source.get('type'))
|
|
if unknown_datasources:
|
|
raise BlockdefImportError(
|
|
_('Unknown datasources'), details=', '.join(sorted(unknown_datasources))
|
|
)
|
|
|
|
return blockdef
|
|
|
|
@classmethod
|
|
def import_from_xml_tree(cls, tree, include_id=False, **kwargs):
|
|
charset = 'utf-8'
|
|
blockdef = cls()
|
|
if tree.find('name') is None or not tree.find('name').text:
|
|
raise BlockdefImportError(_('Missing name'))
|
|
|
|
# if the tree we get is actually a ElementTree for real, we get its
|
|
# root element and go on happily.
|
|
if not ET.iselement(tree):
|
|
tree = tree.getroot()
|
|
|
|
if tree.tag != cls.xml_root_node:
|
|
raise BlockdefImportError(
|
|
_('Provided XML file is invalid, it starts with a <%(seen)s> tag instead of <%(expected)s>')
|
|
% {'seen': tree.tag, 'expected': cls.xml_root_node}
|
|
)
|
|
|
|
if include_id and tree.attrib.get('id'):
|
|
blockdef.id = tree.attrib.get('id')
|
|
for text_attribute in list(cls.TEXT_ATTRIBUTES):
|
|
value = tree.find(text_attribute)
|
|
if value is None or value.text is None:
|
|
continue
|
|
setattr(blockdef, text_attribute, misc.xml_node_text(value))
|
|
|
|
blockdef.fields = []
|
|
for field in tree.find('fields'):
|
|
try:
|
|
field_o = fields.get_field_class_by_type(field.findtext('type'))()
|
|
except KeyError:
|
|
raise BlockdefImportError(_('Unknown field type'), details=field.findtext('type'))
|
|
field_o.init_with_xml(field, charset, include_id=True)
|
|
blockdef.fields.append(field_o)
|
|
|
|
BlockCategory.object_category_xml_import(blockdef, tree, include_id=include_id)
|
|
|
|
return blockdef
|
|
|
|
def get_usage_formdefs(self):
|
|
from wcs.formdef import get_formdefs_of_all_kinds
|
|
|
|
block_identifier = 'block:%s' % self.slug
|
|
for formdef in get_formdefs_of_all_kinds():
|
|
for field in formdef.fields:
|
|
if field.type == block_identifier:
|
|
yield formdef
|
|
break
|
|
|
|
def is_used(self):
|
|
return any(self.get_usage_formdefs())
|
|
|
|
@contextmanager
|
|
def visibility_context(self, value, row_index):
|
|
from .variables import LazyBlockDataVar
|
|
|
|
context = self.get_substitution_counter_variables(row_index)
|
|
context['block_var'] = LazyBlockDataVar(self.fields, value)
|
|
with get_publisher().substitutions.temporary_feed(context):
|
|
yield
|
|
|
|
|
|
class BlockSubWidget(CompositeWidget):
|
|
template_name = 'qommon/forms/widgets/block_sub.html'
|
|
|
|
def __init__(self, name, value=None, *args, **kwargs):
|
|
self.block = kwargs.pop('block')
|
|
self.readonly = kwargs.get('readonly')
|
|
self.remove_button = kwargs.pop('remove_button', False)
|
|
self.index = kwargs.pop('index', 0)
|
|
super().__init__(name, value, *args, **kwargs)
|
|
|
|
def add_to_form(field):
|
|
if 'readonly' in kwargs:
|
|
field_value = None
|
|
if value is not None:
|
|
field_value = value.get(field.id)
|
|
return field.add_to_view_form(form=self, value=field_value)
|
|
else:
|
|
field.add_to_form(form=self)
|
|
widget = self.get_widget('f%s' % field.id)
|
|
if widget:
|
|
widget.div_id = None
|
|
widget.prefill_attributes = field.get_prefill_attributes()
|
|
return widget
|
|
|
|
self.fields = {}
|
|
|
|
live_sources = []
|
|
for field in self.block.fields:
|
|
context = self.block.get_substitution_counter_variables(self.index)
|
|
if field.type in ['title', 'subtitle', 'comment']:
|
|
with get_publisher().substitutions.temporary_feed(context):
|
|
widget = add_to_form(field)
|
|
else:
|
|
widget = add_to_form(field)
|
|
|
|
if field.condition:
|
|
live_sources.extend(field.get_condition_varnames(formdef=self.block))
|
|
field.widget = widget
|
|
self.fields[field.id] = widget
|
|
|
|
for field in self.block.fields:
|
|
if field.varname in live_sources:
|
|
field.widget.live_condition_source = True
|
|
|
|
if value:
|
|
self.set_value(value)
|
|
|
|
self.set_visibility(value)
|
|
|
|
def set_visibility(self, value):
|
|
with self.block.visibility_context(value, self.index):
|
|
for field in self.block.fields:
|
|
widget = self.fields.get(field.id)
|
|
if not widget:
|
|
continue
|
|
visible = field.is_visible({}, formdef=None)
|
|
widget.is_hidden = not (visible)
|
|
|
|
def set_value(self, value):
|
|
self.value = value
|
|
for widget in self.get_widgets():
|
|
if hasattr(widget, 'set_value') and not getattr(widget, 'secondary', False):
|
|
widget.set_value(value.get(widget.field.id))
|
|
|
|
def get_field_data(self, field, widget):
|
|
from wcs.formdef import FormDef
|
|
|
|
return FormDef.get_field_data(field, widget)
|
|
|
|
def _parse(self, request):
|
|
value = {}
|
|
empty = True
|
|
|
|
for widget in self.get_widgets():
|
|
widget_value = self.get_field_data(widget.field, widget)
|
|
with self.block.visibility_context(value, self.index):
|
|
if not widget.field.is_visible({}, formdef=None):
|
|
widget.clear_error()
|
|
continue
|
|
value.update(widget_value)
|
|
if widget_value.get(widget.field.id) is not None:
|
|
empty = False
|
|
if empty:
|
|
value = None
|
|
self.value = value
|
|
|
|
def add_media(self):
|
|
for widget in self.get_widgets():
|
|
if hasattr(widget, 'add_media'):
|
|
widget.add_media()
|
|
|
|
|
|
class BlockWidget(WidgetList):
|
|
template_name = 'qommon/forms/widgets/block.html'
|
|
always_include_add_button = True
|
|
|
|
def __init__(
|
|
self, name, value=None, title=None, block=None, max_items=None, add_element_label=None, **kwargs
|
|
):
|
|
self.block = block
|
|
self.readonly = kwargs.get('readonly')
|
|
self.label_display = kwargs.pop('label_display') or 'normal'
|
|
self.remove_button = kwargs.pop('remove_button', False)
|
|
element_values = None
|
|
if value:
|
|
element_values = value.get('data')
|
|
if not max_items:
|
|
max_items = 1
|
|
hint = kwargs.pop('hint', None)
|
|
element_kwargs = {'block': self.block, 'render_br': False, 'remove_button': self.remove_button}
|
|
element_kwargs.update(kwargs)
|
|
super().__init__(
|
|
name,
|
|
value=element_values,
|
|
title=title,
|
|
max_items=max_items,
|
|
element_type=BlockSubWidget,
|
|
element_kwargs=element_kwargs,
|
|
add_element_label=add_element_label or _('Add another'),
|
|
hint=hint,
|
|
**kwargs,
|
|
)
|
|
|
|
def set_value(self, value):
|
|
super().set_value(value['data'] if value else None)
|
|
self.value = value
|
|
|
|
def _parse(self, request):
|
|
# iterate over existing form keys to get actual list of elements.
|
|
# (maybe this could be moved to WidgetList)
|
|
prefix = '%s$element' % self.name
|
|
known_prefixes = {x.split('$', 2)[1] for x in request.form.keys() if x.startswith(prefix)}
|
|
for prefix in known_prefixes:
|
|
if prefix not in self.element_names:
|
|
self.add_element(element_name=prefix)
|
|
super()._parse(request)
|
|
if self.value:
|
|
self.value = {'data': self.value}
|
|
# keep "schema" next to data, this allows custom behaviour for
|
|
# date fields (time.struct_time) when writing/reading from
|
|
# database in JSON.
|
|
self.value['schema'] = {x.id: x.key for x in self.block.fields}
|
|
|
|
def unparse(self):
|
|
self._parsed = False
|
|
for widget in self.widgets:
|
|
widget._parsed = False
|
|
|
|
def parse(self, request=None):
|
|
if not self._parsed:
|
|
self._parsed = True
|
|
if request is None:
|
|
request = get_request()
|
|
self._parse(request)
|
|
if self.required and self.value is None:
|
|
self.set_error(_(self.REQUIRED_ERROR))
|
|
return self.value
|
|
|
|
def add_media(self):
|
|
for widget in self.get_widgets():
|
|
if hasattr(widget, 'add_media'):
|
|
widget.add_media()
|
|
|
|
def get_error(self, request=None):
|
|
request = request or get_request()
|
|
if request.get_method() == 'POST':
|
|
self.parse(request=request)
|
|
return self.error
|
|
|
|
def has_error(self, request=None):
|
|
if self.get_error():
|
|
return True
|
|
# we know subwidgets have been parsed
|
|
has_error = False
|
|
for widget in self.widgets:
|
|
if widget.value is None:
|
|
continue
|
|
if widget.has_error():
|
|
has_error = True
|
|
return has_error
|
|
|
|
def render_title(self, title):
|
|
if not title or self.label_display == 'hidden':
|
|
return ''
|
|
|
|
if self.label_display == 'normal':
|
|
return super().render_title(title)
|
|
|
|
if self.required:
|
|
title += htmltext('<span title="%s" class="required">*</span>') % _('This field is required.')
|
|
attrs = {}
|
|
hint = self.get_hint()
|
|
if hint:
|
|
attrs['aria-describedby'] = 'form_hint_%s' % self.name
|
|
title_tag = htmltag('h4', **attrs)
|
|
return title_tag + htmltext('%s</h4>') % title
|
|
|
|
def had_add_clicked(self):
|
|
add_widget = self.get_widget('add_element')
|
|
request = get_request()
|
|
request_form = getattr(request, 'orig_form', request.form)
|
|
return request_form.get(add_widget.name) if add_widget else False
|