[IMP] ADD MOBULE MOBILE WEB

This commit is contained in:
odex 2024-08-15 13:51:26 +03:00
parent 3f495222a4
commit 47a8cb9d4c
54 changed files with 2589 additions and 128 deletions

128
.gitignore vendored
View File

@ -1,128 +0,0 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
parts/
sdist/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# github action
.github/workflows/*yaml

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
{
'name': 'Barcode in Mobile',
'category': 'Hidden',
'author': 'Expert Co. Ltd.',
'website': 'http://www.exp-sa.com',
'summary': 'Barcode scan in Mobile',
'version': '1.0',
'description': """ """,
'depends': ['barcodes', 'odex25_web_mobile'],
'data': ['views/odex25_barcodes_mobile_template.xml'],
'qweb': ['static/src/xml/barcodes.xml'],
'installable': True,
'auto_install': True,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,33 @@
odoo.define('odex25_barcodes_mobile.BarcodeEvents', function (require) {
"use strict";
var BarcodeEvents = require('barcodes.BarcodeEvents');
const mobile = require('odex25_web_mobile.core');
if (!mobile.methods.closeVirtualKeyboard) {
return;
}
var barcodeEvents = BarcodeEvents.BarcodeEvents;
// Each time the input has the focus, the mobile virtual keyboard will
// be opened but we don't control exactly when.
// In some are cases, the opening is slowly deferred. The keyboard
// will appear anyway and closeVirtualKeyboard will be executed too early.
barcodeEvents.$barcodeInput.on('focus', function () {
setTimeout(mobile.methods.closeVirtualKeyboard, 0);
});
// On mobile app, we can keep the input focused as the virtual keyboard
// is closed by a specific method.
barcodeEvents._blurBarcodeInput = function () {
if (this.$barcodeInput) {
this.$barcodeInput.val('');
}
}
barcodeEvents.__blurBarcodeInput = _.debounce(barcodeEvents._blurBarcodeInput,
barcodeEvents.inputTimeOut);
});

View File

@ -0,0 +1,28 @@
odoo.define('odex25_web_mobile.barcode_mobile_mixin', function (require) {
"use strict";
const mobile = require('odex25_web_mobile.core');
return {
events: {
'click .o_mobile_barcode': 'open_mobile_scanner'
},
async start() {
const res = await this._super(...arguments);
if (!mobile.methods.scanBarcode) {
this.$el.find(".o_mobile_barcode").remove();
}
return res;
},
async open_mobile_scanner() {
const response = await mobile.methods.scanBarcode();
const barcode = response.data;
if (barcode) {
this._onBarcodeScanned(barcode);
mobile.methods.vibrate({'duration': 100});
} else {
mobile.methods.showToast({'message': 'Please, Scan again !!'});
}
}
};
});

View File

@ -0,0 +1,59 @@
.o_barcode_mobile_container {
position: relative;
display: inline-block;
font-family: "Lato", sans-serif;
font-size: 1.08333333rem;
font-weight: 400;
line-height: 1.5;
color: #fff;
}
.o_barcode_mobile_container img, .o_barcode_mobile_container .o_mobile_barcode {
width: 115px;
height: 60px;
}
.o_barcode_mobile_container .o_mobile_barcode {
width: 100%;
bottom: 0;
position: absolute;
opacity: 0.75;
padding-top: 5px;
}
.o_barcode_mobile_container .o_mobile_barcode .o_barcode_mobile_camera {
margin: 5px;
font-size: 1em;
}
.o_barcode_mobile_container .o_mobile_barcode div {
font-size: 12px;
}
.o_barcode_mobile_container .o_barcode_laser {
position: absolute;
top: 50%;
left: -15px;
bottom: auto;
right: -15px;
height: 5px;
background: rgba(255, 0, 0, 0.6);
box-shadow: 0 1px 10px 1px rgba(255, 0, 0, 0.8);
animation: o_barcode_scanner_intro 1s cubic-bezier(0.6, -0.28, 0.735, 0.045) 0.4s;
}
@keyframes o_barcode_scanner_intro {
25% {
top: 75%;
}
50% {
top: 0;
}
75% {
top: 100%;
}
100% {
top: 50%;
}
}
@media screen and (max-width: 576px) {
.o_event_barcode_bg.o_home_menu_background {
background: white;
height: 100%;
}
}/*# sourceMappingURL=barcode_mobile.css.map */

