');
+ var widget = new ContactSync(this, {
+ res_id: this.state.res_id,
+ res_model: this.state.model,
+ });
+ // Prepare widget rendering and save the related promise
+ var prom = widget._widgetRenderAndInsert(function () { });
+ prom.then(function () {
+ $el.replaceWith(widget.$el);
+ });
+
+ this.widgets.push(widget);
+ this.defs.push(prom);
+
+ return $el;
+ },
+});
+
+});
diff --git a/odex25_base/odex25_web_mobile/static/src/js/form_view.js b/odex25_base/odex25_web_mobile/static/src/js/form_view.js
new file mode 100644
index 000000000..bda894959
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/js/form_view.js
@@ -0,0 +1,40 @@
+odoo.define('odex25_web_mobile.FormView', function (require) {
+"use strict";
+
+var config = require('web.config');
+var FormView = require('web.FormView');
+var QuickCreateFormView = require('web.QuickCreateFormView');
+
+/**
+ * We don't want to see the keyboard after the opening of a form view.
+ * The keyboard takes a lot of space and the user doesn't have a global view
+ * on the form.
+ * Plus, some relational fields are overrided (Many2One for example) and the
+ * user have to click on it to set a value. On this kind of field, the autofocus
+ * doesn't make sense because you can't directly type on it.
+ * So, we have to disable the autofocus in mobile.
+ */
+FormView.include({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+
+ if (config.device.isMobile) {
+ this.controllerParams.disableAutofocus = true;
+ }
+ },
+});
+
+QuickCreateFormView.include({
+ /**
+ * @override
+ */
+ init: function () {
+ this._super.apply(this, arguments);
+ this.controllerParams.disableAutofocus = false;
+ },
+});
+
+});
diff --git a/odex25_base/odex25_web_mobile/static/src/js/pivot_renderer.js b/odex25_base/odex25_web_mobile/static/src/js/pivot_renderer.js
new file mode 100644
index 000000000..42b43c3aa
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/js/pivot_renderer.js
@@ -0,0 +1,39 @@
+odoo.define('odex25_web_mobile.PivotRenderer', async function (require) {
+ 'use strict';
+
+ const config = require('web.config');
+
+ if (!config.device.isMobile) {
+ return;
+ }
+
+ const PivotRenderer = require('web.PivotRenderer');
+
+
+ PivotRenderer.patch("pivot_mobile", T => class extends T {
+ /**
+ * Do not compute the tooltip on mobile
+ * @override
+ */
+ _updateTooltip() { }
+
+ /**
+ * @override
+ */
+ _getPadding(cell) {
+ return 5 + cell.indent * 5;
+ }
+
+ /**
+ * @override
+ */
+ _onClickMenuGroupBy(field, interval, ev) {
+ if (!ev.currentTarget.classList.contains('o_pivot_field_selection')){
+ super._onClickMenuGroupBy(...arguments);
+ } else {
+ ev.stopPropagation();
+ }
+ }
+
+ });
+});
\ No newline at end of file
diff --git a/odex25_base/odex25_web_mobile/static/src/js/services/core.js b/odex25_base/odex25_web_mobile/static/src/js/services/core.js
new file mode 100644
index 000000000..614a9cc57
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/js/services/core.js
@@ -0,0 +1,149 @@
+odoo.define('odex25_web_mobile.core', function () {
+"use strict";
+
+var available = typeof OdooDeviceUtility !== 'undefined';
+var DeviceUtility;
+var deferreds = {};
+var methods = {};
+
+if (available){
+ DeviceUtility = OdooDeviceUtility;
+ delete window.OdooDeviceUtility;
+}
+
+/**
+ * Responsible for invoking native methods which called from JavaScript
+ *
+ * @param {String} name name of action want to perform in mobile
+ * @param {Object} args extra arguments for mobile
+ *
+ * @returns Promise Object
+ */
+function native_invoke(name, args) {
+ if(_.isUndefined(args)){
+ args = {};
+ }
+ var id = _.uniqueId();
+ args = JSON.stringify(args);
+ DeviceUtility.execute(name, args, id);
+ return new Promise(function (resolve, reject) {
+ deferreds[id] = {
+ successCallback: resolve,
+ errorCallback: reject
+ };
+ });
+}
+
+/**
+ * Manages deferred callback from initiate from native mobile
+ *
+ * @param {String} id callback id
+ * @param {Object} result
+ */
+window.odoo.native_notify = function (id, result) {
+ if (deferreds.hasOwnProperty(id)) {
+ if (result.success) {
+ deferreds[id].successCallback(result);
+ } else {
+ deferreds[id].errorCallback(result);
+ }
+ }
+};
+
+var plugins = available ? JSON.parse(DeviceUtility.list_plugins()) : [];
+_.each(plugins, function (plugin) {
+ methods[plugin.name] = function (args) {
+ return native_invoke(plugin.action, args);
+ };
+});
+
+/**
+ * Use to notify an uri hash change on native devices (ios / android)
+ */
+if (methods.hashChange) {
+ var currentHash;
+ $(window).bind('hashchange', function (event) {
+ var hash = event.getState();
+ if (!_.isEqual(currentHash, hash)) {
+ methods.hashChange(hash);
+ }
+ currentHash = hash;
+ });
+}
+
+/**
+ * By using the back button feature the default back button behavior from the
+ * app is actually overridden so it is important to keep count to restore the
+ * default when no custom listener are remaining.
+ */
+class BackButtonManager {
+
+ constructor() {
+ this._listeners = new Map();
+ this._onGlobalBackButton = this._onGlobalBackButton.bind(this);
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Enables the func listener, overriding default back button behavior.
+ *
+ * @param {Widget|Component} listener
+ * @param {function} func
+ */
+ addListener(listener, func) {
+ if (!methods.overrideBackButton) {
+ return;
+ }
+ if (this._listeners.has(listener)) {
+ throw new Error("This listener was already registered.");
+ }
+ this._listeners.set(listener, func);
+ if (this._listeners.size === 1) {
+ document.addEventListener('backbutton', this._onGlobalBackButton);
+ methods.overrideBackButton({ enabled: true });
+ }
+ }
+ /**
+ * Disables the func listener, restoring the default back button behavior if
+ * no other listeners are present.
+ *
+ * @param {Widget|Component} listener
+ * @param {function} func
+ */
+ removeListener(listener, func) {
+ if (!methods.overrideBackButton) {
+ return;
+ }
+ if (!this._listeners.has(listener)) {
+ return;
+ }
+ this._listeners.delete(listener);
+ if (this._listeners.size === 0) {
+ document.removeEventListener('backbutton', this._onGlobalBackButton);
+ methods.overrideBackButton({ enabled: false });
+ }
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ _onGlobalBackButton() {
+ const [listener, func] = [...this._listeners].pop();
+ if (listener) {
+ func.apply(listener, arguments);
+ }
+ }
+};
+
+const backButtonManager = new BackButtonManager();
+
+return {
+ backButtonManager,
+ methods,
+};
+
+});
diff --git a/odex25_base/odex25_web_mobile/static/src/js/user_menu.js b/odex25_base/odex25_web_mobile/static/src/js/user_menu.js
new file mode 100644
index 000000000..908b77adf
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/js/user_menu.js
@@ -0,0 +1,64 @@
+odoo.define('odex25_web_mobile.user_menu', function (require) {
+"use strict";
+
+var core = require('web.core');
+var UserMenu = require('web.UserMenu');
+var web_client = require('web.web_client');
+
+const mobile = require('odex25_web_mobile.core');
+
+var _t = core._t;
+
+// Hide the logout link in mobile
+UserMenu.include({
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function () {
+ if (mobile.methods.switchAccount) {
+ self.$('a[data-menu="logout"]').addClass('d-none');
+ self.$('a[data-menu="account"]').addClass('d-none');
+ self.$('a[data-menu="switch"]').removeClass('d-none');
+ }
+ if (mobile.methods.addHomeShortcut) {
+ self.$('a[data-menu="shortcut"]').removeClass('d-none');
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onMenuSwitch: function () {
+ mobile.methods.switchAccount();
+ },
+ /**
+ * @private
+ */
+ _onMenuShortcut: function () {
+ var urlData = $.bbq.getState();
+ if (urlData.menu_id) {
+ var menus = web_client.menu.menu_data;
+ var menu = _.filter(menus.children, function (child) {
+ return child.id === parseInt(urlData.menu_id);
+ });
+ mobile.methods.addHomeShortcut({
+ 'title': document.title,
+ 'shortcut_url': document.URL,
+ 'web_icon': menu && menu[0].web_icon_data
+ });
+ } else {
+ mobile.methods.showToast({
+ "message": _t("No shortcut for Home Menu")
+ });
+ }
+ },
+});
+
+});
diff --git a/odex25_base/odex25_web_mobile/static/src/js/user_preferences_form_view.js b/odex25_base/odex25_web_mobile/static/src/js/user_preferences_form_view.js
new file mode 100644
index 000000000..46c800d29
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/js/user_preferences_form_view.js
@@ -0,0 +1,19 @@
+odoo.define('odex25_web_mobile.UserPreferencesFormView', function (require) {
+ 'use strict';
+
+ const FormView = require('web.FormView');
+ const viewRegistry = require('web.view_registry');
+ const { UpdateDeviceAccountControllerMixin } = require('odex25_web_mobile.mixins');
+
+ const UserPreferencesFormView = FormView.extend({
+ config: Object.assign({}, FormView.prototype.config, {
+ Controller: FormView.prototype.config.Controller.extend(
+ UpdateDeviceAccountControllerMixin
+ ),
+ }),
+ });
+
+ viewRegistry.add('res_users_preferences_form', UserPreferencesFormView);
+
+ return UserPreferencesFormView;
+});
diff --git a/odex25_base/odex25_web_mobile/static/src/scss/mobile.scss b/odex25_base/odex25_web_mobile/static/src/scss/mobile.scss
new file mode 100644
index 000000000..64741a79e
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/scss/mobile.scss
@@ -0,0 +1,8 @@
+.o_barcode_mobile {
+ -webkit-mask: url('/odex25_web_mobile/static/src/img/barcode.svg') center/contain no-repeat;
+ mask: url('/odex25_web_mobile/static/src/img/barcode.svg') center/contain no-repeat;
+ background-color: $o-enterprise-primary-color;
+ width: 26px;
+ margin-left: 10px;
+ border: 0;
+}
diff --git a/odex25_base/odex25_web_mobile/static/src/scss/pivot_view_mobile.scss b/odex25_base/odex25_web_mobile/static/src/scss/pivot_view_mobile.scss
new file mode 100644
index 000000000..5bfa3010f
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/scss/pivot_view_mobile.scss
@@ -0,0 +1,25 @@
+@include media-breakpoint-down(sm) {
+ .o_pivot {
+ .o_pivot_field_menu {
+ left: 0 !important;
+
+ .dropdown-item {
+ padding-top: 15px;
+ padding-bottom: 15px;
+ &.o_pivot_field_selection::after{
+ top:21px;
+ }
+ }
+
+ // css for open child sub menu
+ &.show > .o_inline_dropdown > .dropdown-menu {
+ top: initial !important;
+ left: 5% !important;
+ width: 95%;
+ }
+ }
+ .o_pivot_field_selection::after {
+ @include o-caret-down;
+ }
+ }
+}
diff --git a/odex25_base/odex25_web_mobile/static/src/xml/barcode_fields.xml b/odex25_base/odex25_web_mobile/static/src/xml/barcode_fields.xml
new file mode 100644
index 000000000..5d43c01b0
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/xml/barcode_fields.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/odex25_base/odex25_web_mobile/static/src/xml/contact_sync.xml b/odex25_base/odex25_web_mobile/static/src/xml/contact_sync.xml
new file mode 100644
index 000000000..4a72c4f5f
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/xml/contact_sync.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/odex25_base/odex25_web_mobile/static/src/xml/settings_dashboard.xml b/odex25_base/odex25_web_mobile/static/src/xml/settings_dashboard.xml
new file mode 100644
index 000000000..fc05bb78c
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/xml/settings_dashboard.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/odex25_base/odex25_web_mobile/static/src/xml/user_menu.xml b/odex25_base/odex25_web_mobile/static/src/xml/user_menu.xml
new file mode 100644
index 000000000..7d127ad0b
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/src/xml/user_menu.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Add to Home Screen
+ Switch/Add Account
+
+
+
+
diff --git a/odex25_base/odex25_web_mobile/static/tests/helpers/test_utils.js b/odex25_base/odex25_web_mobile/static/tests/helpers/test_utils.js
new file mode 100644
index 000000000..60f378646
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/tests/helpers/test_utils.js
@@ -0,0 +1,31 @@
+odoo.define('odex25_web_mobile.testUtils', function () {
+ 'use strict';
+
+ /**
+ * Transforms base64 encoded data to a Blob object
+ *
+ * @param {string} b64Data
+ * @param {string} contentType
+ * @param {int} sliceSize
+ * @returns {Blob}
+ */
+ function base64ToBlob(b64Data, contentType, sliceSize) {
+ contentType = contentType || '';
+ sliceSize = sliceSize || 512;
+
+ const byteCharacters = atob(b64Data);
+ let byteArrays = [];
+
+ for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
+ const slice = byteCharacters.slice(offset, offset + sliceSize);
+ const byteNumbers = Array.from(slice).map((char) => char.charCodeAt(0));
+ byteArrays.push(new Uint8Array(byteNumbers));
+ }
+
+ return new Blob(byteArrays, { type: contentType });
+ }
+
+ return {
+ base64ToBlob,
+ };
+});
diff --git a/odex25_base/odex25_web_mobile/static/tests/odex25_barcodes_mobile_tests.js b/odex25_base/odex25_web_mobile/static/tests/odex25_barcodes_mobile_tests.js
new file mode 100644
index 000000000..270abd075
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/tests/odex25_barcodes_mobile_tests.js
@@ -0,0 +1,136 @@
+odoo.define('odex25_web_mobile.barcode.tests', function (require) {
+ "use strict";
+
+ var field_registry = require('web.field_registry');
+ var FormView = require('web.FormView');
+ var relational_fields = require('web.relational_fields');
+ var testUtils = require('web.test_utils');
+
+ var barcode_fields = require('odex25_web_mobile.barcode_fields');
+ const mobile = require('odex25_web_mobile.core');
+
+ var createView = testUtils.createView;
+
+ var NAME_SEARCH = "name_search";
+ var PRODUCT_PRODUCT = 'product.product';
+ var SALE_ORDER_LINE = 'sale_order_line';
+ var PRODUCT_FIELD_NAME = 'product_id';
+ var ARCHS = {
+ 'product.product,false,kanban': '
' +
+ '' +
+ '
' +
+ '' +
+ '',
+ 'product.product,false,search': '
',
+ };
+
+ QUnit.module('odex25_web_mobile', {
+ beforeEach: function () {
+ this.data = {
+ [PRODUCT_PRODUCT]: {
+ fields: {
+ id: {type: 'integer'},
+ name: {},
+ barcode: {},
+ },
+ records: [{
+ id: 111,
+ name: 'product_cable_management_box',
+ barcode: '601647855631',
+ }]
+ },
+ [SALE_ORDER_LINE]: {
+ fields: {
+ id: {type: 'integer'},
+ [PRODUCT_FIELD_NAME]: {
+ string: PRODUCT_FIELD_NAME,
+ type: 'many2one',
+ relation: PRODUCT_PRODUCT
+ },
+ product_uom_qty: {type: 'integer'}
+ }
+ },
+ };
+ },
+ }, function () {
+
+ QUnit.test("odex25_web_mobile: barcode button in a mobile environment", async function (assert) {
+ var self = this;
+
+ assert.expect(3);
+
+ // simulate a mobile environment
+ field_registry.add('many2one_barcode', barcode_fields);
+ var __scanBarcode = mobile.methods.scanBarcode;
+ var __showToast = mobile.methods.showToast;
+ var __vibrate = mobile.methods.vibrate;
+
+ mobile.methods.scanBarcode = function () {
+ return Promise.resolve({
+ 'data': self
+ .data[PRODUCT_PRODUCT]
+ .records[0]
+ .barcode
+ });
+ };
+
+ mobile.methods.showToast = function (data) {};
+
+ mobile.methods.vibrate = function () {};
+
+ var form = await createView({
+ View: FormView,
+ arch:
+ '
',
+ data: this.data,
+ model: SALE_ORDER_LINE,
+ archs: ARCHS,
+ mockRPC: function (route, args) {
+ if (args.method === NAME_SEARCH && args.model === PRODUCT_PRODUCT) {
+ return this._super.apply(this, arguments).then(function (result) {
+ var records = self
+ .data[PRODUCT_PRODUCT]
+ .records
+ .filter(function (record) {
+ return record.barcode === args.kwargs.name;
+ })
+ .map(function (record) {
+ return [record.id, record.name];
+ })
+ ;
+ return records.concat(result);
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var $scanButton = form.$('.o_barcode_mobile');
+
+ assert.equal($scanButton.length, 1, "has scanner button");
+
+ await testUtils.dom.click($scanButton);
+
+ var $modal = $('.o_modal_full .modal-lg');
+ assert.equal($modal.length, 1, 'there should be one modal opened in full screen');
+
+ await testUtils.dom.click($modal.find('.o_kanban_view .o_kanban_record:first'));
+
+ var selectedId = form.renderer.state.data[PRODUCT_FIELD_NAME].res_id;
+ assert.equal(selectedId, self.data[PRODUCT_PRODUCT].records[0].id,
+ "product found and selected (" +
+ self.data[PRODUCT_PRODUCT].records[0].barcode + ")");
+
+ mobile.methods.vibrate = __vibrate;
+ mobile.methods.showToast = __showToast;
+ mobile.methods.scanBarcode = __scanBarcode;
+ field_registry.add('many2one_barcode', relational_fields.FieldMany2One);
+
+ form.destroy();
+ });
+ });
+});
diff --git a/odex25_base/odex25_web_mobile/static/tests/odex25_web_mobile_tests.js b/odex25_base/odex25_web_mobile/static/tests/odex25_web_mobile_tests.js
new file mode 100644
index 000000000..79e13090f
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/static/tests/odex25_web_mobile_tests.js
@@ -0,0 +1,662 @@
+odoo.define('odex25_web_mobile.tests', function (require) {
+"use strict";
+
+const Dialog = require('web.Dialog');
+const dom = require('web.dom');
+const FormView = require('web.FormView');
+const KanbanView = require('web.KanbanView');
+const session = require('web.session');
+const testUtils = require('web.test_utils');
+const Widget = require('web.Widget');
+
+const { useBackButton } = require('odex25_web_mobile.hooks');
+const { BackButtonEventMixin, UpdateDeviceAccountControllerMixin } = require('odex25_web_mobile.mixins');
+const mobile = require('odex25_web_mobile.core');
+const UserPreferencesFormView = require('odex25_web_mobile.UserPreferencesFormView');
+const { base64ToBlob } = require('odex25_web_mobile.testUtils');
+
+const { Component, tags } = owl;
+const { xml } = tags;
+
+const {createParent, createView, mock} = testUtils;
+
+const MY_IMAGE = 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==';
+
+QUnit.module('odex25_web_mobile', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ name: {string: "name", type: "char"},
+ image_1920: {},
+ parent_id: {string: "Parent", type: "many2one", relation: 'partner'},
+ sibling_ids: {string: "Sibling", type: "many2many", relation: 'partner'},
+ phone: {},
+ mobile: {},
+ email: {},
+ street: {},
+ street2: {},
+ city: {},
+ state_id: {},
+ zip: {},
+ country_id: {},
+ website: {},
+ function: {},
+ title: {},
+ date: {string: "A date", type: "date"},
+ datetime: {string: "A datetime", type: "datetime"},
+ },
+ records: [{
+ id: 1,
+ name: 'coucou1',
+ }, {
+ id: 2,
+ name: 'coucou2',
+ }, {
+ id: 11,
+ name: 'coucou3',
+ image_1920: 'image',
+ parent_id: 1,
+ phone: 'phone',
+ mobile: 'mobile',
+ email: 'email',
+ street: 'street',
+ street2: 'street2',
+ city: 'city',
+ state_id: 'state_id',
+ zip: 'zip',
+ country_id: 'country_id',
+ website: 'website',
+ function: 'function',
+ title: 'title',
+ }],
+ },
+ users: {
+ fields: {
+ name: { string: "name", type: "char" },
+ },
+ records: [],
+ },
+ };
+ },
+}, function () {
+
+ QUnit.test("contact sync in a non-mobile environment", async function (assert) {
+ assert.expect(2);
+
+ let rpcCount = 0;
+
+ const form = await createView({
+ View: FormView,
+ arch: '
',
+ data: this.data,
+ model: 'partner',
+ mockRPC: function () {
+ rpcCount++;
+ return this._super.apply(this, arguments);
+ },
+ res_id: 11,
+ });
+
+ const $button = form.$('button.oe_stat_button[widget="contact_sync"]');
+
+ assert.strictEqual($button.length, 0, "the tag should not be visible in a non-mobile environment");
+ assert.strictEqual(rpcCount, 1, "no extra rpc should be done by the widget (only the one from the view)");
+
+ form.destroy();
+ });
+
+ QUnit.test("contact sync in a mobile environment", async function (assert) {
+ assert.expect(5);
+
+
+ const __addContact = mobile.methods.addContact;
+ let addContactRecord;
+ // override addContact to simulate a mobile environment
+ mobile.methods.addContact = function (r) {
+ addContactRecord = r;
+ };
+
+ let rpcDone;
+ let rpcCount = 0;
+
+ const form = await createView({
+ View: FormView,
+ arch:
+ '
',
+ data: this.data,
+ model: 'partner',
+ mockRPC: function (route, args) {
+ if (args.method === "read" && args.args[0] === 11 && _.contains(args.args[1], 'phone')) {
+ rpcDone = true;
+ }
+ rpcCount++;
+ return this._super(route, args);
+ },
+ res_id: 11,
+ });
+
+ const $button = form.$('button.oe_stat_button[widget="contact_sync"]');
+
+ assert.strictEqual($button.length, 1, "the tag should be visible in a mobile environment");
+ assert.strictEqual(rpcCount, 1, "no extra rpc should be done by the widget (only the one from the view)");
+
+ await testUtils.dom.click($button);
+
+ assert.strictEqual(rpcCount, 2, "an extra rpc should be done on click");
+ assert.ok(rpcDone, "a read rpc should have been done");
+ assert.deepEqual(addContactRecord, {
+ city: "city",
+ country_id: "country_id",
+ email: "email",
+ function: "function",
+ id: 11,
+ image: "image",
+ mobile: "mobile",
+ name: "coucou3",
+ parent_id: [
+ 1,
+ "coucou1",
+ ],
+ phone: "phone",
+ state_id: "state_id",
+ street: "street",
+ street2: "street2",
+ website: "website",
+ zip: "zip"
+ }, "all data should be correctly passed");
+
+ mobile.methods.addContact = __addContact;
+
+ form.destroy();
+ });
+
+ QUnit.test('autofocus quick create form', async function (assert) {
+ assert.expect(2);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '
' +
+ '' +
+ '
' +
+ '' +
+ '',
+ groupBy: ['parent_id'],
+ });
+
+ // quick create in first column
+ await testUtils.dom.click(kanban.$buttons.find('.o-kanban-button-new'));
+ assert.ok(kanban.$('.o_kanban_group:nth(0) > div:nth(1)').hasClass('o_kanban_quick_create'),
+ "clicking on create should open the quick_create in the first column");
+ assert.strictEqual(document.activeElement, kanban.$('.o_kanban_quick_create .o_input:first')[0],
+ "the first input field should get the focus when the quick_create is opened");
+
+ kanban.destroy();
+ });
+
+ QUnit.module('BackButtonEventMixin');
+
+ QUnit.test('widget should receive a backbutton event', async function (assert) {
+ assert.expect(5);
+
+ const __overrideBackButton = mobile.methods.overrideBackButton;
+ mobile.methods.overrideBackButton = function ({enabled}) {
+ assert.step(`overrideBackButton: ${enabled}`);
+ };
+
+ const DummyWidget = Widget.extend(BackButtonEventMixin, {
+ _onBackButton(ev) {
+ assert.step(`${ev.type} event`);
+ },
+ });
+ const backButtonEvent = new Event('backbutton');
+ const dummy = new DummyWidget();
+ dummy.appendTo($('
'));
+
+ // simulate 'backbutton' event triggered by the app
+ document.dispatchEvent(backButtonEvent);
+ // waiting nextTick to match testUtils.dom.triggerEvents() behavior
+ await testUtils.nextTick();
+
+ assert.verifySteps([], "shouldn't have register handle before attached to the DOM");
+
+ dom.append($('qunit-fixture'), dummy.$el, {in_DOM: true, callbacks: [{widget: dummy}]});
+
+ // simulate 'backbutton' event triggered by the app
+ document.dispatchEvent(backButtonEvent);
+ await testUtils.nextTick();
+
+ dom.detach([{widget: dummy}]);
+
+ assert.verifySteps([
+ 'overrideBackButton: true',
+ 'backbutton event',
+ 'overrideBackButton: false',
+ ], "should have enabled/disabled the back-button override");
+
+ dummy.destroy();
+ mobile.methods.overrideBackButton = __overrideBackButton;
+ });
+
+ QUnit.test('multiple widgets should receive backbutton events in the right order', async function (assert) {
+ assert.expect(6);
+
+ const __overrideBackButton = mobile.methods.overrideBackButton;
+ mobile.methods.overrideBackButton = function ({enabled}) {
+ assert.step(`overrideBackButton: ${enabled}`);
+ };
+
+ const DummyWidget = Widget.extend(BackButtonEventMixin, {
+ init(parent, {name}) {
+ this._super.apply(this, arguments);
+ this.name = name;
+ },
+ _onBackButton(ev) {
+ assert.step(`${this.name}: ${ev.type} event`);
+ dom.detach([{widget: this}]);
+ },
+ });
+ const backButtonEvent = new Event('backbutton');
+ const dummy1 = new DummyWidget(null, {name: 'dummy1'});
+ dom.append($('qunit-fixture'), dummy1.$el, {in_DOM: true, callbacks: [{widget: dummy1}]});
+
+ const dummy2 = new DummyWidget(null, {name: 'dummy2'});
+ dom.append($('qunit-fixture'), dummy2.$el, {in_DOM: true, callbacks: [{widget: dummy2}]});
+
+ const dummy3 = new DummyWidget(null, {name: 'dummy3'});
+ dom.append($('qunit-fixture'), dummy3.$el, {in_DOM: true, callbacks: [{widget: dummy3}]});
+
+ // simulate 'backbutton' events triggered by the app
+ document.dispatchEvent(backButtonEvent);
+ // waiting nextTick to match testUtils.dom.triggerEvents() behavior
+ await testUtils.nextTick();
+ document.dispatchEvent(backButtonEvent);
+ await testUtils.nextTick();
+ document.dispatchEvent(backButtonEvent);
+ await testUtils.nextTick();
+
+ assert.verifySteps([
+ 'overrideBackButton: true',
+ 'dummy3: backbutton event',
+ 'dummy2: backbutton event',
+ 'dummy1: backbutton event',
+ 'overrideBackButton: false',
+ ]);
+
+ dummy1.destroy();
+ dummy2.destroy();
+ dummy3.destroy();
+ mobile.methods.overrideBackButton = __overrideBackButton;
+ });
+
+ QUnit.module('useBackButton');
+
+ QUnit.test('component should receive a backbutton event', async function (assert) {
+ assert.expect(5);
+
+ mock.patch(mobile.methods, {
+ overrideBackButton({ enabled }) {
+ assert.step(`overrideBackButton: ${enabled}`);
+ },
+ });
+
+ class DummyComponent extends Component {
+ constructor() {
+ super();
+ this._backButtonHandler = useBackButton(this._onBackButton);
+ }
+
+ mounted() {
+ this._backButtonHandler.enable();
+ }
+
+ _onBackButton(ev) {
+ assert.step(`${ev.type} event`);
+ }
+ }
+ DummyComponent.template = xml`
`;
+
+ const dummy = new DummyComponent();
+
+ await dummy.mount(document.createDocumentFragment());
+ // simulate 'backbutton' event triggered by the app
+ await testUtils.dom.triggerEvent(document, 'backbutton');
+ assert.verifySteps([], "shouldn't have register handle before attached to the DOM");
+ dummy.unmount();
+
+ await dummy.mount(document.getElementById('qunit-fixture'));
+ // simulate 'backbutton' event triggered by the app
+ await testUtils.dom.triggerEvent(document, 'backbutton');
+ dummy.unmount();
+ assert.verifySteps([
+ 'overrideBackButton: true',
+ 'backbutton event',
+ 'overrideBackButton: false',
+ ], "should have enabled/disabled the back-button override");
+
+ dummy.destroy();
+ mock.unpatch(mobile.methods);
+ });
+
+ QUnit.test('multiple components should receive backbutton events in the right order', async function (assert) {
+ assert.expect(6);
+
+ mock.patch(mobile.methods, {
+ overrideBackButton({ enabled }) {
+ assert.step(`overrideBackButton: ${enabled}`);
+ },
+ });
+
+ class DummyComponent extends Component {
+ constructor() {
+ super(...arguments);
+ this._backButtonHandler = useBackButton(this._onBackButton);
+ }
+
+ mounted() {
+ this._backButtonHandler.enable();
+ }
+
+ _onBackButton(ev) {
+ assert.step(`${this.props.name}: ${ev.type} event`);
+ this.unmount();
+ }
+ }
+ DummyComponent.template = xml`
`;
+
+ const fixture = document.getElementById('qunit-fixture');
+
+ const dummy1 = new DummyComponent(null, { name: 'dummy1'});
+ await dummy1.mount(fixture);
+
+ const dummy2 = new DummyComponent(null, { name: 'dummy2'});
+ await dummy2.mount(fixture);
+
+ const dummy3 = new DummyComponent(null, { name: 'dummy3'});
+ await dummy3.mount(fixture);
+
+ // simulate 'backbutton' events triggered by the app
+ await testUtils.dom.triggerEvent(document, 'backbutton');
+ await testUtils.dom.triggerEvent(document, 'backbutton');
+ await testUtils.dom.triggerEvent(document, 'backbutton');
+
+ assert.verifySteps([
+ 'overrideBackButton: true',
+ 'dummy3: backbutton event',
+ 'dummy2: backbutton event',
+ 'dummy1: backbutton event',
+ 'overrideBackButton: false',
+ ]);
+
+ dummy1.destroy();
+ dummy2.destroy();
+ dummy3.destroy();
+ mock.unpatch(mobile.methods);
+ });
+
+ QUnit.module('Dialog');
+
+ QUnit.test('dialog is closable with backbutton event', async function (assert) {
+ assert.expect(5);
+
+ const __overrideBackButton = mobile.methods.overrideBackButton;
+ mobile.methods.overrideBackButton = function () {};
+
+ testUtils.mock.patch(Dialog, {
+ close: function () {
+ assert.step("close");
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ const parent = await createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,form': `
+
+ `,
+ },
+ });
+
+ const backButtonEvent = new Event('backbutton');
+ const dialog = new Dialog(parent, {
+ res_model: 'partner',
+ res_id: 1,
+ }).open();
+ await dialog.opened().then(() => {
+ assert.step('opened');
+ });
+ assert.containsOnce(document.body, '.modal', "should have a modal");
+
+ // simulate 'backbutton' event triggered by the app waiting
+ document.dispatchEvent(backButtonEvent);
+ // nextTick to match testUtils.dom.triggerEvents() behavior
+ await testUtils.nextTick();
+
+ // The goal of this assert is to check that our event called the
+ // opened/close methods on Dialog.
+ assert.verifySteps([
+ 'opened',
+ 'close',
+ ], "should have open/close dialog");
+ assert.containsNone(document.body, '.modal', "modal should be closed");
+
+ parent.destroy();
+ testUtils.mock.unpatch(Dialog);
+ mobile.methods.overrideBackButton = __overrideBackButton;
+ });
+
+ QUnit.module('UpdateDeviceAccountControllerMixin');
+
+ QUnit.test('controller should call native updateAccount method when saving record', async function (assert) {
+ assert.expect(4);
+
+ const __updateAccount = mobile.methods.updateAccount;
+ mobile.methods.updateAccount = function (options) {
+ const { avatar, name, username } = options;
+ assert.ok("should call updateAccount");
+ assert.strictEqual(avatar, MY_IMAGE, "should have a base64 encoded avatar");
+ assert.strictEqual(name, "Marc Demo");
+ assert.strictEqual(username, "demo");
+ return Promise.resolve();
+ };
+
+ testUtils.mock.patch(session, {
+ fetchAvatar() {
+ return Promise.resolve(base64ToBlob(MY_IMAGE, 'image/png'));
+ },
+ });
+
+ const DummyView = FormView.extend({
+ config: Object.assign({}, FormView.prototype.config, {
+ Controller: FormView.prototype.config.Controller.extend(UpdateDeviceAccountControllerMixin),
+ }),
+ });
+
+ const dummy = await createView({
+ View: DummyView,
+ model: 'partner',
+ data: this.data,
+ arch: `
+
`,
+ viewOptions: {
+ mode: 'edit',
+ },
+ session: {
+ username: "demo",
+ name: "Marc Demo",
+ }
+ });
+
+ await testUtils.form.clickSave(dummy);
+ await dummy.savingDef;
+
+ dummy.destroy();
+ testUtils.mock.unpatch(session);
+ mobile.methods.updateAccount = __updateAccount;
+ });
+
+ QUnit.test('UserPreferencesFormView should call native updateAccount method when saving record', async function (assert) {
+ assert.expect(4);
+
+ const __updateAccount = mobile.methods.updateAccount;
+ mobile.methods.updateAccount = function (options) {
+ const { avatar, name, username } = options;
+ assert.ok("should call updateAccount");
+ assert.strictEqual(avatar, MY_IMAGE, "should have a base64 encoded avatar");
+ assert.strictEqual(name, "Marc Demo");
+ assert.strictEqual(username, "demo");
+ return Promise.resolve();
+ };
+
+ testUtils.mock.patch(session, {
+ fetchAvatar() {
+ return Promise.resolve(base64ToBlob(MY_IMAGE, 'image/png'));
+ },
+ });
+
+ const view = await createView({
+ View: UserPreferencesFormView,
+ model: 'users',
+ data: this.data,
+ arch: `
+
`,
+ viewOptions: {
+ mode: 'edit',
+ },
+ session: {
+ username: "demo",
+ name: "Marc Demo",
+ }
+ });
+
+ await testUtils.form.clickSave(view);
+ await view.savingDef;
+
+ view.destroy();
+ testUtils.mock.unpatch(session);
+ mobile.methods.updateAccount = __updateAccount;
+ });
+
+ QUnit.module('FieldDate');
+
+ QUnit.test('date field: toggle datepicker', async function (assert) {
+ assert.expect(8);
+
+ mock.patch(mobile.methods, {
+ requestDateTimePicker({ value, type }) {
+ assert.step("requestDateTimePicker");
+ assert.strictEqual(false, value, "field shouldn't have an initial value");
+ assert.strictEqual("date", type, "datepicker's mode should be 'date'");
+ return Promise.resolve({ data: "2020-01-12", });
+ },
+ });
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'
',
+ translateParameters: { // Avoid issues due to localization formats
+ date_format: '%m/%d/%Y',
+ },
+ });
+
+ assert.containsNone(document.body, '.bootstrap-datetimepicker-widget',
+ "datepicker shouldn't be present initially");
+
+ await testUtils.dom.openDatepicker(form.$('.o_datepicker'));
+
+ assert.containsNone(document.body, '.bootstrap-datetimepicker-widget',
+ "datepicker shouldn't be opened");
+ assert.verifySteps(["requestDateTimePicker"], "native datepicker should have been called");
+ // ensure focus has been restored to the date field
+ form.$('.o_datepicker_input').focus();
+ assert.strictEqual(form.$('.o_datepicker_input').val(), "01/12/2020",
+ "should be properly formatted");
+
+ // focus another field
+ await testUtils.dom.click(form.$('.o_field_widget[name=name]').focus());
+ assert.strictEqual(form.$('.o_datepicker_input').val(), "01/12/2020",
+ "shouldn't have changed after loosing focus");
+
+ form.destroy();
+ mock.unpatch(mobile.methods);
+ });
+
+ QUnit.module('FieldDateTime');
+
+ QUnit.test('datetime field: toggle datepicker', async function (assert) {
+ assert.expect(8);
+
+ mock.patch(mobile.methods, {
+ requestDateTimePicker({ value, type }) {
+ assert.step("requestDateTimePicker");
+ assert.strictEqual(false, value, "field shouldn't have an initial value");
+ assert.strictEqual("datetime", type, "datepicker's mode should be 'datetime'");
+ return Promise.resolve({ data: "2020-01-12 12:00:00" });
+ },
+ });
+
+ const form = await createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'
',
+ translateParameters: { // Avoid issues due to localization formats
+ date_format: '%m/%d/%Y',
+ time_format: '%H:%M:%S',
+ },
+ });
+
+ assert.containsNone(document.body, '.bootstrap-datetimepicker-widget',
+ "datepicker shouldn't be present initially");
+
+ await testUtils.dom.openDatepicker(form.$('.o_datepicker'));
+
+ assert.containsNone(document.body, '.bootstrap-datetimepicker-widget',
+ "datepicker shouldn't be opened");
+ assert.verifySteps(["requestDateTimePicker"], "native datepicker should have been called");
+ // ensure focus has been restored to the datetime field
+ form.$('.o_datepicker_input').focus();
+ assert.strictEqual(form.$('.o_datepicker_input').val(), "01/12/2020 12:00:00",
+ "should be properly formatted");
+
+ // focus another field
+ await testUtils.dom.click(form.$('.o_field_widget[name=name]').focus());
+ assert.strictEqual(form.$('.o_datepicker_input').val(), "01/12/2020 12:00:00",
+ "shouldn't have changed after loosing focus");
+
+ form.destroy();
+ mock.unpatch(mobile.methods);
+ });
+});
+});
diff --git a/odex25_base/odex25_web_mobile/tests/__init__.py b/odex25_base/odex25_web_mobile/tests/__init__.py
new file mode 100644
index 000000000..a4eb59d4b
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/tests/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import test_mobile_routes
diff --git a/odex25_base/odex25_web_mobile/tests/test_mobile_routes.py b/odex25_base/odex25_web_mobile/tests/test_mobile_routes.py
new file mode 100644
index 000000000..b3b9ef99d
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/tests/test_mobile_routes.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+
+
+import json
+
+from PIL import Image
+from io import BytesIO
+from uuid import uuid4
+
+from odoo.tests.common import HttpCase, tagged
+from odoo.tools import config, mute_logger
+
+
+@tagged("-at_install", "post_install")
+class MobileRoutesTest(HttpCase):
+ """
+ This test suite is used to request the routes used by the mobile applications (Android & iOS)
+ """
+
+ def setUp(self):
+ super(MobileRoutesTest, self).setUp()
+ self.headers = {
+ "Content-Type": "application/json",
+ }
+
+ def test_version_info(self):
+ """
+ This request is used to check for a compatible Odoo server
+ """
+ payload = self._build_payload()
+ response = self.url_open(
+ "/web/webclient/version_info",
+ data=json.dumps(payload),
+ headers=self.headers,
+ )
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self._is_success_json_response(data)
+ result = data["result"]
+ self.assertIn("server_version_info", result)
+ self.assertIsInstance(result["server_version_info"], list)
+ self.assertGreater(len(result["server_version_info"]), 0)
+ self.assertEqual(result["server_version_info"][-1], "e")
+
+ @mute_logger("odoo.http")
+ def test_database_list(self):
+ """
+ This request is used to retrieve the databases' list
+ NB: this route has a different behavior depending on the ability to list databases or not.
+ """
+ payload = self._build_payload()
+ response = self.url_open("/web/database/list", data=json.dumps(payload), headers=self.headers)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ if config['list_db']:
+ self._is_success_json_response(data)
+ result = data["result"]
+ self.assertIsInstance(result, list)
+ self.assertGreater(len(result), 0)
+ self.assertIn(self.env.cr.dbname, result)
+ else:
+ self._is_error_json_response(data)
+ error = data["error"]
+ self.assertEqual(error["code"], 200)
+ self.assertEqual(error["message"], "Odoo Server Error")
+ self.assertEqual(error["data"]["name"], "odoo.exceptions.AccessDenied")
+
+ def test_authenticate(self):
+ """
+ This request is used to authenticate a user using its username/password
+ and retrieve its details & session's id
+ """
+ payload = self._build_payload({
+ "db": self.env.cr.dbname,
+ "login": "demo",
+ "password": "demo",
+ "context": {},
+ })
+ response = self.url_open("/web/session/authenticate", data=json.dumps(payload), headers=self.headers)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self._is_success_json_response(data)
+ result = data["result"]
+ self.assertIsInstance(response.cookies.get("session_id"), str, "should have a session cookie")
+ self.assertEqual(result["username"], "demo")
+ self.assertEqual(result["db"], self.env.cr.dbname)
+ user = self.env["res.users"].search_read([("login", "=", "demo")], limit=1)[0]
+ self.assertEqual(result["uid"], user["id"])
+ self.assertEqual(result["name"], user["name"])
+
+ @mute_logger("odoo.http")
+ def test_authenticate_wrong_credentials(self):
+ """
+ This request is used to attempt to authenticate a user using the wrong credentials
+ (username/password) and check the returned error
+ """
+ payload = self._build_payload({
+ "db": self.env.cr.dbname,
+ "login": "demo",
+ "password": "admin",
+ "context": {},
+ })
+ response = self.url_open("/web/session/authenticate", data=json.dumps(payload), headers=self.headers)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self._is_error_json_response(data)
+ error = data["error"]
+ self.assertEqual(error["code"], 200)
+ self.assertEqual(error["message"], "Odoo Server Error")
+ self.assertEqual(error["data"]["name"], "odoo.exceptions.AccessDenied")
+
+ @mute_logger("odoo.http")
+ def test_authenticate_wrong_database(self):
+ """
+ This request is used to authenticate a user against a non existing database and
+ check the returned error
+ """
+ db_name = "dummydb-%s" % str(uuid4())
+ payload = self._build_payload({
+ "db": db_name,
+ "login": "demo",
+ "password": "admin",
+ "context": {},
+ })
+ response = self.url_open("/web/session/authenticate", data=json.dumps(payload), headers=self.headers)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self._is_error_json_response(data)
+ error = data["error"]
+ self.assertEqual(error["code"], 200)
+ self.assertEqual(error["message"], "Odoo Server Error")
+ self.assertEqual(error["data"]["name"], "psycopg2.OperationalError")
+ self.assertEqual(
+ error["data"]["message"],
+ 'FATAL: database "%s" does not exist\n' % db_name,
+ )
+
+ def test_avatar(self):
+ """
+ This request is used to retrieve the user's picture
+ """
+ self.authenticate("demo", "demo")
+ response = self.url_open("/web/image?model=res.users&field=image_medium&id=%s" % self.session.uid)
+ self.assertEqual(response.status_code, 200)
+ avatar = Image.open(BytesIO(response.content))
+ self.assertIsInstance(avatar, Image.Image)
+
+ def test_session_info(self):
+ """
+ This request is used to authenticate a user using its session id
+ """
+ payload = self._build_payload()
+ self.authenticate("demo", "demo")
+ response = self.url_open("/web/session/get_session_info", data=json.dumps(payload), headers=self.headers)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self._is_success_json_response(data)
+ result = data["result"]
+ self.assertEqual(result["username"], "demo")
+ self.assertEqual(result["db"], self.env.cr.dbname)
+ self.assertEqual(result["uid"], self.session.uid)
+
+ def _build_payload(self, params={}):
+ """
+ Helper to properly build jsonrpc payload
+ """
+ return {
+ "jsonrpc": "2.0",
+ "method": "call",
+ "id": str(uuid4()),
+ "params": params,
+ }
+
+ def _is_success_json_response(self, data):
+ """"
+ Helper to validate a standard JSONRPC response's structure
+ """
+ self.assertEqual(list(data.keys()), ["jsonrpc", "id", "result"], "should be a valid jsonrpc response")
+ self.assertTrue(isinstance(data["jsonrpc"], str))
+ self.assertTrue(isinstance(data["id"], str))
+
+ def _is_error_json_response(self, data):
+ """
+ Helper to validate an error JSONRPC response's structure
+ """
+ self.assertEqual(list(data.keys()), ["jsonrpc", "id", "error"], "should be a valid error jsonrpc response")
+ self.assertTrue(isinstance(data["jsonrpc"], str))
+ self.assertTrue(isinstance(data["id"], str))
+ self.assertTrue(isinstance(data["error"], dict))
+ self.assertEqual(list(data["error"].keys()), ["code", "message", "data"], "should be a valid error structure")
+ error = data["error"]
+ self.assertTrue(isinstance(error["data"], dict))
+ self.assertIn("name", error["data"])
+ self.assertIn("message", error["data"])
diff --git a/odex25_base/odex25_web_mobile/views/mobile_template.xml b/odex25_base/odex25_web_mobile/views/mobile_template.xml
new file mode 100644
index 000000000..545c14247
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/views/mobile_template.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/odex25_base/odex25_web_mobile/views/views.xml b/odex25_base/odex25_web_mobile/views/views.xml
new file mode 100644
index 000000000..cf4e82445
--- /dev/null
+++ b/odex25_base/odex25_web_mobile/views/views.xml
@@ -0,0 +1,26 @@
+
+
+
+
+ partner.view.contact.button
+ res.partner
+
+
+
+
+
+
+
+
+
+ res.users.preferences.form.mobile
+ res.users
+
+
+
+ res_users_preferences_form
+
+
+
+
+