diff options
| author | David Monjoie <dmo@odoo.com> | 2015-08-25 21:50:31 +0200 |
|---|---|---|
| committer | David Monjoie <dmo@odoo.com> | 2015-08-26 16:01:19 +0200 |
| commit | a77f5cf42faa75a2dd3931d83ad8ea86648248c0 (patch) | |
| tree | 29590b9351bf73ac573928be7f0dbaa5ea8505a8 /addons/website_form | |
| parent | 57c4787d09eacc548140d7a552a76264eae21a55 (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__.py | 2 | ||||
| -rw-r--r-- | addons/website_form/__openerp__.py | 18 | ||||
| -rw-r--r-- | addons/website_form/controllers/__init__.py | 1 | ||||
| -rw-r--r-- | addons/website_form/controllers/main.py | 217 | ||||
| -rw-r--r-- | addons/website_form/models/__init__.py | 1 | ||||
| -rw-r--r-- | addons/website_form/models/models.py | 47 | ||||
| -rw-r--r-- | addons/website_form/static/src/js/website_form.js | 168 | ||||
| -rw-r--r-- | addons/website_form/static/src/less/website_form.less | 54 | ||||
| -rw-r--r-- | addons/website_form/static/src/xml/website_form.xml | 25 | ||||
| -rw-r--r-- | addons/website_form/views/assets.xml | 17 |
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> |
