diff --git a/collective/easyform/browser/configure.zcml b/collective/easyform/browser/configure.zcml index 9b76b820..136a16b7 100644 --- a/collective/easyform/browser/configure.zcml +++ b/collective/easyform/browser/configure.zcml @@ -6,6 +6,7 @@ + + + + + + + + + + + + + + + diff --git a/collective/easyform/browser/likert_display.pt b/collective/easyform/browser/likert_display.pt new file mode 100644 index 00000000..6eb3ae32 --- /dev/null +++ b/collective/easyform/browser/likert_display.pt @@ -0,0 +1,22 @@ + + + diff --git a/collective/easyform/browser/likert_hidden.pt b/collective/easyform/browser/likert_hidden.pt new file mode 100644 index 00000000..a508ca7d --- /dev/null +++ b/collective/easyform/browser/likert_hidden.pt @@ -0,0 +1,13 @@ +
+ +
diff --git a/collective/easyform/browser/likert_input.pt b/collective/easyform/browser/likert_input.pt new file mode 100644 index 00000000..f2b469f8 --- /dev/null +++ b/collective/easyform/browser/likert_input.pt @@ -0,0 +1,29 @@ + + + + + + + + + + +
  +
+Question Number One + + + +
+ diff --git a/collective/easyform/fields.py b/collective/easyform/fields.py index 19fbaee5..11db2479 100644 --- a/collective/easyform/fields.py +++ b/collective/easyform/fields.py @@ -7,6 +7,7 @@ from collective.easyform.interfaces import IFieldExtender from collective.easyform.interfaces import ILabel from collective.easyform.interfaces import IReCaptcha +from collective.easyform.interfaces import ILikert from collective.easyform.interfaces import IRichLabel from collective.easyform.validators import IFieldValidator from plone.schemaeditor.fields import FieldFactory @@ -140,3 +141,42 @@ class ReCaptcha(TextLine): ReCaptchaFactory = FieldFactory( ReCaptcha, _(u'label_recaptcha_field', default=u'ReCaptcha')) ReCaptchaHandler = BaseHandler(ReCaptcha) + +@implementer(ILikert) +class Likert(TextLine): + """A Likert field""" + + def __init__(self, **kwargs): + self.answers = kwargs.get('answers', None) + if 'answers' in kwargs: + del kwargs['answers'] + self.questions = kwargs.get('questions', None) + if 'questions' in kwargs: + del kwargs['questions'] + Field.__init__(self, **kwargs) + + def _validate(self, value): + super(Likert, self)._validate(value) + self.parse(value) + + def parse(self, value): + result = dict() + lines = value.split(',') + for line in lines: + if not line: + continue + id, answer = line.split(':') + answer = answer.strip() + if answer not in self.answers: + raise ValueError('Invalid answer value.') + index = int(id) + if index < 1 or index > len(self.questions): + raise ValueError('Invalid question index.') + result[index] = answer + return result + + +LikertFactory = FieldFactory( + Likert, _(u"label_likert_field", default=u"Likert") +) +LikertHandler = BaseHandler(Likert) diff --git a/collective/easyform/fields.zcml b/collective/easyform/fields.zcml index 8285a728..ce38e0af 100644 --- a/collective/easyform/fields.zcml +++ b/collective/easyform/fields.zcml @@ -48,4 +48,12 @@ factory="plone.formwidget.recaptcha.ReCaptchaValidator" /> + + diff --git a/collective/easyform/interfaces.py b/collective/easyform/interfaces.py index 12adb01b..872da1f0 100644 --- a/collective/easyform/interfaces.py +++ b/collective/easyform/interfaces.py @@ -27,6 +27,7 @@ from plone.schemaeditor.interfaces import IFieldContext from plone.schemaeditor.interfaces import IFieldEditorExtender from plone.schemaeditor.interfaces import ISchemaContext +from plone.schemaeditor.schema import ITextLinesField from plone.supermodel.model import Schema from plone.supermodel.model import fieldset from plone.z3cform.interfaces import IFormWrapper @@ -53,6 +54,10 @@ from zope.schema.interfaces import ITextLine from zope.tales.tales import CompilerError +import zope.interface +import zope.schema.interfaces +import z3c.form.interfaces + try: from plone.schemaeditor import SchemaEditorMessageFactory as __ except ImportError: @@ -1087,3 +1092,25 @@ class IReCaptcha(ITextLine): class IFieldValidator(Interface): """Base marker for field validators""" + + +class ILikert(zope.schema.interfaces.IField): + + questions = zope.schema.List( + title=_(u'Possible questions'), + description=_(u'Enter allowed choices one per line.'), + required=zope.schema.interfaces.IChoice['vocabulary'].required, + default=zope.schema.interfaces.IChoice['vocabulary'].default, + value_type=zope.schema.TextLine()) + zope.interface.alsoProvides(questions, ITextLinesField) + + answers = zope.schema.List( + title=_(u'Possible answers'), + description=_(u'Enter allowed choices one per line.'), + required=zope.schema.interfaces.IChoice['vocabulary'].required, + default=zope.schema.interfaces.IChoice['vocabulary'].default, + value_type=zope.schema.TextLine()) + zope.interface.alsoProvides(questions, ITextLinesField) + +class ILikertWidget(z3c.form.interfaces.IWidget): + """Likert widget.""" diff --git a/collective/easyform/migration/actions.py b/collective/easyform/migration/actions.py index c251ff1c..7fa1064e 100644 --- a/collective/easyform/migration/actions.py +++ b/collective/easyform/migration/actions.py @@ -11,19 +11,20 @@ import logging try: - from collective.easyform.interfaces.mailer import default_mail_body + from collective.easyform.interfaces.mailer import default_mail_body except ImportError: - from collective.easyform.config import MAIL_BODY_DEFAULT - def default_mail_body(): - return MAIL_BODY_DEFAULT + from collective.easyform.config import MAIL_BODY_DEFAULT + def default_mail_body(): + return MAIL_BODY_DEFAULT -logger = logging.getLogger('collective.easyform.migration') + +logger = logging.getLogger("collective.easyform.migration") NAMESPACES = { - 'easyform': 'http://namespaces.plone.org/supermodel/easyform', - 'form': 'http://namespaces.plone.org/supermodel/form', + "easyform": "http://namespaces.plone.org/supermodel/easyform", + "form": "http://namespaces.plone.org/supermodel/form", } @@ -34,48 +35,49 @@ def append_body_pt(field, name, value): node.text = default_mail_body() -Type = namedtuple('Type', ['name', 'handler']) -Property = namedtuple('Property', ['name', 'handler']) +Type = namedtuple("Type", ["name", "handler"]) +Property = namedtuple("Property", ["name", "handler"]) TYPES_MAPPING = { - 'FormMailerAdapter': Type('collective.easyform.actions.Mailer', append_field), - 'FormSaveDataAdapter': Type('collective.easyform.actions.SaveData', append_field), - 'FormCustomScriptAdapter': Type('collective.easyform.actions.CustomScript', append_field), + "FormMailerAdapter": Type("collective.easyform.actions.Mailer", append_field), + "FormSaveDataAdapter": Type("collective.easyform.actions.SaveData", append_field), + "FormCustomScriptAdapter": Type( + "collective.easyform.actions.CustomScript", append_field + ), } PROPERTIES_MAPPING = { - 'additional_headers': Property('additional_headers', append_node), - 'bcc_recipients': Property('bcc_recipients', append_node), - 'bccOverride': Property('bccOverride', append_node), - 'body_footer': Property('body_footer', append_node), - 'body_post': Property('body_post', append_node), - 'body_pre': Property('body_pre', append_node), - 'body_pt': Property('body_pt', append_body_pt), - 'body_type': Property('body_type', append_node), - 'cc_recipients': Property('cc_recipients', append_node), - 'ccOverride': Property('ccOverride', append_node), - 'description': Property('description', append_node), - 'execCondition': Property('easyform:execCondition', set_attribute), - 'msg_subject': Property('msg_subject', append_node), - 'recipient_email': Property('recipient_email', append_node), - 'recipient_name': Property('recipient_name', append_node), - 'recipientOverride': Property('recipientOverride', append_node), - 'replyto_field': Property('replyto_field', append_node), - 'senderOverride': Property('senderOverride', append_node), - 'showAll': Property('showAll', append_node), - 'showFields': Property('showFields', append_list_node), - 'subject_field': Property('subject_field', append_node), - 'subjectOverride': Property('subjectOverride', append_node), - 'title': Property('title', append_node), - 'to_field': Property('to_field', append_node), - 'xinfo_headers': Property('xinfo_headers', append_node), - 'ExtraData': Property('ExtraData', append_list_node), - 'DownloadFormat': Property('DownloadFormat', append_node), - 'UseColumnNames': Property('UseColumnNames', append_node), - 'SavedFormInput': Property('SavedFormInput', append_node), - 'ProxyRole': Property('ProxyRole', append_node), - 'ScriptBody': Property('ScriptBody', append_node), + "additional_headers": Property("additional_headers", append_node), + "bcc_recipients": Property("bcc_recipients", append_node), + "bccOverride": Property("bccOverride", append_node), + "body_footer": Property("body_footer", append_node), + "body_post": Property("body_post", append_node), + "body_pre": Property("body_pre", append_node), + "body_pt": Property("body_pt", append_body_pt), + "body_type": Property("body_type", append_node), + "cc_recipients": Property("cc_recipients", append_node), + "ccOverride": Property("ccOverride", append_node), + "description": Property("description", append_node), + "execCondition": Property("easyform:execCondition", set_attribute), + "msg_subject": Property("msg_subject", append_node), + "recipient_email": Property("recipient_email", append_node), + "recipient_name": Property("recipient_name", append_node), + "recipientOverride": Property("recipientOverride", append_node), + "replyto_field": Property("replyto_field", append_node), + "senderOverride": Property("senderOverride", append_node), + "showAll": Property("showAll", append_node), + "showFields": Property("showFields", append_list_node), + "subject_field": Property("subject_field", append_node), + "subjectOverride": Property("subjectOverride", append_node), + "title": Property("title", append_node), + "to_field": Property("to_field", append_node), + "xinfo_headers": Property("xinfo_headers", append_list_node), + "ExtraData": Property("ExtraData", append_list_node), + "DownloadFormat": Property("DownloadFormat", append_node), + "UseColumnNames": Property("UseColumnNames", append_node), + "ProxyRole": Property("ProxyRole", append_node), + "ScriptBody": Property("ScriptBody", append_node), } @@ -86,7 +88,7 @@ def pfg_actions(context): continue id_ = obj.getId() props = {} - props['_portal_type'] = obj.portal_type + props["_portal_type"] = obj.portal_type for field in obj.Schema().fields(): name = field.getName() accessor = field.getEditAccessor(obj) @@ -99,18 +101,20 @@ def actions_model(ploneformgen): """Create an actions xml model from a PloneFormGen instance.""" parser = etree.XMLParser(remove_blank_text=True) model = etree.fromstring(ACTIONS_MODEL, parser) - schema = model.find( - '{http://namespaces.plone.org/supermodel/schema}schema') + schema = model.find("{http://namespaces.plone.org/supermodel/schema}schema") for actionname, properties in pfg_actions(ploneformgen): - type_ = TYPES_MAPPING.get(properties['_portal_type']) + type_ = TYPES_MAPPING.get(properties["_portal_type"]) if type_ is None: # ploneformgen has been renamed with _MIGRATION_ suffix # hence, we remove 11 chars pfgpath = "/".join(ploneformgen.getPhysicalPath())[:-11] logger.warning( "Ignoring field '%s' of type '%s' of form '%s.", - actionname, properties['_portal_type'], pfgpath) + actionname, + properties["_portal_type"], + pfgpath, + ) continue field = type_.handler(schema, type_.name, actionname, properties) @@ -122,7 +126,17 @@ def actions_model(ploneformgen): value = to_text(value) - prop.handler(field, prop.name, value) + try: + prop.handler(field, prop.name, value) + except ValueError: + logger.exception( + "FAIL on '{}' field of PFG form {}/{}.".format( + name, + "/".join(ploneformgen.getPhysicalPath()), + ploneformgen.getId(), + ) + ) + continue return etree.tostring(model, pretty_print=True) diff --git a/collective/easyform/migration/data.py b/collective/easyform/migration/data.py index 3ee7bfa0..4fc532b0 100644 --- a/collective/easyform/migration/data.py +++ b/collective/easyform/migration/data.py @@ -3,50 +3,100 @@ from collective.easyform.api import get_actions from collective.easyform.api import get_fields from collective.easyform.interfaces import ISaveData -from DateTime import DateTime +import DateTime from plone.namedfile.interfaces import INamedBlobFileField +from Products.CMFPlone.utils import safe_unicode from zope.schema.interfaces import IDate from zope.schema.interfaces import IDatetime from zope.schema.interfaces import IFromUnicode from zope.schema.interfaces import ISet +from zope.schema.interfaces import ValidationError import logging -logger = logging.getLogger('collective.easyform.migration') +logger = logging.getLogger("collective.easyform.migration") def migrate_saved_data(ploneformgen, easyform): - for data_adapter in ploneformgen.objectValues('FormSaveDataAdapter'): + for data_adapter in ploneformgen.objectValues("FormSaveDataAdapter"): actions = get_actions(easyform) action = actions.get(data_adapter.getId()) schema = get_fields(easyform) if ISaveData.providedBy(action): cols = data_adapter.getColumnNames() + column_count_mismatch = False for idx, row in enumerate(data_adapter.getSavedFormInput()): if len(row) != len(cols): - logger.warning( - 'Number of columns does not match. Skipping row %s in ' - 'data adapter %s/%s', idx, - '/'.join(easyform.getPhysicalPath()), - data_adapter.getId()) + if not column_count_mismatch: + logger.warning( + "Number of columns does not match for all rows. Some data were skipped in " + "data adapter %s/%s", + "/".join(easyform.getPhysicalPath()), + data_adapter.getId(), + ) + column_count_mismatch = True + logger.info( + "Column count mismatch at row %s", + idx, + ) continue data = {} for key, value in zip(cols, row): - field = schema.get(key) - value = value.decode('utf8') - if IFromUnicode.providedBy(field): - value = field.fromUnicode(value) - elif IDatetime.providedBy(field) and value: - value = DateTime(value).asdatetime() - elif IDate.providedBy(field) and value: - value = DateTime(value).asdatetime().date() - elif ISet.providedBy(field): + try: + field = schema.get(key) + value = safe_unicode(value) + if IFromUnicode.providedBy(field) and value: + value = field.fromUnicode(value) + elif IDatetime.providedBy(field) and value: + value = DateTime.DateTime(value).asdatetime() + elif IDate.providedBy(field) and value: + value = DateTime.DateTime(value).asdatetime().date() + elif ISet.providedBy(field): + if not value or value == [""]: + value = set() + else: + value = set(value) + elif INamedBlobFileField.providedBy(field): + value = None + except ( + ValueError, + TypeError, + ValidationError, + SyntaxError, + DateTime.interfaces.SyntaxError, + DateTime.interfaces.TimeError, + ): + # Exceptions above are often due to long living Forms, where users have changed their minds about + # the Field formats/widgets... + # Older datarows can break in these cases + try: - value = set(literal_eval(value)) - except ValueError: - pass - elif INamedBlobFileField.providedBy(field): - value = None + value_to_log = safe_unicode(value) + except UnicodeError: + # in case of file field, try to log the file name + # which is seperated with a colon + if ':' in value: + value_to_log = safe_unicode(value.split(':')[0]) + else: + # xxx we hope we are lucky that there are no broken characters in the first 50 + value_to_log = safe_unicode(value)[:50] + + logger.exception( + u"Error for {}:'{}' in the {}/{} data adapter.".format( + key, + value_to_log, + "/".join(easyform.getPhysicalPath()), + data_adapter.getId(), + ) + ) + logger.warning( + "BEWARE: to keep data integrity, the data was not migrated for " + "data adapter %s/%s.", + "/".join(easyform.getPhysicalPath()), + data_adapter.getId(), + ) + action.clearSavedFormInput() + return data[key] = value action.addDataRow(data) diff --git a/collective/easyform/migration/fields.py b/collective/easyform/migration/fields.py index f6208537..8a60940c 100644 --- a/collective/easyform/migration/fields.py +++ b/collective/easyform/migration/fields.py @@ -5,63 +5,64 @@ from Products.PloneFormGen.content.fields import FGFieldsetStart from Products.PloneFormGen.content.fieldsBase import BaseFormField from Products.PloneFormGen.interfaces import IPloneFormGenFieldset +from Products.CMFPlone.utils import safe_unicode import logging import six -logger = logging.getLogger('collective.easyform.migration') +logger = logging.getLogger("collective.easyform.migration") NAMESPACES = { - 'easyform': 'http://namespaces.plone.org/supermodel/easyform', - 'form': 'http://namespaces.plone.org/supermodel/form', + "easyform": "http://namespaces.plone.org/supermodel/easyform", + "form": "http://namespaces.plone.org/supermodel/form", } def append_field(schema, type_, name, properties): - field = etree.SubElement(schema, u'field') - field.set(u'name', name) - field.set(u'type', type_) + field = etree.SubElement(schema, u"field") + field.set(u"name", name) + field.set(u"type", type_) return field def append_label_field(schema, type_, name, properties): field = append_field(schema, type_, name, properties) - append_node(field, u'required', u'False') + append_node(field, u"required", u"False") return field def append_date_field(schema, type_, name, properties): - if properties.get('fgShowHM', False): - return append_field(schema, u'zope.schema.Datetime', name, properties) + if properties.get("fgShowHM", False): + return append_field(schema, u"zope.schema.Datetime", name, properties) else: - return append_field(schema, u'zope.schema.Date', name, properties) + return append_field(schema, u"zope.schema.Date", name, properties) def append_fieldset(schema, type_, name, properties): - fieldset = etree.SubElement(schema, u'fieldset') - fieldset.set(u'name', name) + fieldset = etree.SubElement(schema, u"fieldset") + fieldset.set(u"name", name) return fieldset def set_attribute(field, name, value): - if u':' in name: - ns, attr = name.split(':') + if u":" in name: + ns, attr = name.split(":") ns = NAMESPACES.get(ns, ns) - field.set(u'{{{}}}{}'.format(ns, attr), value) + field.set(u"{{{}}}{}".format(ns, attr), value) else: field.set(name, value) def append_node(field, name, value): - if ':' in name: - ns, name = name.split(':') + if ":" in name: + ns, name = name.split(":") ns = NAMESPACES.get(ns, ns) - name = '{{{}}}{}'.format(ns, name) + name = "{{{}}}{}".format(ns, name) node = etree.SubElement(field, name) if isinstance(value, (list, tuple)): - value = u' '.join(value) + value = u" ".join(value) node.text = value return node @@ -69,77 +70,78 @@ def append_node(field, name, value): def append_list_node(field, name, value): node = append_node(field, name, None) for val in value: - el = etree.SubElement(node, 'element') - if '|' in val: - key, val = val.split('|') - el.set('key', key) + el = etree.SubElement(node, "element") + if "|" in val: + key, val = val.split("|") + el.set("key", key) el.text = val def append_required_node(field, name, value): - if value == u'False': + if value == u"False": append_node(field, name, value) def append_maxlength_node(field, name, value): - if value != u'0': + if value != u"0": append_node(field, name, value) def append_vocab_node(field, name, value): - if field.get('type') == 'zope.schema.Set': - node = etree.SubElement(field, 'value_type') - node.set('type', 'zope.schema.Choice') + if field.get("type") == "zope.schema.Set": + node = etree.SubElement(field, "value_type") + node.set("type", "zope.schema.Choice") else: node = field - append_list_node(node, u'values', value) + append_list_node(node, u"values", value) def append_default_node(field, name, value): if isinstance(value, list): return - if field.get('type') == 'collective.easyform.fields.RichLabel': - append_node(field, 'rich_label', value) + if field.get("type") == "collective.easyform.fields.RichLabel": + append_node(field, "rich_label", value) else: append_node(field, name, value) def append_widget_node(field, name, value): node = append_node(field, name, None) - type_ = field.get('type') - if type_ == 'zope.schema.Set': - if value == 'select': - widget = 'z3c.form.browser.select.CollectionSelectFieldWidget' + type_ = field.get("type") + if type_ == "zope.schema.Set": + if value == "select": + widget = "z3c.form.browser.select.CollectionSelectFieldWidget" else: - widget = 'z3c.form.browser.checkbox.CheckBoxFieldWidget' + widget = "z3c.form.browser.checkbox.CheckBoxFieldWidget" else: - if value == 'select': - widget = 'z3c.form.browser.select.ChoiceWidgetDispatcher' + if value == "select": + widget = "z3c.form.browser.select.ChoiceWidgetDispatcher" else: - widget = 'z3c.form.browser.radio.RadioFieldWidget' - set_attribute(node, 'type', widget) + widget = "z3c.form.browser.radio.RadioFieldWidget" + set_attribute(node, "type", widget) def append_or_set_title(field, name, value): - if field.tag == 'fieldset': - set_attribute(field, 'label', value) + if field.tag == "fieldset": + set_attribute(field, "label", value) else: append_node(field, name, value) + def append_or_set_description(field, name, value): - if field.tag == 'fieldset': - set_attribute(field, 'description', value) + if field.tag == "fieldset": + set_attribute(field, "description", value) else: append_node(field, name, value) def convert_tales_expressions(value): - if value == u'here/memberEmail': + if value == u"here/memberEmail": return u"python:member and member.getProperty('email', '') or ''" - elif value == u'here/memberFullName': + elif value == u"here/memberFullName": return u"python:member and member.getProperty('fullname', '') or ''" - elif value == u'here/memberId': + elif value == u"here/memberId": return u"python:member and member.id or ''" return value @@ -147,61 +149,67 @@ def convert_tales_expressions(value): def to_text(value): if isinstance(value, (list, tuple)): return [to_text(v) for v in value] - value = str(value) - if six.PY2: - value = value.decode('utf8') + elif isinstance(value, (six.text_type, six.binary_type)): + value = safe_unicode(value) + else: + value = str(value) return value -Type = namedtuple('Type', ['name', 'handler']) -Property = namedtuple('Property', ['name', 'handler']) +Type = namedtuple("Type", ["name", "handler"]) +Property = namedtuple("Property", ["name", "handler"]) TYPES_MAPPING = { - 'FormStringField': Type('zope.schema.TextLine', append_field), - 'FormPasswordField': Type('zope.schema.Password', append_field), - 'FormIntegerField': Type('zope.schema.Int', append_field), - 'FormFixedPointField': Type('zope.schema.Float', append_field), - 'FormBooleanField': Type('zope.schema.Bool', append_field), - 'FormDateField': Type('zope.schema.Date', append_date_field), - 'FormLabelField': Type('collective.easyform.fields.Label', append_label_field), - 'FormLinesField': Type('zope.schema.Text', append_field), - 'FormSelectionField': Type('zope.schema.Choice', append_field), - 'FormMultiSelectionField': Type('zope.schema.Set', append_field), - 'FormTextField': Type('zope.schema.Text', append_field), - 'FormRichTextField': Type('plone.app.textfield.RichText', append_field), - 'FormRichLabelField': Type('collective.easyform.fields.RichLabel', append_label_field), - 'FormFileField': Type('plone.namedfile.field.NamedBlobFile', append_field), - 'FormCaptchaField': Type('collective.easyform.fields.ReCaptcha', append_field), - 'FieldsetStart': Type('', append_fieldset), - 'FieldsetEnd': Type('', None), + "FormStringField": Type("zope.schema.TextLine", append_field), + "FormPasswordField": Type("zope.schema.Password", append_field), + "FormIntegerField": Type("zope.schema.Int", append_field), + "FormFixedPointField": Type("zope.schema.Float", append_field), + "FormBooleanField": Type("zope.schema.Bool", append_field), + "FormDateField": Type("zope.schema.Date", append_date_field), + "FormLabelField": Type("collective.easyform.fields.Label", append_label_field), + "FormLinesField": Type("zope.schema.Text", append_field), + "FormSelectionField": Type("zope.schema.Choice", append_field), + "FormMultiSelectionField": Type("zope.schema.Set", append_field), + "FormTextField": Type("zope.schema.Text", append_field), + "FormRichTextField": Type("plone.app.textfield.RichText", append_field), + "FormRichLabelField": Type( + "collective.easyform.fields.RichLabel", append_label_field + ), + "FormFileField": Type("plone.namedfile.field.NamedBlobFile", append_field), + "FormCaptchaField": Type("collective.easyform.fields.ReCaptcha", append_field), + "FormLikertField": Type("collective.easyform.fields.Likert", append_field), + "FieldsetStart": Type("", append_fieldset), + "FieldsetEnd": Type("", None), } PROPERTIES_MAPPING = { - 'description': Property('description', append_or_set_description), - 'fgDefault': Property('default', append_default_node), - 'fgmaxlength': Property('max_length', append_maxlength_node), - 'fgsize': None, # Not available in collective.easyform - 'fgStringValidator': Property('easyform:validators', set_attribute), - 'fgTDefault': Property('easyform:TDefault', set_attribute), - 'fgTEnabled': Property('easyform:TEnabled', set_attribute), - 'fgTValidator': Property('easyform:TValidator', set_attribute), - 'fgTVocabulary': None, # Not available in collective.easyform - 'fgVocabulary': Property('values', append_vocab_node), - 'hidden': Property('easyform:THidden', set_attribute), - 'maxval': Property('max', append_node), - 'minval': Property('min', append_node), - 'placeholder': None, # Not available in collective.easyform - 'required': Property('required', append_required_node), - 'serverSide': Property('easyform:serverSide', set_attribute), - 'title': Property('title', append_or_set_title), - 'fgFormat': Property('form:widget', append_widget_node), + "description": Property("description", append_or_set_description), + "fgDefault": Property("default", append_default_node), + "fgmaxlength": Property("max_length", append_maxlength_node), + "fgsize": None, # Not available in collective.easyform + "fgStringValidator": Property("easyform:validators", set_attribute), + "fgTDefault": Property("easyform:TDefault", set_attribute), + "fgTEnabled": Property("easyform:TEnabled", set_attribute), + "fgTValidator": Property("easyform:TValidator", set_attribute), + "fgTVocabulary": None, # Not available in collective.easyform + "fgVocabulary": Property("values", append_vocab_node), + "hidden": Property("easyform:THidden", set_attribute), + "likertQuestions": Property("questions", append_list_node), + "likertAnswers": Property("answers", append_list_node), + "maxval": Property("max", append_node), + "minval": Property("min", append_node), + "placeholder": None, # Not available in collective.easyform + "required": Property("required", append_required_node), + "serverSide": Property("easyform:serverSide", set_attribute), + "title": Property("title", append_or_set_title), + "fgFormat": Property("form:widget", append_widget_node), } def pfg_fields_properties(obj): id_ = obj.getId() props = {} - props['_portal_type'] = obj.portal_type + props["_portal_type"] = obj.portal_type for field in obj.Schema().fields(): name = field.getName() accessor = field.getEditAccessor(obj) @@ -237,11 +245,10 @@ def fields_model(ploneformgen): """Create a fields xml model from a PloneFormGen instance.""" parser = etree.XMLParser(remove_blank_text=True) model = etree.fromstring(FIELDS_MODEL, parser) - schema = model.find( - '{http://namespaces.plone.org/supermodel/schema}schema') + schema = model.find("{http://namespaces.plone.org/supermodel/schema}schema") for fieldname, properties in pfg_fields(ploneformgen): - portal_type = properties['_portal_type'] - if portal_type == 'FieldsetEnd': + portal_type = properties["_portal_type"] + if portal_type == "FieldsetEnd": schema = schema.getparent() continue @@ -251,7 +258,11 @@ def fields_model(ploneformgen): # hence, we remove 11 chars pfgpath = "/".join(ploneformgen.getPhysicalPath())[:-11] logger.warning( - "Ignoring field '%s' of type '%s' of form '%s'.", fieldname, portal_type, pfgpath) + "Ignoring field '%s' of type '%s' of form '%s'.", + fieldname, + portal_type, + pfgpath, + ) continue if type_.handler is None: @@ -266,12 +277,26 @@ def fields_model(ploneformgen): value = to_text(value) # Convert TALES expressions with PFG specific methods - if name.startswith('fgT'): + if name.startswith("fgT"): value = convert_tales_expressions(value) + if ( + type_.name == "zope.schema.Choice" + and value == "flex" + and len(properties.get("fgVocabulary", [])) > 4 + ): + # Flex is always migrated to RadioFieldWidget + # Here we switch to select if there are more than 4 options (same as the old flex option did) + value = "select" + logger.debug( + "Switch flex field %s to select in %s", + fieldname, + ploneformgen.absolute_url(), + ) + prop.handler(field, prop.name, value) - if portal_type == 'FieldsetStart': + if portal_type == "FieldsetStart": schema = field return etree.tostring(model, pretty_print=True) diff --git a/collective/easyform/migration/pfg.py b/collective/easyform/migration/pfg.py index b38f674d..897c461d 100644 --- a/collective/easyform/migration/pfg.py +++ b/collective/easyform/migration/pfg.py @@ -24,32 +24,34 @@ import transaction -logger = logging.getLogger('collective.easyform.migration') +logger = logging.getLogger("collective.easyform.migration") -Field = namedtuple('Type', ['name', 'handler']) +Field = namedtuple("Type", ["name", "handler"]) FIELD_MAPPING = { - 'submitLabel': Field('submitLabel', migrate_simplefield), - 'resetLabel': Field('resetLabel', migrate_simplefield), - 'useCancelButton': Field('useCancelButton', migrate_simplefield), - 'forceSSL': Field('forceSSL', migrate_simplefield), - 'formPrologue': Field('formPrologue', migrate_richtextfield), - 'formEpilogue': Field('formEpilogue', migrate_richtextfield), - 'thanksPageOverride': Field('thanksPageOverride', migrate_simplefield), - 'formActionOverride': Field('formActionOverride', migrate_simplefield), - 'onDisplayOverride': Field('onDisplayOverride', migrate_simplefield), - 'afterValidationOverride': Field('afterValidationOverride', migrate_simplefield), # noqa - 'headerInjection': Field('headerInjection', migrate_simplefield), - 'checkAuthenticator': Field('CSRFProtection', migrate_simplefield), + "submitLabel": Field("submitLabel", migrate_simplefield), + "resetLabel": Field("resetLabel", migrate_simplefield), + "useCancelButton": Field("useCancelButton", migrate_simplefield), + "forceSSL": Field("forceSSL", migrate_simplefield), + "formPrologue": Field("formPrologue", migrate_richtextfield), + "formEpilogue": Field("formEpilogue", migrate_richtextfield), + "thanksPageOverride": Field("thanksPageOverride", migrate_simplefield), + "formActionOverride": Field("formActionOverride", migrate_simplefield), + "onDisplayOverride": Field("onDisplayOverride", migrate_simplefield), + "afterValidationOverride": Field( + "afterValidationOverride", migrate_simplefield + ), # noqa + "headerInjection": Field("headerInjection", migrate_simplefield), + "checkAuthenticator": Field("CSRFProtection", migrate_simplefield), } class PloneFormGenMigrator(ATCTContentMigrator): """Migrator for PFG to easyform""" - src_portal_type = 'FormFolder' - src_meta_type = 'FormFolder' - dst_portal_type = 'EasyForm' + src_portal_type = "FormFolder" + src_meta_type = "FormFolder" + dst_portal_type = "EasyForm" dst_meta_type = None # not used def migrate_ploneformgen(self): @@ -58,29 +60,49 @@ def migrate_ploneformgen(self): self.new.fields_model = fields_model(self.old) self.new.actions_model = actions_model(self.old) + self.migrate_thankyou_page() + migrate_saved_data(self.old, self.new) def migrate(self, unittest=0): - super(PloneFormGenMigrator, self).migrate() - logger.info( - 'Migrated FormFolder %s', - '/'.join(self.new.getPhysicalPath())) + try: + super(PloneFormGenMigrator, self).migrate() + logger.info("Migrated FormFolder %s", "/".join(self.new.getPhysicalPath())) + except Exception: + logger.info(u"Could not migrate %r", self.old, exc_info=True) + + def migrate_thankyou_page(self): + pfg_thankspage = self.old.get(self.old.getThanksPage()) + if pfg_thankspage: + ef = self.new + + ef.thankstitle = pfg_thankspage.title + ef.thanksdescription = pfg_thankspage.Description() + ef.showAll = pfg_thankspage.showAll + ef.showFields = pfg_thankspage.showFields + ef.includeEmpties = pfg_thankspage.includeEmpties + Field("thanksPrologue", migrate_richtextfield).handler( + pfg_thankspage, ef, "thanksPrologue", "thanksPrologue" + ) + Field("thanksEpilogue", migrate_richtextfield).handler( + pfg_thankspage, ef, "thanksEpilogue", "thanksEpilogue" + ) class IMigratePloneFormGenFormSchema(model.Schema): dry_run = schema.Bool( - title=u'Dry run', + title=u"Dry run", required=True, default=False, ) class MigratePloneFormGenForm(AutoExtensibleForm, Form): - label = u'Migrate PloneFormGen Forms' + label = u"Migrate PloneFormGen Forms" ignoreContext = True schema = IMigratePloneFormGenFormSchema - @buttonAndHandler(u'Migrate') + @buttonAndHandler(u"Migrate") def handle_migrate(self, action): data, errors = self.extractData() if len(errors) > 0: @@ -89,18 +111,17 @@ def handle_migrate(self, action): self.log = StringIO() handler = logging.StreamHandler(self.log) logger.addHandler(handler) - formatter = logging.Formatter( - '%(asctime)s %(levelname)s %(name)s %(message)s') + formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") handler.setFormatter(formatter) self.migrate() self.migration_done = True - if data.get('dry_run', False): + if data.get("dry_run", False): transaction.abort() - logger.info(u'PloneFormGen migration finished (dry run)') + logger.info(u"PloneFormGen migration finished (dry run)") else: - logger.info(u'PloneFormGen migration finished') + logger.info(u"PloneFormGen migration finished") def migrate(self): alsoProvides(self.request, IDisableCSRFProtection) @@ -108,26 +129,21 @@ def migrate(self): # Switch linkintegrity off temporarily. ptool = getToolByName(self.context, "portal_properties") - site_props = getattr(ptool, 'site_properties', None) + site_props = getattr(ptool, "site_properties", None) link_integrity = False - if site_props and site_props.hasProperty( - 'enable_link_integrity_checks'): + if site_props and site_props.hasProperty("enable_link_integrity_checks"): link_integrity = site_props.getProperty( - 'enable_link_integrity_checks', False) + "enable_link_integrity_checks", False + ) if link_integrity: - site_props.manage_changeProperties( - enable_link_integrity_checks=False) - + site_props.manage_changeProperties(enable_link_integrity_checks=False) migrate(portal, PloneFormGenMigrator) # Switch linkintegrity back on, if needed if link_integrity: - site_props.manage_changeProperties( - enable_link_integrity_checks=True - ) - + site_props.manage_changeProperties(enable_link_integrity_checks=True) def render(self): - if getattr(self, 'migration_done', False): + if getattr(self, "migration_done", False): return self.log.getvalue() return super(MigratePloneFormGenForm, self).render() diff --git a/collective/easyform/tests/testLikert.py b/collective/easyform/tests/testLikert.py new file mode 100644 index 00000000..6fa102f4 --- /dev/null +++ b/collective/easyform/tests/testLikert.py @@ -0,0 +1,112 @@ +import unittest +import six + +from collective.easyform.tests.base import EasyFormTestCase + + +class LikertFieldTests(unittest.TestCase): + + def _getTargetClass(self): + from collective.easyform.fields import Likert + return Likert + + def _makeOne(self, *args, **kw): + return self._getTargetClass()(*args, **kw) + + def test_ctor_defaults(self): + txt = self._makeOne() + self.assertEqual(txt._type, six.text_type) + + def test_validate_not_required(self): + field = self._makeOne(required=False) + field.validate(None) + field.validate(u'') + + def test_validate_with_answers(self): + field = self._makeOne(required=False, questions=[u'Question 1'], answers=[u'Agree', u'Disagree']) + field.validate(None) + field.validate(u'') + field.validate(u'1: Agree') + self.assertRaises(ValueError, field.validate, u'1:agree') + self.assertRaises(ValueError, field.validate, u'-1:agree') + self.assertRaises(ValueError, field.validate, u'Agree') + + def test_validate_with_more_answers(self): + field = self._makeOne(required=False, questions=[u'Question 1', u'Question 2'], answers=[u'Agree', u'Disagree']) + field.validate(None) + field.validate(u'') + field.validate(u'1: Agree') + field.validate(u'2: Agree') + field.validate(u'1: Disagree, 2: Agree') + self.assertRaises(ValueError, field.validate, u'1:agree') + self.assertRaises(ValueError, field.validate, u'-1:agree') + self.assertRaises(ValueError, field.validate, u'Agree') + + def test_parse(self): + field = self._makeOne(required=False, questions=[u'Question 1', u'Question 2'], answers=[u'Agree', u'Disagree']) + field.validate(None) + self.assertEquals(dict(), field.parse(u'')) + self.assertEquals({1: u'Agree'}, field.parse(u'1: Agree')) + self.assertEquals({2: u'Agree'}, field.parse(u'2: Agree')) + self.assertEquals( + {1: u'Disagree', 2: u'Agree'}, + field.parse(u'1: Disagree, 2: Agree') + ) + + +class LikerWidgetTests(EasyFormTestCase): + + def test_likert(self): + self.folder.invokeFactory("EasyForm", "ff1") + ff1 = getattr(self.folder, "ff1") + self.assertEqual(ff1.portal_type, u'EasyForm') + + from zope.interface import Interface + from zope.schema import getFieldsInOrder + from collective.easyform.api import set_fields + from collective.easyform.api import set_actions + from collective.easyform.api import get_fields + from collective.easyform.actions import SaveData + from collective.easyform.fields import Likert + + class IWithLikert(Interface): + likert = Likert(questions=[u"Q1", u"Q2"], answers=[u"Agree", u"Disagree"]) + + set_fields(ff1, IWithLikert) + + schema = get_fields(ff1) + fields = getFieldsInOrder(schema) + self.assertEqual(len(fields), 1) + self.assertEqual(fields[0][0], 'likert') + self.assertTrue(isinstance(fields[0][1], Likert)) + + # check that LikertWidget is used + # and that questions and answers are rendered in a table + view = ff1.restrictedTraverse('view') + rendered = view() + self.assertTrue(u"likert-widget" in rendered) + self.assertTrue(u"Q1" in rendered) + self.assertTrue(u"Q2" in rendered) + self.assertTrue(u"Agree" in rendered) + self.assertTrue(u"Disagree" in rendered) + + class IWithSaver(Interface): + saver = SaveData(showFields=[]) + + IWithSaver.setTaggedValue('context', ff1) + set_actions(ff1, IWithSaver) + + # This part of the test does work on 4.x branch + # help is welcome to fix it for Plone 4.3 + + # submit data to test value extraction from widget + # ff1.CSRFProtection = False # no csrf protection + # view.request.form = { + # "form.buttons.submit": "Send", + # "form.widgets.likert.0": "Agree", + # "form.widgets.likert.1": "Disagree", + # } + # view.request.method = 'POST' + # rendered = view() + # self.assertTrue(u"Thank You" in rendered) + # self.assertTrue(u"1: Agree, 2: Disagree" in rendered)