Skip to content

Commit 30bd85f

Browse files
authored
Merge pull request #345 from peopledoc/plugin-mechanism
Plugin mechanism
2 parents 8229619 + 4aa3835 commit 30bd85f

File tree

7 files changed

+329
-3
lines changed

7 files changed

+329
-3
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ master (unreleased)
77

88
- Upgrade to Circle-CI 2 (before the end of life of Circle-CI v1 on August, 31st 2018). (#342)
99
- Optimize Circle-CI usage by using the tox matrix in tests (#343)
10+
- Added a plugin mechanism, allowing users to define and integrate their own "business logic" fields.
1011
- 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).
1112
- Skip `tox` installation in the circle-ci environment: it's already there (#344).
1213

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from django.test import TestCase
2+
3+
from formidable.register import FieldSerializerRegister, load_serializer
4+
from formidable.serializers import FormidableSerializer
5+
from formidable.serializers.fields import BASE_FIELDS, FieldSerializer
6+
7+
8+
class TestCustomFieldSerializer(TestCase):
9+
def setUp(self):
10+
field_register = FieldSerializerRegister.get_instance()
11+
custom_type_id = 'custom_type_id'
12+
self.custom_type_id = custom_type_id
13+
14+
@load_serializer(field_register)
15+
class CustomFieldSerializer(FieldSerializer):
16+
type_id = custom_type_id
17+
18+
class Meta(FieldSerializer.Meta):
19+
fields = BASE_FIELDS + ('parameters', )
20+
config_fields = ('meta_info', 'some_another_data')
21+
22+
self.custom_field_serializer_class = CustomFieldSerializer
23+
self.field_register = field_register
24+
self.schema = {
25+
"label": "test",
26+
"description": "test",
27+
"fields": [
28+
{
29+
"slug": "custom-type-id",
30+
"label": "Custom field",
31+
"placeholder": None,
32+
"description": None,
33+
"defaults": [],
34+
"multiple": False,
35+
"config_field": "Test test test",
36+
"values": [],
37+
"required": False,
38+
"disabled": False,
39+
"isVisible": True,
40+
"type_id": "custom_type_id",
41+
"validations": [],
42+
"order": 1,
43+
"meta_info": "meta",
44+
"some_another_data": "some_another_data",
45+
"accesses": [
46+
{
47+
"id": "field-access868",
48+
"level": "EDITABLE",
49+
"access_id": "padawan"
50+
}
51+
]
52+
}
53+
]
54+
}
55+
56+
def test_register(self):
57+
self.assertIn(self.custom_type_id, self.field_register)
58+
59+
def test_custom_field_serialize(self):
60+
serializer = FormidableSerializer(data=self.schema)
61+
serializer.is_valid()
62+
serializer.save()
63+
# get field instance
64+
self.instance = serializer.instance
65+
custom_field = serializer.instance.fields.first()
66+
# test field instance
67+
self.assertIn('meta_info', custom_field.parameters)
68+
self.assertEqual(custom_field.parameters['meta_info'], "meta")
69+
self.assertIn('some_another_data', custom_field.parameters)
70+
self.assertEqual(
71+
custom_field.parameters['some_another_data'],
72+
"some_another_data"
73+
)
74+
# get serialized data
75+
data = FormidableSerializer(serializer.instance).data['fields'][0]
76+
# test serialized data
77+
self.assertIn('meta_info', data)
78+
self.assertIn('some_another_data', data)
79+
self.assertNotIn('parameters', data)
80+
# remove instance
81+
self.instance.delete()
82+
83+
def tearDown(self):
84+
self.field_register.pop(self.custom_type_id)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
===============================
2+
External Field Plugin Mechanism
3+
===============================
4+
5+
We've included a mechanism to add your own fields to the collection of available fields in ``django-formidable``.
6+
7+
It'll be possible to:
8+
9+
* define a new form using this new type of field,
10+
* store their definition and parameters in a Formidable object instance (and thus, in the database),
11+
* using this form definition, validate the end-user data when filling this form against your field business logic mechanism.
12+
13+
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 ./``.
14+
15+
Let's also say that you have added it in your ``INSTALLED_APPS``.
16+
17+
Tree structure
18+
==============
19+
20+
::
21+
22+
.
23+
├── formidable_color_picker
24+
│   ├── apps.py
25+
│   ├── __init__.py
26+
│   ├── serializers.py
27+
├── setup.cfg
28+
└── setup.py
29+
30+
Loading the field for building time
31+
===================================
32+
33+
The first file we're going to browse is :file:`serializers.py`. Here's a minimal version of it:
34+
35+
36+
.. code-block:: python
37+
38+
from formidable.register import load_serializer, FieldSerializerRegister
39+
from formidable.serializers.fields import FieldSerializer, BASE_FIELDS
40+
41+
field_register = FieldSerializerRegister.get_instance()
42+
43+
44+
@load_serializer(field_register)
45+
class ColorPickerFieldSerializer(FieldSerializer):
46+
47+
type_id = 'color_picker'
48+
49+
class Meta(FieldSerializer.Meta):
50+
fields = BASE_FIELDS
51+
52+
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.
53+
54+
.. code-block:: python
55+
56+
from __future__ import absolute_import
57+
from django.apps import AppConfig
58+
59+
60+
class FormidableColorPickerConfig(AppConfig):
61+
"""
62+
Formidable Color Picker configuration class.
63+
"""
64+
name = 'formidable_color_picker'
65+
66+
def ready(self):
67+
"""
68+
Load external serializer when ready
69+
"""
70+
from . import serializers # noqa
71+
72+
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:
73+
74+
.. code-block:: python
75+
76+
default_app_config = 'formidable_color_picker.apps.FormidableColorPickerConfig'
77+
78+
Check that it's working
79+
-----------------------
80+
81+
Loading the Django shell:
82+
83+
.. code-block:: pycon
84+
85+
>>> from formidable.serializers import FormidableSerializer
86+
>>> data = {
87+
"label": "Color picker test",
88+
"description": "May I help you pick your favorite color?",
89+
"fields": [{
90+
"slug": "color",
91+
"label": "What is your favorite color?",
92+
"type_id": "color_picker",
93+
"accesses": [],
94+
}]
95+
}
96+
>>> instance = FormidableSerializer(data=data)
97+
>>> instance.is_valid()
98+
True
99+
>>> formidable_instance = instance.save()
100+
101+
This means that you can create a form with a field whose type is not in ``django-formidable`` code, but in your module's.
102+
103+
Then you can also retrieve this instance JSON defintion
104+
105+
.. code-block:: pycon
106+
107+
>>> import json
108+
>>> print(json.dumps(formidable_instance.to_json(), indent=2))
109+
{
110+
"label": "Color picker test",
111+
"description": "May I help you pick your favorite color?",
112+
"fields": [
113+
{
114+
"slug": "color",
115+
"label": "What is your favorite color?",
116+
"type_id": "color_picker",
117+
"placeholder": null,
118+
"description": null,
119+
"accesses": [],
120+
"validations": [],
121+
"defaults": [],
122+
}
123+
],
124+
"id": 42,
125+
"conditions": [],
126+
"version": 5
127+
}
128+
129+
Making your field a bit more clever
130+
===================================
131+
132+
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:
133+
134+
.. code-block:: json
135+
136+
{
137+
"label": "Color picker test",
138+
"description": "May I help you pick your favorite color?",
139+
"fields": [{
140+
"slug": "color",
141+
"label": "What is your favorite color?",
142+
"type_id": "color_picker",
143+
"accesses": [],
144+
"color_format": "hex"
145+
}]
146+
}
147+
148+
You want then to make sure that your user would not send a wrong parameter, as in these BAD examples:
149+
150+
.. code-block:: json
151+
152+
"color_format": ""
153+
"color_format": "foo"
154+
"color_format": "wrong"
155+
156+
For this specific field, you only want one parameter and its key is ``format`` and its values are only ``hex`` or ``rgb``
157+
158+
Let's add some validation in your Serializer, then.
159+
160+
.. code-block:: python
161+
162+
from rest_framework import serializers
163+
from formidable.register import load_serializer, FieldSerializerRegister
164+
from formidable.serializers.fields import FieldSerializer, BASE_FIELDS
165+
166+
field_register = FieldSerializerRegister.get_instance()
167+
168+
169+
@load_serializer(field_register)
170+
class ColorPickerFieldSerializer(FieldSerializer):
171+
172+
type_id = 'color_picker'
173+
174+
allowed_formats = ('rgb', 'hex')
175+
default_error_messages = {
176+
"missing_parameter": "You need a `format` parameter for this field",
177+
"invalid_format": "Invalid format: `{format}` is not one of {formats}."
178+
}
179+
180+
class Meta(FieldSerializer.Meta):
181+
config_fields = ('color_format', )
182+
fields = BASE_FIELDS + ('parameters',)
183+
184+
def to_internal_value(self, data):
185+
data = super(ColorPickerFieldSerializer, self).to_internal_value(data)
186+
# Check if the parameters are compliant
187+
format = data.get('color_format')
188+
if format is None:
189+
self.fail('missing_parameter')
190+
191+
if format not in self.allowed_formats:
192+
self.fail("invalid_format",
193+
format=format, formats=self.allowed_formats)
194+
195+
return super(ColorPickerFieldSerializer, self).to_internal_value(data)
196+
197+
.. note:: Full example
198+
199+
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>`_

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Contents:
1515
dev
1616
translations
1717
deprecations
18+
external-field-plugins
1819

1920
Indices and tables
2021
==================
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# -*- coding: utf-8 -*-
2+
# Generated by Django 1.11.6 on 2018-07-23 10:37
3+
from __future__ import unicode_literals
4+
5+
from django.db import migrations
6+
import jsonfield.fields
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('formidable', '0008_formidable_item_value_field_size'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='field',
18+
name='parameters',
19+
field=jsonfield.fields.JSONField(
20+
blank=True, default={}, null=True
21+
),
22+
),
23+
]

formidable/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ class Meta:
8888
help_text = models.TextField(null=True, blank=True)
8989
multiple = models.BooleanField(default=False)
9090
order = models.IntegerField()
91+
parameters = JSONField(null=True, blank=True, default={})
9192

9293
def get_next_order(self):
9394
"""

formidable/serializers/fields.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,41 @@ class FieldSerializer(WithNestedSerializer):
6565
items = ItemSerializer(many=True)
6666
accesses = AccessSerializer(many=True)
6767
validations = ValidationSerializer(many=True, required=False)
68-
# redifine here the order field just to take it at the save/update time
68+
# redefine here the order field just to take it at the save/update time
6969
# The order is automatically calculated, if the order is define in
70-
# incomming payload, it will be automatically overrided.
70+
# incoming payload, it will be automatically overridden.
71+
parameters = serializers.JSONField(write_only=True)
7172
order = serializers.IntegerField(write_only=True, required=False)
7273
defaults = DefaultSerializer(many=True, required=False)
7374
description = serializers.CharField(required=False, allow_null=True,
7475
allow_blank=True, source='help_text')
75-
7676
nested_objects = ['accesses', 'validations', 'defaults']
7777

7878
def to_internal_value(self, data):
7979
# XXX FIX ME: temporary fix
8080
if 'help_text' in data:
8181
data['description'] = data.pop('help_text')
82+
83+
data['parameters'] = {}
84+
for config_field in self.get_config_fields():
85+
data['parameters'][config_field] = data.pop(config_field, None)
8286
return super(FieldSerializer, self).to_internal_value(data)
8387

88+
def to_representation(self, instance):
89+
field = super(FieldSerializer, self).to_representation(instance)
90+
for config_field in self.get_config_fields():
91+
if instance.parameters is not None:
92+
field[config_field] = instance.parameters.get(config_field)
93+
field.pop('parameters', None)
94+
return field
95+
96+
def get_config_fields(self):
97+
meta = getattr(self, 'Meta', object)
98+
return getattr(meta, 'config_fields', [])
99+
84100
class Meta:
85101
model = Field
102+
config_fields = []
86103
list_serializer_class = FieldListSerializer
87104
fields = '__all__'
88105

0 commit comments

Comments
 (0)