# 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 . import base64 import collections import contextlib import copy import datetime import glob import itertools import json import os import pickle import sys import time import types import xml.etree.ElementTree as ET from operator import itemgetter from django.utils.encoding import force_bytes, force_text from quixote import get_publisher, get_session from quixote.html import htmltext from quixote.http_request import Upload from . import data_sources, fields from .categories import Category from .formdata import FormData from .qommon import PICKLE_KWARGS, _, force_str, get_cfg, pgettext_lazy from .qommon.admin.emails import EmailsDirectory from .qommon.afterjobs import AfterJob from .qommon.cron import CronJob from .qommon.form import Form, HtmlWidget, UploadedFile from .qommon.misc import JSONEncoder, get_as_datetime, is_attachment, is_upload, simplify, xml_node_text from .qommon.publisher import get_publisher_class from .qommon.storage import Equal, StorableObject, classonlymethod, fix_key from .qommon.substitution import Substitutions from .qommon.template import Template from .roles import logged_users_role DRAFTS_DEFAULT_LIFESPAN = 100 # days if not hasattr(types, 'ClassType'): types.ClassType = type class FormdefImportError(Exception): def __init__(self, msg, msg_args=None, details=None): self.msg = msg self.msg_args = msg_args or () self.details = details class UnknownReferencedErrorMixin: def __init__(self, msg, msg_args=None, details=None): self.msg = msg self.msg_args = msg_args or () self._details = details @property def details(self): if not self._details: return None details = [] for kind in sorted(self._details.keys()): details.append('%s: %s' % (kind, ', '.join(sorted(self._details[kind])))) return '; '.join(details) def render(self): result = htmltext('') return result class FormdefImportUnknownReferencedError(UnknownReferencedErrorMixin, FormdefImportError): pass class FormdefImportRecoverableError(FormdefImportError): pass class FormDefDoesNotExist(AttributeError): error_message = _('No such form: %s') def get_error_message(self): return self.error_message % self class FormField: # only used to unpickle form fields from older (<200603) versions def __setstate__(self, dict): type = dict['type'] self.real_field = fields.get_field_class_by_type(type)(**dict) def lax_int(s): try: return int(s) except (ValueError, TypeError): return -1 class FormDef(StorableObject): # noqa pylint: disable=too-many-public-methods _names = 'formdefs' _indexes = ['url_name'] _hashed_indexes = ['backoffice_submission_roles'] backoffice_class = 'wcs.admin.forms.FormDefPage' data_sql_prefix = 'formdata' pickle_module_name = 'formdef' xml_root_node = 'formdef' backoffice_section = 'forms' verbose_name = _('Form') verbose_name_plural = _('Forms') item_name = pgettext_lazy('item', 'form') item_name_plural = pgettext_lazy('item', 'forms') name = None description = None keywords = None url_name = None internal_identifier = None # used to have a stable pickle object class name table_name = None # for SQL only fields = None category_id = None workflow_id = None workflow_options = None workflow_roles = None roles = None required_authentication_contexts = None backoffice_submission_roles = None discussion = False confirmation = True detailed_emails = True disabled = False only_allow_one = False enable_tracking_codes = False tracking_code_verify_fields = None disabled_redirection = None always_advertise = False publication_date = None expiration_date = None has_captcha = False skip_from_360_view = False include_download_all_button = False appearance_keywords = None digest_templates = None lateral_template = None submission_lateral_template = None drafts_lifespan = None user_support = None geolocations = None max_field_id = None # store reverse relations reverse_relations = None # store fields in a separate pickle chunk lightweight = True # prefixes for formdata variables var_prefixes = ['form'] # declarations for serialization TEXT_ATTRIBUTES = [ 'name', 'url_name', 'description', 'keywords', 'publication_date', 'expiration_date', 'internal_identifier', 'disabled_redirection', 'appearance_keywords', 'lateral_template', 'submission_lateral_template', 'drafts_lifespan', 'user_support', ] BOOLEAN_ATTRIBUTES = [ 'discussion', 'detailed_emails', 'disabled', 'only_allow_one', 'enable_tracking_codes', 'confirmation', 'always_advertise', 'include_download_all_button', 'has_captcha', 'skip_from_360_view', ] category_class = Category def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields = [] def __eq__(self, other): return bool( isinstance(other, FormDef) and self.xml_root_node == other.xml_root_node and self.id == other.id ) def migrate(self): changed = False if self.__dict__.get('fields') is Ellipsis: # don't run migration on lightweight objects return if self.max_field_id is None and self.fields: self.max_field_id = max(lax_int(x.id) for x in self.fields) changed = True if isinstance(self.category_id, int): self.category_id = str(self.category_id) changed = True if isinstance(self.workflow_id, int): self.workflow_id = str(self.workflow_id) changed = True if self.roles: for role in self.roles: if isinstance(role, int): self.roles = [str(x) for x in self.roles] changed = True break if self.workflow_roles: workflow_roles_list = self.workflow_roles.items() for role_id in self.workflow_roles.values(): if isinstance(role_id, int): self.workflow_roles = {x: str(y) for x, y in workflow_roles_list} changed = True break if not self.internal_identifier: self.internal_identifier = self.url_name changed = True for f in self.fields or []: changed |= f.migrate() if changed: self.store(comment=_('Automatic update'), snapshot_store_user=False) @classmethod def remove_object(cls, id): super().remove_object(id) if cls == FormDef: # recreate global views so they don't reference formdata from # deleted formefs from . import sql conn, cur = sql.get_connection_and_cursor() sql.clean_global_views(conn, cur) conn.commit() cur.close() @property def data_class_name(self): return '_wcs_%s' % self.url_name.title() def data_class(self, mode=None): if 'formdef' not in sys.modules: sys.modules['formdef'] = sys.modules[__name__] if hasattr(sys.modules['formdef'], self.data_class_name): data_class = getattr(sys.modules['formdef'], self.data_class_name) # only use existing data class if it has a reference to this actual # formdef if data_class._formdef is self: return data_class if (not mode == 'files') or mode == 'sql': from . import sql table_name = sql.get_formdef_table_name(self) cls = types.ClassType( self.data_class_name, (sql.SqlFormData,), {'_formdef': self, '_table_name': table_name} ) else: cls = types.ClassType( self.data_class_name, (FormData,), {'_names': 'form-%s' % self.internal_identifier, '_formdef': self}, ) setattr(sys.modules['formdef'], self.data_class_name, cls) setattr(sys.modules['wcs.formdef'], self.data_class_name, cls) return cls def get_new_field_id(self): if self.max_field_id is None: field_id = 1 else: field_id = self.max_field_id + 1 self.max_field_id = field_id return str(field_id) def get_new_url_name(self): new_url_name = simplify(self.name)[:250] base_new_url_name = new_url_name suffix_no = 0 while True: try: obj = self.get_on_index(new_url_name, 'url_name', ignore_migration=True) except KeyError: break if obj.id == self.id: break suffix_no += 1 new_url_name = '%s-%s' % (base_new_url_name, suffix_no) return new_url_name def get_new_internal_identifier(self): new_internal_identifier = simplify(self.name) base_new_internal_identifier = new_internal_identifier suffix_no = 0 while True: try: formdef = self.get_by_urlname(new_internal_identifier, ignore_migration=True) except KeyError: break if formdef.id == self.id: break suffix_no += 1 new_internal_identifier = '%s-%s' % (base_new_internal_identifier, suffix_no) return new_internal_identifier @classmethod def get_new_id(cls, create=False): id = super().get_new_id(create=False) id = cls.get_sql_new_id(id_start=int(id)) if create: objects_dir = cls.get_objects_dir() object_filename = os.path.join(objects_dir, fix_key(id)) try: fd = os.open(object_filename, os.O_CREAT | os.O_EXCL) except OSError: return cls.get_new_id(create=True) os.close(fd) return str(id) @classmethod def get_sql_new_id(cls, id_start): from . import sql return sql.get_formdef_new_id(id_start=id_start) def get_order_by(self, order_by): if not order_by: return order_by direction = '' if order_by.startswith('-'): order_by = order_by[1:] direction = '-' for field in self.iter_fields(include_block_fields=True): if getattr(field, 'block_field', None): if field.key == 'items': # not yet continue if order_by not in [field.contextual_varname, 'f%s' % field.contextual_id]: continue if field.contextual_varname == order_by: order_by = "f%s" % field.contextual_id if getattr(field, 'block_field', None) and 'f%s' % field.contextual_id == order_by: # field of block field, sort on the first element order_by = "f%s->'data'->0->>'%s%s'" % ( field.block_field.id, field.id, "_display" if field.store_display_value else "", ) elif field.store_display_value: order_by += "_display" break return '%s%s' % (direction, order_by) def has_user_access(self, user): if get_publisher().get_backoffice_root().is_global_accessible(self.backoffice_section): return True if not user: return False if not self.category_id: return False management_roles = {x.id for x in getattr(self.category, 'management_roles') or []} user_roles = set(user.get_roles()) return management_roles.intersection(user_roles) @classonlymethod def wipe(cls): super().wipe() cls.sql_wipe() @classmethod def sql_wipe(cls): from . import sql sql.formdef_wipe() def store(self, comment=None, snapshot_store_user=True, *args, **kwargs): assert not self.is_readonly() if self.url_name is None: # set url name if it's not yet there self.url_name = self.get_new_url_name() object_only = kwargs.pop('object_only', False) new_internal_identifier = self.get_new_internal_identifier() if not self.internal_identifier: self.internal_identifier = new_internal_identifier if new_internal_identifier != self.internal_identifier: # title changed, internal identifier will be changed only if # the formdef is currently being imported (self.id is None) # or if there are not yet any submitted forms (or if site # is using the SQL storage as internal identifier is not used # in that mode. if self.id is None or not self.data_class().exists(): self.internal_identifier = new_internal_identifier if not object_only: self.update_relations() StorableObject.store(self, *args, **kwargs) if object_only: return if get_publisher().snapshot_class: get_publisher().snapshot_class.snap( instance=self, comment=comment, store_user=snapshot_store_user ) if get_publisher().has_postgresql_config(): self.update_storage() self.store_related_custom_views() def update_storage(self): from . import sql actions = sql.do_formdef_tables(self, rebuild_views=True, rebuild_global_views=True) if actions: cls = self.data_class() for action in actions: getattr(cls, action)() def update_relations(self): from wcs.carddef import CardDef self_ref = '%s:%s' % (self.xml_root_node, self.url_name) self_relations_by_ref = self.build_relations_by_ref() reverse_relations = [] # cross each formdef and cardef and check relations for objdef in itertools.chain( FormDef.select(ignore_errors=True, ignore_migration=True), CardDef.select(ignore_errors=True, ignore_migration=True), ): objdef_ref = '%s:%s' % (objdef.xml_root_node, objdef.url_name) if objdef.xml_root_node == self.xml_root_node and objdef.id == self.id: # don't build relations twice objdef_relations_by_ref = self_relations_by_ref else: objdef_relations_by_ref = objdef.build_relations_by_ref() reverse_relations += objdef_relations_by_ref.get(self_ref, []) old_objdef_reverse_relations = copy.deepcopy(objdef.reverse_relations) # remove relations with self in objdef's reverse_relations new_objdef_reverse_relations = [ r for r in (objdef.reverse_relations or []) if r['obj'] != self_ref ] # and update objdef's reverse_relations from self_relations_by_ref new_objdef_reverse_relations += self_relations_by_ref.get(objdef_ref, []) # sort objectdef's reverse_relations new_objdef_reverse_relations = sorted( new_objdef_reverse_relations, key=itemgetter('obj', 'varname', 'type') ) if old_objdef_reverse_relations != new_objdef_reverse_relations: objdef.reverse_relations = new_objdef_reverse_relations objdef.store(object_only=True) # sort self's reverse_relations and set self.reverse_relations = sorted(reverse_relations, key=itemgetter('obj', 'varname', 'type')) def build_relations_by_ref(self): # build relations to other carddefs, to be stored in some object reverse field self_ref = '%s:%s' % (self.xml_root_node, self.url_name) relations_by_ref = collections.defaultdict(list) def _check_field(field): data_source = getattr(field, 'data_source', None) if not data_source or not data_source.get('type', '').startswith('carddef:'): return # reverse relation of data_source['type'] to this object obj_ref = ':'.join(data_source['type'].split(':')[:2]) # remove possible custom-view relations_by_ref[obj_ref].append( { 'varname': field.contextual_varname or '', 'type': field.key, 'obj': self_ref, } ) for field in self.iter_fields(include_block_fields=True): if field.key in ['item', 'items', 'computed']: _check_field(field) # remove duplicated items return { k: list(map(dict, {tuple(sorted(d.items())) for d in v})) for k, v in relations_by_ref.items() } def store_related_custom_views(self): for view in getattr(self, '_custom_views', []): if not view.id: existing_views = get_publisher().custom_view_class.select( [ Equal('formdef_type', self.xml_root_node), Equal('formdef_id', str(self.id)), Equal('visibility', view.visibility), Equal('slug', view.slug), ] ) if existing_views: view.id = existing_views[0].id view.formdef = self view.store() def get_all_fields(self): return (self.fields or []) + self.workflow.get_backoffice_fields() def iter_fields(self, include_block_fields=False): def _iter_fields(fields, block_field=None): for field in fields: # add contextual_id/contextual_varname attributes # they are id/varname for normal fields # but in case of blocks they are concatenation of block id/varname + field id/varname field.contextual_id = field.id field.contextual_varname = None if block_field: field.block_field = block_field field.contextual_id = '%s-%s' % (field.block_field.id, field.id) if field.varname and field.block_field.varname: field.contextual_varname = '%s_%s' % ( field.block_field.varname, field.varname, ) else: field.contextual_varname = field.varname yield field if field.key == 'block' and include_block_fields: try: field.block # load block except KeyError: # blockdef not found continue yield from _iter_fields(field.block.fields, block_field=field) field._block = None # reset cache yield from _iter_fields(self.get_all_fields()) def get_widget_fields(self): return [field for field in self.fields or [] if isinstance(field, fields.WidgetField)] @property def default_digest_template(self): return (self.digest_templates or {}).get('default') def get_category(self): if self.category_id: try: return self.category_class.get(self.category_id) except KeyError: return None else: return None def set_category(self, category): if category: self.category_id = category.id elif self.category_id: self.category_id = None category = property(get_category, set_category) def get_drafts_lifespan(self): return int(self.drafts_lifespan or DRAFTS_DEFAULT_LIFESPAN) _workflow = None def get_workflow(self): if self._workflow: return self._workflow from wcs.workflows import Workflow if self.workflow_id: try: workflow = Workflow.get(self.workflow_id) except KeyError: return Workflow.get_unknown_workflow() self._workflow = self.get_workflow_with_options(workflow) return self._workflow else: return self.get_default_workflow() @classmethod def get_default_workflow(cls): from wcs.workflows import Workflow return Workflow.get_default_workflow() def get_workflow_with_options(self, workflow): # this needs to be kept in sync with admin/forms.ptl, # FormDefPage::workflow if not self.workflow_options: return workflow for status in workflow.possible_status: for item in status.items: prefix = '%s*%s*' % (status.id, item.id) for parameter in item.get_parameters(): value = self.workflow_options.get(prefix + parameter) if value: setattr(item, parameter, value) return workflow def set_workflow(self, workflow): if workflow and workflow.id not in ['_carddef_default', '_default']: self.workflow_id = workflow.id self._workflow = workflow elif self.workflow_id: self.workflow_id = None self._workflow = None workflow = property(get_workflow, set_workflow) def get_dependencies(self): yield self.category if self.workflow_id and self.workflow.id not in ['_carddef_default', '_default']: yield self.workflow for field in self.fields or []: yield from field.get_dependencies() role_class = get_publisher().role_class for role_id in itertools.chain(self.roles or [], self.backoffice_submission_roles or []): yield role_class.get(role_id, ignore_errors=True) for role_id in (self.workflow_roles or {}).values(): yield role_class.get(role_id, ignore_errors=True) @property def keywords_list(self): if not self.keywords: return [] return [x.strip() for x in self.keywords.split(',')] @property def appearance_keywords_list(self): if not get_publisher().has_site_option('formdef-appearance-keywords'): return [] if not self.appearance_keywords: return [] return [x.strip() for x in self.appearance_keywords.split()] def get_variable_options(self): variables = {} if not self.workflow.variables_formdef: return variables if not self.workflow_options: return variables for field in self.workflow.variables_formdef.fields: if not field.varname: continue option_name = 'form_option_' + field.varname variables[option_name] = self.workflow_options.get(field.varname) if field.store_display_value: if '%s_display' % field.varname in self.workflow_options: variables[option_name + '_raw'] = variables[option_name] variables[option_name] = self.workflow_options.get('%s_display' % field.varname) if field.store_structured_value: if '%s_structured' % field.varname in self.workflow_options: variables[option_name + '_structured'] = self.workflow_options.get( '%s_structured' % field.varname ) return variables def get_variable_options_for_form(self): variables = {} if not self.workflow.variables_formdef: return variables if not self.workflow_options: return {} for field in self.workflow.variables_formdef.fields: if not field.varname: continue variables[str(field.id)] = self.workflow_options.get(field.varname) return variables def set_variable_options(self, form): data = self.workflow.variables_formdef.get_data(form) variables = {} for field in self.workflow.variables_formdef.fields: if not field.varname: continue variables[field.varname] = data.get(field.id) if field.store_display_value: variables[field.varname + '_display'] = data.get(field.id + '_display') if field.store_structured_value: variables[field.varname + '_structured'] = data.get(field.id + '_structured') if not self.workflow_options: self.workflow_options = {} self.workflow_options.update(variables) @classmethod def get_by_urlname(cls, url_name, ignore_migration=False, ignore_errors=False): return cls.get_on_index( url_name, 'url_name', ignore_migration=ignore_migration, ignore_errors=ignore_errors ) get_by_slug = get_by_urlname @property def slug(self): return self.url_name @slug.setter def slug(self, value): self.url_name = value def get_url(self, backoffice=False, preview=False, include_category=False, language=None): if backoffice: base_url = get_publisher().get_backoffice_url() + '/management' elif preview: base_url = get_publisher().get_frontoffice_url() + '/preview' else: base_url = get_publisher().get_frontoffice_url() if language and get_publisher().has_i18n_enabled(): base_url += '/' + language if include_category and self.category_id: return '%s/%s/%s/' % (base_url, self.category.slug, self.url_name) return '%s/%s/' % (base_url, self.url_name) def get_api_url(self): base_url = get_publisher().get_frontoffice_url() return '%s/api/forms/%s/' % (base_url, self.url_name) def get_admin_url(self): base_url = get_publisher().get_backoffice_url() return '%s/forms/%s/' % (base_url, self.id) def get_field_admin_url(self, field): return self.get_admin_url() + 'fields/%s/' % field.id def get_backoffice_submission_url(self): base_url = get_publisher().get_backoffice_url() + '/submission' return '%s/%s/' % (base_url, self.url_name) def get_display_id_format(self): return '[formdef_id]-[form_number_raw]' def get_submission_lateral_block(self): context = get_publisher().substitutions.get_context_variables(mode='lazy') if self.submission_lateral_template is None: new_value = None else: try: new_value = Template(self.submission_lateral_template, autoescape=False).render(context) except Exception as e: get_publisher().record_error( _('Could not render submission lateral template (%s)' % e), formdef=self, exception=e, ) return None return new_value def create_form(self, page=None, displayed_fields=None, transient_formdata=None): form = Form(enctype="multipart/form-data", use_tokens=False) if self.appearance_keywords: form.attrs['class'] = 'quixote %s' % self.appearance_keywords if self.keywords: form.attrs['data-keywords'] = ' '.join(self.keywords_list) form.ERROR_NOTICE = _( 'There were errors processing the form and ' 'you cannot go to the next page. Do ' 'check below that you filled all fields correctly.' ) self.add_fields_to_form( form, page=page, displayed_fields=displayed_fields, transient_formdata=transient_formdata ) return form def get_computed_fields_from_page(self, page): on_page = page is None for field in self.fields: if field.key == 'page': if on_page: break if page.id == field.id: on_page = True continue if not on_page: continue if field.key == 'computed': yield field def add_fields_to_form( self, form, page=None, displayed_fields=None, form_data=None, # a dictionary, to fill fields transient_formdata=None, ): # a FormData on_page = page is None hidden_varnames = set() for field in self.fields: field.formdef = self if field.type == 'page': if on_page: break if page.id == field.id: on_page = True continue if not on_page: continue visible = field.is_visible(form_data, self) if not visible: if not field.has_live_conditions(self, hidden_varnames=hidden_varnames): # ignore field.varname when checking later conditions for liveness if field.varname: hidden_varnames.add(field.varname) # no live conditions so field can be skipped continue if isinstance(displayed_fields, list): displayed_fields.append(field) value = None if form_data: value = form_data.get(field.id) if not field.add_to_form: continue widget = field.add_to_form(form, value) widget.is_hidden = not (visible) widget.field = field if transient_formdata and not widget.is_hidden: transient_formdata.data.update(self.get_field_data(field, widget)) # invalidate cache as comment fields (and other things?) may # have accessed variables in non-lazy mode and caused a cache # with now-obsolete values. get_publisher().substitutions.invalidate_cache() widget._parsed = False widget.error = None def get_page(self, page_no): return [x for x in self.fields if x.type == 'page'][page_no] def page_count(self): return len([x for x in self.fields if x.type == 'page']) or 1 def create_view_form(self, dict=None, use_tokens=True, visible=True): dict = dict or {} form = Form(enctype='multipart/form-data', use_tokens=use_tokens) if not visible: form.attrs['style'] = 'display: none;' if self.keywords: form.attrs['data-keywords'] = ' '.join(self.keywords_list) form_fields = self.fields if form_fields and form_fields[0].type != 'page': # add fake initial page in case it's missing form_fields = [fields.PageField(label='', type='page')] + form_fields # 1st pass to group fields on different pages pages = [] current_page = {} for field in form_fields: if field.type == 'page': current_page = {'page': field, 'fields': []} current_page['disabled'] = not field.is_visible(dict, self) pages.append(current_page) continue if current_page['disabled']: continue if field.type == 'title' and ( not current_page['fields'] and current_page['page'].label == field.label ): # don't include first title of a page if that title has the # same text as the page. continue if field.type in ('title', 'subtitle', 'comment') and not field.include_in_validation_page: # don't render field that wouldn't be displayed. continue if not field.is_visible(dict, self): continue current_page['fields'].append(field) # 2nd pass to create view form for page in pages: visible_contents = False if page['fields'] and any(x.include_in_validation_page for x in page['fields']): visible_contents = True form.widgets.append(HtmlWidget(htmltext('
'))) if page['page'].label: form.widgets.append(HtmlWidget(htmltext('

