summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/account/models/account_bank_statement.py10
-rw-r--r--addons/account/models/reconciliation_widget.py11
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_renderer.js5
-rw-r--r--addons/account/static/tests/reconciliation_tests.js1
-rw-r--r--addons/account/views/report_invoice.xml31
-rw-r--r--addons/account/views/report_payment_receipt_templates.xml2
-rw-r--r--addons/account_check_printing/models/account_payment.py4
-rw-r--r--addons/base_address_extended/models/base_address_extended.py7
-rw-r--r--addons/base_geolocalize/models/base_geocoder.py7
-rw-r--r--addons/board/static/tests/dashboard_tests.js47
-rw-r--r--addons/hw_drivers/controllers/__init__.py3
-rwxr-xr-xaddons/hw_drivers/controllers/driver.py721
-rw-r--r--addons/hw_drivers/controllers/loading.py34
-rw-r--r--addons/hw_drivers/static/img/False.jpgbin0 -> 546 bytes
-rw-r--r--addons/hw_drivers/static/img/True.jpgbin0 -> 542 bytes
-rw-r--r--addons/hw_posbox_homepage/controllers/main.py158
-rw-r--r--addons/hw_posbox_homepage/views/homepage.html3
-rw-r--r--addons/hw_posbox_homepage/views/remote_connect.html2
-rw-r--r--addons/l10n_generic_coa/data/account_invoice_demo.xml4
-rw-r--r--addons/l10n_in/views/report_invoice.xml8
-rw-r--r--addons/l10n_jp/data/account.tax.template.csv4
-rw-r--r--addons/l10n_multilang/models/l10n_multilang.py42
-rw-r--r--addons/l10n_mx/data/account_tax_data.xml4
-rw-r--r--addons/mail/models/mail_mail.py6
-rw-r--r--addons/mail/static/src/js/document_viewer.js5
-rw-r--r--addons/mail/static/src/xml/thread.xml20
-rw-r--r--addons/mail/static/tests/document_viewer_tests.js19
-rw-r--r--addons/mrp/models/mrp_production.py1
-rw-r--r--addons/mrp/models/mrp_workorder.py12
-rw-r--r--addons/mrp/tests/test_order.py5
-rw-r--r--addons/note/data/note_data.xml8
-rw-r--r--addons/payment_authorize/models/authorize_request.py6
-rw-r--r--addons/point_of_sale/models/pos_session.py3
-rw-r--r--addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf3
-rw-r--r--addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default6
-rwxr-xr-xaddons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart1
-rw-r--r--addons/point_of_sale/tools/posbox/overwrite_after_init/home/pi/odoo/addons/point_of_sale/__manifest__.py2
-rw-r--r--addons/point_of_sale/tools/posbox/overwrite_after_init/usr/local/lib/python3.5/dist-packages/v4l2.py.iotpatch20
-rwxr-xr-xaddons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh17
-rwxr-xr-xaddons/point_of_sale/tools/posbox/posbox_create_image.sh8
-rwxr-xr-xaddons/point_of_sale/tools/posbox/posbox_download_images.sh5
-rw-r--r--addons/purchase/models/purchase.py2
-rw-r--r--addons/purchase/views/purchase_views.xml4
-rw-r--r--addons/purchase_stock/models/account_invoice.py5
-rw-r--r--addons/purchase_stock/models/purchase.py6
-rw-r--r--addons/sale/report/sale_report_templates.xml33
-rw-r--r--addons/sale/views/sale_portal_templates.xml33
-rw-r--r--addons/sale_stock/models/sale_order.py2
-rw-r--r--addons/sale_stock/views/account_invoice_views.xml6
-rw-r--r--addons/stock_account/models/stock.py34
-rw-r--r--addons/stock_account/tests/test_stockvaluation.py45
-rw-r--r--addons/web/static/src/js/core/domain.js4
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js1
-rw-r--r--addons/web/static/src/scss/layout_background.scss7
-rw-r--r--addons/web/static/tests/views/calendar_tests.js35
-rw-r--r--addons/website/static/src/js/backend/dashboard.js3
-rw-r--r--addons/website_crm_partner_assign/models/crm_lead.py34
-rw-r--r--addons/website_crm_partner_assign/tests/test_partner_assign.py5
-rw-r--r--addons/website_sale/models/sale_order.py27
-rw-r--r--odoo/__init__.py17
-rw-r--r--odoo/addons/base/models/res_bank.py4
-rw-r--r--odoo/addons/base/models/res_partner.py103
-rw-r--r--odoo/addons/base/tests/test_base.py35
-rw-r--r--odoo/addons/base/views/res_bank_views.xml3
-rw-r--r--odoo/addons/test_testing_utilities/ir.model.access.csv3
-rw-r--r--odoo/addons/test_testing_utilities/menu.xml13
-rw-r--r--odoo/addons/test_testing_utilities/models.py28
-rw-r--r--odoo/addons/test_testing_utilities/tests/test_form_impl.py24
-rw-r--r--odoo/tests/common.py127
-rw-r--r--odoo/tools/date_utils.py4
70 files changed, 1178 insertions, 724 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index 553b6080cc6..b6297d153e9 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -235,14 +235,18 @@ class AccountBankStatement(models.Model):
statements = self.filtered(lambda r: r.state == 'open')
for statement in statements:
moves = self.env['account.move']
+ # `line.journal_entry_ids` gets invalidated from the cache during the loop
+ # because new move lines are being created at each iteration.
+ # The below dict is to prevent the ORM to permanently refetch `line.journal_entry_ids`
+ line_journal_entries = {line: line.journal_entry_ids for line in statement.line_ids}
for st_line in statement.line_ids:
#upon bank statement confirmation, look if some lines have the account_id set. It would trigger a journal entry
#creation towards that account, with the wanted side-effect to skip that line in the bank reconciliation widget.
+ journal_entries = line_journal_entries[st_line]
st_line.fast_counterpart_creation()
- if not st_line.account_id and not st_line.journal_entry_ids.ids and not st_line.statement_id.currency_id.is_zero(st_line.amount):
+ if not st_line.account_id and not journal_entries.ids and not st_line.statement_id.currency_id.is_zero(st_line.amount):
raise UserError(_('All the account entries lines must be processed in order to close the statement.'))
- for aml in st_line.journal_entry_ids:
- moves |= aml.move_id
+ moves = statement.mapped('line_ids.journal_entry_ids.move_id')
if moves:
moves.filtered(lambda m: m.state != 'posted').post()
statement.message_post(body=_('Statement %s confirmed, journal items were created.') % (statement.name,))
diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py
index c37953f60d6..3a20e6ae58f 100644
--- a/addons/account/models/reconciliation_widget.py
+++ b/addons/account/models/reconciliation_widget.py
@@ -95,6 +95,17 @@ class AccountReconciliation(models.AbstractModel):
WHERE st_line.id IN %s
'''
params = [tuple(st_lines.ids)]
+
+ # Add the res.partner's IR rules to the WHERE clause. In case partners are not shared
+ # between companies, identical partners may exist in a company we don't have access to.
+ ir_rules_query = self.env['res.partner']._where_calc([])
+ self.env['res.partner']._apply_ir_rules(ir_rules_query, 'read')
+ from_clause, where_clause, where_clause_params = ir_rules_query.get_sql()
+ where_p3 = (" AND %s" % where_clause).replace('res_partner', 'p3') if where_clause else ''
+ if where_p3:
+ query += where_p3
+ params += where_clause_params
+
self._cr.execute(query, params)
result = {}
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
index 1543615a491..8759a404473 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
@@ -553,7 +553,10 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
type: 'boolean',
name: 'to_check',
}], {
- account_id: {string: _t("Account")},
+ account_id: {
+ string: _t("Account"),
+ domain: [['deprecated', '=', false]],
+ },
label: {string: _t("Label")},
amount: {string: _t("Account")},
}).then(function (recordID) {
diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js
index fce0c44411d..043cd09062c 100644
--- a/addons/account/static/tests/reconciliation_tests.js
+++ b/addons/account/static/tests/reconciliation_tests.js
@@ -54,6 +54,7 @@ var db = {
code: {string: "code", type: 'integer'},
name: {string: "Displayed name", type: 'char'},
company_id: {string: "Company", type: 'many2one', relation: 'res.company'},
+ deprecated: {string: "Deprecated", type: 'boolean'},
},
records: [
{id: 282, code: 100000, name: "100000 Fixed Asset Account", company_id: 1},
diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml
index 1c57b6892c9..043c5a3cc47 100644
--- a/addons/account/views/report_invoice.xml
+++ b/addons/account/views/report_invoice.xml
@@ -59,18 +59,19 @@
<span>Disc.(%)</span>
</th>
<th t-attf-class="text-left {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Taxes</span></th>
- <th class="text-right" groups="account.group_show_line_subtotals_tax_excluded">Amount</th>
- <th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th>
+ <th class="text-right">
+ <span groups="account.group_show_line_subtotals_tax_excluded">Amount</span>
+ <span groups="account.group_show_line_subtotals_tax_included">Total Price</span>
+ </th>
</tr>
</thead>
<tbody class="invoice_tbody">
- <t t-set="current_subtotal_tax_excluded" t-value="0"/>
- <t t-set="current_subtotal_tax_included" t-value="0"/>
+ <t t-set="current_subtotal" t-value="0"/>
<t t-foreach="o.invoice_line_ids" t-as="line">
- <t t-set="current_subtotal_tax_excluded" t-value="current_subtotal_tax_excluded + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
- <t t-set="current_subtotal_tax_included" t-value="current_subtotal_tax_included + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
+ <t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
+ <t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
<tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''">
<t t-if="not line.display_type" name="account_invoice_line_accountable">
@@ -89,11 +90,9 @@
<td t-attf-class="text-left {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.invoice_line_tax_ids))"/>
</td>
- <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded">
- <span t-field="line.price_subtotal"/>
- </td>
- <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included">
- <span t-field="line.price_total"/>
+ <td class="text-right o_price_total">
+ <span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
+ <span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
</td>
</t>
<t t-if="line.display_type == 'line_section'">
@@ -101,8 +100,7 @@
<span t-field="line.name"/>
</td>
<t t-set="current_section" t-value="line"/>
- <t t-set="current_subtotal_tax_excluded" t-value="0"/>
- <t t-set="current_subtotal_tax_included" t-value="0"/>
+ <t t-set="current_subtotal" t-value="0"/>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
@@ -116,14 +114,9 @@
<td colspan="99">
<strong class="mr16">Subtotal</strong>
<span
- groups="account.group_show_line_subtotals_tax_excluded"
- t-esc="current_subtotal_tax_excluded"
+ t-esc="current_subtotal"
t-options='{"widget": "monetary", "display_currency": o.currency_id}'
/>
- <span groups="account.group_show_line_subtotals_tax_included"> | <span
- t-esc="current_subtotal_tax_included"
- t-options='{"widget": "monetary", "display_currency": o.currency_id}'
- /></span>
</td>
</tr>
</t>
diff --git a/addons/account/views/report_payment_receipt_templates.xml b/addons/account/views/report_payment_receipt_templates.xml
index 626cff36fe2..871ae39479f 100644
--- a/addons/account/views/report_payment_receipt_templates.xml
+++ b/addons/account/views/report_payment_receipt_templates.xml
@@ -42,7 +42,7 @@
</tr>
</thead>
<tbody>
- <tr t-foreach="o.invoice_ids" t-as="inv">
+ <tr t-foreach="o.reconciled_invoice_ids" t-as="inv">
<td><span t-field="inv.date_invoice"/></td>
<td><span t-field="inv.number"/></td>
<td class="text-right"><span t-field="inv.amount_total"/></td>
diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py
index d2fd855ab07..22b93d30ab2 100644
--- a/addons/account_check_printing/models/account_payment.py
+++ b/addons/account_check_printing/models/account_payment.py
@@ -152,12 +152,12 @@ class AccountPayment(models.Model):
""" The stub is the summary of paid invoices. It may spill on several pages, in which case only the check on
first page is valid. This function returns a list of stub lines per page.
"""
- if len(self.invoice_ids) == 0:
+ if len(self.reconciled_invoice_ids) == 0:
return None
multi_stub = self.company_id.account_check_printing_multi_stub
- invoices = self.invoice_ids.sorted(key=lambda r: r.date_due)
+ invoices = self.reconciled_invoice_ids.sorted(key=lambda r: r.date_due)
debits = invoices.filtered(lambda r: r.type == 'in_invoice')
credits = invoices.filtered(lambda r: r.type == 'in_refund')
diff --git a/addons/base_address_extended/models/base_address_extended.py b/addons/base_address_extended/models/base_address_extended.py
index 766fac55a8b..0fd99cc8e63 100644
--- a/addons/base_address_extended/models/base_address_extended.py
+++ b/addons/base_address_extended/models/base_address_extended.py
@@ -33,11 +33,6 @@ class Partner(models.Model):
street_number2 = fields.Char('Door', compute='_split_street', help="Door Number",
inverse='_set_street', store=True)
- @api.model
- def _address_fields(self):
- """Returns the list of address fields that are synced from the parent."""
- return super(Partner, self)._address_fields() + ['street_name', 'street_number', 'street_number2']
-
def get_street_fields(self):
"""Returns the fields that can be used in a street format.
Overwrite this function if you want to add your own fields."""
@@ -144,7 +139,7 @@ class Partner(models.Model):
def write(self, vals):
res = super(Partner, self).write(vals)
- if 'country_id' in vals:
+ if 'country_id' in vals and 'street' not in vals:
self._set_street()
return res
diff --git a/addons/base_geolocalize/models/base_geocoder.py b/addons/base_geolocalize/models/base_geocoder.py
index ee54fcf6dbd..6da9c2f7f03 100644
--- a/addons/base_geolocalize/models/base_geocoder.py
+++ b/addons/base_geolocalize/models/base_geocoder.py
@@ -115,7 +115,12 @@ class GeoCoder(models.AbstractModel):
if result['status'] != 'OK':
_logger.debug('Invalid Gmaps call: %s - %s',
result['status'], result.get('error_message', ''))
- return None
+ error_msg = _('Unable to geolocate, received the error:\n%s'
+ '\n\nGoogle made this a paid feature.\n'
+ 'You should first enable billing on your Google account.\n'
+ 'Then, go to Developer Console, and enable the APIs:\n'
+ 'Geocoding, Maps Static, Maps Javascript.\n') % result.get('error_message')
+ raise UserError(error_msg)
geo = result['results'][0]['geometry']['location']
return float(geo['lat']), float(geo['lng'])
except (KeyError, ValueError):
diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js
index 19e3ec8c6bd..62dcb1f851c 100644
--- a/addons/board/static/tests/dashboard_tests.js
+++ b/addons/board/static/tests/dashboard_tests.js
@@ -786,6 +786,53 @@ QUnit.test('save two searches to dashboard', function (assert) {
actionManager.destroy();
});
+QUnit.test('save a action domain to dashboard', function (assert) {
+ // View domains are to be added to the dashboard domain
+ assert.expect(1);
+
+ var view_domain = ["display_name", "ilike", "a"];
+ var filter_domain = ["display_name", "ilike", "b"];
+
+ // The filter domain already contains the view domain, but is always added by dashboard..,
+ var expected_domain = ['&', '&', view_domain, view_domain, filter_domain]
+
+ var actionManager = createActionManager({
+ data: this.data,
+ archs: {
+ 'partner,false,list': '<list><field name="foo"/></list>',
+ 'partner,false,search': '<search></search>',
+ },
+ mockRPC: function (route, args) {
+ if (route === '/board/add_to_dashboard') {
+ assert.deepEqual(args.domain, expected_domain,
+ "the correct domain should be sent");
+ return $.when(true);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ actionManager.doAction({
+ id: 1,
+ res_model: 'partner',
+ type: 'ir.actions.act_window',
+ views: [[false, 'list']],
+ domain: [view_domain],
+ });
+
+ // Add a filter
+ testUtils.dom.click(actionManager.$('.o_filters_menu_button'));
+ testUtils.dom.click(actionManager.$('.o_add_custom_filter'));
+ actionManager.$('.o_searchview_extended_prop_value .o_input').val('b');
+ testUtils.dom.click(actionManager.$('.o_apply_filter'));
+ // Add it to dashboard
+ testUtils.dom.click(actionManager.$('.o_favorites_menu_button'));
+ testUtils.dom.click(actionManager.$('.o_add_to_board'));
+ testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button'));
+
+ actionManager.destroy();
+});
+
QUnit.test("Views should be loaded in the user's language", function (assert) {
assert.expect(2);
var form = createView({
diff --git a/addons/hw_drivers/controllers/__init__.py b/addons/hw_drivers/controllers/__init__.py
index 3bd726d7111..8a59703b3c1 100644
--- a/addons/hw_drivers/controllers/__init__.py
+++ b/addons/hw_drivers/controllers/__init__.py
@@ -1,2 +1 @@
-from . import driver
-from . import loading \ No newline at end of file
+from . import driver \ No newline at end of file
diff --git a/addons/hw_drivers/controllers/driver.py b/addons/hw_drivers/controllers/driver.py
index 3772dfa32f9..64d45a1fcfc 100755
--- a/addons/hw_drivers/controllers/driver.py
+++ b/addons/hw_drivers/controllers/driver.py
@@ -1,432 +1,401 @@
#!/usr/bin/python3
import logging
import time
-from threading import Thread
-import usb
-import gatt
+from threading import Thread, Event
+from usb import core
+from gatt import DeviceManager as Gatt_DeviceManager
import subprocess
-import netifaces as ni
+import netifaces
import json
-import re
-from odoo import http
+from re import sub
import urllib3
-from odoo.http import request as httprequest
-import datetime
+import os
+import socket
+from importlib import util
+import v4l2
+from fcntl import ioctl
+from cups import Connection as cups_connection
+from glob import glob
+from base64 import b64decode
+from pathlib import Path
-logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(name)s: %(message)s')
-_logger = logging.getLogger('dispatcher')
+from odoo import http, _
+from odoo.modules.module import get_resource_path
-owner_dict = {}
-last_ping = {}
+_logger = logging.getLogger(__name__)
-class StatusController(http.Controller):
-
- @http.route('/hw_drivers/owner/check', type='json', auth='none', cors='*', csrf=False)
- def check_cantakeowner(self): #, devices, tab
- data = httprequest.jsonrequest
- for device in data['devices']:
- if owner_dict.get(device) and owner_dict[device] != data['tab']:
- before_date = datetime.datetime.now() - datetime.timedelta(seconds=10)
- if last_ping.get(owner_dict[device]) and last_ping.get(owner_dict[device]) > before_date:
- return 'no'
- else:
- old_tab = owner_dict[device]
- for dev2 in owner_dict:
- if owner_dict[dev2] == old_tab:
- owner_dict[dev2] = ''
- return 'yes'
-
- @http.route('/hw_drivers/owner/take', type='json', auth='none', cors='*', csrf=False)
- def take_ownership(self): #, devices, tab
- data = httprequest.jsonrequest
- for device in data['devices']:
- owner_dict[device] = data['tab']
- last_ping[data['tab']] = datetime.datetime.now()
- return data['tab']
-
- @http.route('/hw_drivers/owner/ping', type='json', auth='none', cors='*', csrf=False)
- def ping_trigger(self): #, tab
- data = httprequest.jsonrequest
- ping_dict = {}
- last_ping[data['tab']] = datetime.datetime.now()
- for dev in data['devices']:
- if owner_dict.get(dev) and owner_dict[dev] == data['tab']:
- for driver_path in drivers:
- if driver_path.find(dev) == 0 and drivers[driver_path].ping_value:
- ping_dict[dev] = drivers[driver_path].ping_value
- drivers[driver_path].ping_value = '' # or set it to nothing
- else:
- ping_dict[dev] = 'STOP'
- return ping_dict
-
- @http.route('/hw_drivers/box/connect', type='json', auth='none', cors='*', csrf=False)
- def connect_box(self):
- data = httprequest.jsonrequest
- server = "" # read from file
- try:
- f = open('/home/pi/odoo-remote-server.conf', 'r')
- for line in f:
- server += line
- f.close()
- server = server.split('\n')[0]
- except:
- server = ''
- if server:
- return 'This IoTBox has already been connected'
- else:
- iotname = ''
- token = data['token'].split('|')[1]
- url = data['token'].split('|')[0]
- reboot = 'noreboot'
- subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server.sh', url, iotname, token, reboot])
- send_iot_box_device(False)
- return 'IoTBox connected'
-
- @http.route('/hw_drivers/drivers/status', type='http', auth='none', cors='*')
- def status(self):
- result = "<html><head></head><body>List of drivers and values: <br/> <ul>"
- for path in drivers:
- result += "<li>" + path + ":" + str(drivers[path].value) + "</li>"
- result += "</ul>"
- result +=" </body></html>"
- return result
-
- @http.route('/hw_drivers/driverdetails/<string:identifier>', type='http', auth='none', cors='*')
- def statusdetail(self, identifier):
- for device in drivers:
- if device.find(identifier) != -1:
- return str(drivers[device].value)
- return ''
-
- @http.route('/hw_drivers/driveraction/<string:identifier>', type='json', auth='none', cors='*', csrf=False)
- def driveraction(self, identifier):
- data = httprequest.jsonrequest
- result = 'device not found'
- if data.get('action') == 'print':
- with open('/tmp/toprinter', 'w') as file:
- file.write(data['data'])
- subprocess.call("cat /tmp/toprinter | base64 -d | lp -d " + identifier, shell=True)
- result = 'ok'
- if data.get('action') == 'camera':
- cameras = subprocess.check_output("v4l2-ctl --list-devices", shell=True).decode('utf-8').split('\n\n')
- adrress = '/dev/video0'
- for camera in cameras:
- if camera:
- camera = camera.split('\n\t')
- serial = re.sub('[^a-zA-Z0-9 ]+', '', camera[0].split(': ')[0]).replace(' ','_')
- if serial == data.get('identifier'):
- adrress = camera[1]
- picture = subprocess.check_output("v4l2-ctl --list-formats-ext|grep 'Size'|awk '{print $3}'|sort -rn|awk NR==1", shell=True).decode('utf-8')
- subprocess.call("fswebcam -d " + adrress + " /tmp/testimage -r " + picture, shell=True)
- image_bytes = subprocess.check_output('cat /tmp/testimage | base64',shell=True)
- result = {'image': image_bytes}
- return result
-
- @http.route('/hw_drivers/send_iot_box', type='http', auth='none', cors='*')
- def send_iot_box(self):
- send_iot_box_device(False)
- return 'ok'
#----------------------------------------------------------
-# Driver common interface
+# Helper
#----------------------------------------------------------
-class Driver(Thread):
- ping_value = ""
-
- def supported(self):
- pass
-
- def value(self):
- pass
-
- def get_name(self):
- pass
-
- def get_connection(self):
- pass
-
- def action(self, action):
- pass
-#----------------------------------------------------------
-# Usb drivers
-#----------------------------------------------------------
-usbdrivers = []
-drivers = {}
-
-class UsbMetaClass(type):
- def __new__(cls, clsname, bases, attrs):
- newclass = super(UsbMetaClass, cls).__new__(cls, clsname, bases, attrs)
- usbdrivers.append(newclass)
- return newclass
+def get_mac_address():
+ try:
+ return netifaces.ifaddresses('eth0')[netifaces.AF_LINK][0]['addr']
+ except:
+ return netifaces.ifaddresses('wlan0')[netifaces.AF_LINK][0]['addr']
-class USBDriver(Driver,metaclass=UsbMetaClass):
- def __init__(self, dev):
- super(USBDriver, self).__init__()
- self.dev = dev
- self.value = ""
+def get_ip():
+ try:
+ return netifaces.ifaddresses('eth0')[netifaces.AF_INET][0]['addr']
+ except:
+ return netifaces.ifaddresses('wlan0')[netifaces.AF_INET][0]['addr']
- def get_name(self):
- lsusb = str(subprocess.check_output('lsusb')).split("\\n")
- for usbpath in lsusb: # Should filter on usb devices or inverse loops?
- device = self.dev
- if "%04x:%04x" % (device.idVendor, device.idProduct) in usbpath:
- return usbpath.split("%04x:%04x" % (device.idVendor, device.idProduct))[1]
- return str(device.idVendor) + ":" + str(device.idProduct)
+def read_file_first_line(filename):
+ path = Path.home() / filename
+ if path.exists():
+ with path.open('r') as f:
+ return f.readline().strip('\n')
+ return ''
- def get_connection(self):
- return 'direct'
+def get_odoo_server_url():
+ return read_file_first_line('odoo-remote-server.conf')
- def value(self):
- return self.value
+def get_token():
+ return read_file_first_line('token')
+def get_version():
+ return '19_04'
#----------------------------------------------------------
-# Bluetooth
+# Controllers
#----------------------------------------------------------
-class GattBtManager(gatt.DeviceManager):
- def device_discovered(self, device):
- # TODO: need some kind of updated_devices mechanism or not?
- path = "bt_%s" % (device.mac_address,)
- if path not in drivers:
- for driverclass in btdrivers:
- d = driverclass(device = device, manager=self)
- if d.supported():
- drivers[path] = d
- d.connect()
- send_iot_box_device(False)
-
-
-class BtManager(Thread):
- gatt_manager = False
-
- def run(self):
- dm = GattBtManager(adapter_name='hci0')
- self.gatt_manager = dm
- dm.start_discovery()
- dm.run()
+class StatusController(http.Controller):
+ @http.route('/hw_drivers/action', type='json', auth='none', cors='*', csrf=False, save_session=False)
+ def action(self, session_id, device_id, data):
+ """
+ This route is called when we want to make a action with device (take picture, printing,...)
+ We specify in data from which session_id that action is called
+ And call the action of specific device
+ """
+ iot_device = iot_devices.get(device_id)
+ if iot_device:
+ iot_device.data['owner'] = session_id
+ data = json.loads(data)
+ iot_device.action(data)
+ return True
+ return False
+
+ @http.route('/hw_drivers/event', type='json', auth='none', cors='*', csrf=False, save_session=False)
+ def event(self, listener):
+ """
+ listener is a dict in witch there are a sessions_id and a dict of device_id to listen
+ """
+ req = event_manager.add_request(listener)
+ if req['event'].wait(50):
+ req['event'].clear()
+ req['result']['session_id'] = req['session_id']
+ return req['result']
+
+ @http.route('/hw_drivers/box/connect', type='http', auth='none', cors='*', csrf=False, save_session=False)
+ def connect_box(self, token):
+ """
+ This route is called when we want that a IoT Box will be connected to a Odoo DB
+ token is a base 64 encoded string and have 2 argument separate by |
+ 1 - url of odoo DB
+ 2 - token. This token will be compared to the token of Odoo. He have 1 hour lifetime
+ """
+ server = get_odoo_server_url()
+ image = get_resource_path('hw_drivers', 'static/img', 'False.jpg')
+ if server == '':
+ token = b64decode(token).decode('utf-8')
+ url, token = token.split('|')
+ try:
+ subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, '', token, 'noreboot'])
+ m.send_alldevices()
+ image = get_resource_path('hw_drivers', 'static/img', 'True.jpg')
+ except subprocess.CalledProcessError as e:
+ _logger.error('A error encountered : %s ' % e.output)
+ if os.path.isfile(image):
+ with open(image, 'rb') as f:
+ return f.read()
#----------------------------------------------------------
-# Bluetooth drivers
+# Drivers
#----------------------------------------------------------
-btdrivers = []
-class BtMetaClass(type):
+drivers = []
+bt_devices = {}
+iot_devices = {}
+
+class DriverMetaClass(type):
def __new__(cls, clsname, bases, attrs):
- newclass = super(BtMetaClass, cls).__new__(cls, clsname, bases, attrs)
- btdrivers.append(newclass)
+ newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs)
+ drivers.append(newclass)
return newclass
+class Driver(Thread, metaclass=DriverMetaClass):
+ """
+ Hook to register the driver into the drivers list
+ """
+ connection_type = ""
-class BtDriver(Driver, metaclass=BtMetaClass):
-
-
- def __init__(self, device, manager):
- super(BtDriver, self).__init__()
+ def __init__(self, device):
+ super(Driver, self).__init__()
self.dev = device
- self.manager = manager
- self.value = ''
+ self.data = {'value': ''}
self.gatt_device = False
- def disconnect(self):
- path = "bt_%s" % (self.dev.mac_address,)
- del drivers[path]
+ @property
+ def device_name(self):
+ return self._device_name
+
+ @property
+ def device_identifier(self):
+ return self._device_identifier
+
+ @property
+ def device_connection(self):
+ """
+ On specific driver override this method to give connection type of device
+ return string
+ possible value : direct - network - bluetooth
+ """
+ return self._device_connection
+
+ @property
+ def device_type(self):
+ """
+ On specific driver override this method to give type of device
+ return string
+ possible value : printer - camera - device
+ """
+ return self._device_type
+
+ @classmethod
+ def supported(cls, device):
+ """
+ On specific driver override this method to check if device is supported or not
+ return True or False
+ """
+ pass
- def get_name(self):
- return self.dev.alias()
+ def get_message(self):
+ return ''
- def value(self):
- return self.value
+ def action(self, data):
+ """
+ On specific driver override this method to make a action with device (take picture, printing,...)
+ """
+ raise NotImplementedError()
- def action(self, action):
- pass
+ def disconnect(self):
+ del iot_devices[self.device_identifier]
- def get_connection(self):
- return 'bluetooth'
- def connect(self):
- pass
+#----------------------------------------------------------
+# Device manager
+#----------------------------------------------------------
+class EventManager(object):
+ def __init__(self):
+ self.sessions = {}
+
+ def _delete_expired_sessions(self, max_time=70):
+ '''
+ Clears sessions that are no longer called.
+
+ :param max_time: time a session can stay unused before being deleted
+ '''
+ now = time.time()
+ expired_sessions = [session for session in self.sessions if now - self.sessions[session]['time_request'] > max_time]
+ for session in expired_sessions:
+ del self.sessions[session]
+
+ def add_request(self, listener):
+ self.session = {
+ 'session_id': listener['session_id'],
+ 'devices': listener['devices'],
+ 'event': Event(),
+ 'result': {},
+ 'time_request': time.time(),
+ }
+ self._delete_expired_sessions()
+ self.sessions[listener['session_id']] = self.session
+ return self.sessions[listener['session_id']]
+
+ def device_changed(self, device):
+ for session in self.sessions:
+ if device.device_identifier in self.sessions[session]['devices']:
+ self.sessions[session]['result'] = device.data
+ self.sessions[session]['result']['device_id'] = device.device_identifier
+ self.sessions[session]['event'].set()
+
+
+class IoTDevice(object):
+
+ def __init__(self, dev, connection_type):
+ self.dev = dev
+ self.connection_type = connection_type
+event_manager = EventManager()
+#----------------------------------------------------------
+# Manager
+#----------------------------------------------------------
+class Manager(Thread):
+
+ def __init__(self):
+ super(Manager, self).__init__()
+ self.load_drivers()
+
+ def load_drivers(self):
+ """
+ This method loads local files: 'odoo/addons/hw_drivers/drivers'
+ And execute these python drivers
+ """
+ path = get_resource_path('hw_drivers', 'drivers')
+ driversList = os.listdir(path)
+ for driver in driversList:
+ path_file = os.path.join(path, driver)
+ spec = util.spec_from_file_location(driver, path_file)
+ if spec:
+ module = util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ def send_alldevices(self):
+ """
+ This method send IoT Box and devices informations to Odoo database
+ """
+ server = get_odoo_server_url()
+ if server:
+ iot_box = {
+ 'name': socket.gethostname(),
+ 'identifier': get_mac_address(),
+ 'ip': get_ip(),
+ 'token': get_token(),
+ 'version': get_version()
+ }
+ devices_list = {}
+ for device in iot_devices:
+ identifier = iot_devices[device].device_identifier
+ devices_list[identifier] = {
+ 'name': iot_devices[device].device_name,
+ 'type': iot_devices[device].device_type,
+ 'connection': iot_devices[device].device_connection,
+ }
+ data = {
+ 'params': {
+ 'iot_box' : iot_box,
+ 'devices' : devices_list,
+ }
+ }
+ # disable certifiacte verification
+ urllib3.disable_warnings()
+ http = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ try:
+ http.request(
+ 'POST',
+ server + "/iot/setup",
+ body = json.dumps(data).encode('utf8'),
+ headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
+ )
+ except Exception as e:
+ _logger.error('Could not reach configured server')
+ _logger.error('A error encountered : %s ' % e)
+ else:
+ _logger.warning('Odoo server not set')
+
+ def usb_loop(self):
+ usb_devices = {}
+ devs = core.find(find_all=True)
+ for dev in devs:
+ path = "usb_%04x:%04x_%03d_%03d_" % (dev.idVendor, dev.idProduct, dev.bus, dev.address)
+ iot_device = IoTDevice(dev, 'usb')
+ usb_devices[path] = iot_device
+ return usb_devices
+
+ def video_loop(self):
+ camera_devices = {}
+ videos = glob('/dev/video*')
+ for video in videos:
+ with open(video, 'w') as path:
+ dev = v4l2.v4l2_capability()
+ ioctl(path, v4l2.VIDIOC_QUERYCAP, dev)
+ dev.interface = video
+ iot_device = IoTDevice(dev, 'video')
+ camera_devices[dev.bus_info.decode('utf-8')] = iot_device
+ return camera_devices
+
+ def printer_loop(self):
+ printer_devices = {}
+ devices = conn.getDevices()
+ for path in [printer_lo for printer_lo in devices if devices[printer_lo]['device-make-and-model'] != 'Unknown']:
+ if 'uuid=' in path:
+ serial = sub('[^a-zA-Z0-9 ]+', '', path.split('uuid=')[1])
+ elif 'serial=' in path:
+ serial = sub('[^a-zA-Z0-9 ]+', '', path.split('serial=')[1])
+ else:
+ serial = sub('[^a-zA-Z0-9 ]+', '', path)
+ devices[path]['identifier'] = serial
+ devices[path]['url'] = path
+ iot_device = IoTDevice(devices[path], 'printer')
+ printer_devices[serial] = iot_device
+ return printer_devices
-class USBDeviceManager(Thread):
- devices = {}
def run(self):
- first_time = True
- send_iot_box_device(False)
+ """
+ Thread that will check connected/disconnected device, load drivers if needed and contact the odoo server with the updates
+ """
+ devices = {}
+ updated_devices = {}
+ self.send_alldevices()
+ cpt = 0
while 1:
- sendJSON = False
- devs = usb.core.find(find_all=True)
- updated_devices = {}
- for dev in devs:
- path = "usb_%04x:%04x_%03d_%03d_" % (dev.idVendor, dev.idProduct, dev.bus, dev.address)
- updated_devices[path] = self.devices.get(path, dev)
- added = updated_devices.keys() - self.devices.keys()
- removed = self.devices.keys() - updated_devices.keys()
- self.devices = updated_devices
- if (removed):
- for path in list(drivers):
- if (path in removed):
- del drivers[path]
- sendJSON = True
- for path in added:
- dev = updated_devices[path]
- for driverclass in usbdrivers:
- d = driverclass(updated_devices[path])
- if d.supported():
+ updated_devices = self.usb_loop()
+ updated_devices.update(self.video_loop())
+ updated_devices.update(bt_devices)
+ if cpt % 40 == 0:
+ printer_devices = self.printer_loop()
+ cpt = 0
+ updated_devices.update(printer_devices)
+ cpt += 1
+ added = updated_devices.keys() - devices.keys()
+ removed = devices.keys() - updated_devices.keys()
+ devices = updated_devices
+ for path in [device_rm for device_rm in removed if device_rm in iot_devices]:
+ iot_devices[path].disconnect()
+ for path in [device_add for device_add in added if device_add not in iot_devices]:
+ for driverclass in [d for d in drivers if d.connection_type == devices[path].connection_type]:
+ if driverclass.supported(device = updated_devices[path].dev):
_logger.info('For device %s will be driven', path)
- drivers[path] = d
- # launch thread
+ d = driverclass(device = updated_devices[path].dev)
d.daemon = True
d.start()
- sendJSON = True
- if sendJSON or first_time:
- send_iot_box_device(send_printer = first_time)
- first_time = False
+ iot_devices[path] = d
+ self.send_alldevices()
+ break
time.sleep(3)
-
+class GattBtManager(Gatt_DeviceManager):
+
+ def device_discovered(self, device):
+ path = "bt_%s" % (device.mac_address,)
+ if path not in bt_devices:
+ device.manager = self
+ iot_device = IoTDevice(device, 'bluetooth')
+ bt_devices[path] = iot_device
+class BtManager(Thread):
+ def run(self):
+ dm = GattBtManager(adapter_name='hci0')
+ for device in [device_con for device_con in dm.devices() if device_con.is_connected()]:
+ device.disconnect()
+ dm.start_discovery()
+ dm.run()
-def send_iot_box_device(send_printer):
- maciotbox = subprocess.check_output("/sbin/ifconfig eth0 |grep -Eo ..\(\:..\){5}", shell=True).decode('utf-8').split('\n')[0]
- server = "" # read from file
- try:
- f = open('/home/pi/odoo-remote-server.conf', 'r')
- for line in f:
- server += line
- f.close()
- except: #In case the file does not exist
- server=''
- server = server.split('\n')[0]
- if server:
- url = server + "/iot/setup"
- interfaces = ni.interfaces()
- for iface_id in interfaces:
- iface_obj = ni.ifaddresses(iface_id)
- ifconfigs = iface_obj.get(ni.AF_INET, [])
- for conf in ifconfigs:
- if conf.get('addr') and conf.get('addr') != '127.0.0.1':
- ips = conf.get('addr')
- break
-
- # Build device JSON
- devicesList = {}
- for path in drivers:
- device_name = drivers[path].get_name()
- device_connection = drivers[path].get_connection()
- identifier = path.split('_')[0] + '_' + path.split('_')[1]
- devicesList[identifier] = {'name': device_name,
- 'type': 'device',
- 'connection': device_connection}
-
- # Build camera JSON
- try:
- cameras = subprocess.check_output("v4l2-ctl --list-devices", shell=True).decode('utf-8').split('\n\n')
- for camera in cameras:
- if camera:
- camera = camera.split('\n\t')
- serial = re.sub('[^a-zA-Z0-9 ]+', '', camera[0].split(': ')[0]).replace(' ','_')
- devicesList[serial] = {
- 'name': camera[0].split(': ')[0],
- 'connection': 'direct',
- 'type': 'camera'
- }
- except:
- pass
-
- # Build printer JSON
- printerList = {}
- if send_printer:
- printers = subprocess.check_output("sudo lpinfo -lv", shell=True).decode('utf-8').split('Device')
- printers_installed = ''
- try:
- printers_installed = subprocess.check_output("sudo lpstat -a", shell=True).decode('utf-8')
- except:
- pass
- for printer in printers:
- printerTab = printer.split('\n')
- if printer and printerTab[4].split('=')[1] != ' ' or 'lpd://' in printerTab[0]:
- device_connection = printerTab[1].split('= ')[1]
- model = ''
- for device_id in printerTab[4].split('= ')[1].split(';'):
- if any(x in device_id for x in ['MDL','MODEL']):
- model = device_id.split(':')[1]
- serial = re.sub('[^a-zA-Z0-9 ]+', '', model).replace(' ','_')
- identifier = ''
- if device_connection == 'direct':
- identifier = serial + '_' + maciotbox #name + macIOTBOX
- elif device_connection == 'network' and 'socket' in printerTab[0]:
- socketIP = printerTab[0].split('://')[1]
- macprinter = subprocess.check_output("arp -a " + socketIP + " |awk NR==1'{print $4}'", shell=True).decode('utf-8').split('\n')[0]
- identifier = macprinter # macPRINTER
- elif device_connection == 'network' and 'lpd' in printerTab[0]:
- identifier = re.sub('[^a-zA-Z0-9 ]+', '', printerTab[0].split('://')[1])
- model = printerTab[2].split('= ')[1]
- elif device_connection == 'network' and 'dnssd' in printerTab[0]:
- hostname_printer = subprocess.check_output("ippfind -n \"" + model + "\" | awk \'{split($0,a,\"/\"); print a[3]}\' | awk \'{split($0,b,\":\"); print b[1]}\'", shell=True).decode('utf-8').split('\n')[0]
- if hostname_printer:
- macprinter = subprocess.check_output("arp -a " + hostname_printer + " |awk NR==1'{print $4}'", shell=True).decode('utf-8').split('\n')[0]
- identifier = macprinter # macprinter
-
- identifier = identifier.replace(':','_')
- if identifier and identifier not in printerList:
- printerList[identifier] = {
- 'name': model,
- 'connection': device_connection,
- 'type': 'printer'
- }
-
- if identifier not in printers_installed:
- # install these printers
- try:
- ppd = subprocess.check_output("sudo lpinfo -m |grep '" + model + "'", shell=True).decode('utf-8').split('\n')
- if len(ppd) > 2:
- subprocess.call("sudo lpadmin -p '" + identifier + "' -E -v '" + printerTab[0].split('= ')[1] + "'", shell=True)
- else:
- subprocess.call("sudo lpadmin -p '" + identifier + "' -E -v '" + printerTab[0].split('= ')[1] + "' -m '" + ppd[0].split(' ')[0] + "'", shell=True)
- except:
- subprocess.call("sudo lpadmin -p '" + identifier + "' -E -v '" + printerTab[0].split('= ')[1] + "'", shell=True)
- subprocess.call('> /tmp/printers', shell=True)
- for printer in printerList:
- subprocess.call('echo "' + printerList[printer]['name'] + '" >> /tmp/printers', shell=True)
-
- if devicesList:
- subprocess.call('> /tmp/devices', shell=True)
- for device in devicesList:
- subprocess.call('echo "' + str(device) + '|' + devicesList[device]['name'] + '" >> /tmp/devices', shell=True)
-
- #build JSON with all devices
- hostname = subprocess.check_output('hostname').decode('utf-8').split('\n')[0]
- token = "" # read from file
- try:
- f = open('/home/pi/token', 'r')
- for line in f:
- token += line
- f.close()
- except: #In case the file does not exist
- token=''
- token = token.split('\n')[0]
- data = {'name': hostname,'identifier': maciotbox, 'ip': ips, 'token': token}
- devicesList.update(printerList)
- data['devices'] = devicesList
- data_json = json.dumps(data).encode('utf8')
- headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
- urllib3.disable_warnings()
- http = urllib3.PoolManager(cert_reqs='CERT_NONE')
- req = False
- try:
- req = http.request('POST',
- url,
- body=data_json,
- headers=headers)
- except Exception as e:
- _logger.warning('Could not reach configured server')
- _logger.error('A error encountered : %s ' % e)
+conn = cups_connection()
+PPDs = conn.getPPDs()
+printers = conn.getPrinters()
+m = Manager()
+m.daemon = True
+m.start()
+bm = BtManager()
+bm.daemon = True
+bm.start()
diff --git a/addons/hw_drivers/controllers/loading.py b/addons/hw_drivers/controllers/loading.py
deleted file mode 100644
index cd02d3dd82f..00000000000
--- a/addons/hw_drivers/controllers/loading.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import importlib.util
-import os
-from .driver import BtManager
-from .driver import USBDeviceManager
-from odoo import modules
-
-driversList = os.listdir("/home/pi/odoo/addons/hw_drivers/drivers")
-for driver in driversList:
- path = "/home/pi/odoo/addons/hw_drivers/drivers/" + driver
- spec = importlib.util.spec_from_file_location(driver, path)
- if spec:
- foo = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(foo)
-
-
-if not getattr(modules, '_iot_daemon_started', False):
- # ----------------------------------------------------------
- # Bluetooth start
- # ----------------------------------------------------------
- bm = BtManager()
- bm.daemon = True
- bm.start()
-
- #----------------------------------------------------------
- #USB start
- #----------------------------------------------------------
-
- udm = USBDeviceManager()
- udm.daemon = True
- udm.start()
-
- # Did this because of the
- modules._iot_daemon_started = True
-
diff --git a/addons/hw_drivers/static/img/False.jpg b/addons/hw_drivers/static/img/False.jpg
new file mode 100644
index 00000000000..8f8e6489b30
--- /dev/null
+++ b/addons/hw_drivers/static/img/False.jpg
Binary files differ
diff --git a/addons/hw_drivers/static/img/True.jpg b/addons/hw_drivers/static/img/True.jpg
new file mode 100644
index 00000000000..0488574a699
--- /dev/null
+++ b/addons/hw_drivers/static/img/True.jpg
Binary files differ
diff --git a/addons/hw_posbox_homepage/controllers/main.py b/addons/hw_posbox_homepage/controllers/main.py
index f3de5a0d138..538b6acbf42 100644
--- a/addons/hw_posbox_homepage/controllers/main.py
+++ b/addons/hw_posbox_homepage/controllers/main.py
@@ -7,7 +7,7 @@ import jinja2
import subprocess
import socket
import sys
-import netifaces as ni
+import netifaces
import odoo
from odoo import http
import zipfile
@@ -15,12 +15,28 @@ import io
import os
from odoo.tools import misc
import urllib3
+from pathlib import Path
from uuid import getnode as get_mac
from odoo.addons.hw_proxy.controllers import main as hw_proxy
+from odoo.addons.web.controllers import main as web
+from odoo.modules.module import get_resource_path
+from odoo.addons.hw_drivers.controllers.driver import iot_devices, get_ip, get_odoo_server_url
_logger = logging.getLogger(__name__)
+
+#----------------------------------------------------------
+# Helper
+#----------------------------------------------------------
+
+def access_point():
+ return get_ip() == '10.11.12.1'
+
+#----------------------------------------------------------
+# Controllers
+#----------------------------------------------------------
+
if hasattr(sys, 'frozen'):
# When running on compiled windows binary, we don't have access to package loader.
path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'views'))
@@ -38,18 +54,7 @@ driver_list_template = jinja_env.get_template('driver_list.html')
remote_connect_template = jinja_env.get_template('remote_connect.html')
configure_wizard_template = jinja_env.get_template('configure_wizard.html')
-class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
-
- def get_ip_iotbox(self):
- interfaces = ni.interfaces()
- for iface_id in interfaces:
- iface_obj = ni.ifaddresses(iface_id)
- ifconfigs = iface_obj.get(ni.AF_INET, [])
- for conf in ifconfigs:
- if conf.get('addr') and conf.get('addr') != '127.0.0.1':
- ips = conf.get('addr')
- break
- return ips
+class IoTboxHomepage(web.Home):
def get_hw_screen_message(self):
return """
@@ -67,18 +72,6 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
statuses[driver] = hw_proxy.drivers[driver].get_status()
return statuses
- def get_server_status(self):
- server_template = ""
- try:
- f = open('/home/pi/odoo-remote-server.conf', 'r')
- for line in f:
- server_template += line
- f.close()
- except:
- return ''
-
- return server_template.split('\n')[0]
-
def get_homepage_data(self):
hostname = str(socket.gethostname())
mac = get_mac()
@@ -88,7 +81,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
if wired == 'up':
network = 'Ethernet'
elif ssid:
- if self.get_ip_iotbox() == '10.11.12.1':
+ if access_point():
network = 'Wifi access point'
else:
network = 'Wifi : ' + ssid
@@ -105,70 +98,33 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
'message': ' '.join(pos_device[status]['messages'])
})
- hdmi_name = subprocess.check_output('tvservice -n', shell=True).decode('utf-8')
- if hdmi_name.find('=') != -1:
- hdmi_name = hdmi_name.split('=')[1]
- hdmi_message = subprocess.check_output('tvservice -s', shell=True).decode('utf-8')
+ for device in iot_devices:
iot_device.append({
- 'name': 'display : ' + hdmi_name,
- 'type': 'device',
- 'message': hdmi_message
- })
-
- try:
- f = open('/tmp/devices', 'r')
- for line in f:
- url = 'http://' + self.get_ip_iotbox() + ':8069/hw_drivers/driverdetails/' + line.split('|')[0]
- urllib3.disable_warnings()
- http = urllib3.PoolManager(cert_reqs='CERT_NONE')
- value = ''
- try:
- req = http.request('GET', url)
- except Exception as e:
- req = ''
- _logger.warning('Could not reach configured server')
- _logger.error('A error encountered : %s ' % e)
-
- if req:
- value = req.data.decode('utf-8')
- iot_device.append({
- 'name': line.split('|')[1],
- 'message': line.split('|')[0] + ' : ' + value,
- 'type': 'device',
- })
- f.close()
- except:
- pass
-
- try:
- f = open('/tmp/printers', 'r')
- for line in f:
- iot_device.append({
- 'name': line,
- 'type': 'printer',
- })
- f.close()
- except:
- pass
+ 'name': iot_devices[device].device_name + ' : ' + str(iot_devices[device].data['value']),
+ 'type': iot_devices[device].device_type,
+ 'message': iot_devices[device].device_identifier + iot_devices[device].get_message()
+ })
return {
'hostname': hostname,
- 'ip': self.get_ip_iotbox(),
+ 'ip': get_ip(),
'mac': ":".join(i + next(h) for i in h),
'iot_device_status': iot_device,
- 'server_status': self.get_server_status() or 'Not Configured',
+ 'server_status': get_odoo_server_url() or 'Not Configured',
'network_status': network,
}
@http.route('/', type='http', auth='none')
def index(self):
- if (os.path.isfile('/home/pi/wifi_network.txt') == False or os.path.isfile('/home/pi/odoo-remote-server.conf') == False) and self.get_ip_iotbox() == '10.11.12.1':
+ wifi = Path.home() / 'wifi_network.txt'
+ remote_server = Path.home() / 'odoo-remote-server.conf'
+ if (wifi.exists() == False or remote_server.exists() == False) and access_point():
return configure_wizard_template.render({
'title': 'Configure IoT Box',
'breadcrumb': 'Configure IoT Box',
'loading_message': 'Configuring your IoT Box',
'ssid': self.get_wifi_essid(),
- 'server': self.get_server_status(),
+ 'server': get_odoo_server_url(),
'hostname': subprocess.check_output('hostname').decode('utf-8'),
})
else:
@@ -177,7 +133,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
@http.route('/list_drivers', type='http', auth='none', website=True)
def list_drivers(self):
drivers_list = []
- for driver in os.listdir("/home/pi/odoo/addons/hw_drivers/drivers"):
+ for driver in os.listdir(get_resource_path('hw_drivers', 'drivers')):
if driver != '__pycache__':
drivers_list.append(driver)
return driver_list_template.render({
@@ -188,13 +144,13 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
@http.route('/load_drivers', type='http', auth='none', website=True)
def load_drivers(self):
- subprocess.call("sudo mount -o remount,rw /", shell=True)
- subprocess.call("sudo mount -o remount,rw /root_bypass_ramdisks", shell=True)
+ subprocess.check_call("sudo mount -o remount,rw /", shell=True)
+ subprocess.check_call("sudo mount -o remount,rw /root_bypass_ramdisks", shell=True)
mac = subprocess.check_output("/sbin/ifconfig eth0 |grep -Eo ..\(\:..\){5}", shell=True).decode('utf-8').split('\n')[0]
#response = requests.get(url, auth=(username, db_uuid.split('\n')[0]), stream=True)
- server = self.get_server_status()
+ server = get_odoo_server_url()
if server:
urllib3.disable_warnings()
pm = urllib3.PoolManager(cert_reqs='CERT_NONE')
@@ -205,17 +161,16 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
server,
fields={'mac': mac})
except Exception as e:
- req = ''
- _logger.warning('Could not reach configured server')
+ _logger.error('Could not reach configured server')
_logger.error('A error encountered : %s ' % e)
if resp and resp.data:
zip_file = zipfile.ZipFile(io.BytesIO(resp.data))
- zip_file.extractall("/home/pi/odoo/addons/hw_drivers/drivers")
- subprocess.call("sudo service odoo restart", shell=True)
- subprocess.call("sudo mount -o remount,ro /", shell=True)
- subprocess.call("sudo mount -o remount,ro /root_bypass_ramdisks", shell=True)
+ zip_file.extractall(get_resource_path('hw_drivers', 'drivers'))
+ subprocess.check_call("sudo service odoo restart", shell=True)
+ subprocess.check_call("sudo mount -o remount,ro /", shell=True)
+ subprocess.check_call("sudo mount -o remount,ro /root_bypass_ramdisks", shell=True)
- return "<meta http-equiv='refresh' content='20; url=http://" + self.get_ip_iotbox() + ":8069/list_drivers'>"
+ return "<meta http-equiv='refresh' content='20; url=http://" + get_ip() + ":8069/list_drivers'>"
def get_wifi_essid(self):
wifi_options = []
@@ -247,8 +202,8 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
else:
persistent = ""
- subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_wifi.sh', essid, password, persistent])
- server = self.get_server_status()
+ subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_wifi.sh'), essid, password, persistent])
+ server = get_odoo_server_url()
res_payload = {
'message': 'Connecting to ' + essid,
}
@@ -262,29 +217,29 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
@http.route('/wifi_clear', type='http', auth='none', cors='*', csrf=False)
def clear_wifi_configuration(self):
- os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/clear_wifi_configuration.sh')
+ os.system(get_resource_path('point_of_sale', 'tools/posbox/configuration/clear_wifi_configuration.sh'))
- return "<meta http-equiv='refresh' content='0; url=http://" + self.get_ip_iotbox() + ":8069'>"
+ return "<meta http-equiv='refresh' content='0; url=http://" + get_ip() + ":8069'>"
@http.route('/server_clear', type='http', auth='none', cors='*', csrf=False)
def clear_server_configuration(self):
- os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/clear_server_configuration.sh')
+ os.system(get_resource_path('point_of_sale', 'tools/posbox/configuration/clear_server_configuration.sh'))
- return "<meta http-equiv='refresh' content='0; url=http://" + self.get_ip_iotbox() + ":8069'>"
+ return "<meta http-equiv='refresh' content='0; url=http://" + get_ip() + ":8069'>"
@http.route('/drivers_clear', type='http', auth='none', cors='*', csrf=False)
def clear_drivers_list(self):
- os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/clear_drivers_list.sh')
+ os.system(get_resource_path('point_of_sale', 'tools/posbox/configuration/clear_drivers_list.sh'))
- return "<meta http-equiv='refresh' content='0; url=http://" + self.get_ip_iotbox() + ":8069/list_drivers'>"
+ return "<meta http-equiv='refresh' content='0; url=http://" + get_ip() + ":8069/list_drivers'>"
@http.route('/server_connect', type='http', auth='none', cors='*', csrf=False)
def connect_to_server(self, token, iotname):
url = token.split('|')[0]
token = token.split('|')[1]
reboot = 'reboot'
- subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server.sh', url, iotname, token, reboot])
- return 'http://' + self.get_ip_iotbox() + ':8069'
+ subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, iotname, token, reboot])
+ return 'http://' + get_ip() + ':8069'
@http.route('/steps', type='http', auth='none', cors='*', csrf=False)
def step_by_step_configure_page(self):
@@ -293,7 +248,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
'breadcrumb': 'Configure IoT Box',
'loading_message': 'Configuring your IoT Box',
'ssid': self.get_wifi_essid(),
- 'server': self.get_server_status(),
+ 'server': get_odoo_server_url(),
'hostname': subprocess.check_output('hostname').decode('utf-8').strip('\n'),
})
@@ -304,7 +259,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
token = token.split('|')[1]
else:
url = ''
- subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server_wifi.sh',url, iotname, token, essid, password, persistent])
+ subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server_wifi.sh'), url, iotname, token, essid, password, persistent])
return url
# Set server address
@@ -314,12 +269,19 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
'title': 'IoT -> Odoo server configuration',
'breadcrumb': 'Configure Odoo Server',
'hostname': subprocess.check_output('hostname').decode('utf-8').strip('\n'),
- 'server_status': self.get_server_status() or 'Not configured yet',
+ 'server_status': get_odoo_server_url() or 'Not configured yet',
'loading_message': 'Configure Domain Server'
})
@http.route('/remote_connect', type='http', auth='none', cors='*')
def remote_connect(self):
+ """
+ Establish a link with a customer box trough internet with a ssh tunnel
+ 1 - take a new auth_token on https://dashboard.ngrok.com/
+ 2 - copy past this auth_token on the IoT Box : http://IoT_Box:8069/remote_connect
+ 3 - check on ngrok the port and url to get access to the box
+ 4 - you can connect to the box with this command : ssh -p port -v pi@url
+ """
return remote_connect_template.render({
'title': 'Remote debugging',
'breadcrumb': 'Remote Debugging',
diff --git a/addons/hw_posbox_homepage/views/homepage.html b/addons/hw_posbox_homepage/views/homepage.html
index 71c0f26c565..b14573e9805 100644
--- a/addons/hw_posbox_homepage/views/homepage.html
+++ b/addons/hw_posbox_homepage/views/homepage.html
@@ -28,6 +28,7 @@
}
.device-status .message {
font-size: 0.8rem;
+ max-width: 350px;
}
.device-status .indicator {
margin-left: 4px;
@@ -90,7 +91,7 @@
</tr>
<tr>
<td class="heading">Version</td>
- <td>V18.12 <a class="btn btn-sm float-right" href='/hw_proxy/upgrade/'>update</a></td>
+ <td>V19.04 <a class="btn btn-sm float-right" href='/hw_proxy/upgrade/'>update</a></td>
</tr>
<tr>
<td class="heading">IP Address</td>
diff --git a/addons/hw_posbox_homepage/views/remote_connect.html b/addons/hw_posbox_homepage/views/remote_connect.html
index e83ced4653c..ab9628ec36a 100644
--- a/addons/hw_posbox_homepage/views/remote_connect.html
+++ b/addons/hw_posbox_homepage/views/remote_connect.html
@@ -31,7 +31,7 @@
{% block content %}
<h2 class="text-center">Remote Debugging</h2>
<p class='text-red'>
- This allows someone to gain remote access to your IoT Box, and
+ This allows someone who give a ngrok authtoken to gain remote access to your IoT Box, and
thus your entire local network. Only enable this for someone
you trust.
</p>
diff --git a/addons/l10n_generic_coa/data/account_invoice_demo.xml b/addons/l10n_generic_coa/data/account_invoice_demo.xml
index 90ec3240e54..667c2445054 100644
--- a/addons/l10n_generic_coa/data/account_invoice_demo.xml
+++ b/addons/l10n_generic_coa/data/account_invoice_demo.xml
@@ -191,10 +191,10 @@
('user_type_id', '=', ref('account.data_account_type_expenses')),
('company_id', '=', obj().env['res.company']._company_default_get('account.account').id)]"/>
<field name="invoice_line_tax_ids" model="account.tax"
- eval="[(6, 0, [obj().search([
+ eval="[(6, 0, obj().search([
('type_tax_use', '=', 'purchase'),
('amount', '&gt;', 0),
- ('company_id', '=', obj().env['res.company']._company_default_get('account.tax').id)], limit=1).id])]"/>
+ ('company_id', '=', obj().env['res.company']._company_default_get('account.tax').id)], limit=1).ids)]"/>
</record>
<function model="account.invoice" name="_onchange_invoice_line_ids">
diff --git a/addons/l10n_in/views/report_invoice.xml b/addons/l10n_in/views/report_invoice.xml
index b71c2b5cfea..52bd759dafc 100644
--- a/addons/l10n_in/views/report_invoice.xml
+++ b/addons/l10n_in/views/report_invoice.xml
@@ -28,6 +28,7 @@
<xpath expr="//table[@name='invoice_line_table']/thead/tr" position="inside">
<t t-if="o.company_id.country_id.code == 'IN'">
+ <th>HSN/SAC<t t-set="colspan" t-value="colspan+1"/></th>
<th class="text-right" t-if="is_cgst">CGST<t t-set="colspan" t-value="colspan+1"/></th>
<th class="text-right" t-if="is_cgst">SGST<t t-set="colspan" t-value="colspan+1"/></th>
<th class="text-right" t-if="is_igst">IGST<t t-set="colspan" t-value="colspan+1"/></th>
@@ -35,11 +36,10 @@
</t>
</xpath>
- <xpath expr="//td[@name='account_invoice_line_name']" position="after">
- <t t-if="line.product_id.l10n_in_hsn_code"><h6><strong class="ml16">HSN/SAC Code:</strong> <span t-field="line.product_id.l10n_in_hsn_code"/></h6></t>
- </xpath>
-
<xpath expr="//t[@name='account_invoice_line_accountable']" position="inside">
+ <td>
+ <span t-if="line.product_id.l10n_in_hsn_code" t-field="line.product_id.l10n_in_hsn_code"></span>
+ </td>
<t t-if="o.company_id.country_id.code == 'IN'">
<td class="text-right" t-if="is_cgst">
<t t-foreach="tax_datas[line.id]" t-as="tax_data">
diff --git a/addons/l10n_jp/data/account.tax.template.csv b/addons/l10n_jp/data/account.tax.template.csv
index 1b8e083b42d..a9ef3e9ae8e 100644
--- a/addons/l10n_jp/data/account.tax.template.csv
+++ b/addons/l10n_jp/data/account.tax.template.csv
@@ -1,6 +1,6 @@
id,name,description,chart_template_id/id,type_tax_use,amount_type,amount,price_include,sequence,include_base_amount,account_id/id,refund_account_id/id,tag_ids/id,tax_group_id/id
-tax_in_e,仮受消費税(外),仮受消費税(外),l10n_jp1,sale,percent,8,FALSE,1,FALSE,A21807,A21807,"tag_jp_03,tag_jp_12",tax_group_8
-tax_in_i,仮受消費税(内),仮受消費税(内),l10n_jp1,sale,percent,8,TRUE,1,FALSE,A21807,A21807,"tag_jp_03,tag_jp_12",tax_group_8
+tax_in_e,仮受消費税(外),仮受消費税(外),l10n_jp1,sale,percent,8,FALSE,1,FALSE,A21809,A21809,"tag_jp_03,tag_jp_12",tax_group_8
+tax_in_i,仮受消費税(内),仮受消費税(内),l10n_jp1,sale,percent,8,TRUE,1,FALSE,A21809,A21809,"tag_jp_03,tag_jp_12",tax_group_8
tax_in_x,輸出免税,輸出免税,l10n_jp1,sale,percent,,FALSE,1,FALSE,,,"tag_jp_04,tag_jp_13",tax_group_0
tax_in_o,非課税販売,非課税販売,l10n_jp1,sale,percent,,FALSE,1,FALSE,,,"tag_jp_09,tag_jp_14",tax_group_0
tax_out_e,仮払消費税(外),仮払消費税(外),l10n_jp1,purchase,percent,8,FALSE,1,FALSE,A11807,A11807,"tag_jp_07,tag_jp_16",tax_group_8
diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py
index 08ecb01a665..2f6e3731faa 100644
--- a/addons/l10n_multilang/models/l10n_multilang.py
+++ b/addons/l10n_multilang/models/l10n_multilang.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from collections import defaultdict
import logging
from odoo import api, models
@@ -88,20 +89,33 @@ class AccountChartTemplate(models.Model):
def _get_template_from_model(self, company_id, model):
""" Find the records and their matching template """
- # genereated records have an external id with the format <company id>_<template id>
- out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')])
- out_ids = self.env[model].browse(out_data.mapped('res_id'))
-
- # templates and records may have been created in a different order
- # reorder them based on external id names
- in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')]
- in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)}
-
- in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', list(in_xml_id_names))])
- in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
-
- in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
- return (in_ids, out_ids)
+ # generated records have an external id with the format <company id>_<template xml id>
+ grouped_out_data = defaultdict(lambda: self.env['ir.model.data'])
+ for imd in self.env['ir.model.data'].search([
+ ('model', '=', model),
+ ('name', '=like', str(company_id) + '_%')
+ ]):
+ grouped_out_data[imd.module] += imd
+
+ in_records = self.env[model + '.template']
+ out_records = self.env[model]
+ for module, out_data in grouped_out_data.items():
+ # templates and records may have been created in a different order
+ # reorder them based on external id names
+ expected_in_xml_id_names = {xml_id.name.partition(str(company_id) + '_')[-1]: xml_id for xml_id in out_data}
+
+ in_xml_ids = self.env['ir.model.data'].search([
+ ('model', '=', model + '.template'),
+ ('module', '=', module),
+ ('name', 'in', list(expected_in_xml_id_names))
+ ])
+ in_xml_ids = {xml_id.name: xml_id for xml_id in in_xml_ids}
+
+ for name, xml_id in expected_in_xml_id_names.items():
+ in_records += self.env[model + '.template'].browse(in_xml_ids[name].res_id)
+ out_records += self.env[model].browse(xml_id.res_id)
+
+ return (in_records, out_records)
class BaseLanguageInstall(models.TransientModel):
""" Install Language"""
diff --git a/addons/l10n_mx/data/account_tax_data.xml b/addons/l10n_mx/data/account_tax_data.xml
index 12555ba9ed4..5bc9932a5a0 100644
--- a/addons/l10n_mx/data/account_tax_data.xml
+++ b/addons/l10n_mx/data/account_tax_data.xml
@@ -34,6 +34,10 @@
<field name="name">DIOT: 8%</field>
<field name="applicability">taxes</field>
</record>
+ <record id="tag_diot_8_non_cre" model="account.account.tag">
+ <field name="name">DIOT: 8% NO ACREDITABLE</field>
+ <field name="applicability">taxes</field>
+ </record>
<record id="tag_diot_ret" model="account.account.tag">
<field name="name">DIOT: Retención</field>
<field name="applicability">taxes</field>
diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py
index 7aff20cf59e..f758456c77e 100644
--- a/addons/mail/models/mail_mail.py
+++ b/addons/mail/models/mail_mail.py
@@ -162,14 +162,14 @@ class MailMail(models.Model):
failed = self.env['mail.notification']
if failure_type:
failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids)
- failed.write({
+ failed.sudo().write({
'email_status': 'exception',
'failure_type': failure_type,
'failure_reason': failure_reason,
})
messages = notifications.mapped('mail_message_id').filtered(lambda m: m.res_id and m.model)
messages._notify_failure_update() # notify user that we have a failure
- (notifications - failed).write({
+ (notifications - failed).sudo().write({
'email_status': 'sent',
})
if not failure_type or failure_type == 'RECIPIENT': # if we have another error, we want to keep the mail.
@@ -338,7 +338,7 @@ class MailMail(models.Model):
])
if notifs:
notif_msg = _('Error without exception. Probably due do concurrent access update of notification records. Please see with an administrator.')
- notifs.write({
+ notifs.sudo().write({
'email_status': 'exception',
'failure_type': 'UNKNOWN',
'failure_reason': notif_msg,
diff --git a/addons/mail/static/src/js/document_viewer.js b/addons/mail/static/src/js/document_viewer.js
index 338b5889828..8b6f27b480e 100644
--- a/addons/mail/static/src/js/document_viewer.js
+++ b/addons/mail/static/src/js/document_viewer.js
@@ -44,11 +44,10 @@ var DocumentViewer = Widget.extend({
this._super.apply(this, arguments);
this.attachment = _.filter(attachments, function (attachment) {
var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)");
-
if (match) {
- attachment.type = match[1];
+ attachment.fileType = match[1];
if (match[1].match("(.png|.jpg|.gif)")) {
- attachment.type = 'image';
+ attachment.fileType = 'image';
}
if (match[1] === 'youtu') {
var youtube_array = attachment.url.split('/');
diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml
index 9f490bba0b5..2a5fd7ee30b 100644
--- a/addons/mail/static/src/xml/thread.xml
+++ b/addons/mail/static/src/xml/thread.xml
@@ -38,9 +38,9 @@
<t t-set="model" t-value="widget.modelName"/>
<div class="o_viewer-header">
<span class="o_image_caption">
- <i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.type == 'image'" role="img" aria-label="Image" title="Image"/>
- <i class="fa fa-file-text mr8" t-if="widget.activeAttachment.type == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
- <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.type == 'video'" role="img" aria-label="Video" title="Video"/>
+ <i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
+ <i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
+ <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
<t t-esc="widget.activeAttachment.name"/>
<a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
</span>
@@ -48,19 +48,19 @@
</div>
<div class="o_viewer_img_wrapper">
<div class="o_viewer_zoomer">
- <div t-if="widget.activeAttachment.type == 'image'" class="o_loading_img text-center">
+ <div t-if="widget.activeAttachment.fileType == 'image'" class="o_loading_img text-center">
<i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
</div>
- <img t-if="widget.activeAttachment.type === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&amp;signature=#{widget.activeAttachment.checksum}&amp;model=#{model}" alt="Viewer"/>
- <iframe t-if="widget.activeAttachment.type == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
- <iframe t-if="(widget.activeAttachment.type || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
- <iframe t-if="widget.activeAttachment.type == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
- <video t-if="widget.activeAttachment.type == 'video'" class="o_viewer_video" controls="controls">
+ <img t-if="widget.activeAttachment.fileType === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&amp;signature=#{widget.activeAttachment.checksum}&amp;model=#{model}" alt="Viewer"/>
+ <iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
+ <iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
+ <iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
+ <video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
<source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
</video>
</div>
</div>
- <div t-if="widget.activeAttachment.type == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
+ <div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
<div class="btn-group ml0" role="group">
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
diff --git a/addons/mail/static/tests/document_viewer_tests.js b/addons/mail/static/tests/document_viewer_tests.js
index 87fa2dbed94..b7ba9763386 100644
--- a/addons/mail/static/tests/document_viewer_tests.js
+++ b/addons/mail/static/tests/document_viewer_tests.js
@@ -206,5 +206,24 @@ QUnit.module('DocumentViewer', {
assert.ok(viewer.isDestroyed(), 'viewer should be destroyed');
});
+
+ QUnit.test('fileType and integrity test', function (assert) {
+ assert.expect(3);
+
+ var viewer = createViewer({
+ attachmentID: 2,
+ attachments: this.attachments,
+ });
+
+ assert.strictEqual(this.attachments[1].type, 'url',
+ "the type should be url");
+ assert.strictEqual(this.attachments[1].fileType, 'youtu',
+ "there should be a fileType 'youtu'");
+ assert.strictEqual(this.attachments[1].youtube, 'FYqW0Gdwbzk',
+ "there should be a youtube token");
+
+ viewer.destroy();
+ });
+
});
});
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 11c96450c2b..6ff131130f5 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -743,6 +743,7 @@ class MrpProduction(models.Model):
})
if workorders:
workorders[-1].next_work_order_id = workorder.id
+ workorders[-1]._init_nextworkorder_states()
workorders += workorder
# assign moves; last operation receive all unassigned moves (which case ?)
diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py
index 136537c13f7..0452dd0b370 100644
--- a/addons/mrp/models/mrp_workorder.py
+++ b/addons/mrp/models/mrp_workorder.py
@@ -220,8 +220,7 @@ class MrpWorkorder(models.Model):
raise UserError(_('Please set the quantity you are currently producing. It should be different from zero.'))
# One a piece is produced, you can launch the next work order
- if self.next_work_order_id.state == 'pending':
- self.next_work_order_id.state = 'ready'
+ self._start_nextworkorder()
# If last work order, then post lots used
# TODO: should be same as checking if for every workorder something has been done?
@@ -258,6 +257,15 @@ class MrpWorkorder(models.Model):
return True
@api.multi
+ def _start_nextworkorder(self):
+ for record in self:
+ if record.next_work_order_id.state == 'pending':
+ record.next_work_order_id.state = 'ready'
+
+ def _init_nextworkorder_states(self):
+ return 'pending'
+
+ @api.multi
def button_start(self):
self.ensure_one()
# As button_start is automatically called in the new view
diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py
index 99827f021fe..f45dc86d3f9 100644
--- a/addons/mrp/tests/test_order.py
+++ b/addons/mrp/tests/test_order.py
@@ -574,6 +574,9 @@ class TestMrpOrder(TestMrpCommon):
'active_ids': [mo.id],
}))
produce_form.qty_producing = 1.0
+ for i in range(len(produce_form.workorder_line_ids)):
+ with produce_form.workorder_line_ids.edit(i) as line:
+ line.qty_done += 1
product_produce = produce_form.save()
product_produce.final_lot_id = final_product_lot.id
# product 1 lot 1 shelf1
@@ -581,8 +584,6 @@ class TestMrpOrder(TestMrpCommon):
# product 1 lot 2
self.assertEqual(len(product_produce.workorder_line_ids), 4, 'You should have 4 produce lines. lot 1 shelf_1, lot 1 shelf_2, lot2 and for product which have tracking None')
- for produce_line in product_produce.workorder_line_ids:
- produce_line.qty_done = produce_line.qty_to_consume + 1
product_produce.do_produce()
move_1 = mo.move_raw_ids.filtered(lambda m: m.product_id == p1)
diff --git a/addons/note/data/note_data.xml b/addons/note/data/note_data.xml
index cffb8b081f8..b4d2518f0c9 100644
--- a/addons/note/data/note_data.xml
+++ b/addons/note/data/note_data.xml
@@ -4,25 +4,25 @@
<record model="note.stage" id="note_stage_00">
<field name="name">New</field>
<field name="sequence">0</field>
- <field name="user_id" eval="ref('base.user_root')"/>
+ <field name="user_id" eval="ref('base.user_admin')"/>
</record>
<record model="note.stage" id="note_stage_01">
<field name="name">Meeting Minutes</field>
<field name="sequence">5</field>
- <field name="user_id" eval="ref('base.user_root')"/>
+ <field name="user_id" eval="ref('base.user_admin')"/>
</record>
<record model="note.stage" id="note_stage_02">
<field name="name">Notes</field>
<field name="sequence">10</field>
- <field name="user_id" eval="ref('base.user_root')"/>
+ <field name="user_id" eval="ref('base.user_admin')"/>
</record>
<record model="note.stage" id="note_stage_03">
<field name="name">Todo</field>
<field name="sequence">50</field>
- <field name="user_id" eval="ref('base.user_root')"/>
+ <field name="user_id" eval="ref('base.user_admin')"/>
</record>
</data>
</odoo>
diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py
index bca2164dac1..79d0a7589c5 100644
--- a/addons/payment_authorize/models/authorize_request.py
+++ b/addons/payment_authorize/models/authorize_request.py
@@ -148,7 +148,8 @@ class AuthorizeAPI():
"""
root = self._base_tree('createCustomerProfileRequest')
profile = etree.SubElement(root, "profile")
- etree.SubElement(profile, "merchantCustomerId").text = 'ODOO-%s-%s' % (partner.id, uuid4().hex[:8])
+ # merchantCustomerId is ODOO-{partner.id}-{random hex string} truncated to maximum 20 characters
+ etree.SubElement(profile, "merchantCustomerId").text = ('ODOO-%s-%s' % (partner.id, uuid4().hex[:8]))[:20]
etree.SubElement(profile, "email").text = partner.email or ''
payment_profile = etree.SubElement(profile, "paymentProfiles")
etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual'
@@ -220,7 +221,8 @@ class AuthorizeAPI():
root = self._base_tree('createCustomerProfileFromTransactionRequest')
etree.SubElement(root, "transId").text = transaction_id
customer = etree.SubElement(root, "customer")
- etree.SubElement(customer, "merchantCustomerId").text = 'ODOO-%s-%s' % (partner.id, uuid4().hex[:8])
+ # merchantCustomerId is ODOO-{partner.id}-{random hex string} truncated to maximum 20 characters
+ etree.SubElement(customer, "merchantCustomerId").text = ('ODOO-%s-%s' % (partner.id, uuid4().hex[:8]))[:20]
etree.SubElement(customer, "email").text = partner.email or ''
response = self._authorize_request(root)
res = dict()
diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py
index f5d1dc707d4..f934ff526da 100644
--- a/addons/point_of_sale/models/pos_session.py
+++ b/addons/point_of_sale/models/pos_session.py
@@ -273,6 +273,7 @@ class PosSession(models.Model):
for session in self:
company_id = session.config_id.company_id.id
ctx = dict(self.env.context, force_company=company_id, company_id=company_id)
+ ctx_notrack = dict(ctx, mail_notrack=True)
for st in session.statement_ids:
if abs(st.difference) > st.journal_id.amount_authorized_diff:
# The pos manager can close statements with maximums.
@@ -280,7 +281,7 @@ class PosSession(models.Model):
raise UserError(_("Your ending balance is too different from the theoretical cash closing (%.2f), the maximum allowed is: %.2f. You can contact your manager to force it.") % (st.difference, st.journal_id.amount_authorized_diff))
if (st.journal_id.type not in ['bank', 'cash']):
raise UserError(_("The journal type for your payment method should be bank or cash."))
- st.with_context(ctx).sudo().button_confirm_bank()
+ st.with_context(ctx_notrack).sudo().button_confirm_bank()
self.with_context(ctx)._confirm_orders()
self.write({'state': 'closed'})
return {
diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf
index ee6cbbf93d4..cefc3cf06ab 100644
--- a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf
+++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/cups/cupsd.conf
@@ -20,6 +20,9 @@ Listen /run/cups/cups.sock
Browsing On
BrowseLocalProtocols dnssd
+# Local printers are not shared by default
+DefaultShared No
+
# Default authentication type, when authentication is required...
DefaultAuthType Basic
diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default
index cd95a43753a..fc802829d69 100644
--- a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default
+++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/nginx/sites-enabled/default
@@ -85,7 +85,7 @@ server {
root /var/www/;
index index.html;
#
-# location / {
-# try_files $uri $uri/ =404;
-# }
+ location /hw_drivers/ {
+ proxy_pass http://127.0.0.1:8069;
+ }
}
diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart
index 96ea352c761..d7b5349a74f 100755
--- a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart
+++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart
@@ -15,6 +15,7 @@ if [ -e /home/pi/odoo-remote-server.conf ]
var=0
while [ ${var} -lt 30 ]; do
var=$((var+1))
+ sleep 1
status_code=$(wget ${URL_SERVER} --server-response 2>&1|grep 'HTTP/'|awk '{print $2}')
if [ ${status_code} -eq 200 ]
then
diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/home/pi/odoo/addons/point_of_sale/__manifest__.py b/addons/point_of_sale/tools/posbox/overwrite_after_init/home/pi/odoo/addons/point_of_sale/__manifest__.py
new file mode 100644
index 00000000000..67dee8c60db
--- /dev/null
+++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/home/pi/odoo/addons/point_of_sale/__manifest__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/usr/local/lib/python3.5/dist-packages/v4l2.py.iotpatch b/addons/point_of_sale/tools/posbox/overwrite_after_init/usr/local/lib/python3.5/dist-packages/v4l2.py.iotpatch
new file mode 100644
index 00000000000..5cc87a78c88
--- /dev/null
+++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/usr/local/lib/python3.5/dist-packages/v4l2.py.iotpatch
@@ -0,0 +1,20 @@
+--- v4l2_old.py 2019-01-23 12:49:01.081564000 +0100
++++ v4l2_new.py 2019-01-23 12:50:32.369730171 +0100
+@@ -194,7 +194,7 @@
+ V4L2_BUF_TYPE_SLICED_VBI_OUTPUT,
+ V4L2_BUF_TYPE_VIDEO_OUTPUT_OVERLAY,
+ V4L2_BUF_TYPE_PRIVATE,
+-) = range(1, 9) + [0x80]
++) = list(range(1, 9)) + [0x80]
+
+
+ v4l2_ctrl_type = enum
+@@ -245,7 +245,7 @@
+ V4L2_PRIORITY_INTERACTIVE,
+ V4L2_PRIORITY_RECORD,
+ V4L2_PRIORITY_DEFAULT,
+-) = range(0, 4) + [2]
++) = list(range(0, 4)) + [2]
+
+
+ class v4l2_rect(ctypes.Structure):
diff --git a/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh b/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh
index 85c1933f096..53ab15a8748 100755
--- a/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh
+++ b/addons/point_of_sale/tools/posbox/overwrite_before_init/etc/init_posbox_image.sh
@@ -28,6 +28,8 @@ PKGS_TO_INSTALL="
cups \
printer-driver-all \
cups-ipp-utils \
+ libcups2-dev \
+ pcscd \
localepurge \
vim \
mc \
@@ -37,6 +39,7 @@ PKGS_TO_INSTALL="
hostapd \
git \
rsync \
+ swig \
console-data \
lightdm \
xserver-xorg-video-fbdev \
@@ -49,7 +52,9 @@ PKGS_TO_INSTALL="
rpi-update \
adduser \
postgresql \
+ python-cups \
python3 \
+ python3-pyscard \
python3-urllib3 \
python3-dateutil \
python3-decorator \
@@ -104,9 +109,14 @@ rm -rf /usr/share/doc
# this may be fixed with libusb>2:1.0.11-1, but that's the most recent one in raspbian
# so we install the latest pyusb that works with this libusb.
# Even in stretch, we had an error with langid (but worked otherwise)
-pip3 install pyusb==1.0.0b1
-pip3 install evdev
-pip3 install gatt
+PIP_TO_INSTALL="
+ pyusb==1.0.0b1 \
+ evdev \
+ gatt \
+ v4l2 \
+ pycups"
+
+pip3 install ${PIP_TO_INSTALL}
groupadd usbusers
@@ -148,6 +158,7 @@ if [ $SYSTEMD -eq 1 ]; then
systemctl set-default graphical.target
ln -fs /etc/systemd/system/autologin@.service /etc/systemd/system/getty.target.wants/getty@tty1.service
rm /etc/systemd/system/sysinit.target.wants/systemd-timesyncd.service
+ rm /etc/systemd/system/hostapd.service
else
update-rc.d lightdm enable 2
fi
diff --git a/addons/point_of_sale/tools/posbox/posbox_create_image.sh b/addons/point_of_sale/tools/posbox/posbox_create_image.sh
index 0c517da73d7..6401444200c 100755
--- a/addons/point_of_sale/tools/posbox/posbox_create_image.sh
+++ b/addons/point_of_sale/tools/posbox/posbox_create_image.sh
@@ -120,6 +120,14 @@ qemu-system-arm "${QEMU_OPTS[@]}"
mount "${LOOP_MAPPER_PATH}" "${MOUNT_POINT}"
cp -av "${OVERWRITE_FILES_AFTER_INIT_DIR}"/* "${MOUNT_POINT}"
+find "${MOUNT_POINT}"/usr -type f -name "*.iotpatch"|while read iotpatch; do
+ DIR=$(dirname "${iotpatch}")
+ BASE=$(basename "${iotpatch%.iotpatch}")
+ find "${DIR}" -type f -name "${BASE}" ! -name "*.iotpatch"|while read file; do
+ patch -f "${file}" < "${iotpatch}"
+ done
+done
+
# cleanup
sleep 2
umount "${MOUNT_POINT}"
diff --git a/addons/point_of_sale/tools/posbox/posbox_download_images.sh b/addons/point_of_sale/tools/posbox/posbox_download_images.sh
index b13505ce16b..939e9879c2c 100755
--- a/addons/point_of_sale/tools/posbox/posbox_download_images.sh
+++ b/addons/point_of_sale/tools/posbox/posbox_download_images.sh
@@ -1,8 +1,7 @@
#!/bin/sh
-wget 'http://downloads.raspberrypi.org/raspbian_lite/images/raspbian_lite-2018-06-29/2018-06-27-raspbian-stretch-lite.zip' -O raspbian.img.zip
+wget 'http://downloads.raspberrypi.org/raspbian_lite/images/raspbian_lite-2018-11-15/2018-11-13-raspbian-stretch-lite.zip' -O raspbian.img.zip
unzip raspbian.img.zip
-wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.9.59-stretch' -O kernel-qemu
+wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.14.79-stretch' -O kernel-qemu
wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/versatile-pb.dtb'
-
diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py
index 4aa4100587d..773d8962d49 100644
--- a/addons/purchase/models/purchase.py
+++ b/addons/purchase/models/purchase.py
@@ -192,7 +192,7 @@ class PurchaseOrder(models.Model):
if not self.partner_id:
self.fiscal_position_id = False
self.payment_term_id = False
- self.currency_id = False
+ self.currency_id = self.env.user.company_id.currency_id.id
else:
self.fiscal_position_id = self.env['account.fiscal.position'].with_context(company_id=self.company_id.id).get_fiscal_position(self.partner_id.id)
self.payment_term_id = self.partner_id.property_supplier_payment_term_id.id
diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml
index 89638ee4563..8513f4827f0 100644
--- a/addons/purchase/views/purchase_views.xml
+++ b/addons/purchase/views/purchase_views.xml
@@ -157,8 +157,8 @@
</button>
</div>
<div class="oe_title">
- <span class="o_form_label" string="Request for Quotation " attrs="{'invisible': [('state','not in',('draft','sent'))]}"/>
- <span class="o_form_label" string="Purchase Order " attrs="{'invisible': [('state','in',('draft','sent'))]}"/>
+ <span class="o_form_label" attrs="{'invisible': [('state','not in',('draft','sent'))]}">Request for Quotation </span>
+ <span class="o_form_label" attrs="{'invisible': [('state','in',('draft','sent'))]}">Purchase Order </span>
<h1>
<field name="name" readonly="1"/>
</h1>
diff --git a/addons/purchase_stock/models/account_invoice.py b/addons/purchase_stock/models/account_invoice.py
index 54c9bdc19fb..efb494777cd 100644
--- a/addons/purchase_stock/models/account_invoice.py
+++ b/addons/purchase_stock/models/account_invoice.py
@@ -123,7 +123,7 @@ class AccountInvoice(models.Model):
diff_line = {
'type': 'src',
'name': i_line.name[:64],
- 'price_unit': inv.currency_id.round(price_unit_val_dif),
+ 'price_unit': price_unit_val_dif,
'quantity': line_quantity,
'price': inv.currency_id.round(price_val_dif),
'account_id': acc,
@@ -133,8 +133,9 @@ class AccountInvoice(models.Model):
'tax_ids': tax_ids,
}
# We update the original line accordingly
- line['price_unit'] = inv.currency_id.round(line['price_unit'] - diff_line['price_unit'])
+ line['price_unit'] = line['price_unit'] - diff_line['price_unit']
line['price'] = inv.currency_id.round(line['quantity'] * line['price_unit'])
+ line['price_unit'] = inv.currency_id.round(line['price_unit'])
diff_res.append(diff_line)
return diff_res
return []
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py
index 968bcffef67..f3c3a3d1757 100644
--- a/addons/purchase_stock/models/purchase.py
+++ b/addons/purchase_stock/models/purchase.py
@@ -246,6 +246,12 @@ class PurchaseOrderLine(models.Model):
if move.location_dest_id.usage == "supplier":
if move.to_refund:
total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
+ elif move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned():
+ # Edge case: the dropship is returned to the stock, no to the supplier.
+ # In this case, the received quantity on the PO is set although we didn't
+ # receive the product physically in our stock. To avoid counting the
+ # quantity twice, we do nothing.
+ pass
else:
total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
line.qty_received = total
diff --git a/addons/sale/report/sale_report_templates.xml b/addons/sale/report/sale_report_templates.xml
index 28a5d105dfc..380596eb181 100644
--- a/addons/sale/report/sale_report_templates.xml
+++ b/addons/sale/report/sale_report_templates.xml
@@ -77,19 +77,20 @@
<span>Disc.(%)</span>
</th>
<th class="text-right">Taxes</th>
- <th class="text-right" groups="account.group_show_line_subtotals_tax_excluded">Amount</th>
- <th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th>
+ <th class="text-right">
+ <t groups="account.group_show_line_subtotals_tax_excluded">Amount</t>
+ <t groups="account.group_show_line_subtotals_tax_included">Total Price</t>
+ </th>
</tr>
</thead>
<tbody class="sale_tbody">
- <t t-set="current_subtotal_tax_excluded" t-value="0"/>
- <t t-set="current_subtotal_tax_included" t-value="0"/>
+ <t t-set="current_subtotal" t-value="0"/>
<t t-foreach="doc.order_line" t-as="line">
- <t t-set="current_subtotal_tax_excluded" t-value="current_subtotal_tax_excluded + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
- <t t-set="current_subtotal_tax_included" t-value="current_subtotal_tax_included + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
+ <t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
+ <t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
<tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''">
<t t-if="not line.display_type">
@@ -107,11 +108,9 @@
<td class="text-right">
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
</td>
- <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded">
- <span t-field="line.price_subtotal"/>
- </td>
- <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included">
- <span t-field="line.price_total"/>
+ <td class="text-right o_price_total">
+ <span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
+ <span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
</td>
</t>
<t t-if="line.display_type == 'line_section'">
@@ -119,8 +118,7 @@
<span t-field="line.name"/>
</td>
<t t-set="current_section" t-value="line"/>
- <t t-set="current_subtotal_tax_excluded" t-value="0"/>
- <t t-set="current_subtotal_tax_included" t-value="0"/>
+ <t t-set="current_subtotal" t-value="0"/>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
@@ -131,17 +129,12 @@
<t t-if="current_section and (line_last or doc.order_line[line_index+1].display_type == 'line_section')">
<tr class="is-subtotal text-right">
- <td colspan="99" groups="account.group_show_line_subtotals_tax_excluded,account.group_show_line_subtotals_tax_included">
+ <td colspan="99">
<strong class="mr16">Subtotal</strong>
<span
- groups="account.group_show_line_subtotals_tax_excluded"
- t-esc="current_subtotal_tax_excluded"
+ t-esc="current_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
/>
- <span groups="account.group_show_line_subtotals_tax_included"> | <span
- t-esc="current_subtotal_tax_included"
- t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
- /></span>
</td>
</tr>
</t>
diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml
index 0475d39eac9..c2a12f037d2 100644
--- a/addons/sale/views/sale_portal_templates.xml
+++ b/addons/sale/views/sale_portal_templates.xml
@@ -430,19 +430,20 @@
<span>Discount</span>
</th>
<th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">Taxes</th>
- <th class="text-right" groups="account.group_show_line_subtotals_tax_excluded">Amount</th>
- <th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th>
+ <th class="text-right" >
+ <span groups="account.group_show_line_subtotals_tax_excluded">Amount</span>
+ <span groups="account.group_show_line_subtotals_tax_included">Total Price</span>
+ </th>
</tr>
</thead>
<tbody class="sale_tbody">
- <t t-set="current_subtotal_tax_excluded" t-value="0"/>
- <t t-set="current_subtotal_tax_included" t-value="0"/>
+ <t t-set="current_subtotal" t-value="0"/>
<t t-foreach="sale_order.order_line" t-as="line">
- <t t-set="current_subtotal_tax_excluded" t-value="current_subtotal_tax_excluded + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
- <t t-set="current_subtotal_tax_included" t-value="current_subtotal_tax_included + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
+ <t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
+ <t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
<tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''">
<t t-if="not line.display_type">
@@ -473,11 +474,9 @@
<td t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
</td>
- <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded">
- <span t-field="line.price_subtotal"/>
- </td>
- <td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included">
- <span t-field="line.price_total"/>
+ <td class="text-right">
+ <span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
+ <span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
</td>
</t>
<t t-if="line.display_type == 'line_section'">
@@ -485,8 +484,7 @@
<span t-field="line.name"/>
</td>
<t t-set="current_section" t-value="line"/>
- <t t-set="current_subtotal_tax_excluded" t-value="0"/>
- <t t-set="current_subtotal_tax_included" t-value="0"/>
+ <t t-set="current_subtotal" t-value="0"/>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
@@ -500,14 +498,9 @@
<td colspan="99">
<strong class="mr16">Subtotal</strong>
<span
- groups="account.group_show_line_subtotals_tax_excluded"
- t-esc="current_subtotal_tax_excluded"
- t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
+ t-esc="current_subtotal"
+ t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'
/>
- <span groups="account.group_show_line_subtotals_tax_included"> | <span
- t-esc="current_subtotal_tax_included"
- t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
- /></span>
</td>
</tr>
</t>
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index fc636b8177a..126adc72abc 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -135,6 +135,8 @@ class SaleOrder(models.Model):
def _prepare_invoice(self):
invoice_vals = super(SaleOrder, self)._prepare_invoice()
invoice_vals['incoterms_id'] = self.incoterm.id or False
+ if self.incoterm.id:
+ invoice_vals['incoterm_id'] = self.incoterm.id
return invoice_vals
@api.model
diff --git a/addons/sale_stock/views/account_invoice_views.xml b/addons/sale_stock/views/account_invoice_views.xml
index ed8e07b1a54..e7d2dc1daa6 100644
--- a/addons/sale_stock/views/account_invoice_views.xml
+++ b/addons/sale_stock/views/account_invoice_views.xml
@@ -7,7 +7,7 @@
<field name="arch" type="xml">
<data>
<xpath expr="//page[@name='other_info']//field[@name='origin']" position="after">
- <field name="incoterms_id" widget="selection" groups="sale_stock.group_display_incoterm"/>
+ <field name="incoterms_id" widget="selection" groups="sale_stock.group_display_incoterm" invisible="1"/>
</xpath>
</data>
</field>
@@ -16,9 +16,9 @@
<template id="report_invoice_document_inherit_sale_stock" inherit_id="account.report_invoice_document">
<!-- HTML Preview for mobile responsive -->
<xpath expr="//div[@name='reference']" position="after">
- <div t-att-class="o_grid_layout" t-if="o.incoterms_id" groups="sale_stock.group_display_incoterm">
+ <div t-att-class="o_grid_layout" t-if="o.incoterm_id" groups="sale_stock.group_display_incoterm">
<strong>Incoterms:</strong>
- <p t-field="o.incoterms_id.code"/>
+ <p t-field="o.incoterm_id.code"/>
</div>
</xpath>
</template>
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index f7ccabb3089..35742c6de79 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -156,24 +156,50 @@ class StockMove(models.Model):
@api.model
def _get_in_base_domain(self, company_id=False):
+ # Domain:
+ # - state is done
+ # - coming from a location without company, or an inventory location within the same company
+ # - going to a location within the same company
domain = [
('state', '=', 'done'),
- ('location_id.company_id', '=', False),
- ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id)
+ '&',
+ '|',
+ ('location_id.company_id', '=', False),
+ '&',
+ ('location_id.usage', 'in', ['inventory', 'production']),
+ ('location_id.company_id', '=', company_id or self.env.user.company_id.id),
+ ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
]
return domain
@api.model
def _get_all_base_domain(self, company_id=False):
+ # Domain:
+ # - state is done
+ # Then, either 'in' or 'out' moves.
+ # 'in' moves:
+ # - coming from a location without company, or an inventory location within the same company
+ # - going to a location within the same company
+ # 'out' moves:
+ # - coming from to a location within the same company
+ # - going to a location without company, or an inventory location within the same company
domain = [
('state', '=', 'done'),
'|',
'&',
- ('location_id.company_id', '=', False),
+ '|',
+ ('location_id.company_id', '=', False),
+ '&',
+ ('location_id.usage', 'in', ['inventory', 'production']),
+ ('location_id.company_id', '=', company_id or self.env.user.company_id.id),
('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
'&',
('location_id.company_id', '=', company_id or self.env.user.company_id.id),
- ('location_dest_id.company_id', '=', False)
+ '|',
+ ('location_dest_id.company_id', '=', False),
+ '&',
+ ('location_dest_id.usage', '=', 'inventory'),
+ ('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
]
return domain
diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py
index ca66f7154f5..1315586a208 100644
--- a/addons/stock_account/tests/test_stockvaluation.py
+++ b/addons/stock_account/tests/test_stockvaluation.py
@@ -3899,3 +3899,48 @@ class TestStockValuation(TransactionCase):
self.assertEqual(self.product1.with_context(to_date=Datetime.to_string(date6)).stock_value, 100)
self.assertEqual(self.product1.qty_at_date, 10)
self.assertEqual(self.product1.stock_value, 100)
+
+ def test_inventory_fifo_1(self):
+ """ Make an inventory from a location with a company set, and ensure the product has a stock
+ value. When the product is sold, ensure there is no remaining quantity on the original move
+ and no stock value.
+ """
+ self.product1.standard_price = 15
+ self.product1.product_tmpl_id.cost_method = 'fifo'
+ inventory_location = self.product1.property_stock_inventory
+ inventory_location.company_id = self.env.user.company_id.id
+
+ # Start Inventory: 12 units
+ move1 = self.env['stock.move'].create({
+ 'name': 'Adjustment of 12 units',
+ 'location_id': inventory_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 12.0,
+ })
+ move1._action_confirm()
+ move1._action_assign()
+ move1.move_line_ids.qty_done = 12.0
+ move1._action_done()
+
+ self.assertAlmostEqual(move1.value, 180.0)
+ self.assertAlmostEqual(move1.remaining_qty, 12.0)
+ self.assertAlmostEqual(self.product1.stock_value, 180.0)
+
+ # Sell the 12 units
+ move2 = self.env['stock.move'].create({
+ 'name': 'Sell 12 units',
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'product_id': self.product1.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 12.0,
+ })
+ move2._action_confirm()
+ move2._action_assign()
+ move2.move_line_ids.qty_done = 12.0
+ move2._action_done()
+
+ self.assertAlmostEqual(move1.remaining_qty, 0.0)
+ self.assertAlmostEqual(self.product1.stock_value, 0.0)
diff --git a/addons/web/static/src/js/core/domain.js b/addons/web/static/src/js/core/domain.js
index c7533aabae0..9c74dcb953e 100644
--- a/addons/web/static/src/js/core/domain.js
+++ b/addons/web/static/src/js/core/domain.js
@@ -263,8 +263,8 @@ var Domain = collections.Tree.extend({
});
break;
case 'previous_year':
- leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years-- : -1;
- rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years-- : -1;
+ leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years - 1 : -1;
+ rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years - 1 : -1;
break;
}
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
index af31c252898..b40da7ca0a5 100644
--- a/addons/web/static/src/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -272,6 +272,7 @@ var CalendarController = AbstractController.extend({
res_model: this.modelName,
context: context,
title: title,
+ view_id: this.formViewId || false,
disable_multiple_selection: true,
on_saved: function () {
if (event.data.on_save) {
diff --git a/addons/web/static/src/scss/layout_background.scss b/addons/web/static/src/scss/layout_background.scss
index 9b137fde8c1..caeb1f0c2bf 100644
--- a/addons/web/static/src/scss/layout_background.scss
+++ b/addons/web/static/src/scss/layout_background.scss
@@ -82,3 +82,10 @@
}
}
}
+
+/* special case for displaying report in iframe */
+.o_in_iframe {
+ .o_background_header {
+ min-width: 0;
+ }
+}
diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js
index 5a1ecba8264..6d37751dc0a 100644
--- a/addons/web/static/tests/views/calendar_tests.js
+++ b/addons/web/static/tests/views/calendar_tests.js
@@ -2381,6 +2381,41 @@ QUnit.module('Views', {
calendar.destroy();
});
+ QUnit.test('form_view_id attribute works with popup (for creating events)', function (assert) {
+ assert.expect(1);
+
+ var calendar = createView({
+ View: CalendarView,
+ model: 'event',
+ data: this.data,
+ arch: '<calendar class="o_calendar_test" '+
+ 'date_start="start" '+
+ 'date_stop="stop" '+
+ 'mode="month" '+
+ 'event_open_popup="true" ' +
+ 'quick_add="false" ' +
+ 'form_view_id="1">'+
+ '<field name="name"/>'+
+ '</calendar>',
+ archs: archs,
+ viewOptions: {
+ initialDate: initialDate,
+ },
+ mockRPC: function (route, args) {
+ if (args.method === "load_views") {
+ assert.strictEqual(args.kwargs.views[0][0], 1,
+ "should load view with id 1");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
+ testUtils.dom.triggerMouseEvent($cell, "mousedown");
+ testUtils.dom.triggerMouseEvent($cell, "mouseup");
+ calendar.destroy();
+ });
+
QUnit.test('calendar fallback to form view id in action if necessary', function (assert) {
assert.expect(1);
diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js
index 0ec306c10f7..ecc0dc4cb1d 100644
--- a/addons/website/static/src/js/backend/dashboard.js
+++ b/addons/website/static/src/js/backend/dashboard.js
@@ -397,7 +397,8 @@ var Dashboard = AbstractAction.extend({
container: $analytics_chart_2[0],
options: {
title: 'All',
- width: '100%'
+ width: '100%',
+ tooltip: {isHtml: true},
}
}
});
diff --git a/addons/website_crm_partner_assign/models/crm_lead.py b/addons/website_crm_partner_assign/models/crm_lead.py
index 4f1cb4cfe81..a4fad8e8732 100644
--- a/addons/website_crm_partner_assign/models/crm_lead.py
+++ b/addons/website_crm_partner_assign/models/crm_lead.py
@@ -4,7 +4,7 @@
import random
from odoo import api, fields, models, _
-from odoo.exceptions import AccessDenied
+from odoo.exceptions import AccessDenied, AccessError
class CrmLead(models.Model):
@@ -270,3 +270,35 @@ class CrmLead(models.Model):
return {
'id': lead.id
}
+
+ #
+ # DO NOT FORWARD PORT IN MASTER
+ # instead, crm.lead should implement portal.mixin
+ #
+ @api.multi
+ def get_access_action(self, access_uid=None):
+ """ Instead of the classic form view, redirect to the online document for
+ portal users or if force_website=True in the context. """
+ self.ensure_one()
+
+ user, record = self.env.user, self
+ if access_uid:
+ try:
+ record.check_access_rights('read')
+ record.check_access_rule("read")
+ except AccessError:
+ return super(CrmLead, self).get_access_action(access_uid)
+ user = self.env['res.users'].sudo().browse(access_uid)
+ record = self.sudo(user)
+ if user.share or self.env.context.get('force_website'):
+ try:
+ record.check_access_rights('read')
+ record.check_access_rule('read')
+ except AccessError:
+ pass
+ else:
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': '/my/opportunity/%s' % record.id,
+ }
+ return super(CrmLead, self).get_access_action(access_uid)
diff --git a/addons/website_crm_partner_assign/tests/test_partner_assign.py b/addons/website_crm_partner_assign/tests/test_partner_assign.py
index dde78e0d410..77e65d1b8d0 100644
--- a/addons/website_crm_partner_assign/tests/test_partner_assign.py
+++ b/addons/website_crm_partner_assign/tests/test_partner_assign.py
@@ -151,3 +151,8 @@ class TestPartnerLeadPortal(TestCrmCases):
self.assertEqual(opportunity.team_id, salesmanteam, 'The created opportunity should have the same team as the salesman default team of the opportunity creator.')
self.assertEqual(opportunity.partner_assigned_id, self.portal_partner, 'Assigned Partner of created opportunity is the (portal) creator.')
+
+ def test_portal_mixin_url(self):
+ record_action = self.lead.get_access_action(self.portal_user.id)
+ self.assertEqual(record_action['url'], '/my/opportunity/%s' % self.lead.id)
+ self.assertEqual(record_action['type'], 'ir.actions.act_url')
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
index 2d6704bc6ab..ac325e501c5 100644
--- a/addons/website_sale/models/sale_order.py
+++ b/addons/website_sale/models/sale_order.py
@@ -119,11 +119,27 @@ class SaleOrder(models.Model):
'pricelist': order.pricelist_id.id,
})
product = self.env['product.product'].with_context(product_context).browse(product_id)
- pu = product.price
- if order.pricelist_id and order.partner_id:
- order_line = order._cart_find_product_line(product.id)
- if order_line:
- pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
+ discount = 0
+
+ if order.pricelist_id.discount_policy == 'without_discount':
+ # This part is pretty much a copy-paste of the method '_onchange_discount' of
+ # 'sale.order.line'.
+ price, rule_id = order.pricelist_id.with_context(product_context).get_product_price_rule(product, qty or 1.0, order.partner_id)
+ pu, currency = request.env['sale.order.line'].with_context(product_context)._get_real_price_currency(product, rule_id, qty, product.uom_id, order.pricelist_id.id)
+ if pu != 0:
+ if order.pricelist_id.currency_id != currency:
+ # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency
+ date = order.date_order or fields.Date.today()
+ pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date)
+ discount = (pu - price) / pu * 100
+ if discount < 0:
+ discount = 0
+ else:
+ pu = product.price
+ if order.pricelist_id and order.partner_id:
+ order_line = order._cart_find_product_line(product.id)
+ if order_line:
+ pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
return {
'product_id': product_id,
@@ -131,6 +147,7 @@ class SaleOrder(models.Model):
'order_id': order_id,
'product_uom': product.uom_id.id,
'price_unit': pu,
+ 'discount': discount,
}
@api.multi
diff --git a/odoo/__init__.py b/odoo/__init__.py
index 6bfe00b8499..aa27b79ae03 100644
--- a/odoo/__init__.py
+++ b/odoo/__init__.py
@@ -62,6 +62,23 @@ import time
if hasattr(time, 'tzset'):
time.tzset()
+# ----------------------------------------------------------
+# module babel hack
+# make sure unicode [world] territory is "001"
+# whereas "unitag" notation is "AA"
+# http://www.unicode.org/reports/tr35/#unicode_region_subtag
+# http://www.unicode.org/reports/tr35/#unicode_region_subtag_validity
+# BCP47: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
+# ----------------------------------------------------------
+import babel
+_babelCoreParseLocale = babel.core.parse_locale
+def _babelCoreParseLocale_unitag(identifier, sep='_'):
+ lang, territory, script, variant = _babelCoreParseLocale(identifier, sep)
+ territory = '001' if territory == 'AA' else territory
+ return lang, territory, script, variant
+
+babel.core.parse_locale = _babelCoreParseLocale_unitag
+
#----------------------------------------------------------
# Shortcuts
#----------------------------------------------------------
diff --git a/odoo/addons/base/models/res_bank.py b/odoo/addons/base/models/res_bank.py
index a4e75a2532c..d9a0433ecc2 100644
--- a/odoo/addons/base/models/res_bank.py
+++ b/odoo/addons/base/models/res_bank.py
@@ -66,7 +66,7 @@ class ResPartnerBank(models.Model):
_name = 'res.partner.bank'
_rec_name = 'acc_number'
_description = 'Bank Accounts'
- _order = 'sequence'
+ _order = 'sequence, id'
@api.model
def get_supported_account_types(self):
@@ -84,7 +84,7 @@ class ResPartnerBank(models.Model):
bank_id = fields.Many2one('res.bank', string='Bank')
bank_name = fields.Char(related='bank_id.name', readonly=False)
bank_bic = fields.Char(related='bank_id.bic', readonly=False)
- sequence = fields.Integer()
+ sequence = fields.Integer(default=10)
currency_id = fields.Many2one('res.currency', string='Currency')
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.user.company_id, ondelete='cascade')
qr_code_valid = fields.Boolean(string="Has all required arguments", compute="_validate_qr_code_arguments")
diff --git a/odoo/addons/base/models/res_partner.py b/odoo/addons/base/models/res_partner.py
index b7f6a5e3f89..59b80f9185c 100644
--- a/odoo/addons/base/models/res_partner.py
+++ b/odoo/addons/base/models/res_partner.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
+import collections
import datetime
import hashlib
import pytz
@@ -272,8 +273,32 @@ class Partner(models.Model):
@api.depends('is_company', 'parent_id.commercial_partner_id')
def _compute_commercial_partner(self):
+ self.env.cr.execute("""
+ WITH RECURSIVE cpid(id, parent_id, commercial_partner_id, final) AS (
+ SELECT
+ id, parent_id, id,
+ (coalesce(is_company, false) OR parent_id IS NULL) as final
+ FROM res_partner
+ WHERE id = ANY(%s)
+ UNION
+ SELECT
+ cpid.id, p.parent_id, p.id,
+ (coalesce(is_company, false) OR p.parent_id IS NULL) as final
+ FROM res_partner p
+ JOIN cpid ON (cpid.parent_id = p.id)
+ WHERE NOT cpid.final
+ )
+ SELECT cpid.id, cpid.commercial_partner_id
+ FROM cpid
+ WHERE final AND id = ANY(%s);
+ """, [self.ids, self.ids])
+
+ d = dict(self.env.cr.fetchall())
for partner in self:
- if partner.is_company or not partner.parent_id:
+ fetched = d.get(partner.id)
+ if fetched is not None:
+ partner.commercial_partner_id = fetched
+ elif partner.is_company or not partner.parent_id:
partner.commercial_partner_id = partner
else:
partner.commercial_partner_id = partner.parent_id.commercial_partner_id
@@ -467,21 +492,25 @@ class Partner(models.Model):
self.update_address(onchange_vals)
# 2. To DOWNSTREAM: sync children
- if self.child_ids:
- # 2a. Commercial Fields: sync if commercial entity
- if self.commercial_partner_id == self:
- commercial_fields = self._commercial_fields()
- if any(field in values for field in commercial_fields):
- self._commercial_sync_to_children()
- for child in self.child_ids.filtered(lambda c: not c.is_company):
- if child.commercial_partner_id != self.commercial_partner_id :
- self._commercial_sync_to_children()
- break
- # 2b. Address fields: sync if address changed
- address_fields = self._address_fields()
- if any(field in values for field in address_fields):
- contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
- contacts.update_address(values)
+ self._children_sync(values)
+
+ def _children_sync(self, values):
+ if not self.child_ids:
+ return
+ # 2a. Commercial Fields: sync if commercial entity
+ if self.commercial_partner_id == self:
+ commercial_fields = self._commercial_fields()
+ if any(field in values for field in commercial_fields):
+ self._commercial_sync_to_children()
+ for child in self.child_ids.filtered(lambda c: not c.is_company):
+ if child.commercial_partner_id != self.commercial_partner_id:
+ self._commercial_sync_to_children()
+ break
+ # 2b. Address fields: sync if address changed
+ address_fields = self._address_fields()
+ if any(field in values for field in address_fields):
+ contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
+ contacts.update_address(values)
@api.multi
def _handle_first_contact_creation(self):
@@ -558,11 +587,53 @@ class Partner(models.Model):
tools.image_resize_images(vals, return_big=False)
tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True)
partners = super(Partner, self).create(vals_list)
+
+ if self.env.context.get('_partners_skip_fields_sync'):
+ return partners
+
for partner, vals in zip(partners, vals_list):
partner._fields_sync(vals)
partner._handle_first_contact_creation()
return partners
+ def _load_records_create(self, vals_list):
+ partners = super(Partner, self.with_context(_partners_skip_fields_sync=True))._load_records_create(vals_list)
+
+ # batch up first part of _fields_sync
+ # group partners by commercial_partner_id (if not self) and parent_id (if type == contact)
+ groups = collections.defaultdict(list)
+ for partner, vals in zip(partners, vals_list):
+ cp_id = None
+ if vals.get('parent_id') and partner.commercial_partner_id != partner:
+ cp_id = partner.commercial_partner_id.id
+
+ add_id = None
+ if partner.parent_id and partner.type == 'contact':
+ add_id = partner.parent_id.id
+ groups[(cp_id, add_id)].append(partner.id)
+
+ for (cp_id, add_id), children in groups.items():
+ # values from parents (commercial, regular) written to their common children
+ to_write = {}
+ # commercial fields from commercial partner
+ if cp_id:
+ to_write = self.browse(cp_id)._update_fields_values(self._commercial_fields())
+ # address fields from parent
+ if add_id:
+ parent = self.browse(add_id)
+ for f in self._address_fields():
+ v = parent[f]
+ if v:
+ to_write[f] = v.id if isinstance(v, models.BaseModel) else v
+ if to_write:
+ self.browse(children).write(to_write)
+
+ # do the second half of _fields_sync the "normal" way
+ for partner, vals in zip(partners, vals_list):
+ partner._children_sync(vals)
+ partner._handle_first_contact_creation()
+ return partners
+
@api.multi
def create_company(self):
self.ensure_one()
diff --git a/odoo/addons/base/tests/test_base.py b/odoo/addons/base/tests/test_base.py
index 5e1a24958bf..a823c65dc31 100644
--- a/odoo/addons/base/tests/test_base.py
+++ b/odoo/addons/base/tests/test_base.py
@@ -252,6 +252,41 @@ class TestBase(TransactionCase):
self.assertEqual(leaf111.address_get([]),
{'contact': branch11.id}, 'Invalid address resolution, branch11 should now be contact')
+ def test_commercial_partner_nullcompany(self):
+ """ The commercial partner is the first/nearest ancestor-or-self which
+ is a company or doesn't have a parent
+ """
+ P = self.env['res.partner']
+ p0 = P.create({'name': '0', 'email': '0'})
+ self.assertEqual(p0.commercial_partner_id, p0, "partner without a parent is their own commercial partner")
+
+ p1 = P.create({'name': '1', 'email': '1', 'parent_id': p0.id})
+ self.assertEqual(p1.commercial_partner_id, p0, "partner's parent is their commercial partner")
+ p12 = P.create({'name': '12', 'email': '12', 'parent_id': p1.id})
+ self.assertEqual(p12.commercial_partner_id, p0, "partner's GP is their commercial partner")
+
+ p2 = P.create({'name': '2', 'email': '2', 'parent_id': p0.id, 'is_company': True})
+ self.assertEqual(p2.commercial_partner_id, p2, "partner flagged as company is their own commercial partner")
+ p21 = P.create({'name': '21', 'email': '21', 'parent_id': p2.id})
+ self.assertEqual(p21.commercial_partner_id, p2, "commercial partner is closest ancestor with themselves as commercial partner")
+
+ p3 = P.create({'name': '3', 'email': '3', 'is_company': True})
+ self.assertEqual(p3.commercial_partner_id, p3, "being both parent-less and company should be the same as either")
+
+ notcompanies = p0 | p1 | p12 | p21
+ self.env.cr.execute('update res_partner set is_company=null where id = any(%s)', [notcompanies.ids])
+ for parent in notcompanies:
+ p = P.create({
+ 'name': parent.name + '_sub',
+ 'email': parent.email + '_sub',
+ 'parent_id': parent.id,
+ })
+ self.assertEqual(
+ p.commercial_partner_id,
+ parent.commercial_partner_id,
+ "check that is_company=null is properly handled when looking for ancestor"
+ )
+
def test_50_res_partner_commercial_sync(self):
res_partner = self.env['res.partner']
p0 = res_partner.create({'name': 'Sigurd Sunknife',
diff --git a/odoo/addons/base/views/res_bank_views.xml b/odoo/addons/base/views/res_bank_views.xml
index b83bcb46222..dcec863abfa 100644
--- a/odoo/addons/base/views/res_bank_views.xml
+++ b/odoo/addons/base/views/res_bank_views.xml
@@ -69,6 +69,7 @@
<form string="Bank account">
<group>
<group>
+ <field name="sequence" invisible="1"/>
<field name="acc_number"/>
<field name="acc_type"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
@@ -89,7 +90,7 @@
<field name="model">res.partner.bank</field>
<field name="arch" type="xml">
<tree string="Bank Accounts">
- <field name="sequence" invisible="1"/>
+ <field name="sequence" widget="handle"/>
<field name="acc_number"/>
<field name="bank_name"/>
<field name="company_id" groups="base.group_multi_company"/>
diff --git a/odoo/addons/test_testing_utilities/ir.model.access.csv b/odoo/addons/test_testing_utilities/ir.model.access.csv
index c069b568ad4..c43b736c531 100644
--- a/odoo/addons/test_testing_utilities/ir.model.access.csv
+++ b/odoo/addons/test_testing_utilities/ir.model.access.csv
@@ -12,7 +12,10 @@ access_test_testing_utilities_parent,access_test_testing_utilities_parent,model_
access_test_testing_utilities_sub,access_test_testing_utilities_sub,model_test_testing_utilities_sub,,1,0,0,0
access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test_testing_utilities_sub2,,1,0,0,0
access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,,1,0,0,0
+access_test_testing_utilities_recursive,access_test_testing_utilities_recursive,model_test_testing_utilities_recursive,,1,0,0,0
access_test_testing_utilities_onchange_parent,access_test_testing_utilities_onchange_parent,model_test_testing_utilities_onchange_parent,,1,0,0,0
access_test_testing_utilities_onchange_line,access_test_testing_utilities_onchange_line,model_test_testing_utilities_onchange_line,,1,0,0,0
access_model_test_testing_utilities_onchange_count,access_model_test_testing_utilities_onchange_count,model_test_testing_utilities_onchange_count,,1,1,1,1
access_test_testing_utilities_onchange_count_sub,access_test_testing_utilities_onchange_count_sub,model_test_testing_utilities_onchange_count_sub,,1,1,1,1
+access_o2m_readonly_subfield_parent,access_o2m_readonly_subfield_parent,model_o2m_readonly_subfield_parent,,1,1,1,1
+access_o2m_readonly_subfield_child,access_o2m_readonly_subfield_child,model_o2m_readonly_subfield_child,,1,1,1,1
diff --git a/odoo/addons/test_testing_utilities/menu.xml b/odoo/addons/test_testing_utilities/menu.xml
index fa5f4f37163..491a74dda46 100644
--- a/odoo/addons/test_testing_utilities/menu.xml
+++ b/odoo/addons/test_testing_utilities/menu.xml
@@ -151,4 +151,17 @@
</form>
</field>
</record>
+
+ <record id="o2m_recursive_relation_view" model="ir.ui.view">
+ <field name="name">Recursive Relation Test View</field>
+ <field name="model">test_testing_utilities.recursive</field>
+ <field name="arch" type="xml">
+ <form>
+ <sheet>
+ <field name="one_to_many_id"/>
+ <field name="many_to_one_ids"/>
+ </sheet>
+ </form>
+ </field>
+ </record>
</odoo>
diff --git a/odoo/addons/test_testing_utilities/models.py b/odoo/addons/test_testing_utilities/models.py
index fbdec47a7cf..0c04bc06e8d 100644
--- a/odoo/addons/test_testing_utilities/models.py
+++ b/odoo/addons/test_testing_utilities/models.py
@@ -188,6 +188,13 @@ class O2MSub3(models.Model):
r.name = str(r.v)
+class O2MRecursive(models.Model):
+ _name = _description = 'test_testing_utilities.recursive'
+
+ one_to_many_id = fields.Many2one('test_testing_utilities.recursive', readonly=True)
+ many_to_one_ids = fields.One2many('test_testing_utilities.recursive', 'one_to_many_id', readonly=True)
+
+
class O2MOnchangeParent(models.Model):
_name = 'test_testing_utilities.onchange_parent'
_description = 'Testing Utilities Onchange Parent'
@@ -233,3 +240,24 @@ class O2MChangeSub(models.Model):
parent = fields.Many2one('test_testing_utilities.onchange_count')
name = fields.Char()
+
+class O2MReadonlySubfield(models.Model):
+ _name = 'o2m_readonly_subfield_parent'
+ _description = _name
+
+ line_ids = fields.One2many('o2m_readonly_subfield_child', 'parent_id')
+
+class O2MReadonlySubfieldChild(models.Model):
+ _name = _description = 'o2m_readonly_subfield_child'
+
+ name = fields.Char()
+ parent_id = fields.Many2one('o2m_readonly_subfield_parent')
+ f = fields.Integer(compute='_compute_f', inverse='_inverse_f', readonly=True)
+
+ @api.depends('name')
+ def _compute_f(self):
+ for r in self:
+ r.f = len(r.name) if r.name else 0
+
+ def _inverse_f(self):
+ raise AssertionError("Inverse of f should not be called")
diff --git a/odoo/addons/test_testing_utilities/tests/test_form_impl.py b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
index c13dbd73a60..0191a4da7ad 100644
--- a/odoo/addons/test_testing_utilities/tests/test_form_impl.py
+++ b/odoo/addons/test_testing_utilities/tests/test_form_impl.py
@@ -359,7 +359,7 @@ class TestO2M(TransactionCase):
self.assertEqual(
[get(s) for s in r.subs],
- [("5", 2, 5)]
+ [("5", 0, 5)]
)
def test_o2m_inner_default(self):
@@ -409,7 +409,10 @@ class TestO2M(TransactionCase):
self.assertEqual(sub.value, 1)
self.assertEqual(sub.v, 1)
- def test_m2o_readonly(self):
+ def test_readonly_o2m(self):
+ """ Tests that o2m fields flagged as readonly (readonly="1" in the
+ view) can't be written to
+ """
r = self.env['test_testing_utilities.parent'].create({
'subs': [(0, 0, {})]
})
@@ -422,6 +425,20 @@ class TestO2M(TransactionCase):
with self.assertRaises(AssertionError):
f.subs.remove(index=0)
+ def test_o2m_readonly_subfield(self):
+ """ Tests that readonly is applied to the field of the o2m = not sent
+ as part of the create / write values
+ """
+ f = Form(self.env['o2m_readonly_subfield_parent'])
+ with f.line_ids.new() as new_line:
+ new_line.name = "ok"
+ self.assertEqual(new_line.f, 2)
+ r = f.save()
+ self.assertEqual(
+ (r.line_ids.name, r.line_ids.f),
+ ('ok', 2)
+ )
+
def test_o2m_dyn_onchange(self):
f = Form(self.env['test_testing_utilities.onchange_parent'], view='test_testing_utilities.m2o_onchange_view')
@@ -467,6 +484,9 @@ class TestO2M(TransactionCase):
self.assertEqual(commands(), [0, 2, 2], "should contain 1 '0' command and 2 deletions")
self.assertEqual(len(r.line_ids), 1)
+ def test_o2m_self_recursive(self):
+ Form(self.env['test_testing_utilities.recursive'], view='test_testing_utilities.o2m_recursive_relation_view')
+
class TestEdition(TransactionCase):
""" These use the context manager form as we don't need the record
post-save (we already have it) and it's easier to see what bits act on
diff --git a/odoo/tests/common.py b/odoo/tests/common.py
index f5b04f7e307..6f9472f6ee5 100644
--- a/odoo/tests/common.py
+++ b/odoo/tests/common.py
@@ -1110,7 +1110,7 @@ class Form(object):
else:
self._init_from_defaults(self._model)
- def _o2m_set_edition_view(self, descr, node):
+ def _o2m_set_edition_view(self, descr, node, level):
default_view = next(
(m for m in node.get('mode', 'tree').split(',') if m != 'form'),
'tree'
@@ -1138,17 +1138,10 @@ class Form(object):
edition = views['tree']
edition['tree'] = subarch
- self._process_fvg(submodel, edition)
+ # don't recursively process o2ms in o2ms
+ self._process_fvg(submodel, edition, level=level-1)
descr['views']['edition'] = edition
- def _get_node(self, f):
- """ Find etree node for the field ``f`` in the current arch
- """
- return next(
- n for n in self._view['tree'].iter('field')
- if n.get('name') == f
- )
-
def __str__(self):
return "<%s %s(%s)>" % (
type(self).__name__,
@@ -1156,16 +1149,16 @@ class Form(object):
self._values.get('id', False),
)
- def _process_fvg(self, model, fvg):
+ def _process_fvg(self, model, fvg, level=2):
""" Post-processes to augment the fields_view_get with:
* an id field (may not be present if not in the view but needed)
* pre-processed modifiers (map of modifier name to json-loaded domain)
* pre-processed onchanges list
"""
- fvg['fields']['id'] = {'type': 'id'}
+ fvg['fields'].setdefault('id', {'type': 'id'})
# pre-resolve modifiers & bind to arch toplevel
- modifiers = fvg['modifiers'] = {}
+ modifiers = fvg['modifiers'] = {'id': {'required': False, 'readonly': True}}
contexts = fvg['contexts'] = {}
order = fvg['fields_ordered'] = []
for f in fvg['tree'].xpath('//field[not(ancestor::field)]'):
@@ -1181,10 +1174,9 @@ class Form(object):
contexts[fname] = ctx
descr = fvg['fields'].get(fname) or {'type': None}
- if descr['type'] == 'one2many':
- self._o2m_set_edition_view(descr, f)
+ if level and descr['type'] == 'one2many':
+ self._o2m_set_edition_view(descr, f, level)
- fvg['modifiers']['id'] = {'required': False, 'readonly': True}
fvg['onchange'] = model._onchange_spec(fvg)
def _init_from_defaults(self, model):
@@ -1242,12 +1234,13 @@ class Form(object):
return O2MProxy(self, field)
return v
- def _get_modifier(self, field, modifier, default=False):
- d = self._view['modifiers'][field].get(modifier, default)
+ def _get_modifier(self, field, modifier, default=False, modmap=None, vals=None):
+ d = (modmap or self._view['modifiers'])[field].get(modifier, default)
if isinstance(d, bool):
return d
- vals = self._values
+ if vals is None:
+ vals = self._values
stack = []
for it in reversed(d):
if it == '!':
@@ -1262,7 +1255,9 @@ class Form(object):
stack.append(e1 or e2)
elif isinstance(it, list):
f, op, val = it
- field_val = vals[f]
+ # hack-ish handling of parent.<field> modifiers
+ f, n = re.subn(r'^parent\.', '', f, 1)
+ field_val = (vals['•parent•'] if n else vals)[f]
stack.append(self._OPS[op](field_val, val))
else:
raise ValueError("Unknown domain element %s" % it)
@@ -1361,7 +1356,8 @@ class Form(object):
load/save
"""
values = {}
- for f in self._view['fields']:
+ fields = self._view['fields']
+ for f in fields:
v = self._values[f]
if self._get_modifier(f, 'required'):
assert v is not False, "{} is a required field".format(f)
@@ -1371,10 +1367,34 @@ class Form(object):
continue
if self._get_modifier(f, 'readonly'):
- node = self._get_node(f)
+ node = _get_node(self._view, f)
if not node.get('force_save'):
continue
+ if fields[f]['type'] == 'one2many':
+ view = fields[f]['views']['edition']
+ modifiers = view['modifiers']
+ oldvals = v
+ v = []
+
+ nodes = {
+ n.get('name'): n
+ for n in view['tree'].iter('field')
+ }
+ nodes['id'] = etree.Element('field', attrib={'name': 'id'})
+
+ for (c, rid, vs) in oldvals:
+ if c in (0, 1):
+ items = list(getattr(vs, 'changed_items', vs.items)())
+ # FIXME: should be more extensive processing of o2m defaults
+ vs.setdefault('id', False)
+ vs['•parent•'] = self._values
+ vs = {
+ k: v for k, v in items
+ if nodes[k].get('force_save') or not self._get_modifier(k, 'readonly', modmap=modifiers, vals=vs)
+ }
+ v.append((c, rid, vs))
+
values[f] = v
return values
@@ -1409,11 +1429,15 @@ class Form(object):
values = {}
for k, v in self._values.items():
if f[k]['type'] == 'one2many':
- # web client sends a 4 for unmodified o2m rows
- values[k] = [
- (4, rid, False) if (c == 1 and not vs) else (c, rid, vs)
- for (c, rid, vs) in v
- ]
+ it = values[k] = []
+ for (c, rid, vs) in v:
+ if c == 1 and not vs:
+ # web client sends a 4 for unmodified o2m rows
+ it.append((4, rid, False))
+ elif c == 1 and isinstance(vs, UpdateDict):
+ it.append((1, rid, dict(vs.changed_items())))
+ else:
+ it.append((c, rid, vs))
else:
values[k] = v
return values
@@ -1512,9 +1536,16 @@ class O2MForm(Form):
if self._index is None:
commands.append((0, 0, values))
else:
- (c, _, vs) = commands[proxy._command_index(self._index)]
- assert c in (0, 1)
- vs.update(values)
+ index = proxy._command_index(self._index)
+ (c, id_, vs) = commands[index]
+ if c == 0:
+ vs.update(values)
+ elif c == 1:
+ vs = UpdateDict(vs)
+ vs.update(values)
+ commands[index] = (1, id_, vs)
+ else:
+ raise AssertionError("Expected command type 0 or 1, found %s" % c)
# FIXME: should be called when performing on change => value needs to be serialised into parent every time?
proxy._parent._perform_onchange([proxy._field])
@@ -1523,20 +1554,31 @@ class O2MForm(Form):
""" Validates values and returns only fields modified since
load/save
"""
- values = {}
+ values = UpdateDict(self._values)
+ values._changed.update(self._changed)
+
for f in self._view['fields']:
- v = self._values[f]
if self._get_modifier(f, 'required'):
- assert v is not False, "{} is a required field".format(f)
+ assert self._values[f] is not False, "{} is a required field".format(f)
- # skip unmodified fields
- if f not in self._changed:
- continue
- # if self._get_modifier(f, 'readonly'):
- # continue
- values[f] = v
return values
+class UpdateDict(dict):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._changed = set()
+
+ def changed_items(self):
+ return (
+ (k, v) for k, v in self.items()
+ if k in self._changed
+ )
+
+ def update(self, *args, **kw):
+ super().update(*args, **kw)
+ if args and isinstance(args[0], UpdateDict):
+ self._changed.update(args[0]._changed)
+
class X2MProxy(object):
_parent = None
_field = None
@@ -1738,6 +1780,13 @@ def record_to_values(fields, record):
r[f] = v
return r
+def _get_node(view, f, *arg):
+ """ Find etree node for the field ``f`` in the view's arch
+ """
+ return next((
+ n for n in view['tree'].iter('field')
+ if n.get('name') == f
+ ), *arg)
def tagged(*tags):
"""
diff --git a/odoo/tools/date_utils.py b/odoo/tools/date_utils.py
index 26bfdabd835..702913ff04e 100644
--- a/odoo/tools/date_utils.py
+++ b/odoo/tools/date_utils.py
@@ -73,6 +73,10 @@ def get_fiscal_year(date, day=31, month=12):
date_from = date_to + relativedelta(days=1)
max_day = calendar.monthrange(date_to.year + 1, date_to.month)[1]
date_to = type(date)(date.year + 1, month, min(day, max_day))
+
+ # Force at 29 February instead of 28 in case of leap year.
+ if date_to.month == 2 and date_to.day == 28 and max_day == 29:
+ date_to += relativedelta(days=1)
return date_from, date_to