View File

@ -0,0 +1 @@
{"version":3,"sources":["barcode_mobile.scss","barcode_mobile.css"],"names":[],"mappings":"AAAA;EACI,kBAAA;EACA,qBAAA;EACA,+BAAA;EACA,wBAAA;EACA,gBAAA;EACA,gBAAA;EACA,WAAA;ACCJ;ADCI;EACI,YAAA;EACA,YAAA;ACCR;ADEI;EACI,WAAA;EACA,SAAA;EACA,kBAAA;EACA,aAAA;EACA,gBAAA;ACAR;ADEQ;EACI,WAAA;EACA,cAAA;ACAZ;ADGQ;EACI,eAAA;ACDZ;ADKI;EAEI,kBAAA;EACA,QAAA;EACA,WAAA;EACA,YAAA;EACA,YAAA;EAEA,WAAA;EACA,gCAAA;EACA,+CAAA;EACA,iFAAA;ACLR;ADQI;EACI;IACI,QAAA;ECNV;EDQM;IACI,MAAA;ECNV;EDQM;IACI,SAAA;ECNV;EDQM;IACI,QAAA;ECNV;AACF;;ADSA;EACI;IACI,iBAAA;IACA,YAAA;ECNN;AACF","file":"barcode_mobile.css"}

View File