%s

') % page['page'].label)) form.widgets.append(HtmlWidget(htmltext('
'))) for field in page['fields']: value = dict.get(field.id) if not field.add_to_view_form: continue if not field.include_in_validation_page: form.widgets.append(HtmlWidget(htmltext('
'))) field.add_to_view_form(form, value) form.widgets.append(HtmlWidget(htmltext('
'))) else: field.add_to_view_form(form, value) if visible_contents: form.widgets.append(HtmlWidget(htmltext('
'))) return form def set_live_condition_sources(self, form, fields): live_condition_fields = {} fields_ids = {str(x.id) for x in fields} for field in self.iter_fields(include_block_fields=True): if (hasattr(field, 'block_field') and str(field.block_field.id) not in fields_ids) or ( not hasattr(field, 'block_field') and str(field.id) not in fields_ids ): continue if field.condition: field.varnames = field.get_condition_varnames(formdef=self) for varname in field.varnames: if varname not in live_condition_fields: live_condition_fields[varname] = [] live_condition_fields[varname].append(field) if field.key in ('item', 'items') and field.data_source: data_source = data_sources.get_object(field.data_source) if data_source.type not in ('json', 'geojson') and not data_source.type.startswith( 'carddef:' ): continue varnames = data_source.get_referenced_varnames(formdef=self) for varname in varnames: if varname not in live_condition_fields: live_condition_fields[varname] = [] live_condition_fields[varname].append(field) if field.prefill and field.prefill.get('type') == 'string': for varname in field.get_referenced_varnames( formdef=self, value=field.prefill.get('value', '') ): if varname not in live_condition_fields: live_condition_fields[varname] = [] live_condition_fields[varname].append(field) if field.key == 'comment': for varname in field.get_referenced_varnames(formdef=self, value=field.label): if varname not in live_condition_fields: live_condition_fields[varname] = [] live_condition_fields[varname].append(field) for field in fields: if field.varname in live_condition_fields: widget = form.get_widget('f%s' % field.id) if widget: widget.live_condition_source = True widget.live_condition_fields = live_condition_fields[field.varname] @classmethod def get_field_data(cls, field, widget, raise_on_error=False): d = {} d[field.id] = widget.parse() if d.get(field.id) is not None and field.convert_value_from_str: d[field.id] = field.convert_value_from_str(d[field.id]) field.set_value(d, d[field.id], raise_on_error=raise_on_error) if getattr(widget, 'cleanup', None): widget.cleanup() return d def get_data(self, form, raise_on_error=False): d = {} for field in self.fields: widget = form.get_widget('f%s' % field.id) if widget: d.update(self.get_field_data(field, widget, raise_on_error=raise_on_error)) return d def export_to_json(self, include_id=False, indent=None, with_user_fields=False): from wcs.carddef import CardDef charset = get_publisher().site_charset root = {} root['name'] = force_text(self.name, charset) if include_id and self.id: root['id'] = str(self.id) if self.category: root['category'] = force_text(self.category.name, charset) root['category_id'] = str(self.category.id) if self.workflow: root['workflow'] = self.workflow.get_json_export_dict(include_id=include_id) if self.max_field_id is None and self.fields: self.max_field_id = max(lax_int(x.id) for x in self.fields) more_attributes = ['tracking_code_verify_fields'] if self.max_field_id: more_attributes.append('max_field_id') for attribute in self.TEXT_ATTRIBUTES + self.BOOLEAN_ATTRIBUTES + more_attributes: if not hasattr(self, attribute): continue root[attribute] = getattr(self, attribute) if isinstance(root[attribute], time.struct_time): root[attribute] = time.strftime('%Y-%m-%dT%H:%M:%S', root[attribute]) root['fields'] = [] if self.fields: for field in self.fields: root['fields'].append(field.export_to_json(include_id=include_id)) if self.geolocations: root['geolocations'] = self.geolocations.copy() if self.workflow_options: root['options'] = self.workflow_options.copy() for k, v in list(root['options'].items()): # convert time.struct_time to strings as python3 would # serialize it as tuple. if isinstance(v, time.struct_time): root['options'][k] = time.strftime('%Y-%m-%dT%H:%M:%S', v) if self.required_authentication_contexts: root['required_authentication_contexts'] = self.required_authentication_contexts[:] if isinstance(self, CardDef): all_carddefs = CardDef.select(ignore_errors=True) all_carddefs = [c for c in all_carddefs if c] all_carddefs_by_slug = {c.url_name: c for c in all_carddefs} def get_field_label(obj, field_varname): card_slug = obj.split(':')[1] carddef = all_carddefs_by_slug.get(card_slug) if not carddef: return for field in carddef.iter_fields(include_block_fields=True): if field.contextual_varname == field_varname: if getattr(field, 'block_field', None): return '%s - %s' % (field.block_field.label, field.label) return field.label card_relations = [] current_objdef_ref = '%s:%s' % (self.xml_root_node, self.url_name) for objdef, relations in self.build_relations_by_ref().items(): if not objdef.startswith('carddef:'): continue try: CardDef.get_by_urlname(objdef.split(':')[1]) except KeyError: continue for relation in relations: if not relation['varname']: continue card_relations.append( { 'varname': relation['varname'], 'label': get_field_label(current_objdef_ref, relation['varname']), 'type': relation['type'], 'obj': objdef, 'reverse': False, } ) for relation in self.reverse_relations or []: if not relation['obj'].startswith('carddef:'): continue if not relation['varname']: continue rel = relation.copy() rel.update( { 'reverse': True, 'label': get_field_label(relation['obj'], relation['varname']), } ) card_relations.append(rel) root['relations'] = sorted(card_relations, key=itemgetter('varname')) if with_user_fields: root['user'] = { 'fields': [ { 'varname': 'name', 'label': _('Full name'), 'type': 'string', }, { 'varname': 'email', 'label': _('Email'), 'type': 'email', }, ] } user_formdef = get_publisher().user_class.get_formdef() if user_formdef: root['user']['fields'] += [ { 'varname': f.varname or '', 'label': f.label, 'type': f.type, } for f in user_formdef.fields ] return json.dumps(root, indent=indent, sort_keys=True, cls=JSONEncoder) @classmethod def import_from_json(cls, fd, charset=None, include_id=False): if charset is None: charset = get_publisher().site_charset formdef = cls() def unicode2str(v): if isinstance(v, dict): return {unicode2str(k): unicode2str(v) for k, v in v.items()} elif isinstance(v, list): return [unicode2str(x) for x in v] elif isinstance(v, str): return force_str(v) else: return v # we have to make sure all strings are str object, not unicode. value = unicode2str(json.load(fd)) if include_id and 'id' in value: formdef.id = value.get('id') if include_id and 'category_id' in value: formdef.category_id = value.get('category_id') elif 'category' in value: category = value.get('category') for c in Category.select(): if c.name == category: formdef.category_id = c.id break if include_id and 'workflow_id' in value: formdef.workflow_id = value.get('workflow_id') elif ( include_id and 'workflow' in value and isinstance(value['workflow'], dict) and 'id' in value['workflow'] ): formdef.workflow_id = value['workflow'].get('id') elif 'workflow' in value: if isinstance(value['workflow'], str): workflow = value.get('workflow') else: workflow = value['workflow'].get('name') from wcs.workflows import Workflow for w in Workflow.select(): if w.name == workflow: formdef.workflow_id = w.id break more_attributes = ['max_field_id', 'tracking_code_verify_fields'] for attribute in cls.TEXT_ATTRIBUTES + cls.BOOLEAN_ATTRIBUTES + more_attributes: if attribute in value: setattr(formdef, attribute, value.get(attribute)) formdef.fields = [] for i, field in enumerate(value.get('fields', [])): try: field_o = fields.get_field_class_by_type(field.get('type'))() except KeyError: raise FormdefImportError(_('Unknown field type'), details=field.findtext('type')) field_o.init_with_json(field, include_id=True) if not field_o.id: # this assumes all fields will have id, or none of them field_o.id = str(i) formdef.fields.append(field_o) if formdef.fields and not formdef.max_field_id: formdef.max_field_id = max(lax_int(x.id) for x in formdef.fields) if value.get('options'): formdef.workflow_options = value.get('options') for option_key, option_value in formdef.workflow_options.items(): if isinstance(option_value, dict) and 'filename' in option_value: filename = option_value['filename'] upload = Upload(filename, content_type=option_value['content_type']) new_value = UploadedFile(get_publisher().app_dir, filename, upload) new_value.set_content(base64.decodebytes(force_bytes(option_value['content']))) formdef.workflow_options[option_key] = new_value if value.get('geolocations'): formdef.geolocations = value.get('geolocations') if value.get('required_authentication_contexts'): formdef.required_authentication_contexts = [ str(x) for x in value.get('required_authentication_contexts') ] return formdef def export_to_xml(self, include_id=False): charset = get_publisher().site_charset 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 = force_text(getattr(self, text_attribute), charset) for boolean_attribute in self.BOOLEAN_ATTRIBUTES: if not hasattr(self, boolean_attribute): continue value = getattr(self, boolean_attribute) if value: value = 'true' else: value = 'false' ET.SubElement(root, boolean_attribute).text = value self.category_class.object_category_xml_export(self, root, include_id=include_id) workflow = None if self.workflow_id: from wcs.workflows import Workflow workflow = Workflow.get(self.workflow_id, ignore_errors=True, ignore_migration=True) if not workflow: workflow = self.get_default_workflow() elem = ET.SubElement(root, 'workflow') elem.text = force_text(workflow.name, charset) if workflow.slug: elem.attrib['slug'] = str(workflow.slug) if include_id: elem.attrib['workflow_id'] = str(workflow.id) if self.max_field_id is None and self.fields: self.max_field_id = max(lax_int(x.id) for x in self.fields) if self.max_field_id: ET.SubElement(root, 'max_field_id').text = str(self.max_field_id) if self.tracking_code_verify_fields is not None: verify_fields = ET.SubElement(root, 'tracking_code_verify_fields') for field_id in self.tracking_code_verify_fields: ET.SubElement(verify_fields, 'field_id').text = str(field_id) fields = ET.SubElement(root, 'fields') for field in self.fields or []: fields.append(field.export_to_xml(charset=charset, include_id=include_id)) from wcs.workflows import get_role_name_and_slug def add_role_element(roles_root, role_id): if not role_id: return role_name, role_slug = get_role_name_and_slug(role_id) sub = ET.SubElement(roles_root, 'role') if role_slug: sub.attrib['slug'] = role_slug if include_id: sub.attrib['role_id'] = str(role_id) sub.text = role_name return sub roles_elements = [ ('roles', 'user-roles'), ('backoffice_submission_roles', 'backoffice-submission-roles'), ] for attr_name, node_name in roles_elements: if not getattr(self, attr_name, None): continue roles = ET.SubElement(root, node_name) for role_id in getattr(self, attr_name): add_role_element(roles, role_id) if self.workflow_roles: roles = ET.SubElement(root, 'roles') for role_key, role_id in self.workflow_roles.items(): sub = add_role_element(roles, role_id) if sub is not None: sub.attrib['role_key'] = role_key options = ET.SubElement(root, 'options') for option in sorted(self.workflow_options or []): element = ET.SubElement(options, 'option') element.attrib['varname'] = option option_value = self.workflow_options.get(option) if isinstance(option_value, str): element.text = force_text(self.workflow_options.get(option, ''), charset) elif hasattr(option_value, 'base_filename'): ET.SubElement(element, 'filename').text = option_value.base_filename ET.SubElement(element, 'content_type').text = ( option_value.content_type or 'application/octet-stream' ) ET.SubElement(element, 'content').text = force_text( base64.b64encode(option_value.get_content()) ) elif isinstance(option_value, time.struct_time): element.text = time.strftime('%Y-%m-%d', option_value) element.attrib['type'] = 'date' else: pass # TODO: extend support to other types custom_views_element = ET.SubElement(root, 'custom_views') if hasattr(self, '_custom_views'): # it has just been loaded, it's reexported as part as the overwrite # confirmation dialog, do not get custom views from database. custom_views = self._custom_views else: custom_views = [] for view in get_publisher().custom_view_class.select(): if view.match(user=None, formdef=self): custom_views.append(view) for view in custom_views: custom_views_element.append(view.export_to_xml(charset=charset)) geolocations = ET.SubElement(root, 'geolocations') for geoloc_key, geoloc_label in (self.geolocations or {}).items(): element = ET.SubElement(geolocations, 'geolocation') element.attrib['key'] = geoloc_key element.text = force_text(geoloc_label, charset) if self.required_authentication_contexts: element = ET.SubElement(root, 'required_authentication_contexts') for auth_context in self.required_authentication_contexts: ET.SubElement(element, 'method').text = force_text(auth_context) if self.digest_templates: digest_templates = ET.SubElement(root, 'digest_templates') for key, value in self.digest_templates.items(): if not value: continue sub = ET.SubElement(digest_templates, 'template') sub.attrib['key'] = key sub.text = value return root @classmethod def import_from_xml(cls, fd, charset=None, include_id=False, fix_on_error=False, check_datasources=True): try: tree = ET.parse(fd) except Exception: raise ValueError() formdef = cls.import_from_xml_tree( tree, charset=charset, include_id=include_id, fix_on_error=fix_on_error, check_datasources=check_datasources, ) if formdef.url_name: try: cls.get_on_index(formdef.url_name, 'url_name', ignore_migration=True) except KeyError: pass else: formdef.url_name = formdef.get_new_url_name() # fix max_field_id if necessary if formdef.max_field_id is not None: max_field_id = max(lax_int(x.id) for x in formdef.fields) formdef.max_field_id = max(max_field_id, formdef.max_field_id) # check if all field id are unique known_field_ids = set() for field in formdef.fields: if field.id in known_field_ids: raise FormdefImportRecoverableError(_('Duplicated field identifiers')) known_field_ids.add(field.id) return formdef @classmethod def import_from_xml_tree( cls, tree, include_id=False, charset=None, fix_on_error=False, snapshot=False, check_datasources=True ): from wcs.carddef import CardDef if charset is None: charset = get_publisher().site_charset assert charset == 'utf-8' formdef = cls() if tree.find('name') is None or not tree.find('name').text: raise FormdefImportError(_('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 FormdefImportError( _('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'): formdef.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(formdef, text_attribute, xml_node_text(value)) for boolean_attribute in cls.BOOLEAN_ATTRIBUTES: value = tree.find(boolean_attribute) if value is None: continue setattr(formdef, boolean_attribute, value.text == 'true') formdef.fields = [] unknown_field_types = set() unknown_fields_blocks = set() for i, field in enumerate(tree.find('fields')): try: field_o = fields.get_field_class_by_type(field.findtext('type'))() except KeyError: field_type = field.findtext('type') if field_type.startswith('block:'): unknown_fields_blocks.add(field_type[6:]) else: unknown_field_types.add(field_type) continue field_o.init_with_xml(field, charset, include_id=True) if fix_on_error or not field_o.id: # this assumes all fields will have id, or none of them field_o.id = str(i + 1) formdef.fields.append(field_o) if formdef.fields: value = tree.find('max_field_id') if value is not None: formdef.max_field_id = int(value.text) else: formdef.max_field_id = max(lax_int(x.id) for x in formdef.fields) if tree.find('tracking_code_verify_fields') is not None: formdef.tracking_code_verify_fields = [ xml_node_text(verify_field_id) for verify_field_id in tree.findall('tracking_code_verify_fields/field_id') ] formdef.workflow_options = {} for option in tree.findall('options/option'): option_value = None if option.attrib.get('type') == 'date': option_value = time.strptime(option.text, '%Y-%m-%d') elif option.text: option_value = xml_node_text(option) elif option.findall('filename'): filename = xml_node_text(option.find('filename')) upload = Upload(filename, content_type=xml_node_text(option.find('content_type'))) option_value = UploadedFile(get_publisher().app_dir, filename, upload) option_value.set_content(base64.decodebytes(force_bytes(option.find('content').text))) formdef.workflow_options[option.attrib.get('varname')] = option_value formdef._custom_views = [] for view in tree.findall('custom_views/%s' % get_publisher().custom_view_class.xml_root_node): view_o = get_publisher().custom_view_class() view_o.init_with_xml(view, charset) formdef._custom_views.append(view_o) cls.category_class.object_category_xml_import(formdef, tree, include_id=include_id) if tree.find('workflow') is not None: from wcs.workflows import Workflow workflow_node = tree.find('workflow') if include_id and workflow_node.attrib.get('workflow_id'): workflow_id = workflow_node.attrib.get('workflow_id') if Workflow.has_key(workflow_id): formdef.workflow_id = workflow_id else: workflow_slug = workflow_node.attrib.get('slug') if workflow_slug: formdef.workflow = Workflow.get_by_slug(workflow_slug) else: workflow = xml_node_text(workflow_node) for w in Workflow.select(ignore_errors=True, ignore_migration=True): if w and w.name == workflow: formdef.workflow_id = w.id break def get_role_by_node(role_node): role_id = None value = xml_node_text(role_node) if value.startswith('_') or value == 'logged-users': return value if include_id: role_id = role_node.attrib.get('role_id') if role_id and get_publisher().role_class.get(role_id, ignore_errors=True): return role_id role_slug = role_node.attrib.get('slug') role = get_publisher().role_class.resolve(uuid=None, slug=role_slug, name=value) if role: return role.id return None roles_elements = [ ('roles', 'user-roles'), ('backoffice_submission_roles', 'backoffice-submission-roles'), ] for attr_name, node_name in roles_elements: if tree.find(node_name) is None: continue roles_node = tree.find(node_name) roles = [] setattr(formdef, attr_name, roles) for child in roles_node: role_id = get_role_by_node(child) if role_id: roles.append(role_id) if tree.find('roles') is not None: roles_node = tree.find('roles') formdef.workflow_roles = {} for child in roles_node: role_key = child.attrib['role_key'] role_id = get_role_by_node(child) formdef.workflow_roles[role_key] = role_id if tree.find('geolocations') is not None: geolocations_node = tree.find('geolocations') formdef.geolocations = {} for child in geolocations_node: geoloc_key = child.attrib['key'] geoloc_value = xml_node_text(child) formdef.geolocations[geoloc_key] = geoloc_value if tree.find('required_authentication_contexts') is not None: node = tree.find('required_authentication_contexts') formdef.required_authentication_contexts = [] for child in node: formdef.required_authentication_contexts.append(str(child.text)) if tree.find('digest_templates') is not None: digest_templates_node = tree.find('digest_templates') formdef.digest_templates = {} for child in digest_templates_node: key = child.attrib['key'] value = xml_node_text(child) formdef.digest_templates[key] = value unknown_datasources = set() if check_datasources: # check if datasources are defined for field in formdef.fields: data_source = getattr(field, 'data_source', None) if data_source: data_source_id = data_source.get('type') if isinstance(data_sources.get_object(data_source), data_sources.StubNamedDataSource): unknown_datasources.add(data_source_id) elif data_source_id and data_source_id.startswith('carddef:'): parts = data_source_id.split(':') # check if carddef exists url_name = parts[1] if formdef.xml_root_node == 'carddef' and formdef.url_name == url_name: # reference to itself, it's ok continue try: CardDef.get_by_urlname(url_name) except KeyError: unknown_datasources.add(data_source_id) continue if len(parts) == 2 or parts[2] == '_with_user_filter': continue lookup_criterias = [ Equal('formdef_type', 'carddef'), Equal('visibility', 'datasource'), Equal('slug', parts[2]), ] try: get_publisher().custom_view_class.select(lookup_criterias)[0] except IndexError: unknown_datasources.add(data_source_id) if unknown_field_types or unknown_fields_blocks or unknown_datasources: details = collections.defaultdict(set) if unknown_field_types: details[_('Unknown field types')].update(unknown_field_types) if unknown_fields_blocks: details[_('Unknown fields blocks')].update(unknown_fields_blocks) if unknown_datasources: details[_('Unknown datasources')].update(unknown_datasources) raise FormdefImportUnknownReferencedError(_('Unknown referenced objects'), details=details) return formdef def get_detailed_email_form(self, formdata, url): details = [] if formdata.user_id and formdata.user: details.append(_('User name:')) details.append(' %s' % formdata.user.name) details.append('') data = formdata.data for field in self.fields: if isinstance( field, ( fields.SubtitleField, fields.TitleField, fields.CommentField, fields.PageField, fields.ComputedField, ), ): continue if isinstance(field, fields.TextField) and field.display_mode == 'rich': continue if data is None: continue if data.get(field.id) is None: continue if data.get(field.id + '_display'): value = data.get(field.id + '_display') else: value = data.get(field.id) details.append(_('%s:') % field.label) if field.type in ('text', 'file'): # XXX: howto support preformatted text in a dl in docutils ? details.append((' %s' % value).replace('\n', '\n ')) else: details.append('%s' % field.get_rst_view_value(value, indent=' ')) details.append('') return '\n'.join([str(x) for x in details]) def get_submitter_email(self, formdata): users_cfg = get_cfg('users', {}) field_email = users_cfg.get('field_email') or 'email' # look up in submitted form for one that would hold the user # email (the one set to be prefilled by user email) def is_user_field(field): if not getattr(field, 'prefill', None): return False if field.prefill.get('type') != 'user': return False if field.prefill.get('value') != field_email: return False return True if formdata.data: # check first in "normal" fields for field in formdata.formdef.fields: if not is_user_field(field): continue v = formdata.data.get(field.id) if v: return v # then check in block fields for field in formdata.formdef.fields: if field.key != 'block': continue for subfield in field.block.fields: if not is_user_field(subfield): continue v = formdata.data.get(field.id) if not (v and v.get('data')): continue for data in v.get('data'): w = data.get(subfield.id) if w: return w # if nothing was found, get email from user profile if formdata.user and formdata.user.email and formdata.user.is_active: return formdata.user.email return None def get_static_substitution_variables(self, minimal=False): d = { 'form_name': self.name, 'form_slug': self.url_name, 'form_class_name': self.__class__.__name__, # reserved for logged errors } if not minimal: from wcs.variables import LazyFormDef d['form_objects'] = LazyFormDef(self).objects if self.category: d.update(self.category.get_substitution_variables(minimal=minimal)) d.update(self.get_variable_options()) return d def get_substitution_variables(self, minimal=False): from wcs.variables import LazyFormDef from .qommon.substitution import CompatibilityNamesDict return CompatibilityNamesDict({'form': LazyFormDef(self)}) def get_detailed_evolution(self, formdata): if not formdata.evolution: return None details = [] evo = formdata.evolution[-1] if evo.who: evo_who = None if evo.who == '_submitter': if formdata.user_id: evo_who = formdata.user_id else: evo_who = evo.who if evo_who: user_who = get_publisher().user_class.get(evo_who, ignore_errors=True) if user_who: details.append(_('User name')) details.append(' %s' % user_who.name) if evo.status: details.append(_('Status')) details.append(' %s' % formdata.get_status_label()) comment = evo.get_plain_text_comment() if comment: details.append('\n%s\n' % comment) return '\n\n----\n\n' + '\n'.join([str(x) for x in details]) def is_of_concern_for_role_id(self, role_id): if not self.workflow_roles: return False return role_id in self.workflow_roles.values() def is_of_concern_for_user(self, user, formdata=None): if not self.workflow_roles: self.workflow_roles = {} user_roles = set(user.get_roles()) # if the formdef itself has some function attributed to the user, grant # access. for role_id in self.workflow_roles.values(): if role_id in user_roles: return True # if there was some redispatching of function, values will be different # in formdata, check them. if formdata and formdata.workflow_roles: for role_id in formdata.workflow_roles.values(): if role_id is None: continue if isinstance(role_id, list): role_ids = set(role_id) else: role_ids = {role_id} if user_roles.intersection(role_ids): return True # if no formdata was given, lookup if there are some existing formdata # where the user has access. if not formdata: data_class = self.data_class() for role_id in user.get_roles(): if data_class.get_ids_with_indexed_value('workflow_roles', role_id): return True return False def is_user_allowed_read(self, user, formdata=None): if not user: if formdata and get_session() and get_session().is_anonymous_submitter(formdata): return True return False if user.is_admin: return True user_roles = set(user.get_roles()) user_roles.add(logged_users_role().id) def ensure_role_are_strings(roles): # makes sure all roles are defined as strings, as different origins # (formdef, user, workflow status...) may define them differently. return {str(x) for x in roles if x} user_roles = ensure_role_are_strings(user_roles) if formdata and formdata.is_submitter(user): return True if self.is_of_concern_for_user(user): if not formdata: return True if formdata: # current status concerned_roles = ensure_role_are_strings(formdata.get_concerned_roles()) if '_submitter' in concerned_roles and formdata.is_submitter(user): return True if user_roles.intersection(concerned_roles): return True return False def is_user_allowed_read_status_and_history(self, user, formdata=None): if user and user.is_admin: return True if not self.workflow_roles: self.workflow_roles = {} form_roles = [x for x in self.workflow_roles.values() if x] if formdata and formdata.workflow_roles: for x in formdata.workflow_roles.values(): if isinstance(x, list): form_roles.extend(x) elif x: form_roles.append(x) return self.is_user_allowed_read(user, formdata=formdata) def is_disabled(self): if self.disabled: return True if self.publication_date: try: publication_datetime = get_as_datetime(self.publication_date) except ValueError: return False if publication_datetime > datetime.datetime.now(): return True if self.expiration_date: try: expiration_datetime = get_as_datetime(self.expiration_date) except ValueError: return False if expiration_datetime < datetime.datetime.now(): return True return False @classmethod def update_filetype(cls, filetype_id, previous_filetype, new_filetype): # look for file fields in all formdefs, to update them with the # new mimetypes. if previous_filetype == new_filetype: return for formdef in cls.select(): changed = False for field in formdef.fields: if not hasattr(field, 'document_type'): continue if not field.document_type: continue if field.document_type['id'] == filetype_id: previous_filetype = field.document_type.copy() del previous_filetype['id'] if previous_filetype == new_filetype: continue field.document_type = new_filetype.copy() field.document_type['id'] = filetype_id changed = True if changed: formdef.store() class _EmptyClass: # helper for instance creation without calling __init__ pass def __copy__(self, memo=None, deepcopy=False): formdef_copy = self._EmptyClass() formdef_copy.__class__ = self.__class__ if deepcopy: formdef_copy.__dict__ = copy.deepcopy(self.__dict__, memo=memo) else: formdef_copy.__dict__ = copy.copy(self.__dict__) return formdef_copy def __deepcopy__(self, memo=None): return self.__copy__(memo=memo, deepcopy=True) # don't pickle computed attributes def __getstate__(self): odict = copy.copy(self.__dict__) if '_workflow' in odict: del odict['_workflow'] if '_start_page' in odict: del odict['_start_page'] if self.lightweight and 'fields' in odict: # will be stored independently del odict['fields'] if '_custom_views' in odict: del odict['_custom_views'] return odict def __setstate__(self, dict): self.__dict__ = dict self._workflow = None self._start_page = None if hasattr(self, 'snapshot_object'): # don't restore snapshot object that would have been stored erroneously delattr(self, 'snapshot_object') @classmethod def storage_load(cls, fd, **kwargs): o = super().storage_load(fd) if kwargs.get('lightweight'): o.fields = Ellipsis return o if cls.lightweight: try: o.fields = pickle.load(fd, **PICKLE_KWARGS) except EOFError: pass # old format return o @classmethod def storage_dumps(cls, object): if getattr(object, 'fields', None) is Ellipsis: raise RuntimeError('storing a lightweight object is not allowed') # use two separate pickle chunks to store the formdef, the first field # is everything but fields (excluded via __getstate__) while the second # chunk contains the fields. return pickle.dumps(object, protocol=2) + pickle.dumps(object.fields, protocol=2) def change_workflow(self, new_workflow, status_mapping=None): old_workflow = self.get_workflow() formdata_count = self.data_class().count() if formdata_count: assert status_mapping, 'status mapping is required if there are formdatas' assert all( status.id in status_mapping for status in old_workflow.possible_status ), 'a status was not mapped' mapping = {} for old_status, new_status in status_mapping.items(): mapping['wf-%s' % old_status] = 'wf-%s' % new_status mapping['draft'] = 'draft' if any(x[0] != x[1] for x in mapping.items()): # if there are status changes, update all formdatas (except drafts) from . import sql sql.formdef_remap_statuses(self, mapping) self.workflow = new_workflow if new_workflow.has_action('geolocate') and not self.geolocations: self.geolocations = {'base': str(_('Geolocation'))} self.store(comment=_('Workflow change')) if formdata_count: # instruct formdef to update its security rules self.data_class().rebuild_security() def i18n_scan(self): location = '%s/%s/' % (self.backoffice_section, self.id) yield location, None, self.name yield location, None, self.description for field in self.fields or []: yield from field.i18n_scan(base_location=location + 'fields/') EmailsDirectory.register( 'new_user', _('Notification of creation to user'), enabled=False, category=_('Workflow'), default_subject=_('New form ({{ form_name }})'), default_body=_( '''\ Hello, This mail is a reminder about the form you just submitted. {% if form_user %} You can consult it with this link: {{ form_url }} {% endif %} {% if form_details %} For reference, here are the details: {{ form_details }} {% endif %} ''' ), ) EmailsDirectory.register( 'change_user', _('Notification of change to user'), category=_('Workflow'), default_subject=_('Form status change ({{ form_name }})'), default_body=_( '''\ Hello, {% if form_status_changed %} Status of the form you submitted just changed (from "{{ form_previous_status }}" to "{{ form_status }}"). {% endif %} {% if form_user %} You can consult it with this link: {{ form_url }} {% endif %} {% if form_comment %}New comment: {{ form_comment }}{% endif %} {% if form_evolution %} {{ form_evolution }} {% endif %} ''' ), ) EmailsDirectory.register( 'new_receiver', _('Notification of creation to receiver'), enabled=False, category=_('Workflow'), default_subject=_('New form ({{ form_name }})'), default_body=_( '''\ Hello, A new form has been submitted, you can see it with this link: {{ form_url_backoffice }} {% if form_details %} For reference, here are the details: {{ form_details }} {% endif %} ''' ), ) EmailsDirectory.register( 'change_receiver', _('Notification of change to receiver'), category=_('Workflow'), default_subject=_('Form status change ({{ form_name }})'), default_body=_( '''\ Hello, A form just changed, you can consult it with this link: {{ form_url_backoffice }} {% if form_status_changed %} Status of the form just changed (from "{{ form_previous_status }}" to "{{ form_status }}"). {% endif %} {% if form_comment %}New comment: {{ form_comment }}{% endif %} {% if form_evolution %} {{ form_evolution }} {% endif %} ''' ), ) Substitutions.register('form_name', category=_('Form'), comment=_('Form Name')) def clean_drafts(publisher, **kwargs): import wcs.qommon.storage as st from wcs.carddef import CardDef job = kwargs.pop('job', None) for formdef in FormDef.select() + CardDef.select(): with job.log_long_job( '%s %s' % (formdef.xml_root_node, formdef.url_name) ) if job else contextlib.ExitStack(): removal_date = datetime.date.today() - datetime.timedelta(days=formdef.get_drafts_lifespan()) for formdata in formdef.data_class().select( [st.Equal('status', 'draft'), st.Less('receipt_time', removal_date.timetuple())] ): formdata.remove_self() def clean_unused_files(publisher, **kwargs): unused_files_behaviour = publisher.get_site_option('unused-files-behaviour') if unused_files_behaviour not in ('move', 'remove'): return known_filenames = set() known_filenames.update([x for x in glob.glob(os.path.join(publisher.app_dir, 'uploads/*'))]) known_filenames.update([x for x in glob.glob(os.path.join(publisher.app_dir, 'attachments/*/*'))]) def accumulate_filenames(): from wcs.carddef import CardDef for formdef in FormDef.select(ignore_migration=True) + CardDef.select(ignore_migration=True): for option_data in (formdef.workflow_options or {}).values(): if is_upload(option_data): yield option_data.get_fs_filename() for formdata in formdef.data_class().select_iterator(ignore_errors=True, itersize=200): for field_data in formdata.get_all_file_data(with_history=True): if is_upload(field_data): yield field_data.get_fs_filename() elif is_attachment(field_data): yield field_data.filename for user in publisher.user_class.select(): for field_data in (user.form_data or {}).values(): if is_upload(field_data): yield field_data.get_fs_filename() used_filenames = set() for filename in accumulate_filenames(): if not filename: # alternative storage continue if not os.path.isabs(filename): filename = os.path.join(publisher.app_dir, filename) used_filenames.add(filename) unused_filenames = known_filenames - used_filenames for filename in unused_filenames: try: if unused_files_behaviour == 'move': new_filename = os.path.join( publisher.app_dir, 'unused-files', filename[len(publisher.app_dir) + 1 :] ) if os.path.exists(new_filename): os.unlink(filename) else: new_dirname = os.path.dirname(new_filename) if not os.path.exists(new_dirname): os.makedirs(new_dirname) os.rename(filename, new_filename) else: os.unlink(filename) except OSError: pass def get_formdefs_of_all_kinds(**kwargs): from wcs.admin.settings import UserFieldsFormDef from wcs.blocks import BlockDef from wcs.carddef import CardDef from wcs.wf.form import FormWorkflowStatusItem from wcs.workflows import Workflow select_kwargs = { 'ignore_errors': True, 'ignore_migration': True, } select_kwargs.update(kwargs) formdefs = [UserFieldsFormDef()] formdefs += FormDef.select(**select_kwargs) formdefs += BlockDef.select(**select_kwargs) formdefs += CardDef.select(**select_kwargs) for workflow in Workflow.select(**select_kwargs): for status in workflow.possible_status: for item in status.items: if isinstance(item, FormWorkflowStatusItem) and item.formdef: formdefs.append(item.formdef) if workflow.variables_formdef: formdefs.append(workflow.variables_formdef) if workflow.backoffice_fields_formdef: formdefs.append(workflow.backoffice_fields_formdef) return formdefs def register_cronjobs(): # once a day, look for: # * expired drafts get_publisher_class().register_cronjob(CronJob(clean_drafts, name='clean_drafts', hours=[2], minutes=[0])) # * unused files get_publisher_class().register_cronjob( CronJob(clean_unused_files, name='clean_unused_files', hours=[2], minutes=[0]) ) class UpdateDigestAfterJob(AfterJob): label = _('Updating digests') def __init__(self, formdefs): super().__init__(formdefs=[(x.__class__, x.id) for x in formdefs]) def execute(self): for formdef_class, formdef_id in self.kwargs['formdefs']: formdef = formdef_class.get(formdef_id) for formdata in formdef.data_class().select(order_by='id'): formdata.store()