summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/portal/controllers/portal.py23
-rw-r--r--addons/portal/static/font/sign/Aspire-DemiBold.ttfbin0 -> 30040 bytes
-rw-r--r--addons/portal/static/font/sign/JennaSue.ttfbin0 -> 42948 bytes
-rw-r--r--addons/portal/static/font/sign/Otto.ttfbin0 -> 96416 bytes
-rw-r--r--addons/portal/static/font/sign/Pen_Frenzy.ttfbin0 -> 34156 bytes
-rw-r--r--addons/portal/static/font/sign/Prophecy_Script.ttfbin0 -> 69604 bytes
-rw-r--r--addons/portal/static/font/sign/khand.ttfbin0 -> 38344 bytes
-rw-r--r--addons/portal/static/src/js/portal_name_and_signature.js589
-rw-r--r--addons/portal/static/src/js/portal_signature.js232
-rw-r--r--addons/portal/static/src/scss/portal.scss4
-rw-r--r--addons/portal/static/src/scss/portal_name_and_signature.scss23
-rw-r--r--addons/portal/static/src/xml/portal_name_and_signature.xml101
-rw-r--r--addons/portal/static/src/xml/portal_signature.xml42
-rw-r--r--addons/portal/views/assets.xml4
-rw-r--r--addons/portal/views/portal_templates.xml41
-rw-r--r--addons/sale/controllers/portal.py25
-rw-r--r--addons/sale/models/sale.py4
-rw-r--r--addons/sale/static/src/js/sale_tour_sale_signature.js43
-rw-r--r--addons/sale/tests/__init__.py1
-rw-r--r--addons/sale/tests/test_sale_signature.py27
-rw-r--r--addons/sale/views/assets.xml1
-rw-r--r--addons/sale/views/sale_portal_templates.xml62
-rw-r--r--addons/sale/views/sale_views.xml7
23 files changed, 1060 insertions, 169 deletions
diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py
index ea019834c15..ea75de8073d 100644
--- a/addons/portal/controllers/portal.py
+++ b/addons/portal/controllers/portal.py
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import base64
import math
+import os
import re
from werkzeug import urls
@@ -186,6 +188,27 @@ class CustomerPortal(Controller):
response.headers['X-Frame-Options'] = 'DENY'
return response
+ @route(['/portal/sign/get_fonts'], type='json', auth='public')
+ def get_fonts(self):
+ """This route will return a list of base64 encoded fonts.
+
+ Those fonts will be proposed to the user when creating a signature
+ using mode 'auto'.
+
+ :return: base64 encoded fonts
+ :rtype: list
+ """
+ current_dir = os.path.dirname(os.path.abspath(__file__))
+ fonts_directory = os.path.join(current_dir, '..', 'static', 'font', 'sign')
+ font_filenames = sorted(os.listdir(fonts_directory))
+
+ fonts = []
+ for filename in font_filenames:
+ font_file = open(os.path.join(fonts_directory, filename), 'rb')
+ font = base64.b64encode(font_file.read())
+ fonts.append(font)
+ return fonts
+
def details_form_validate(self, data):
error = dict()
error_message = []
diff --git a/addons/portal/static/font/sign/Aspire-DemiBold.ttf b/addons/portal/static/font/sign/Aspire-DemiBold.ttf
new file mode 100644
index 00000000000..d8cb1992b86
--- /dev/null
+++ b/addons/portal/static/font/sign/Aspire-DemiBold.ttf
Binary files differ
diff --git a/addons/portal/static/font/sign/JennaSue.ttf b/addons/portal/static/font/sign/JennaSue.ttf
new file mode 100644
index 00000000000..667a55112dd
--- /dev/null
+++ b/addons/portal/static/font/sign/JennaSue.ttf
Binary files differ
diff --git a/addons/portal/static/font/sign/Otto.ttf b/addons/portal/static/font/sign/Otto.ttf
new file mode 100644
index 00000000000..9a2a51dadd2
--- /dev/null
+++ b/addons/portal/static/font/sign/Otto.ttf
Binary files differ
diff --git a/addons/portal/static/font/sign/Pen_Frenzy.ttf b/addons/portal/static/font/sign/Pen_Frenzy.ttf
new file mode 100644
index 00000000000..d9f72c59340
--- /dev/null
+++ b/addons/portal/static/font/sign/Pen_Frenzy.ttf
Binary files differ
diff --git a/addons/portal/static/font/sign/Prophecy_Script.ttf b/addons/portal/static/font/sign/Prophecy_Script.ttf
new file mode 100644
index 00000000000..57fd1b3647a
--- /dev/null
+++ b/addons/portal/static/font/sign/Prophecy_Script.ttf
Binary files differ
diff --git a/addons/portal/static/font/sign/khand.ttf b/addons/portal/static/font/sign/khand.ttf
new file mode 100644
index 00000000000..f71b7493a3f
--- /dev/null
+++ b/addons/portal/static/font/sign/khand.ttf
Binary files differ
diff --git a/addons/portal/static/src/js/portal_name_and_signature.js b/addons/portal/static/src/js/portal_name_and_signature.js
new file mode 100644
index 00000000000..20ce8d5ae22
--- /dev/null
+++ b/addons/portal/static/src/js/portal_name_and_signature.js
@@ -0,0 +1,589 @@
+odoo.define('portal.name_and_signature', function (require) {
+'use strict';
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+
+/**
+ * This widget allows the user to input his name and to draw his signature.
+ * Alternatively the signature can also be generated automatically based on
+ * the given name and a selected font, or loaded from an image file.
+ */
+var NameAndSignature = Widget.extend({
+ template: 'portal.sign_name_and_signature',
+ xmlDependencies: ['/portal/static/src/xml/portal_name_and_signature.xml'],
+ events: {
+ // name
+ 'input .o_portal_sign_name_input': '_onInputSignName',
+ // signature
+ 'click .o_portal_sign_signature': '_onClickSignature',
+ // draw
+ 'click .o_portal_sign_draw_button': '_onClickSignDrawButton',
+ 'click .o_portal_sign_draw_clear a': '_onClickSignDrawClear',
+ // auto
+ 'click .o_portal_sign_auto_button': '_onClickSignAutoButton',
+ 'click .o_portal_sign_auto_select_style a': '_onClickSignAutoSelectStyle',
+ 'click .o_portal_sign_auto_font_selection a': '_onClickSignAutoFontSelection',
+ 'mouseover .o_portal_sign_auto_font_selection a': '_onMouseOverSignAutoFontSelection',
+ 'touchmove .o_portal_sign_auto_font_selection a': '_onTouchStartSignAutoFontSelection',
+ // load
+ 'click .o_portal_sign_load_button': '_onClickSignLoadButton',
+ 'change .o_portal_sign_load_file input': '_onChangeSignLoadInput',
+ },
+
+ /**
+ * Allows options.
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} [options={}]
+ * @param {number} [options.signatureRatio=3.0] - The ratio used when
+ * (re)computing the size of the signature (width = height * ratio)
+ * @param {string} [options.defaultName=''] - The default name of
+ * the signer.
+ * @param {string} [options.mode='draw'] - @see this.setMode
+ * @param {string} [options.signatureType='signature'] - The type of
+ * signature used in 'auto' mode. Can be one of the following values:
+ * - 'signature': it will adapt the characters width to fit the whole
+ * text in the image.
+ * - 'initial': it will adapt the space between characters to fill
+ * the image with the text. The text will be the first letter of
+ * every word in the name, separated by dots.
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ options = options || {};
+ this.htmlId = _.uniqueId();
+ this.defaultName = options.defaultName || '';
+ this.signatureRatio = options.signatureRatio || 3.0;
+ this.signatureType = options.signatureType || 'signature';
+ this.signMode = options.mode || 'draw';
+ this.currentFont = 0;
+ this.drawTimeout = null;
+ this.drawPreviewTimeout = null;
+ },
+ /**
+ * Loads the fonts.
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ return $.when(
+ this._super.apply(this, arguments),
+ this._rpc({route: '/portal/sign/get_fonts'}).then(function (data) {
+ self.fonts = data;
+ })
+ );
+ },
+ /**
+ * Finds the DOM elements, initializes the signature area,
+ * and focus the name field.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ // signature and name input
+ this.$signatureGroup = this.$('.o_portal_sign_signature_group');
+ this.$signatureField = this.$('.o_portal_sign_signature');
+ this.$nameInput = this.$('.o_portal_sign_name_input');
+
+ // mode selection buttons
+ this.$drawButton = this.$('a.o_portal_sign_draw_button');
+ this.$autoButton = this.$('a.o_portal_sign_auto_button');
+ this.$loadButton = this.$('a.o_portal_sign_load_button');
+
+ // mode: draw
+ this.$drawClear = this.$('.o_portal_sign_draw_clear');
+
+ // mode: auto
+ this.$autoSelectStyle = this.$('.o_portal_sign_auto_select_style');
+ this.$autoFontSelection = this.$('.o_portal_sign_auto_font_selection');
+ this.$autoFontList = this.$('.o_portal_sign_auto_font_list');
+ for (var i in this.fonts) {
+ var $img = $('<img/>').addClass('img-fluid');
+ var $a = $('<a/>').addClass('btn p-0').append($img).data('fontNb', i);
+ this.$autoFontList.append($a);
+ }
+
+ // mode: load
+ this.$loadFile = this.$('.o_portal_sign_load_file');
+ this.$loadInvalid = this.$('.o_portal_sign_load_invalid');
+
+ // Resize the signature area if it is resized
+ $(window).on('resize.o_portal_sign_name_and_signature', _.debounce(function () {
+ if (self.isDestroyed()) {
+ // May happen since this is debounced
+ return;
+ }
+ self.resetSignature();
+ }, 250));
+
+ return this._super.apply(this, arguments).then(function () {
+ // initialize the signature area
+ return self.resetSignature();
+ });
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ this._super.apply(this, arguments);
+ $(window).off('resize.o_portal_sign_name_and_signature');
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Focuses the name.
+ */
+ focusName: function () {
+ this.$nameInput.focus();
+ },
+
+ /**
+ * Gets the name currently given by the user.
+ *
+ * @returns {string} name
+ */
+ getName: function () {
+ return this.$nameInput.val();
+ },
+ /**
+ * Gets the signature currently drawn. The data format is that produced
+ * natively by Canvas - base64 encoded (likely PNG) bitmap data.
+ *
+ * @returns {string[]} Array that contains the signature as a bitmap.
+ * The first element is the mimetype, the second element is the data.
+ */
+ getSignatureImage: function () {
+ return this.$signatureField.jSignature('getData', 'image');
+ },
+ /**
+ * Gets the signature currently drawn, in a format ready to be used in
+ * an <img/> src attribute.
+ *
+ * @returns {string} the signature currently drawn, src ready
+ */
+ getSignatureImageSrc: function () {
+ return this.$signatureField.jSignature('getData');
+ },
+ /**
+ * Returns whether the drawing area is currently empty.
+ *
+ * @returns {boolean} Whether the drawing area is currently empty.
+ */
+ isSignatureEmpty: function () {
+ var signature = this.$signatureField.jSignature('getData');
+ return signature ? this.emptySignature === signature : true;
+ },
+ /**
+ * (Re)initializes the signature area:
+ * - set the correct width and height of the drawing based on the width
+ * of the container and the ratio option
+ * - empty any previous content
+ * - correctly reset the empty state
+ * - call @see setMode with reset
+ *
+ * @returns {Deferred}
+ */
+ resetSignature: function () {
+ if (!this.$signatureField) {
+ // no action if called before start
+ return $.Deferred().reject();
+ }
+ // recompute size based on the current width
+ this.$signatureField.css({width: 'unset'});
+ var width = this.$signatureField.width();
+ var height = parseInt(width / this.signatureRatio);
+
+ // necessary because the lib is adding invisible div with margin
+ // signature field too tall without this code
+ this.$signatureField.css({
+ width: width,
+ height: height,
+ });
+
+ this.$signatureField
+ .empty()
+ .jSignature({
+ 'decor-color': '#D1D0CE',
+ 'background-color': '#FFF',
+ 'color': '#000',
+ 'lineWidth': 2,
+ 'width': width,
+ 'height': height,
+ });
+
+ this.emptySignature = this.$signatureField.jSignature('getData');
+
+ this.setMode(this.signMode, true);
+
+ this.focusName();
+
+ return $.Deferred().resolve();
+ },
+ /**
+ * Changes the signature mode. Toggles the display of the relevant
+ * controls and resets the drawing.
+ *
+ * @param {string} mode - the mode to use. Can be one of the following:
+ * - 'draw': the user draws the signature manually with the mouse
+ * - 'auto': the signature is drawn automatically using a selected font
+ * - 'load': the signature is loaded from an image file
+ * @param {boolean} [reset=false] - Set to true to reset the elements
+ * even if the @see mode has not changed. By default nothing happens
+ * if the @see mode is already selected.
+ */
+ setMode: function (mode, reset) {
+ if (reset !== true && mode === this.signMode) {
+ // prevent flickering and unnecessary compute
+ return;
+ }
+
+ this.signMode = mode;
+
+ this.$drawClear.toggleClass('d-none', this.signMode !== 'draw');
+ this.$autoSelectStyle.toggleClass('d-none', this.signMode !== 'auto');
+ this.$loadFile.toggleClass('d-none', this.signMode !== 'load');
+
+ this.$drawButton.toggleClass('active', this.signMode === 'draw');
+ this.$autoButton.toggleClass('active', this.signMode === 'auto');
+ this.$loadButton.toggleClass('active', this.signMode === 'load');
+
+ this.$signatureField.jSignature(this.signMode === 'draw' ? 'enable' : 'disable');
+ this.$signatureField.jSignature('reset');
+
+ if (this.signMode === 'auto') {
+ // draw based on name
+ this._drawCurrentName();
+ } else {
+ // close style dialog
+ this.$autoFontSelection.addClass('d-none');
+ }
+
+ if (this.signMode !== 'load') {
+ // close invalid file alert
+ this.$loadInvalid.addClass('d-none');
+ }
+ },
+ /**
+ * Gets the current name and signature, validates them, and returns
+ * the result. If they are invalid, displays the errors to the user.
+ *
+ * @returns {boolean} whether the current name and signature are valid
+ */
+ validateSignature: function () {
+ var name = this.getName();
+ var isSignatureEmpty = this.isSignatureEmpty();
+ this.$nameInput.parent().toggleClass('o_has_error', !name)
+ .find('.form-control, .custom-select').toggleClass('is-invalid', !name);
+ this.$signatureGroup.toggleClass('border-danger', isSignatureEmpty);
+ return name && !isSignatureEmpty;
+ },
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * Draws the current name with the current font in the signature field.
+ *
+ * @private
+ */
+ _drawCurrentName: function () {
+ var font = this.fonts[this.currentFont];
+ var text = this._getCleanedName();
+ var canvas = this.$signatureField.find('canvas')[0];
+ var img = this._getSVGText(font, text, canvas.width, canvas.height);
+ return this._printImage(img);
+ },
+ /**
+ * Returns the given name after cleaning it by removing characters that
+ * are not supposed to be used in a signature. If @see signatureType is set
+ * to 'initial', returns the first letter of each word, separated by dots.
+ *
+ * @private
+ * @returns {string} cleaned name
+ */
+ _getCleanedName: function () {
+ var text = this.getName().replace(/[^[\w\u00E0-\u00FC]-'" ]/g, '');
+ if (this.signatureType === 'initial') {
+ return (text.split(' ').map(function (w) {
+ return w[0];
+ }).join('.') + '.');
+ }
+ return text;
+ },
+ /**
+ * Gets an SVG matching the given parameters, output compatible with the
+ * src attribute of <img/>.
+ *
+ * @private
+ * @param {string} font: base64 encoded font to use
+ * @param {string} text: the name to draw
+ * @param {number} width: the width of the resulting image in px
+ * @param {number} height: the height of the resulting image in px
+ * @returns {string} image = mimetype + image data
+ */
+ _getSVGText: function (font, text, width, height) {
+ var $svg = $(core.qweb.render('portal.sign_svg_text', {
+ width: width,
+ height: height,
+ font: font,
+ text: text,
+ type: this.signatureType,
+ }));
+ $svg.attr({
+ 'xmlns': "http://www.w3.org/2000/svg",
+ 'xmlns:xlink': "http://www.w3.org/1999/xlink",
+ });
+
+ return "data:image/svg+xml," + encodeURI($svg[0].outerHTML);
+ },
+ /**
+ * Displays the given image in the signature field.
+ * If needed, resizes the image to fit the existing area.
+ *
+ * @private
+ * @param {string} imgSrc - data of the image to display
+ */
+ _printImage: function (imgSrc) {
+ var self = this;
+
+ var image = new Image;
+ image.onload = function () {
+ // don't slow down the UI if the drawing is slow, and prevent
+ // drawing twice when calling this method in rapid succession
+ clearTimeout(self.drawTimeout);
+ self.drawTimeout = setTimeout(function () {
+ var width = 0;
+ var height = 0;
+ var ratio = image.width / image.height;
+
+ var $canvas = self.$signatureField.find('canvas');
+ var context = $canvas[0].getContext('2d');
+
+ if (image.width / $canvas[0].width > image.height / $canvas[0].height) {
+ width = $canvas[0].width;
+ height = parseInt(width / ratio);
+ } else {
+ height = $canvas[0].height;
+ width = parseInt(height * ratio);
+ }
+ self.$signatureField.jSignature('reset');
+ var ignoredContext = _.pick(context, ['shadowOffsetX', 'shadowOffsetY']);
+ _.extend(context, {shadowOffsetX: 0, shadowOffsetY: 0});
+ context.drawImage(image,
+ 0,
+ 0,
+ image.width,
+ image.height,
+ ($canvas[0].width - width) / 2,
+ ($canvas[0].height - height) / 2,
+ width,
+ height
+ );
+ _.extend(context, ignoredContext);
+ }, 0);
+ };
+ image.src = imgSrc;
+ },
+ /**
+ * Sets the font to use in @see mode 'auto'. Redraws the signature if
+ * the font has been changed.
+ *
+ * @private
+ * @param {number} index - index of the font in @see this.fonts
+ */
+ _setFont: function (index) {
+ if (index !== this.currentFont) {
+ this.currentFont = index;
+ this._drawCurrentName();
+ }
+ },
+ /**
+ * Updates the preview buttons by rendering the signature for each font.
+ *
+ * @private
+ */
+ _updatePreviewButtons: function () {
+ var self = this;
+ // don't slow down the UI if the drawing is slow, and prevent
+ // drawing twice when calling this method in rapid succession
+ clearTimeout(this.drawPreviewTimeout);
+ this.drawPreviewTimeout = setTimeout(function () {
+ var height = 100;
+ var width = parseInt(height * self.signatureRatio);
+ var $existingButtons = self.$autoFontList.find('a');
+ for (var i = 0; i < self.fonts.length; i++) {
+ var imgSrc = self._getSVGText(
+ self.fonts[i],
+ self._getCleanedName() || _t("Your name"),
+ width,
+ height
+ );
+ $existingButtons.eq(i).find('img').attr('src', imgSrc);
+ }
+ }, 0);
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Handles click on the signature: closes the font selection.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignature: function (ev) {
+ this.$autoFontSelection.addClass('d-none');
+ },
+ /**
+ * Handles click on the Auto button: activates @see mode 'auto'.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignAutoButton: function (ev) {
+ ev.preventDefault();
+ this.setMode('auto');
+ },
+ /**
+ * Handles click on a font: uses it and closes the font selection.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignAutoFontSelection: function (ev) {
+ this.$autoFontSelection.addClass('d-none').removeClass('d-flex').css('width', 0);
+ this._setFont(parseInt($(ev.currentTarget).data('font-nb')));
+ },
+ /**
+ * Handles click on Select Style: opens and updates the font selection.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignAutoSelectStyle: function (ev) {
+ var self = this;
+ var width = Math.min(
+ self.$autoFontSelection.find('a').first().height() * self.signatureRatio * 1.25,
+ this.$signatureField.width()
+ );
+
+ ev.preventDefault();
+ self._updatePreviewButtons();
+
+ this.$autoFontSelection.removeClass('d-none').addClass('d-flex');
+ this.$autoFontSelection.show().animate({'width': width}, 500, function () {});
+ },
+ /**
+ * Handles click on the Draw button: activates @see mode 'draw'.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignDrawButton: function (ev) {
+ ev.preventDefault();
+ this.setMode('draw');
+ },
+ /**
+ * Handles click on clear: empties the signature field.
+ *
+ * @see mode 'draw'
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignDrawClear: function (ev) {
+ ev.preventDefault();
+ this.$signatureField.jSignature('reset');
+ },
+ /**
+ * Handles click on the Load button: activates @see mode 'load'.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickSignLoadButton: function (ev) {
+ ev.preventDefault();
+ // open file upload automatically (saves 1 click)
+ this.$loadFile.find('input').click();
+ this.setMode('load');
+ },
+ /**
+ * Handles change on load file input: displays the loaded image if the
+ * format is correct, or diplays an error otherwise.
+ *
+ * @see mode 'load'
+ * @private
+ * @param {Event} ev
+ */
+ _onChangeSignLoadInput: function (ev) {
+ var f = ev.target.files[0];
+ if (f.type.substr(0, 5) !== 'image') {
+ this.$signatureField.jSignature('reset');
+ this.$loadInvalid.removeClass('d-none');
+ return false;
+ }
+ this.$loadInvalid.addClass('d-none');
+
+ var self = this;
+ var reader = new FileReader();
+ reader.onload = function (ev) {
+ self._printImage(this.result);
+ };
+ reader.readAsDataURL(f);
+ },
+ /**
+ * Handles input on name field: if the @see mode is 'auto', redraws the
+ * signature with the new name. Also updates the font selection if open.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onInputSignName: function (ev) {
+ if (this.signMode !== 'auto') {
+ return;
+ }
+ this._drawCurrentName();
+ if (!this.$autoFontSelection.hasClass('d-none')) {
+ this._updatePreviewButtons();
+ }
+ },
+ /**
+ * Handles mouse over on font selection: uses this font.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onMouseOverSignAutoFontSelection: function (ev) {
+ this._setFont(parseInt($(ev.currentTarget).data('font-nb')));
+ },
+ /**
+ * Handles touch start on font selection: uses this font.
+ *
+ * @see mode 'auto'
+ * @private
+ * @param {Event} ev
+ */
+ _onTouchStartSignAutoFontSelection: function (ev) {
+ this._setFont(parseInt($(ev.currentTarget).data('font-nb')));
+ },
+});
+
+return {
+ NameAndSignature: NameAndSignature,
+};
+});
diff --git a/addons/portal/static/src/js/portal_signature.js b/addons/portal/static/src/js/portal_signature.js
index 825db234cd3..f2741c7d741 100644
--- a/addons/portal/static/src/js/portal_signature.js
+++ b/addons/portal/static/src/js/portal_signature.js
@@ -1,93 +1,127 @@
-odoo.define('portal.signature_form', function (require){
- "use strict";
+odoo.define('portal.signature_form', function (require) {
+ 'use strict';
- var ajax = require('web.ajax');
- var base = require('web_editor.base');
var core = require('web.core');
- var Widget = require("web.Widget");
- var rpc = require("web.rpc");
-
+ var dom = require('web.dom');
+ var NameAndSignature = require('portal.name_and_signature').NameAndSignature;
var qweb = core.qweb;
+ var Widget = require('web.Widget');
+
+ var _t = core._t;
+ /**
+ * This widget is a signature request form. It uses
+ * @see NameAndSignature for the input fields, adds a submit
+ * button, and handles the RPC to save the result.
+ */
var SignatureForm = Widget.extend({
template: 'portal.portal_signature',
+ xmlDependencies: ['/portal/static/src/xml/portal_signature.xml'],
events: {
- 'click #o_portal_sign_clear': 'clearSign',
- 'click .o_portal_sign_submit': 'submitSign',
- 'init #o_portal_sign_accept': 'initSign',
+ 'click .o_portal_sign_submit': '_onClickSignSubmit',
},
- init: function(parent, options) {
+ /**
+ * Overridden to allow options.
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {string} options.callUrl - make RPC to this url
+ * @param {string} [options.sendLabel='Accept & Sign'] - label of the
+ * send button
+ * @param {Object} [options.rpcParams={}] - params for the RPC
+ * @param {Object} [options.nameAndSignatureOptions={}] - options for
+ * @see NameAndSignature.init()
+ */
+ init: function (parent, options) {
this._super.apply(this, arguments);
- this.options = _.extend(options || {}, {
- csrf_token: odoo.csrf_token,
- });
- },
- willStart: function() {
- return this._loadTemplates();
- },
+ this.csrf_token = odoo.csrf_token;
- start: function() {
- this.initSign();
- },
+ this.callUrl = options.callUrl || '';
+ this.rpcParams = options.rpcParams || {};
+ this.sendLabel = options.sendLabel || _t("Accept & Sign");
- // Signature
- initSign: function () {
- this.$("#o_portal_signature").empty().jSignature({
- 'decor-color': '#D1D0CE',
- 'color': '#000',
- 'background-color': '#fff',
- 'height': '142px',
- 'width': '100%', // prevent the signature from being too big
- });
- this.empty_sign = this.$("#o_portal_signature").jSignature('getData', 'image');
+ this.nameAndSignature = new NameAndSignature(this,
+ options.nameAndSignatureOptions || {});
+
+ // debounce on click to not make multiple RPC if clicking too fast
+ this._onClickSignSubmit = dom.makeButtonHandler(this._onClickSignSubmit);
+ },
+ /**
+ * Overridden to get the DOM elements
+ * and to insert the name and signature.
+ *
+ * @override
+ */
+ start: function () {
+ this.$confirm_btn = this.$('.o_portal_sign_submit');
+ this.$controls = this.$('.o_portal_sign_controls');
+ this.nameAndSignature.replace(this.$('.o_portal_sign_name_and_signature'));
+ return this._super.apply(this, arguments);
},
- clearSign: function () {
- this.$("#o_portal_signature").jSignature('reset');
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Focuses the name.
+ *
+ * @see NameAndSignature.focusName();
+ */
+ focusName: function () {
+ this.nameAndSignature.focusName();
+ },
+ /**
+ * Resets the signature.
+ *
+ * @see NameAndSignature.resetSignature();
+ */
+ resetSignature: function () {
+ return this.nameAndSignature.resetSignature();
},
- submitSign: function (ev) {
- ev.preventDefault();
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
- // extract data
+ /**
+ * Handles click on the submit button.
+ *
+ * This will get the current name and signature and validate them.
+ * If they are valid, they are sent to the server, and the reponse is
+ * handled. If they are invalid, it will display the errors to the user.
+ *
+ * @private
+ * @param {Event} ev
+ * @returns {Deferred}
+ */
+ _onClickSignSubmit: function (ev) {
var self = this;
- var $confirm_btn = self.$el.find('button[type="submit"]');
-
- // process : display errors, or submit
- var partner_name = self.$("#o_portal_sign_name").val();
- var signature = self.$("#o_portal_signature").jSignature('getData', 'image');
- var is_empty = signature ? this.empty_sign[1] === signature[1] : true;
+ ev.preventDefault();
- this.$('#o_portal_sign_name').parent().toggleClass('o_has_error', !partner_name).find('.form-control, .custom-select').toggleClass('is-invalid', !partner_name);
- this.$('#o_portal_sign_draw').toggleClass('bg-danger text-white', is_empty);
- if (is_empty || ! partner_name) {
- return false;
+ if (!this.nameAndSignature.validateSignature()) {
+ return;
}
- $confirm_btn.prepend('<i class="fa fa-spinner fa-spin"></i> ');
- $confirm_btn.attr('disabled', true);
-
- return rpc.query({
- route: this.options.callUrl,
- params: {
- 'res_id': this.options.resId,
- 'access_token': this.options.accessToken,
- 'partner_name': partner_name,
- 'signature': signature ? signature[1] : false,
- },
+ var name = this.nameAndSignature.getName();
+ var signature = this.nameAndSignature.getSignatureImage()[1];
+
+ return this._rpc({
+ route: this.callUrl,
+ params: _.extend(this.rpcParams, {
+ 'name': name,
+ 'signature': signature,
+ }),
}).then(function (data) {
- self.$('.fa-spinner').remove();
if (data.error) {
self.$('.o_portal_sign_error_msg').remove();
- $confirm_btn.before(qweb.render('portal.portal_signature_error', {message: data.error}));
- $confirm_btn.attr('disabled', false);
- }
- else if (data.success) {
- $confirm_btn.remove();
- var $success = qweb.render("portal.portal_signature_success", {widget: data});
- self.$('#o_portal_sign_draw').parent().replaceWith($success);
+ self.$controls.prepend(qweb.render('portal.portal_signature_error', {widget: data}));
+ } else if (data.success) {
+ var $success = qweb.render('portal.portal_signature_success', {widget: data});
+ self.$el.empty().append($success);
}
if (data.force_refresh) {
if (data.redirect_url) {
@@ -95,37 +129,55 @@ odoo.define('portal.signature_form', function (require){
} else {
window.location.reload();
}
+ // no resolve if we reload the page
+ return $.Deferred();
}
});
},
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @returns {Deferred}
- */
- _loadTemplates: function () {
- return ajax.loadXML('/portal/static/src/xml/portal_signature.xml', qweb);
- },
- });
-
- base.ready().then(function () {
- $('.o_portal_signature_form').each(function () {
- var $elem = $(this);
- var form = new SignatureForm(null, $elem.data());
- form.appendTo($elem);
- });
- // Make the signature responsive when it is displayed in bootstrap modal.
- // More precisely it is too small if this code is not here.
- $('.o_portal_signature_form').parents('.modal').on('shown.bs.modal', function (ev) {
- $('.o_portal_signature_form').trigger('resize');
- });
});
return {
SignatureForm: SignatureForm,
};
});
+
+odoo.define('portal.signature_form_init', function (require) {
+'use strict';
+
+var SignatureForm = require('portal.signature_form').SignatureForm;
+var rootWidget = require('root.widget');
+require('web.dom_ready');
+
+$('.o_portal_signature_form').each(function () {
+ var hasBeenReset = false;
+ var $elem = $(this);
+
+ var callUrl = $elem.data('call-url');
+ var nameAndSignatureOptions = {
+ defaultName: $elem.data('default-name'),
+ mode: $elem.data('mode'),
+ signatureRatio: $elem.data('signature-ratio'),
+ signatureType: $elem.data('signature-type'),
+ };
+ var sendLabel = $elem.data('send-label');
+
+ var form = new SignatureForm(rootWidget, {
+ callUrl: callUrl,
+ nameAndSignatureOptions: nameAndSignatureOptions,
+ sendLabel: sendLabel,
+ });
+ form.appendTo($elem);
+ // Correctly set up the signature area if it is inside a modal
+ $('.o_portal_signature_form').parents('.modal').on('shown.bs.modal', function (ev) {
+ if (!hasBeenReset) {
+ // Reset it only the first time it is open to get correct
+ // size. After we want to keep its content on reopen.
+ hasBeenReset = true;
+ form.resetSignature();
+ } else {
+ form.focusName();
+ }
+ });
+});
+
+});
diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss
index 190a9c23fa9..73c1c20d421 100644
--- a/addons/portal/static/src/scss/portal.scss
+++ b/addons/portal/static/src/scss/portal.scss
@@ -432,10 +432,6 @@ img.o_we_custom_image {
}
}
-.o_portal_signature_form .o_portal_sign_error_msg {
- color: $danger;
-}
-
form label {
font-weight: $font-weight-bold;
&.label-optional {
diff --git a/addons/portal/static/src/scss/portal_name_and_signature.scss b/addons/portal/static/src/scss/portal_name_and_signature.scss
new file mode 100644
index 00000000000..557731948fb
--- /dev/null
+++ b/addons/portal/static/src/scss/portal_name_and_signature.scss
@@ -0,0 +1,23 @@
+.o_portal_sign_name_and_signature {
+ // for the absolute of the font selection to work
+ position: relative;
+}
+
+/* Signature Dialog */
+// The .card selector is important to make sure the position absolute will take
+// priority over the position relative of the card.
+.card.o_portal_sign_auto_font_selection {
+ @include o-position-absolute(0, 0);
+
+ .o_portal_sign_auto_font_list {
+ overflow: auto;
+
+ > a {
+ height: 100px;
+
+ > img {
+ height: 100%;
+ }
+ }
+ }
+}
diff --git a/addons/portal/static/src/xml/portal_name_and_signature.xml b/addons/portal/static/src/xml/portal_name_and_signature.xml
new file mode 100644
index 00000000000..a8c111710e6
--- /dev/null
+++ b/addons/portal/static/src/xml/portal_name_and_signature.xml
@@ -0,0 +1,101 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="template" xml:space="preserve">
+
+ <!--
+ This template is used to generate the SVG containing the signature when
+ using mode 'auto'.
+
+ The following variables have to be defined:
+ - width: (number) the width of the resulting image in px
+ - height: (number) the height of the resulting image in px
+ - font: base64 encoded font to use
+ - text: (string) the name to draw
+ - type: (string)
+ -> if set to 'signature', it will adapt the characters width to
+ fit the whole text in the image.
+ -> otherwise, it will adapt the space between characters to fill
+ the image with the text.
+ -->
+ <t t-name="portal.sign_svg_text" name="SVG Signature Text">
+ <svg t-att-width="width" t-att-height="height">
+ <defs>
+ <style type="text/css">
+ @font-face {
+ font-family: "font";
+ src: url(data:font/ttf;base64,<t t-esc="font"/>) format("woff");
+ font-weight: normal;
+ font-style: normal;
+ }
+ </style>
+ </defs>
+ <!-- adapt textLength to prevent distortion and left overflow with small words -->
+ <text t-esc="text" x="50%" t-att-y="height * 4 / 5" t-att-font-size="height * 4 / 5"
+ t-att-textLength="width * 4 / 5 * (type === 'signature' ? Math.min(1, text.length / 7) : 1)"
+ t-att-lengthAdjust="'spacing' + (type === 'signature' ? 'AndGlyphs' : '')"
+ style="font-family:'font'" fill="black" text-anchor="middle"/>
+ </svg>
+ </t>
+
+ <!-- Template for the widget PortalSignNameAndDraw. -->
+ <t t-name="portal.sign_name_and_signature">
+ <div class="o_portal_sign_name_and_signature">
+ <div class="o_portal_sign_name_group form-group">
+ <label class="col-form-label" t-att-for="'o_portal_sign_name_input_' + widget.htmlId">Full Name</label>
+ <input type="text" name="signer" t-att-id="'o_portal_sign_name_input_' + widget.htmlId" class="o_portal_sign_name_input form-control" t-att-value="widget.defaultName" placeholder="Your name" required=""/>
+ </div>
+
+ <!--
+ Overflow hidden is necessary because the card has a rounded
+ border, but we remove the padding from the body, so the canvas
+ will overflow on the corners without this rule.
+ -->
+ <div class="o_portal_sign_signature_group card mt-3" style="overflow: hidden;">
+ <div class="card-header">
+ <div class="row no-gutters">
+ <div class="col-auto">
+ <a role="button" href="#" t-attf-class="o_portal_sign_draw_button mr-2 btn btn-light {{ widget.signMode === 'draw' ? 'active': '' }}">
+ Draw
+ </a>
+ </div>
+ <div class="col-auto">
+ <a role="button" href="#" t-attf-class="o_portal_sign_auto_button mr-2 btn btn-light {{ widget.signMode === 'auto' ? 'active': '' }}">
+ Auto
+ </a>
+ </div>
+ <div class="col-auto">
+ <a role="button" href="#" t-attf-class="o_portal_sign_load_button mr-2 btn btn-light {{ widget.signMode === 'load' ? 'active': '' }}">
+ Load
+ </a>
+ </div>
+ <div t-attf-class="o_portal_sign_draw_clear col-auto ml-auto {{ widget.signMode !== 'draw' ? 'd-none' : '' }}">
+ <a role="button" href="#" class="btn btn-link">
+ Clear
+ </a>
+ </div>
+ <div t-attf-class="o_portal_sign_auto_select_style col-auto ml-auto {{ widget.signMode !== 'auto' ? 'd-none' : '' }}">
+ <a role="button" href="#" class="btn btn-link">
+ Style
+ </a>
+ </div>
+ <div t-attf-class="o_portal_sign_load_file col-auto {{ widget.signMode !== 'load' ? 'd-none' : '' }}">
+ <input type="file" role="button" name="files[]" class="btn btn-link py-0"/>
+ </div>
+ </div>
+ </div>
+
+ <div class="o_portal_sign_signature card-body p-0"/>
+
+ <div t-attf-class="o_portal_sign_load_invalid card-footer d-none">
+ <div class="alert alert-danger mb-0" role="alert">
+ This file is invalid. Please select an image.
+ </div>
+ </div>
+ </div>
+
+ <div class="o_portal_sign_auto_font_selection card d-none h-100 flex-column">
+ <div class="card-header">Styles</div>
+ <div class="o_portal_sign_auto_font_list card-body text-center p-3 flex-grow-1"/>
+ </div>
+ </div>
+ </t>
+</templates>
diff --git a/addons/portal/static/src/xml/portal_signature.xml b/addons/portal/static/src/xml/portal_signature.xml
index 2b2fcd148b0..9904ce88407 100644
--- a/addons/portal/static/src/xml/portal_signature.xml
+++ b/addons/portal/static/src/xml/portal_signature.xml
@@ -1,35 +1,35 @@
<templates id="template" xml:space="preserve">
+
+ <!-- Template for the widget SignatureForm. -->
<t t-name="portal.portal_signature">
- <form id="o_portal_sign_accept" method="POST">
- <input type="hidden" name="csrf_token" t-att-value="widget.options.csrf_token"/>
- <div class="form-group">
- <label class="col-form-label" for="name">Your Name</label>
- <input type="text" name="partner_name" id="o_portal_sign_name" class="form-control" t-att-value="widget.options.partnerName"/>
- </div>
- <div class="card mt16 mb0" id="o_portal_sign_draw">
- <div class="card-header">
- <div class="float-right">
- <a role="button" id="o_portal_sign_clear" class="btn btn-sm">Clear</a>
- </div>
- <strong>Draw your signature</strong>
+ <form method="POST">
+ <input type="hidden" name="csrf_token" t-att-value="widget.csrf_token"/>
+ <div class="o_portal_sign_name_and_signature"/>
+ <div class="o_portal_sign_controls my-3">
+ <div class="text-center my-3">
+ <button type="submit" class="o_portal_sign_submit btn btn-primary">
+ <i class="fa fa-check"/>
+ <t t-esc="widget.sendLabel"/>
+ </button>
</div>
- <div id="o_portal_signature" class="card-body" style="padding: 0"/>
- </div>
- <div class="mt16 clearfix">
- <button type="submit" class="btn btn-primary o_portal_sign_submit float-right"><t t-esc="widget.options.signLabel or 'Accept &amp; Sign'"/></button>
</div>
</form>
</t>
+ <!-- Template when the sign rpc is successful. -->
<t t-name="portal.portal_signature_success">
<div class="alert alert-success" role="status">
- <strong>Thank You !</strong><br />
- <span t-esc="widget.success"/>
- <a t-att-href="widget.redirect_url">Click here to see your document.</a>
+ <span t-if="widget.message" t-esc="widget.message"/>
+ <span t-else="">Thank You!</span>
+ <a t-if="widget.redirect_url" t-att-href="widget.redirect_url">
+ <t t-if="widget.redirect_message" t-esc="widget.redirect_message"/>
+ <t t-else="">Click here to see your document.</t>
+ </a>
</div>
</t>
+ <!-- Template when the sign rpc returns an error. -->
<t t-name="portal.portal_signature_error">
- <div class="o_portal_sign_error_msg">
- <t t-esc="message"/>
+ <div class="o_portal_sign_error_msg alert alert-danger" role="status">
+ <t t-esc="widget.error"/>
</div>
</t>
</templates>
diff --git a/addons/portal/views/assets.xml b/addons/portal/views/assets.xml
index f9a9197f855..ee955dfec47 100644
--- a/addons/portal/views/assets.xml
+++ b/addons/portal/views/assets.xml
@@ -27,8 +27,12 @@
</template>
<template id="assets_common" inherit_id="web.assets_common" name="Portal Common Assets">
+ <xpath expr="link[last()]" position="after">
+ <link rel="stylesheet" type="text/scss" href="/portal/static/src/scss/portal_name_and_signature.scss"/>
+ </xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/web/static/lib/jSignature/jSignatureCustom.js"></script>
+ <script type="text/javascript" src="/portal/static/src/js/portal_name_and_signature.js"></script>
</xpath>
</template>
</odoo>
diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml
index 0f44fb75fdb..77cb9578362 100644
--- a/addons/portal/views/portal_templates.xml
+++ b/addons/portal/views/portal_templates.xml
@@ -481,22 +481,33 @@
</template>
<!--
- Container signature template for frontend
- :partner-name string: customer name that signs
- :res-model string: model of the signed document
- :res-id stringied int: id of the signed document
- :call-url string: url to call once clicking on the sign button
- :access-token string: access token for customer portal
- -->
+ Snippet to request user signature in the portal. The feature comes with
+ the JS file `portal_signature.js`.
+
+ The following variable has to be set:
+ - {string} call_url: url where to send the name and signature by RPC
+ The url should contain a query string if additional parameters
+ have to be sent, such as an access token.
- <template id="portal_signature" name="Ask Signature">
- <div t-att-class="'o_portal_signature_form'"
- t-att-data-partner-name="partner_name"
- t-att-data-res-model="object._name"
- t-att-data-res-id="object.id"
- t-att-data-call-url="callUrl"
- t-att-data-sign-label="signLabel"
- t-att-data-access-token="accessToken"/>
+ The following variables are optional:
+ - {string} default_name: the default name to display
+ - {string} mode: 'draw', 'auto', or 'load'
+ - {string} send_label: label of the send button
+ - {number} signature_ratio: ratio of the signature area
+ - {string} signature_type: 'signature' or 'initial'
+
+ For the default values and more information, see init() of the widgets
+ SignatureForm and NameAndSignature.
+ -->
+ <template id="portal.signature_form" name="Ask Signature">
+ <div class="o_portal_signature_form"
+ t-att-data-call-url="call_url"
+ t-att-data-default-name="default_name"
+ t-att-data-mode="mode"
+ t-att-data-send-label="send_label"
+ t-att-data-signature-ratio="signature_ratio"
+ t-att-data-signature-type="signature_type"
+ />
</template>
<template id="portal_sidebar" name="Sidebar">
diff --git a/addons/sale/controllers/portal.py b/addons/sale/controllers/portal.py
index 88084288b0a..38109ec61c6 100644
--- a/addons/sale/controllers/portal.py
+++ b/addons/sale/controllers/portal.py
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import binascii
+
from odoo import fields, http, _
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
@@ -193,28 +195,37 @@ class CustomerPortal(CustomerPortal):
return request.render('sale.sale_order_portal_template', values)
@http.route(['/my/orders/<int:order_id>/accept'], type='json', auth="public", website=True)
- def portal_quote_accept(self, res_id, access_token=None, partner_name=None, signature=None, order_id=None):
+ def portal_quote_accept(self, order_id, access_token=None, name=None, signature=None):
+ # get from query string if not on json param
+ access_token = access_token or request.httprequest.args.get('access_token')
try:
- order_sudo = self._document_check_access('sale.order', res_id, access_token=access_token)
+ order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
except (AccessError, MissingError):
- return {'error': _('Invalid order')}
+ return {'error': _('Invalid order.')}
if not order_sudo.has_to_be_signed():
return {'error': _('The order is not in a state requiring customer signature.')}
if not signature:
return {'error': _('Signature is missing.')}
+ try:
+ order_sudo.write({
+ 'signed_by': name,
+ 'signed_on': fields.Datetime.now(),
+ 'signature': signature,
+ })
+ except (TypeError, binascii.Error) as e:
+ return {'error': _('Invalid signature data.')}
+
if not order_sudo.has_to_be_paid():
order_sudo.action_confirm()
- order_sudo.signature = signature
- order_sudo.signed_by = partner_name
-
pdf = request.env.ref('sale.action_report_saleorder').sudo().render_qweb_pdf([order_sudo.id])[0]
+
_message_post_helper(
res_model='sale.order',
res_id=order_sudo.id,
- message=_('Order signed by %s') % (partner_name,),
+ message=_('Order signed by %s') % (name,),
attachments=[('%s.pdf' % order_sudo.name, pdf)],
**({'token': access_token} if access_token else {}))
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 8303ce2e442..83c846fc237 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -177,7 +177,8 @@ class SaleOrder(models.Model):
team_id = fields.Many2one('crm.team', 'Sales Team', change_default=True, default=_get_default_team, oldname='section_id')
signature = fields.Binary('Signature', help='Signature received through the portal.', copy=False, attachment=True)
- signed_by = fields.Char('Signed by', help='Name of the person that signed the SO.', copy=False)
+ signed_by = fields.Char('Signed By', help='Name of the person that signed the SO.', copy=False)
+ signed_on = fields.Datetime('Signed On', help='Date of the signature.', copy=False)
commitment_date = fields.Datetime('Commitment Date',
states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
@@ -572,6 +573,7 @@ class SaleOrder(models.Model):
'state': 'draft',
'signature': False,
'signed_by': False,
+ 'signed_on': False,
})
@api.multi
diff --git a/addons/sale/static/src/js/sale_tour_sale_signature.js b/addons/sale/static/src/js/sale_tour_sale_signature.js
new file mode 100644
index 00000000000..ffbdd849ffb
--- /dev/null
+++ b/addons/sale/static/src/js/sale_tour_sale_signature.js
@@ -0,0 +1,43 @@
+odoo.define('sale.tour_sale_signature', function (require) {
+'use strict';
+
+var tour = require('web_tour.tour');
+var base = require('web_editor.base');
+
+// This tour relies on data created on the Python test.
+tour.register('sale_signature', {
+ test: true,
+ url: '/my/quotes',
+ wait_for: base.ready(),
+},
+[
+ {
+ content: "open the test SO",
+ trigger: 'a:containsExact("test SO")',
+ },
+ {
+ content: "click sign",
+ trigger: 'a:contains("Sign")',
+ },
+ {
+ content: "click Auto",
+ trigger: '.o_portal_sign_auto_button',
+ },
+ {
+ content: "click select style",
+ trigger: '.o_portal_sign_auto_select_style a',
+ },
+ {
+ content: "click style 4",
+ trigger: '.o_portal_sign_auto_font_selection a:eq(3)',
+ },
+ {
+ content: "click submit",
+ trigger: '.o_portal_sign_submit',
+ },
+ {
+ content: "check it's confirmed",
+ trigger: '#quote_content:contains("Thank You")',
+ },
+]);
+});
diff --git a/addons/sale/tests/__init__.py b/addons/sale/tests/__init__.py
index 48bee457a5a..335cd20e7c9 100644
--- a/addons/sale/tests/__init__.py
+++ b/addons/sale/tests/__init__.py
@@ -9,4 +9,5 @@ from . import test_onchange
from . import test_reinvoice
from . import test_access_rights
from . import test_sale_refund
+from . import test_sale_signature
from . import test_sale_transaction
diff --git a/addons/sale/tests/test_sale_signature.py b/addons/sale/tests/test_sale_signature.py
new file mode 100644
index 00000000000..5e259103bc3
--- /dev/null
+++ b/addons/sale/tests/test_sale_signature.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo.tests
+
+
+@odoo.tests.common.tagged('post_install', '-at_install')
+class TestSaleSignature(odoo.tests.HttpCase):
+ def test_01_portal_sale_signature_tour(self):
+ """The goal of this test is to make sure the portal user can sign SO."""
+
+ portal_user = self.env.ref('base.partner_demo_portal')
+ # create a SO to be signed
+ sales_order = self.env['sale.order'].create({
+ 'name': 'test SO',
+ 'partner_id': portal_user.id,
+ 'state': 'sent',
+ })
+ self.env['sale.order.line'].create({
+ 'order_id': sales_order.id,
+ 'product_id': self.env.ref('product.product_product_6').id,
+ })
+
+ # must be sent to the user so he can see it
+ sales_order.force_quotation_send()
+
+ self.browser_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('sale_signature')", "odoo.__DEBUG__.services['web_tour.tour'].tours.sale_signature.ready", login="portal")
diff --git a/addons/sale/views/assets.xml b/addons/sale/views/assets.xml
index e29db4a4545..ea5c82b1c86 100644
--- a/addons/sale/views/assets.xml
+++ b/addons/sale/views/assets.xml
@@ -22,6 +22,7 @@
</xpath>
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale/static/src/js/sale_portal_sidebar.js"></script>
+ <script type="text/javascript" src="/sale/static/src/js/sale_tour_sale_signature.js"></script>
</xpath>
</template>
<template id="qunit_suite" name="sale_tests" inherit_id="web.qunit_suite">
diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml
index 4962253e810..82887030563 100644
--- a/addons/sale/views/sale_portal_templates.xml
+++ b/addons/sale/views/sale_portal_templates.xml
@@ -217,42 +217,42 @@
<li t-if="sale_order.payment_term_id"><span>With payment terms:</span> <b t-field="sale_order.payment_term_id"/></li>
</ul>
</p>
- <t t-call="portal.portal_signature">
- <t t-set="object" t-value="sale_order"/>
- <t t-set="partner_name" t-value="sale_order.partner_id.name"/>
- <t t-set="callUrl" t-value="sale_order.get_portal_url(suffix='/accept')"/>
- <t t-set="accessToken" t-value="sale_order.access_token"/>
+ <t t-call="portal.signature_form">
+ <t t-set="call_url" t-value="sale_order.get_portal_url(suffix='/accept')"/>
+ <t t-set="default_name" t-value="sale_order.partner_id.name"/>
</t>
</main>
</form>
</div>
- <div class="modal-dialog modal-content" t-if="not sale_order.has_to_be_signed(True) and sale_order.has_to_be_paid(True)">
- <header class="modal-header">
- <h4 class="modal-title">Validate Order</h4>
- <button type="button" class="close" data-dismiss="modal" aria-label="Close">&amp;times;</button>
- </header>
- <main class="modal-body" id="sign-dialog">
- <p>
- <span>By paying this proposal, I agree to the following terms:</span>
- <ul>
- <li><span>Accepted on the behalf of:</span> <b t-field="sale_order.partner_id.commercial_partner_id"/></li>
- <li><span>For an amount of:</span> <b data-id="total_amount" t-field="sale_order.amount_total"/></li>
- <li t-if="sale_order.payment_term_id"><span>With payment terms:</span> <b t-field="sale_order.payment_term_id"/></li>
- </ul>
- </p>
- <div t-if="pms or acquirers" id="payment_method" class="text-left">
- <h3 class="mb24">Pay with</h3>
- <t t-call="payment.payment_tokens_list">
- <t t-set="mode" t-value="'payment'"/>
- <t t-set="submit_txt">Pay &amp; Confirm</t>
- <t t-set="icon_class" t-value="'fa-lock'"/>
- <t t-set="form_action" t-value="sale_order.get_portal_url(suffix='/transaction/token')"/>
- <t t-set="prepare_tx_url" t-value="sale_order.get_portal_url(suffix='/transaction/')"/>
- <t t-set="access_token" t-value="sale_order.access_token"/>
- </t>
- </div>
- </main>
+ <div class="modal-dialog" t-if="not sale_order.has_to_be_signed(True) and sale_order.has_to_be_paid(True)">
+ <div class="modal-content">
+ <header class="modal-header">
+ <h4 class="modal-title">Validate Order</h4>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">&amp;times;</button>
+ </header>
+ <main class="modal-body" id="sign-dialog">
+ <p>
+ <span>By paying this proposal, I agree to the following terms:</span>
+ <ul>
+ <li><span>Accepted on the behalf of:</span> <b t-field="sale_order.partner_id.commercial_partner_id"/></li>
+ <li><span>For an amount of:</span> <b data-id="total_amount" t-field="sale_order.amount_total"/></li>
+ <li t-if="sale_order.payment_term_id"><span>With payment terms:</span> <b t-field="sale_order.payment_term_id"/></li>
+ </ul>
+ </p>
+ <div t-if="pms or acquirers" id="payment_method" class="text-left">
+ <h3 class="mb24">Pay with</h3>
+ <t t-call="payment.payment_tokens_list">
+ <t t-set="mode" t-value="'payment'"/>
+ <t t-set="submit_txt">Pay &amp; Confirm</t>
+ <t t-set="icon_class" t-value="'fa-lock'"/>
+ <t t-set="form_action" t-value="sale_order.get_portal_url(suffix='/transaction/token')"/>
+ <t t-set="prepare_tx_url" t-value="sale_order.get_portal_url(suffix='/transaction/')"/>
+ <t t-set="access_token" t-value="sale_order.access_token"/>
+ </t>
+ </div>
+ </main>
+ </div>
</div>
</div>
diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml
index ade6a2e3f52..19a82499e21 100644
--- a/addons/sale/views/sale_views.xml
+++ b/addons/sale/views/sale_views.xml
@@ -573,6 +573,13 @@
<group name="utm_link" groups="base.group_no_one"/>
</group>
</page>
+ <page groups="base.group_no_one" string="Customer Signature" name="customer_signature" attrs="{'invisible': [('require_signature', '=', False), ('signed_by', '=', False), ('signature', '=', False), ('signed_on', '=', False)]}">
+ <group>
+ <field name="signed_by"/>
+ <field name="signed_on"/>
+ <field name="signature" widget="image"/>
+ </group>
+ </page>
</notebook>
</sheet>
<div class="oe_chatter">