diff options
Diffstat (limited to 'addons')
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 Binary files differnew file mode 100644 index 00000000000..d8cb1992b86 --- /dev/null +++ b/addons/portal/static/font/sign/Aspire-DemiBold.ttf diff --git a/addons/portal/static/font/sign/JennaSue.ttf b/addons/portal/static/font/sign/JennaSue.ttf Binary files differnew file mode 100644 index 00000000000..667a55112dd --- /dev/null +++ b/addons/portal/static/font/sign/JennaSue.ttf diff --git a/addons/portal/static/font/sign/Otto.ttf b/addons/portal/static/font/sign/Otto.ttf Binary files differnew file mode 100644 index 00000000000..9a2a51dadd2 --- /dev/null +++ b/addons/portal/static/font/sign/Otto.ttf diff --git a/addons/portal/static/font/sign/Pen_Frenzy.ttf b/addons/portal/static/font/sign/Pen_Frenzy.ttf Binary files differnew file mode 100644 index 00000000000..d9f72c59340 --- /dev/null +++ b/addons/portal/static/font/sign/Pen_Frenzy.ttf diff --git a/addons/portal/static/font/sign/Prophecy_Script.ttf b/addons/portal/static/font/sign/Prophecy_Script.ttf Binary files differnew file mode 100644 index 00000000000..57fd1b3647a --- /dev/null +++ b/addons/portal/static/font/sign/Prophecy_Script.ttf diff --git a/addons/portal/static/font/sign/khand.ttf b/addons/portal/static/font/sign/khand.ttf Binary files differnew file mode 100644 index 00000000000..f71b7493a3f --- /dev/null +++ b/addons/portal/static/font/sign/khand.ttf 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 & 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">&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 & 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">&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 & 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"> |