@ -0,0 +1,66 @@
.o_barcode_mobile_container {
position: relative;
display: inline-block;
font-family: 'Lato', sans-serif;
font-size: 1.08333333rem;
font-weight: 400;
line-height: 1.5;
color: #fff;
img, .o_mobile_barcode {
width: 115px;
height: 60px;
}
.o_mobile_barcode {
width: 100%;
bottom: 0;
position: absolute;
opacity: 0.75;
padding-top: 5px;
.o_barcode_mobile_camera {
margin: 5px;
font-size: 1em;
}
div {
font-size: 12px;
}
}
.o_barcode_laser {
// avoid dependencies to web file in pos
position: absolute;
top: 50%;
left: -15px;
bottom: auto;
right: -15px;
height: 5px;
background: rgba(red, 0.6);
box-shadow: 0 1px 10px 1px rgba(red, 0.8);
animation: o_barcode_scanner_intro 1s cubic-bezier(0.6, -0.28, 0.735, 0.045) 0.4s;
}
@keyframes o_barcode_scanner_intro {
25% {
top: 75%;
}
50% {
top: 0;
}
75% {
top: 100%;
}
100% {
top: 50%;
}
}
}
@media screen and (max-width: 576px) {
.o_event_barcode_bg.o_home_menu_background {
background: white;
height: 100%;
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="mobile_barcode_template">
<div class="o_barcode_mobile_container">
<a role="button" class="btn btn-primary o_mobile_barcode">
<i class="fa fa-camera fa-2x o_barcode_mobile_camera"/>
Tap to scan
</a>
<img src="/barcodes/static/img/barcode.png" alt="Barcode"/>
<span class="o_barcode_laser"/>
</div>
</t>
</templates>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="odex25_barcodes_mobile assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/odex25_barcodes_mobile/static/src/js/barcode_events.js"></script>
<script type="text/javascript" src="/odex25_barcodes_mobile/static/src/js/barcode_mobile_mixin.js"></script>
<link rel="stylesheet" type="text/scss" href="/odex25_barcodes_mobile/static/src/scss/barcode_mobile.scss"/>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
{
'name': 'Event Barcode in Mobile',
'category': 'Odex25 Marketing/Events',
'author': "Expert Co. Ltd.",
'website': "http://www.exp-sa.com",
'summary': 'Event Barcode scan in Mobile',
'version': '1.0',
'description': """ """,
'depends': ['odex25_event_barcode', 'odex25_barcodes_mobile'],
'qweb': ['static/src/xml/odex25_event_barcode_mobile.xml'],
'data': ['views/odex25_event_barcode_mobile_template.xml'],
'installable': True,
'auto_install': True,
}

View File

@ -0,0 +1,35 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * odex25_event_barcode_mobile
#
# Translators:
# Mustafa Rawi <mustafa@cubexco.com>, 2019
# Martin Trigaux, 2019
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~11.5+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-09-18 10:05+0000\n"
"PO-Revision-Date: 2019-08-26 09:35+0000\n"
"Last-Translator: Martin Trigaux, 2019\n"
"Language-Team: Arabic (https://www.transifex.com/odoo/teams/41243/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: odex25_event_barcode_mobile
#. openerp-web
#: code:addons/odex25_event_barcode_mobile/static/src/xml/odex25_event_barcode_mobile.xml:8
#, python-format
msgid "Barcode"
msgstr "باركود"
#. module: odex25_event_barcode_mobile
#. openerp-web
#: code:addons/odex25_event_barcode_mobile/static/src/xml/odex25_event_barcode_mobile.xml:7
#, python-format
msgid "Tap to scan"
msgstr "انقر للقراءة"

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -0,0 +1,10 @@
odoo.define('web.event.barcode_mobile', function (require) {
"use strict";
const EventBarcodeScanView = require('odex25_event_barcode.EventScanView');
const barcodeMobileMixin = require('odex25_web_mobile.barcode_mobile_mixin');
EventBarcodeScanView.include(Object.assign({}, barcodeMobileMixin, {
events: Object.assign({}, barcodeMobileMixin.events, EventBarcodeScanView.prototype.events)
}));
});

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-extend="odex25_event_barcode_template">
<t t-jquery=".o_odex25_event_barcode_image" t-operation="replace">
<t t-call="mobile_barcode_template"/>
</t>
</t>
</templates>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend" name="Event Barcode Mobile Assets">
<xpath expr="." position="inside">
<script type="text/javascript" src="/odex25_event_barcode_mobile/static/src/js/odex25_event_barcode_mobile.js"/>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': 'Mobile',
'category': 'Odex25 Hidden',
'author': 'Expert Co. Ltd.',
'website': 'http://www.exp-sa.com',
'summary': 'Odoo Mobile Core module',
'version': '1.0',
'description': """
This module provides the core of the Odoo Mobile App.
""",
'depends': [
'odex25_web',
],
'qweb': ['static/src/xml/*.xml'],
'data': [
'views/mobile_template.xml',
'views/views.xml',
],
'installable': True,
#'auto_install': True,
}

View File

@ -0,0 +1,115 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * odex25_web_mobile
#
# Translators:
# Mustafa Rawi <mustafa@cubexco.com>, 2020
# amrnegm <amrnegm.01@gmail.com>, 2020
# Osama Ahmaro <osamaahmaro@gmail.com>, 2020
# Zuhair Hammadi <zuhair12@gmail.com>, 2020
# Shaima Safar <shaima.safar@open-inside.com>, 2020
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 14.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-09-01 07:40+0000\n"
"PO-Revision-Date: 2020-09-07 08:25+0000\n"
"Last-Translator: Shaima Safar <shaima.safar@open-inside.com>, 2020\n"
"Language-Team: Arabic (https://www.transifex.com/odoo/teams/41243/ar/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Language: ar\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/contact_sync.xml:0
#: code:addons/odex25_web_mobile/static/src/xml/contact_sync.xml:0
#, python-format
msgid "Add"
msgstr "إضافة"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/contact_sync.xml:0
#, python-format
msgid "Add to"
msgstr "إضافة إلى"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/user_menu.xml:0
#, python-format
msgid "Add to Home Screen"
msgstr "إضافته للقائمة الرئيسية"
#. module: odex25_web_mobile
#: model:ir.model,name:odex25_web_mobile.model_res_partner
msgid "Contact"
msgstr "جهة الاتصال"
#. module: odex25_web_mobile
#: model:ir.model.fields,field_description:odex25_web_mobile.field_res_partner__display_name
msgid "Display Name"
msgstr "الاسم المعروض"
#. module: odex25_web_mobile
#: model:ir.model.fields,field_description:odex25_web_mobile.field_res_partner__id
msgid "ID"
msgstr "المُعرف"
#. module: odex25_web_mobile
#: model:ir.model.fields,field_description:odex25_web_mobile.field_res_partner____last_update
msgid "Last Modified on"
msgstr "آخر تعديل في"
#. module: odex25_web_mobile
#: model:ir.model.fields,field_description:odex25_web_mobile.field_res_partner__image_medium
#: model:ir.model.fields,field_description:odex25_web_mobile.field_res_users__image_medium
msgid "Medium-sized image"
msgstr "صورة متوسطة الحجم"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/contact_sync.xml:0
#, python-format
msgid "Mobile"
msgstr "الهاتف المحمول"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/js/user_menu.js:0
#, python-format
msgid "No shortcut for Home Menu"
msgstr "لا توجد اختصارات للقائمة الرئيسية"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/settings_dashboard.xml:0
#, python-format
msgid "On Apple Store"
msgstr "على متجر Apple"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/settings_dashboard.xml:0
#, python-format
msgid "On Google Play"
msgstr "على متجر جوجل بلاي"
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/barcode_fields.xml:0
#: code:addons/odex25_web_mobile/static/src/xml/barcode_fields.xml:0
#, python-format
msgid "Scan barcode"
msgstr ""
#. module: odex25_web_mobile
#. openerp-web
#: code:addons/odex25_web_mobile/static/src/xml/user_menu.xml:0
#, python-format
msgid "Switch/Add Account"
msgstr "تغيير الحساب/إضافة حساب جديد"

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import res_partner

View File

@ -0,0 +1,7 @@
from odoo import fields, models
class Partners(models.Model):
_inherit = 'res.partner'
# related for backward compatibility with < 13.0
image_medium = fields.Binary(string="Medium-sized image", related='image_128', store=False, readonly=True)

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<metadata id="metadata27">image/svg+xml</metadata>
<g class="layer">
<title>Layer 1</title>
<g id="g20">
<g id="g12">
<rect height="186" id="rect2" width="44" x="61" y="57"/>
<rect height="186" id="rect4" width="44" x="236.5" y="57"/>
<rect height="186" id="rect6" width="44" x="295" y="57"/>
<rect height="186" id="rect8" width="24" x="129.5" y="57"/>
<rect height="186" id="rect10" width="14" x="193" y="57"/>
</g>
<g id="g18">
<polygon id="polygon14" points="59.5,269.5 25.5,269.5 25.5,30.5 59.5,30.5 59.5,19.5 14.5,19.5 14.5,280.5 59.5,280.5 "/>
<polygon id="polygon16" points="339.4999694824219,19.5 339.4999694824219,30.5 374.4999694824219,30.5 374.4999694824219,269.5 339.4999694824219,269.5 339.4999694824219,280.5 385.4999694824219,280.5 385.4999694824219,19.5 "/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,29 @@
odoo.define('odex25_web_mobile.ActionManager', function (require) {
"use strict";
var ActionManager = require('web.ActionManager');
const mobile = require('odex25_web_mobile.core');
/*
We don't want to open website urls in the Odoo apps (iOS and Android).
The apps detect the redirection and open the url in a seprate browser.
In Odoo desktop, the redirection occurs in the same tab and the returned
promise is never resolved.
This override returns a resolved promise in case of mobile app redirects
because Odoo is not aware of this and we need to reactivate status button.
This behavior is the same as the one already done when opening the url in a new window.
*/
ActionManager.include({
ir_actions_act_url: function (action) {
var url = action.url;
var result = this._super.apply(this, arguments);
if (!_.isEmpty(mobile.methods) && !url.startsWith("/web")) {
return Promise.resolve();
}
return result;
},
});
});

View File

@ -0,0 +1,104 @@
odoo.define('odex25_web_mobile.barcode_fields', function (require) {
"use strict";
var field_registry = require('web.field_registry');
require('web._field_registry');
var relational_fields = require('web.relational_fields');
const mobile = require('odex25_web_mobile.core');
/**
* Override the Many2One to open a dialog in mobile.
*/
var FieldMany2OneBarcode = relational_fields.FieldMany2One.extend({
template: "FieldMany2OneBarcode",
events: _.extend({}, relational_fields.FieldMany2One.prototype.events, {
'click .o_barcode_mobile': '_onBarcodeButtonClick',
}),
/**
* @override
*/
start: function () {
var result = this._super.apply(this, arguments);
this._startBarcode();
return result;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* External button is visible
*
* @return {boolean}
* @private
*/
_isExternalButtonVisible: function () {
return this.$external_button.is(':visible');
},
/**
* @override
* @private
*/
_renderEdit: function () {
this._super.apply(this, arguments);
// Hide button if a record is set or external button is visible
if (this.$barcode_button) {
this.$barcode_button.toggle(!this._isExternalButtonVisible());
}
},
/**
* Initialisation of barcode button
*
* @private
*/
_startBarcode: function () {
this.$barcode_button = this.$('.o_barcode_mobile');
// Hide button if a record is set
this.$barcode_button.toggle(!this.isSet());
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* On click on button
*
* @private
*/
_onBarcodeButtonClick: function () {
var self = this;
mobile.methods.scanBarcode().then(function (response){
var barcode = response.data;
if (barcode) {
self._onBarcodeScanned(barcode);
mobile.methods.vibrate({
duration: 100,
});
} else {
mobile.methods.showToast({
message: 'Please, scan again !!',
});
}
});
},
/**
* When barcode is scanned
*
* @param barcode
* @private
*/
_onBarcodeScanned: function (barcode) {
this._search(barcode);
},
});
if (mobile.methods.scanBarcode) {
field_registry.add('many2one_barcode', FieldMany2OneBarcode);
}
return FieldMany2OneBarcode;
});

View File

@ -0,0 +1,52 @@
odoo.define('odex25_web_mobile.ContactSync', function (require) {
"use strict";
var Widget = require('web.Widget');
const mobile = require('odex25_web_mobile.core');
var ContactSync = Widget.extend({
template: 'ContactSync',
events: {
'click': '_onClick',
},
/**
* @constructor
*/
init: function (parent, params) {
this.res_id = params.res_id;
this.res_model = params.res_model;
this.is_mobile = mobile.methods.addContact;
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onClick: function () {
var fieldNames = [
'name', 'image_1920', 'parent_id', 'phone', 'mobile', 'email',
'street', 'street2', 'city', 'state_id', 'zip', 'country_id',
'website', 'function',
];
this._rpc({
model: this.res_model,
method: 'read',
args: [this.res_id, fieldNames],
}).then(function (r) {
const contact = Object.assign({}, r[0], {
image: r[0].image_1920,
});
delete contact.image_1920;
mobile.methods.addContact(contact);
});
},
});
return ContactSync;
});

View File

@ -0,0 +1,50 @@
odoo.define('odex25_web_mobile.Dialog', function (require) {
"use strict";
var Dialog = require('web.Dialog');
var mobileMixins = require('odex25_web_mobile.mixins');
Dialog.include(_.extend({}, mobileMixins.BackButtonEventMixin, {
/**
* Ensure that the on_attach_callback is called after the Dialog has been
* attached to the DOM and opened.
*
* @override
*/
init() {
this._super(...arguments);
this._opened = this._opened.then(this.on_attach_callback.bind(this));
},
/**
* As the Dialog is based on Bootstrap's Modal we don't handle ourself when
* the modal is detached from the DOM and we have to rely on their events
* to call on_detach_callback.
* The 'hidden.bs.modal' is triggered when the hidding animation (if any)
* is finished and the modal is detached from the DOM.
*
* @override
*/
willStart: function () {
var self = this;
return this._super.apply(this, arguments).then(function () {
self.$modal.on('hidden.bs.modal', self.on_detach_callback.bind(self));
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* Close the current dialog on 'backbutton' event.
*
* @private
* @override
* @param {Event} ev
*/
_onBackButton: function () {
this.close();
},
}));
});

View File

@ -0,0 +1,132 @@
odoo.define('odex25_web_mobile.mixins', function (require) {
"use strict";
const session = require('web.session');
const mobile = require('odex25_web_mobile.core');
/**
* Mixin to setup lifecycle methods and allow to use 'backbutton' events sent
* from the native application.
*
* @mixin
* @name BackButtonEventMixin
*
*/
var BackButtonEventMixin = {
/**
* Register event listener for 'backbutton' event when attached to the DOM
*/
on_attach_callback: function () {
mobile.backButtonManager.addListener(this, this._onBackButton);
},
/**
* Unregister event listener for 'backbutton' event when detached from the DOM
*/
on_detach_callback: function () {
mobile.backButtonManager.removeListener(this, this._onBackButton);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {Event} ev 'backbutton' type event
*/
_onBackButton: function () {},
};
/**
* Mixin to hook into the controller record's saving method and
* trigger the update of the user's account details on the mobile app.
*
* @mixin
* @name UpdateDeviceAccountControllerMixin
*
*/
const UpdateDeviceAccountControllerMixin = {
/**
* @override
*/
async saveRecord() {
const changedFields = await this._super(...arguments);
this.savingDef = this.savingDef.then(() => session.updateAccountOnMobileDevice());
return changedFields;
},
};
/**
* Trigger the update of the user's account details on the mobile app as soon as
* the session is correctly initialized.
*/
session.is_bound.then(() => session.updateAccountOnMobileDevice());
return {
BackButtonEventMixin: BackButtonEventMixin,
UpdateDeviceAccountControllerMixin,
};
});
odoo.define('odex25_web_mobile.hooks', function (require) {
"use strict";
const { backButtonManager } = require('odex25_web_mobile.core');
const { Component } = owl;
const { onWillUnmount } = owl.hooks;
/**
* This hook provides support for executing code when the back button is pressed
* on the mobile application of Odoo. This actually replaces the default back
* button behavior so this feature should only be enabled when it is actually
* useful.
*
* The feature must be enabled manually after every (re)mount (or whenever it is
* appropriate), it can be disabled manually, and it is automatically disabled
* on unmount and on destroy.
*
* @param {function} func the function to execute when the back button is
* pressed. The function is called with the custom event as param.
* @returns {Object} exports the enable and disable functions to allow
* controlling the state of the listeners.
*/
function useBackButton(func) {
const component = Component.current;
/**
* Enables the func listener, overriding default back button behavior.
*/
function enable() {
backButtonManager.addListener(component, func);
}
/**
* Disables the func listener, restoring the default back button behavior if
* no other listeners are present.
*/
function disable() {
backButtonManager.removeListener(component, func);
}
onWillUnmount(() => {
disable();
});
const superDestroy = component.destroy.bind(component);
component.destroy = function () {
disable();
superDestroy();
};
return {
disable,
enable,
};
}
return {
useBackButton,
};
});

View File

@ -0,0 +1,77 @@
odoo.define('odex25_web_mobile.Session', function (require) {
"use strict";
const core = require('web.core');
const Session = require('web.Session');
const utils = require('web.utils');
const mobile = require('odex25_web_mobile.core');
/*
Android webview not supporting post download and odoo is using post method to download
so here override get_file of session and passed all data to native mobile downloader
ISSUE: https://code.google.com/p/android/issues/detail?id=1780
*/
Session.include({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* @override
*/
get_file: function (options) {
if (mobile.methods.downloadFile) {
if (core.csrf_token) {
options.csrf_token = core.csrf_token;
}
mobile.methods.downloadFile(options);
// There is no need to wait downloadFile because we delegate this to
// Download Manager Service where error handling will be handled correclty.
// On our side, we do not want to block the UI and consider the request
// as success.
if (options.success) { options.success(); }
if (options.complete) { options.complete(); }
return true;
} else {
return this._super.apply(this, arguments);
}
},
/**
* Update the user's account details on the mobile app
*
* @returns {Promise}
*/
async updateAccountOnMobileDevice() {
if (!mobile.methods.updateAccount) {
return;
}
const avatar = await this.fetchAvatar();
const base64Avatar = await utils.getDataURLFromFile(avatar);
return mobile.methods.updateAccount({
avatar: base64Avatar.substring(base64Avatar.indexOf(',') + 1),
name: this.name,
username: this.username,
});
},
/**
* Fetch current user's avatar
*
* @returns {Promise<Blob>}
*/
async fetchAvatar() {
const url = this.url('/web/image', {
model: 'res.users',
field: 'image_medium',
id: this.uid,
});
const response = await fetch(url);
return response.blob();
},
});
});

View File

@ -0,0 +1,20 @@
odoo.define('odex25_web_mobile.CrashManager', function (require) {
"use strict";
var CrashManager = require('web.CrashManager').CrashManager;
const mobile = require('odex25_web_mobile.core');
CrashManager.include({
/**
* @override
*/
rpc_error: function (error) {
if (mobile.methods.crashManager) {
mobile.methods.crashManager(error);
}
return this._super.apply(this, arguments);
},
});
});

View File

@ -0,0 +1,102 @@
odoo.define("odex25_web_mobile.datepicker", function (require) {
"use strict";
const mobile = require("odex25_web_mobile.core");
const web_datepicker = require("web.datepicker");
const Widget = require("web.Widget");
/**
* Override odoo date-picker (bootstrap date-picker) to display mobile native
* date picker. Because of it is better to show native mobile date-picker to
* improve usability of Application (Due to Mobile users are used to native
* date picker).
*/
web_datepicker.DateWidget.include({
/**
* @override
*/
start() {
if (!mobile.methods.requestDateTimePicker) {
return this._super(...arguments);
}
this.$input = this.$("input.o_datepicker_input");
// forcefully removes the library's classname to "disable" library's event listeners
this.$input.removeClass('datetimepicker-input')
this._setupMobilePicker();
},
/**
* Bootstrap date-picker already destroyed at initialization
*
* @override
*/
destroy() {
if (!mobile.methods.requestDateTimePicker) {
return this._super(...arguments);
}
Widget.prototype.destroy.apply(this, arguments);
},
/**
* @override
*/
maxDate() {
if (!mobile.methods.requestDateTimePicker) {
return this._super(...arguments);
}
console.warn("Unsupported in the mobile applications");
},
/**
* @override
*/
minDate() {
if (!mobile.methods.requestDateTimePicker) {
return this._super(...arguments);
}
console.warn("Unsupported in the mobile applications");
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @override
* @private
*/
_setLibInputValue() {
if (!mobile.methods.requestDateTimePicker) {
return this._super(...arguments);
}
},
/**
* @private
*/
_setupMobilePicker() {
this.$el.on("click", async () => {
const { data } = await mobile.methods.requestDateTimePicker({
value: this.getValue() ? this.getValue().format("YYYY-MM-DD HH:mm:ss") : false,
type: this.type_of_date,
ignore_timezone: true,
});
this.$input.val(data);
this.changeDatetime();
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @override
*/
_onInputClicked: function () {
if (!mobile.methods.requestDateTimePicker) {
return this._super(...arguments);
}
},
});
});

View File

@ -0,0 +1,42 @@
odoo.define('odex25_web_mobile.FormRenderer', function (require) {
"use strict";
var FormRenderer = require('web.FormRenderer');
var ContactSync = require('odex25_web_mobile.ContactSync');
/**
* Include the FormRenderer to instanciate widget ContactSync.
* The method will be automatically called to replace the tag <contactsync>.
*/
FormRenderer.include({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/** We always return a $el even if it's asynchronous (see _renderFieldWidget).
*
* @private
* @returns {jQueryElement}
*/
_renderTagContactsync: function () {
var $el = $('<div>');
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;
},
});
});

View File

@ -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;
},
});
});

View File

@ -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();
}
}
});
});

View File

@ -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,
};
});

