summaryrefslogtreecommitdiff
path: root/addons/website_form
diff options
context:
space:
mode:
authorDavid Monjoie <dmo@odoo.com>2015-08-25 21:50:31 +0200
committerDavid Monjoie <dmo@odoo.com>2015-08-26 16:01:19 +0200
commita77f5cf42faa75a2dd3931d83ad8ea86648248c0 (patch)
tree29590b9351bf73ac573928be7f0dbaa5ea8505a8 /addons/website_form
parent57c4787d09eacc548140d7a552a76264eae21a55 (diff)
[ADD] website_form: generic form controller
Contains a new ajax post function that comes from mdo's original code and that I was not able to get rid of.
Diffstat (limited to 'addons/website_form')
-rw-r--r--addons/website_form/__init__.py2
-rw-r--r--addons/website_form/__openerp__.py18
-rw-r--r--addons/website_form/controllers/__init__.py1
-rw-r--r--addons/website_form/controllers/main.py217
-rw-r--r--addons/website_form/models/__init__.py1
-rw-r--r--addons/website_form/models/models.py47
-rw-r--r--addons/website_form/static/src/js/website_form.js168
-rw-r--r--addons/website_form/static/src/less/website_form.less54
-rw-r--r--addons/website_form/static/src/xml/website_form.xml25
-rw-r--r--addons/website_form/views/assets.xml17
10 files changed, 550 insertions, 0 deletions
diff --git a/addons/website_form/__init__.py b/addons/website_form/__init__.py
new file mode 100644
index 00000000000..9f86759e32b
--- /dev/null
+++ b/addons/website_form/__init__.py
@@ -0,0 +1,2 @@
+import controllers
+import models
diff --git a/addons/website_form/__openerp__.py b/addons/website_form/__openerp__.py
new file mode 100644
index 00000000000..7fef0b7fd63
--- /dev/null
+++ b/addons/website_form/__openerp__.py
@@ -0,0 +1,18 @@
+{
+ 'name': 'Website Form Builder',
+ 'category': 'Website',
+ 'summary': 'Build custom web forms using the website builder',
+ 'version': '1.0',
+ 'description': """
+Odoo Form Builder
+====================
+
+Allows you to build web forms on the website using the website builder.
+ """,
+ 'depends': ['website', 'mail'],
+ 'data': [
+ 'views/assets.xml',
+ ],
+ 'installable': True,
+ 'auto_install': False,
+}
diff --git a/addons/website_form/controllers/__init__.py b/addons/website_form/controllers/__init__.py
new file mode 100644
index 00000000000..8ee9bae18d9
--- /dev/null
+++ b/addons/website_form/controllers/__init__.py
@@ -0,0 +1 @@
+import main
diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py
new file mode 100644
index 00000000000..214a8647e07
--- /dev/null
+++ b/addons/website_form/controllers/main.py
@@ -0,0 +1,217 @@
+# -*- coding: utf-8 -*-
+import base64
+
+import json
+from psycopg2 import IntegrityError
+from openerp import http, SUPERUSER_ID
+from openerp.http import request
+from openerp.tools.translate import _
+from openerp.exceptions import ValidationError
+from openerp.addons.base.ir.ir_qweb import nl2br
+
+class WebsiteForm(http.Controller):
+
+ # Check and insert values from the form on the model <model>
+ @http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True)
+ def website_form(self, model_name, **kwargs):
+ model_record = request.env['ir.model'].search([('model', '=', model_name), ('website_form_access', '=', True)])
+
+ if not model_record:
+ return json.dumps(False)
+
+ try:
+ data = self.extract_data(model_record, ** kwargs)
+
+ # If we encounter an issue while extracting data
+ except ValidationError, e:
+ # I couldn't find a cleaner way to pass data to an exception
+ return json.dumps({'error_fields' : e.args[0]})
+
+ try:
+ id_record = self.insert_record(request, model_record, data['record'], data['custom'], data.get('meta'))
+ if id_record:
+ self.insert_attachment(model_record, id_record, data['attachments'])
+
+ # Some fields have additionnal SQL constraints that we can't check generically
+ # Ex: crm.lead.probability which is a float between 0 and 1
+ # TODO: How to get the name of the erroneous field ?
+ except IntegrityError:
+ return json.dumps(False)
+
+ request.session['form_builder_model'] = model_record.name
+ request.session['form_builder_id'] = id_record
+
+ return json.dumps({'id': id_record})
+
+ # Constants string to make custom info and metadata readable on a text field
+
+ _custom_label = "%s\n___________\n\n" % _("Custom infos") # Title for custom fields
+ _meta_label = "%s\n________\n\n" % _("Metadata") # Title for meta data
+
+ # Dict of dynamically called filters following type of field to be fault tolerent
+
+ def identity(self, field_label, field_input):
+ return field_input
+
+ def integer(self, field_label, field_input):
+ return int(field_input)
+
+ def floating(self, field_label, field_input):
+ return float(field_input)
+
+ def boolean(self, field_label, field_input):
+ return bool(field_input)
+
+ def binary(self, field_label, field_input):
+ return base64.b64encode(field_input.read())
+
+ def one2many(self, field_label, field_input):
+ return [int(i) for i in field_input.split(',')]
+
+ def many2many(self, field_label, field_input, *args):
+ return [(args[0] if args else (6,0)) + (self.one2many(field_label, field_input),)]
+
+ _input_filters = {
+ 'char': identity,
+ 'text': identity,
+ 'html': identity,
+ 'datetime': identity,
+ 'many2one': integer,
+ 'one2many': one2many,
+ 'many2many':many2many,
+ 'selection': identity,
+ 'boolean': boolean,
+ 'integer': integer,
+ 'float': floating,
+ 'binary': binary,
+ }
+
+
+ # Extract all data sent by the form and sort its on several properties
+ def extract_data(self, model, **kwargs):
+
+ data = {
+ 'record': {}, # Values to create record
+ 'attachments': [], # Attached files
+ 'custom': '', # Custom fields values
+ }
+
+ authorized_fields = model.get_authorized_fields();
+ error_fields = []
+
+ for field_name, field_value in kwargs.items():
+ # If the value of the field if a file
+ if hasattr(field_value, 'filename'):
+ # Undo file upload field name indexing
+ field_name = field_name.rsplit('[', 1)[0]
+
+ # If it's an actual binary field, convert the input file
+ # If it's not, we'll use attachments instead
+ if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary':
+ data['record'][field_name] = base64.b64encode(field_value.read())
+ else:
+ field_value.field_name = field_name
+ data['attachments'].append(field_value)
+
+ # If it's a known field
+ elif field_name in authorized_fields:
+ try:
+ input_filter = self._input_filters[authorized_fields[field_name]['type']]
+ data['record'][field_name] = input_filter(self, field_name, field_value)
+ except ValueError:
+ error_fields.append(field_name)
+
+ # If it's a custom field
+ elif field_name != 'context':
+ data['custom'] += "%s : %s\n" % (field_name, field_value)
+
+ # Add metadata if enabled
+ environ = request.httprequest.headers.environ
+ if(request.website.website_form_enable_metadata):
+ data['meta'] += "%s : %s\n%s : %s\n%s : %s\n%s : %s\n" % (
+ "IP" , environ.get("REMOTE_ADDR"),
+ "USER_AGENT" , environ.get("HTTP_USER_AGENT"),
+ "ACCEPT_LANGUAGE" , environ.get("HTTP_ACCEPT_LANGUAGE"),
+ "REFERER" , environ.get("HTTP_REFERER")
+ )
+
+ # This function can be defined on any model to provide
+ # a model-specific filtering of the record values
+ # Example:
+ # def website_form_input_filter(self, values):
+ # values['name'] = '%s\'s Application' % values['partner_name']
+ # return values
+ dest_model = request.env[model.model]
+ if hasattr(dest_model, "website_form_input_filter"):
+ data['record'] = dest_model.website_form_input_filter(request, data['record'])
+
+ missing_required_fields = [label for label, field in authorized_fields.iteritems() if field['required'] and not label in data['record']]
+ if any(error_fields):
+ raise ValidationError(error_fields + missing_required_fields)
+
+ return data
+
+ def insert_record(self, request, model, values, custom, meta=None):
+ record = request.env[model.model].sudo().create(values)
+
+ if custom or meta:
+ default_field_name = model.website_form_default_field_id.name
+ default_field_data = values.get(default_field_name, '')
+ custom_content = (default_field_data + "\n\n" if default_field_data else '') \
+ + (self._custom_label + custom + "\n\n" if custom else '') \
+ + (self._meta_label + meta if meta else '')
+
+ # If there is a default field configured for this model, use it.
+ # If there isn't, put the custom data in a message instead
+ if default_field_name:
+ record.update({default_field_name: custom_content})
+ else:
+ values = {
+ 'body': nl2br(custom_content),
+ 'model': model.model,
+ 'message_type': 'comment',
+ 'no_auto_thread': False,
+ 'res_id': record.id,
+ }
+ mail_id = request.env['mail.message'].sudo().create(values)
+
+ return record.id
+
+ # Link all files attached on the form
+ def insert_attachment(self, model, id_record, files):
+ orphan_attachment_ids = []
+ record = model.env[model.model].browse(id_record)
+ authorized_fields = model.get_authorized_fields()
+ for file in files:
+ custom_field = file.field_name not in authorized_fields
+ attachment_value = {
+ 'name': file.field_name if custom_field else file.filename,
+ 'datas': base64.encodestring(file.read()),
+ 'datas_fname': file.filename,
+ 'res_model': model.model,
+ 'res_id': record.id,
+ }
+ attachment_id = request.env['ir.attachment'].sudo().create(attachment_value)
+ if attachment_id and not custom_field:
+ record.sudo()[file.field_name] = [(4, attachment_id.id)]
+ else:
+ orphan_attachment_ids.append(attachment_id.id)
+
+ # If some attachments didn't match a field on the model,
+ # we create a mail.message to link them to the record
+ if orphan_attachment_ids:
+ if model.name != 'mail.mail':
+ values = {
+ 'body': _('<p>Attached files : </p>'),
+ 'model': model.model,
+ 'message_type': 'comment',
+ 'no_auto_thread': False,
+ 'res_id': id_record,
+ 'attachment_ids': [(6, 0, orphan_attachment_ids)],
+ }
+ mail_id = request.env['mail.message'].sudo().create(values)
+ else:
+ # If the model is mail.mail then we have no other choice but to
+ # attach the custom binary field files on the attachment_ids field.
+ for attachment_id_id in orphan_attachment_ids:
+ record.attachment_ids = [(4, attachment_id_id)]
diff --git a/addons/website_form/models/__init__.py b/addons/website_form/models/__init__.py
new file mode 100644
index 00000000000..bff786c0885
--- /dev/null
+++ b/addons/website_form/models/__init__.py
@@ -0,0 +1 @@
+import models
diff --git a/addons/website_form/models/models.py b/addons/website_form/models/models.py
new file mode 100644
index 00000000000..8fdc5e4bf9a
--- /dev/null
+++ b/addons/website_form/models/models.py
@@ -0,0 +1,47 @@
+from openerp import tools
+from openerp import models, fields, api
+
+
+class website_form_config(models.Model):
+ _inherit = 'website'
+ website_form_enable_metadata = fields.Boolean('Write metadata',help="Enable writing metadata on form submit.")
+
+
+class website_form_model(models.Model):
+ _name = 'ir.model'
+ _inherit = 'ir.model'
+
+ website_form_access = fields.Boolean('Allowed to use in forms', help='Enable the form builder feature for this model.')
+ website_form_default_field_id = fields.Many2one('ir.model.fields', 'Field for custom form data', domain="[('model', '=', model), ('ttype', '=', 'text')]", help="Specify the field wich will contain meta and custom form fields datas.")
+ website_form_label = fields.Char("Label for form action", help="Form action label. Ex: crm.lead could be 'Send an e-mail' and project.issue could be 'Create an Issue'.")
+
+
+ def all_inherited_model_ids(self):
+ return [self.id] + [m.all_inherited_model_ids() for m in self.inherited_model_ids]
+
+ @api.multi
+ def get_authorized_fields(self):
+ model = self.env[self.model]
+ fields_get = model.fields_get()
+ domain = [('model_id', 'in', self.all_inherited_model_ids()), ('website_form_blacklisted', '=', False)]
+
+ for elem in self.env['ir.model.fields'].search(domain):
+ if elem.website_form_blacklisted:
+ fields_get.pop(elem.name, None)
+ for key, val in model._inherits.iteritems():
+ fields_get.pop(val,None)
+
+ # Unrequire fields with default values
+ default_values = model.default_get(fields_get.keys())
+ for field in [f for f in fields_get if f in default_values]:
+ fields_get[field]['required'] = False
+
+ return fields_get
+
+
+class website_form_model_fields(models.Model):
+ """ fields configuration for form builder """
+ _name = 'ir.model.fields'
+ _inherit = 'ir.model.fields'
+
+ website_form_blacklisted = fields.Boolean('Blacklisted in web forms', help='Blacklist this field for web forms')
diff --git a/addons/website_form/static/src/js/website_form.js b/addons/website_form/static/src/js/website_form.js
new file mode 100644
index 00000000000..88859643129
--- /dev/null
+++ b/addons/website_form/static/src/js/website_form.js
@@ -0,0 +1,168 @@
+odoo.define('website_form.animation', function (require) {
+'use strict';
+
+ var core = require('web.core');
+ var time = require('web.time');
+ var ajax = require('web.ajax');
+ var snippet_animation = require('web_editor.snippets.animation');
+
+ var _t = core._t;
+ var qweb = core.qweb;
+
+
+ snippet_animation.registry.form_builder_send = snippet_animation.Class.extend({
+ selector: '.s_website_form',
+
+ start: function() {
+ var self = this;
+ qweb.add_template('/website_form/static/src/xml/website_form.xml');
+ this.$target.find('.o_website_form_send').on('click',function(e) {self.send(e);});
+
+ // Initialize datetimepickers
+ var l10n = _t.database.parameters;
+ this.$target.find('.o_website_form_datetime').datetimepicker({
+ pickTime: true,
+ useSeconds: true,
+ startDate: moment({ y: 1900 }),
+ endDate: moment().add(200, "y"),
+ calendarWeeks: true,
+ icons : {
+ time: 'fa fa-clock-o',
+ date: 'fa fa-calendar',
+ up: 'fa fa-chevron-up',
+ down: 'fa fa-chevron-down'
+ },
+ language : moment.locale(),
+ format : time.strftime_to_moment_format(l10n.date_format +' '+ l10n.time_format),
+ });
+ },
+
+ stop: function() {
+ this.$target.find('button').off('click');
+ },
+
+ send: function(e) {
+ e.preventDefault(); // Prevent the default submit behavior
+
+ var self = this;
+
+ self.$target.find('#o_website_form_result').empty();
+ if (!self.check_error_fields([])) {
+ self.update_status('invalid');
+ return false;
+ }
+
+ // Prepare form inputs
+ this.form_fields = this.$target.serializeArray();
+ _.each(this.$target.find('input[type=file]'), function(input) {
+ $.each($(input).prop('files'), function(index, file) {
+ // Index field name as ajax won't accept arrays of files
+ // when aggregating multiple files into a single field value
+ self.form_fields.push({
+ name: input.name + '[' + index + ']',
+ value: file
+ });
+ });
+ });
+
+ // Serialize form inputs into a single object
+ // Aggregate multiple values into arrays
+ var form_values = {};
+ _.each(this.form_fields, function(input) {
+ if (input.name in form_values) {
+ // If a value already exists for this field,
+ // we are facing a x2many field, so we store
+ // the values in an array.
+ if (Array.isArray(form_values[input.name])) {
+ form_values[input.name].push(input.value);
+ } else {
+ form_values[input.name] = [form_values[input.name], input.value];
+ }
+ } else {
+ if (input.value != '') {
+ form_values[input.name] = input.value;
+ }
+ }
+ });
+
+ // Overwrite form_values array with values from the form tag
+ // Necessary to handle field values generated server-side, since
+ // using t-att- inside a snippet makes it non-editable !
+ for (var key in this.$target.data()) {
+ if (_.str.startsWith(key, 'form_field_')){
+ form_values[key.replace('form_field_', '')] = this.$target.data(key);
+ }
+ }
+
+ // Post form and handle result
+ ajax.post(this.$target.attr('action') + this.$target.data('model_name'), form_values)
+ .then(function(result_data) {
+ result_data = $.parseJSON(result_data);
+ if(!result_data.id) {
+ // Failure, the server didn't return the created record ID
+ self.update_status('error');
+ if (result_data.error_fields && result_data.error_fields.length) {
+ // If the server return a list of bad fields, show these fields for users
+ self.check_error_fields(result_data.error_fields);
+ }
+ } else {
+ // Success, redirect or update status
+ var success_page = self.$target.attr('data-success_page');
+ if(success_page) {
+ $(location).attr('href', success_page);
+ }
+ else {
+ self.update_status('success');
+ }
+ }
+ })
+ .fail(function(result_data){
+ self.update_status('error');
+ });
+ },
+
+ check_error_fields: function(error_fields) {
+ var form_valid = true;
+ // Loop on all fields
+ this.$target.find('.form-field').each(function(k, field){
+ var $field = $(field);
+ var $fields = self.$fields;
+ var field_name = $field.find('.control-label').attr('for')
+
+ // Validate inputs for this field
+ var field_valid = true;
+ var inputs = $field.find('.o_website_form_input');
+ var invalid_inputs = inputs.toArray().filter(function(input, k, inputs) {
+ // Special check for multiple required checkbox for same
+ // field as it seems checkValidity forces every required
+ // checkbox to be checked, instead of looking at other
+ // checkboxes with the same name and only requiring one
+ // of them to be checked.
+ if (input.required && input.type == 'checkbox') {
+ // Considering we are currently processing a single
+ // field, we can assume that all checkboxes in the
+ // inputs variable have the same name
+ var checkboxes = _.filter(inputs, function(input){
+ return input.required && input.type == 'checkbox'
+ })
+ return !_.any(checkboxes, function(checkbox){return checkbox.checked})
+ } else {
+ return !input.checkValidity();
+ }
+ })
+
+ // Update field color if invalid or erroneous
+ $field.removeClass('has-error');
+ if(invalid_inputs.length || error_fields.indexOf(field_name) >= 0){
+ $field.addClass('has-error');
+ form_valid = false;
+ }
+ });
+ return form_valid;
+ },
+
+ update_status: function(status) {
+ this.$target.find('#o_website_form_result').replaceWith(qweb.render("website_form.status_" + status))
+ },
+ });
+});
diff --git a/addons/website_form/static/src/less/website_form.less b/addons/website_form/static/src/less/website_form.less
new file mode 100644
index 00000000000..f26718da9c1
--- /dev/null
+++ b/addons/website_form/static/src/less/website_form.less
@@ -0,0 +1,54 @@
+// Radio buttons and checkboxes flex layout
+.o_website_form_flex {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.o_website_form_flex_item {
+ // col-md-4
+ flex-basis: 33%;
+
+ // col-sm-6
+ @media (max-width: @screen-sm-max) {
+ flex-basis: 50%;
+ }
+
+ // col-xs-12
+ @media (max-width: @screen-xs-max) {
+ flex-basis: 100%;
+ }
+}
+
+// Hidden field is only partially hidden in editor
+.o_website_form_field_hidden {
+ display: none;
+}
+
+.editor_enable .o_website_form_field_hidden {
+ display: block;
+ opacity: 0.5;
+}
+
+// Select inputs do not trigger the default browser behavior
+// Since we use a custom editable element
+.editor_enable .s_website_form .form-field select {
+ pointer-events: none;
+}
+
+// Required fields have a star which is not part of the field label
+.o_website_form_required, .o_website_form_required_custom {
+ .control-label:after {
+ content: ' *';
+ }
+}
+
+// Quickfix to display the editable select as a single big field
+#editable_select.form-control {
+ height: 100%;
+}
+
+// Fix for firefox browse button which is too big for Bootstrap form-field
+// http://stackoverflow.com/questions/22049739/fix-for-firefox-file-input-using-bootstrap-3-1
+.form-field input[type=file].form-control {
+ height: 100%;
+}
diff --git a/addons/website_form/static/src/xml/website_form.xml b/addons/website_form/static/src/xml/website_form.xml
new file mode 100644
index 00000000000..3a1102bb5e1
--- /dev/null
+++ b/addons/website_form/static/src/xml/website_form.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <!-- Success status -->
+ <t t-name="website_form.status_success">
+ <span id="o_website_form_result" class="text-success ml8">
+ <i class="fa fa-check mr4"/>The form has been sent successfully.
+ </span>
+ </t>
+
+ <!-- Missing field status -->
+ <t t-name="website_form.status_invalid">
+ <span id="o_website_form_result" class="text-danger ml8">
+ <i class="fa fa-close mr4"/>Please fill in the form correctly.
+ </span>
+ </t>
+
+ <!-- Error status -->
+ <t t-name="website_form.status_error">
+ <span id="o_website_form_result" class="text-danger ml8">
+ <i class="fa fa-close mr4"/>An error has occured, the form has not been sent.
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/website_form/views/assets.xml b/addons/website_form/views/assets.xml
new file mode 100644
index 00000000000..0cf95cca454
--- /dev/null
+++ b/addons/website_form/views/assets.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<openerp>
+ <data>
+
+ <template id="assets_frontend" name="Website Form Assets Frontend" inherit_id="website.assets_frontend">
+ <xpath expr="." position="inside">
+ <link rel='stylesheet' href='/website_form/static/src/less/website_form.less'/>
+ <script type="text/javascript" src="/website_form/static/src/js/website_form.js"/>
+
+ <!-- Bootstrap datetimepicker is currently only included in the backend -->
+ <link rel="stylesheet" href="/web/static/lib/bootstrap-datetimepicker/css/bootstrap-datetimepicker.css"/>
+ <script type="text/javascript" src="/web/static/lib/bootstrap-datetimepicker/src/js/bootstrap-datetimepicker.js"/>
+ </xpath>
+ </template>
+
+ </data>
+</openerp>