Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ master (unreleased)

- Upgrade to Circle-CI 2 (before the end of life of Circle-CI v1 on August, 31st 2018). (#342)
- Optimize Circle-CI usage by using the tox matrix in tests (#343)
- Added a plugin mechanism, allowing users to define and integrate their own "business logic" fields.
- Change the global exception handling error level, from "error" to "exception". It'll provide better insights if you're using Logmatic or any other logging aggregator (#336).
- Skip `tox` installation in the circle-ci environment: it's already there (#344).

Expand Down
84 changes: 84 additions & 0 deletions demo/tests/serializers/test_formidable_serializer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from django.test import TestCase

from formidable.register import FieldSerializerRegister, load_serializer
from formidable.serializers import FormidableSerializer
from formidable.serializers.fields import BASE_FIELDS, FieldSerializer


class TestCustomFieldSerializer(TestCase):
def setUp(self):
field_register = FieldSerializerRegister.get_instance()
custom_type_id = 'custom_type_id'
self.custom_type_id = custom_type_id

@load_serializer(field_register)
class CustomFieldSerializer(FieldSerializer):
type_id = custom_type_id

class Meta(FieldSerializer.Meta):
fields = BASE_FIELDS + ('parameters', )
config_fields = ('meta_info', 'some_another_data')

self.custom_field_serializer_class = CustomFieldSerializer
self.field_register = field_register
self.schema = {
"label": "test",
"description": "test",
"fields": [
{
"slug": "custom-type-id",
"label": "Custom field",
"placeholder": None,
"description": None,
"defaults": [],
"multiple": False,
"config_field": "Test test test",
"values": [],
"required": False,
"disabled": False,
"isVisible": True,
"type_id": "custom_type_id",
"validations": [],
"order": 1,
"meta_info": "meta",
"some_another_data": "some_another_data",
"accesses": [
{
"id": "field-access868",
"level": "EDITABLE",
"access_id": "padawan"
}
]
}
]
}

def test_register(self):
self.assertIn(self.custom_type_id, self.field_register)

def test_custom_field_serialize(self):
serializer = FormidableSerializer(data=self.schema)
serializer.is_valid()
serializer.save()
# get field instance
self.instance = serializer.instance
custom_field = serializer.instance.fields.first()
# test field instance
self.assertIn('meta_info', custom_field.parameters)
self.assertEqual(custom_field.parameters['meta_info'], "meta")
self.assertIn('some_another_data', custom_field.parameters)
self.assertEqual(
custom_field.parameters['some_another_data'],
"some_another_data"
)
# get serialized data
data = FormidableSerializer(serializer.instance).data['fields'][0]
# test serialized data
self.assertIn('meta_info', data)
self.assertIn('some_another_data', data)
self.assertNotIn('parameters', data)
# remove instance
self.instance.delete()

def tearDown(self):
self.field_register.pop(self.custom_type_id)
199 changes: 199 additions & 0 deletions docs/source/external-field-plugins.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
===============================
External Field Plugin Mechanism
===============================

We've included a mechanism to add your own fields to the collection of available fields in ``django-formidable``.

It'll be possible to:

* define a new form using this new type of field,
* store their definition and parameters in a Formidable object instance (and thus, in the database),
* using this form definition, validate the end-user data when filling this form against your field business logic mechanism.

For the sake of the example, let's say you want to add a "Color Picker" field in django-formidable. You'll have to create a django library project that we'll call ``django-formidable-color-picker``. Let's say that this module has its own ``setup.py`` with the appropriate scripts to be installed in dev mode using ``pip install -e ./``.

Let's also say that you have added it in your ``INSTALLED_APPS``.

Tree structure
==============

::

.
├── formidable_color_picker
│   ├── apps.py
│   ├── __init__.py
│   ├── serializers.py
├── setup.cfg
└── setup.py

Loading the field for building time
===================================

The first file we're going to browse is :file:`serializers.py`. Here's a minimal version of it:


.. code-block:: python

from formidable.register import load_serializer, FieldSerializerRegister
from formidable.serializers.fields import FieldSerializer, BASE_FIELDS

field_register = FieldSerializerRegister.get_instance()


@load_serializer(field_register)
class ColorPickerFieldSerializer(FieldSerializer):

type_id = 'color_picker'

class Meta(FieldSerializer.Meta):
fields = BASE_FIELDS

Then you're going to need to make sure that Django would catch this file at startup, and thus load the Serializer. It's done via the :file:`apps.py` file.

.. code-block:: python

from __future__ import absolute_import
from django.apps import AppConfig


class FormidableColorPickerConfig(AppConfig):
"""
Formidable Color Picker configuration class.
"""
name = 'formidable_color_picker'

def ready(self):
"""
Load external serializer when ready
"""
from . import serializers # noqa

As you'd do for any other Django application, you can now add this line to your :file:`__init__.py` file at the root of the python module:

.. code-block:: python

default_app_config = 'formidable_color_picker.apps.FormidableColorPickerConfig'

Check that it's working
-----------------------

Loading the Django shell:

.. code-block:: pycon

>>> from formidable.serializers import FormidableSerializer
>>> data = {
"label": "Color picker test",
"description": "May I help you pick your favorite color?",
"fields": [{
"slug": "color",
"label": "What is your favorite color?",
"type_id": "color_picker",
"accesses": [],
}]
}
>>> instance = FormidableSerializer(data=data)
>>> instance.is_valid()
True
>>> formidable_instance = instance.save()

This means that you can create a form with a field whose type is not in ``django-formidable`` code, but in your module's.

Then you can also retrieve this instance JSON defintion

.. code-block:: pycon

>>> import json
>>> print(json.dumps(formidable_instance.to_json(), indent=2))
{
"label": "Color picker test",
"description": "May I help you pick your favorite color?",
"fields": [
{
"slug": "color",
"label": "What is your favorite color?",
"type_id": "color_picker",
"placeholder": null,
"description": null,
"accesses": [],
"validations": [],
"defaults": [],
}
],
"id": 42,
"conditions": [],
"version": 5
}

Making your field a bit more clever
===================================

Let's say that colors can be expressed in two ways: RGB tuple (``rgb``) or Hexadecimal expression (``hex``). This means your field has to be parametrized in order to store this information at the builder step. Let's imagine your JSON payload would look like:

.. code-block:: json

{
"label": "Color picker test",
"description": "May I help you pick your favorite color?",
"fields": [{
"slug": "color",
"label": "What is your favorite color?",
"type_id": "color_picker",
"accesses": [],
"color_format": "hex"
}]
}

You want then to make sure that your user would not send a wrong parameter, as in these BAD examples:

.. code-block:: json

"color_format": ""
"color_format": "foo"
"color_format": "wrong"

For this specific field, you only want one parameter and its key is ``format`` and its values are only ``hex`` or ``rgb``

Let's add some validation in your Serializer, then.

.. code-block:: python

from rest_framework import serializers
from formidable.register import load_serializer, FieldSerializerRegister
from formidable.serializers.fields import FieldSerializer, BASE_FIELDS

field_register = FieldSerializerRegister.get_instance()


@load_serializer(field_register)
class ColorPickerFieldSerializer(FieldSerializer):

type_id = 'color_picker'

allowed_formats = ('rgb', 'hex')
default_error_messages = {
"missing_parameter": "You need a `format` parameter for this field",
"invalid_format": "Invalid format: `{format}` is not one of {formats}."
}

class Meta(FieldSerializer.Meta):
config_fields = ('color_format', )
fields = BASE_FIELDS + ('parameters',)

def to_internal_value(self, data):
data = super(ColorPickerFieldSerializer, self).to_internal_value(data)
# Check if the parameters are compliant
format = data.get('color_format')
if format is None:
self.fail('missing_parameter')

if format not in self.allowed_formats:
self.fail("invalid_format",
format=format, formats=self.allowed_formats)

return super(ColorPickerFieldSerializer, self).to_internal_value(data)

.. note:: Full example

You may browse this as a complete directly usable example in `the following repository: "django-formidable-color-picker" <https://github.com/peopledoc/django-formidable-color-picker>`_
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Contents:
dev
translations
deprecations
external-field-plugins

Indices and tables
==================
Expand Down
23 changes: 23 additions & 0 deletions formidable/migrations/0009_field_parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.6 on 2018-07-23 10:37
from __future__ import unicode_literals

from django.db import migrations
import jsonfield.fields


class Migration(migrations.Migration):

dependencies = [
('formidable', '0008_formidable_item_value_field_size'),
]

operations = [
migrations.AddField(
model_name='field',
name='parameters',
field=jsonfield.fields.JSONField(
blank=True, default={}, null=True
),
),
]
1 change: 1 addition & 0 deletions formidable/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ class Meta:
help_text = models.TextField(null=True, blank=True)
multiple = models.BooleanField(default=False)
order = models.IntegerField()
parameters = JSONField(null=True, blank=True, default={})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need blank and null while there is a default value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a habit for the Django models. When you use it from the admin panel, you can't save a blank field if you haven't specified null=True, blank=True even with a declared default value. Maybe it's an overkill here but I'd like to keep it.


def get_next_order(self):
"""
Expand Down
23 changes: 20 additions & 3 deletions formidable/serializers/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,24 +65,41 @@ class FieldSerializer(WithNestedSerializer):
items = ItemSerializer(many=True)
accesses = AccessSerializer(many=True)
validations = ValidationSerializer(many=True, required=False)
# redifine here the order field just to take it at the save/update time
# redefine here the order field just to take it at the save/update time
# The order is automatically calculated, if the order is define in
# incomming payload, it will be automatically overrided.
# incoming payload, it will be automatically overridden.
parameters = serializers.JSONField(write_only=True)
order = serializers.IntegerField(write_only=True, required=False)
defaults = DefaultSerializer(many=True, required=False)
description = serializers.CharField(required=False, allow_null=True,
allow_blank=True, source='help_text')

nested_objects = ['accesses', 'validations', 'defaults']

def to_internal_value(self, data):
# XXX FIX ME: temporary fix
if 'help_text' in data:
data['description'] = data.pop('help_text')

data['parameters'] = {}
for config_field in self.get_config_fields():
data['parameters'][config_field] = data.pop(config_field, None)
return super(FieldSerializer, self).to_internal_value(data)

def to_representation(self, instance):
field = super(FieldSerializer, self).to_representation(instance)
for config_field in self.get_config_fields():
if instance.parameters is not None:
field[config_field] = instance.parameters.get(config_field)
field.pop('parameters', None)
return field

def get_config_fields(self):
meta = getattr(self, 'Meta', object)
return getattr(meta, 'config_fields', [])

class Meta:
model = Field
config_fields = []
list_serializer_class = FieldListSerializer
fields = '__all__'

Expand Down