View File

@ -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")
});
}
},
});
});

View File

@ -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;
});

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="FieldMany2OneBarcode" t-extend="FieldMany2One">
<t t-jquery="button.o_external_button " t-operation="after">
<button
type="button"
class="btn o_barcode_mobile"
tabindex="-1"
draggable="false"
aria-label="Scan barcode"
title="Scan barcode"
/>
</t>
</t>
</templates>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="ContactSync">
<button t-attf-class="btn oe_stat_button"
widget="contact_sync" t-if="widget.is_mobile">
<i class="fa fa-fw fa-user-plus o_button_icon" role="img" aria-label="Add" title="Add"></i>
<span class="o_field_widget o_stat_info">
<span class="o_stat_text text-primary">Add to<br/>Mobile</span>
</span>
</button>
</t>
</templates>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-extend="DashboardMain">
<t t-jquery=".o_web_settings_dashboard_share" t-operation="after">
<div class="mt8 row mr0 ml0">
<div class="col-lg-6 col-6 col-md-6">
<a class="d-block mx-auto" href="https://play.google.com/store/apps/details?id=com.odoo.mobile" target="_blank">
<img class="d-block mx-auto img img-fluid" src="/odex25_web_mobile/static/src/img/google_play.png" alt="On Google Play"/>
</a>
</div>
<div class="col-lg-6 col-6 col-md-6">
<a class="d-block mx-auto" href="https://itunes.apple.com/us/app/odoo/id1272543640" target="_blank">
<img class="d-block mx-auto img img-fluid" src="/odex25_web_mobile/static/src/img/app_store.png" alt="On Apple Store"/>
</a>
</div>
</div>
</t>
</t>
</templates>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-extend="UserMenu.Actions">
<t t-jquery=".dropdown-item:last" t-operation="after">
<a href="#" class="dropdown-item d-none" data-menu="shortcut">Add to Home Screen</a>
<a href="#" class="dropdown-item d-none" data-menu="switch"> Switch/Add Account</a>
</t>
</t>
</templates>

View File

@ -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,
};
});

View File

@ -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': '<kanban>' +
'<templates><t t-name="kanban-box">' +
'<div class="oe_kanban_global_click"><field name="display_name"/></div>' +
'</t></templates>' +
'</kanban>',
'product.product,false,search': '<search></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:
'<form>' +
'<sheet>' +
'<field name="' + PRODUCT_FIELD_NAME + '" widget="many2one_barcode"/>' +
'</sheet>' +
'</form>',
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();
});
});
});

View File

@ -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: '<form>' +
'<sheet>' +
'<div name="button_box">' +
'<contactsync> </contactsync>' +
'</div>' +
'<field name="name"/>' +
'</sheet>' +
'</form>',
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:
'<form>' +
'<sheet>' +
'<div name="button_box">' +
'<contactsync> </contactsync>' +
'</div>' +
'<field name="name"/>' +
'</sheet>' +
'</form>',
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: '<kanban on_create="quick_create">' +
'<templates><t t-name="kanban-box">' +
'<div><field name="name"/></div>' +
'</t></templates>' +
'</kanban>',
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($('<div>'));
// 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`<div/>`;
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`<div/>`;
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': `
<form>
<sheet>
<field name="name"/>
</sheet>
</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: `
<form>
<sheet>
<field name="name"/>
</sheet>
</form>`,
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: `
<form>
<sheet>
<field name="name"/>
</sheet>
</form>`,
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:'<form><field name="date"/><field name="name"/></form>',
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:'<form><field name="datetime"/><field name="name"/></form>',
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);
});
});
});

View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_mobile_routes

View File

@ -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"])

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend" name="Mobile Assets" priority="1">
<xpath expr="." position="inside">
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/core/mixins.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/core/dialog.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/core/session.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/services/core.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/action_manager.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/barcode_fields.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/contact_sync.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/crash_manager.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/datepicker.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/form_renderer.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/form_view.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/pivot_renderer.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/user_menu.js"></script>
<script type="text/javascript" src="/odex25_web_mobile/static/src/js/user_preferences_form_view.js"></script>
<link rel="stylesheet" type="text/scss" href="/odex25_web_mobile/static/src/scss/mobile.scss"/>
<link rel="stylesheet" type="text/scss" href="/odex25_web_mobile/static/src/scss/pivot_view_mobile.scss"/>
</xpath>
</template>
<template id="tests_assets" name="mobile_tests_assets" inherit_id="web.tests_assets">
<xpath expr="." position="inside">
<script type="text/javascript" src="/odex25_web_mobile/static/tests/helpers/test_utils.js"></script>
</xpath>
</template>
<template id="qunit_suite" name="odex25_web_mobile_tests" inherit_id="web.qunit_mobile_suite_tests">
<xpath expr="." position="inside">
<script type="text/javascript" src="/odex25_web_mobile/static/tests/odex25_web_mobile_tests.js"/>
<script type="text/javascript" src="/odex25_web_mobile/static/tests/odex25_barcodes_mobile_tests.js"/>
</xpath>
</template>
</odoo>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record model="ir.ui.view" id="partner_view_mobile_sync_button">
<field name="name">partner.view.contact.button</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<div name="button_box" position="inside">
<contactsync> </contactsync>
</div>
</field>
</record>
<record id="view_users_form_simple_modif" model="ir.ui.view">
<field name="name">res.users.preferences.form.mobile</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//form" position="attributes">
<attribute name="js_class">res_users_preferences_form</attribute>
</xpath>
</field>
</record>
</odoo>