diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-04-05 18:46:10 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-04-05 18:46:10 +0200 |
| commit | 3f95b074e959f0f4ac93fbf4048c0c7276f2c17a (patch) | |
| tree | f4e793da5d83bcd1bd4347f71cb3f45cbc953df3 /addons | |
| parent | 1a871d72438c731d08ef10e668b81b4ade900b85 (diff) | |
| parent | f2be461f0906ed47306da58c31240014b6239d80 (diff) | |
[MERGE] forward port branch saas-12.2 up to f2be461f090
Diffstat (limited to 'addons')
62 files changed, 988 insertions, 776 deletions
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index b63543a6b0f..487477dee6a 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 875a346dabd..4683afab86f 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 7e432a0e0a5..33b4430ab2f 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -573,7 +573,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 87fbddd31eb..5e8d71b466d 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 6957b5c2389..69bec9ea956 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -140,12 +140,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 771325e9281..ebd8bcdcc5a 100644 --- a/addons/board/static/tests/dashboard_tests.js +++ b/addons/board/static/tests/dashboard_tests.js @@ -787,6 +787,53 @@ QUnit.test('save two searches to dashboard', async function (assert) { actionManager.destroy(); }); +QUnit.test('save a action domain to dashboard', async 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 = await 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 Promise.resolve(true); + } + return this._super.apply(this, arguments); + }, + }); + + await actionManager.doAction({ + id: 1, + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'list']], + domain: [view_domain], + }); + + // Add a filter + await testUtils.dom.click(actionManager.$('.o_filters_menu_button')); + await testUtils.dom.click(actionManager.$('.o_add_custom_filter')); + actionManager.$('.o_searchview_extended_prop_value .o_input').val('b'); + await testUtils.dom.click(actionManager.$('.o_apply_filter')); + // Add it to dashboard + await testUtils.dom.click(actionManager.$('.o_favorites_menu_button')); + await testUtils.dom.click(actionManager.$('.o_add_to_board')); + await testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button')); + + actionManager.destroy(); +}); + QUnit.test("Views should be loaded in the user's language", async function (assert) { assert.expect(2); var form = await 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 Binary files differnew file mode 100644 index 00000000000..8f8e6489b30 --- /dev/null +++ b/addons/hw_drivers/static/img/False.jpg diff --git a/addons/hw_drivers/static/img/True.jpg b/addons/hw_drivers/static/img/True.jpg Binary files differnew file mode 100644 index 00000000000..0488574a699 --- /dev/null +++ b/addons/hw_drivers/static/img/True.jpg 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', '>', 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 fc084b48204..be7dfeb9115 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&signature=#{widget.activeAttachment.checksum}&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&signature=#{widget.activeAttachment.checksum}&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 5d3dcf7069f..b5748ab72ee 100644 --- a/addons/mail/static/tests/document_viewer_tests.js +++ b/addons/mail/static/tests/document_viewer_tests.js @@ -207,5 +207,24 @@ QUnit.module('DocumentViewer', { assert.ok(viewer.isDestroyed(), 'viewer should be destroyed'); }); + + QUnit.test('fileType and integrity test', async function (assert) { + assert.expect(3); + + var viewer = await 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 e50ba0a7e0e..2aa6ba8ba94 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -720,6 +720,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 6384b5a78ff..6392490a2cf 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -315,8 +315,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 if not self.next_work_order_id: @@ -407,6 +406,15 @@ class MrpWorkorder(models.Model): current_lot_lines.qty_done += self.qty_producing @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 1d71880011b..f8aa7f29109 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -408,6 +408,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 @@ -415,8 +418,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/mrp/views/mrp_workorder_views.xml b/addons/mrp/views/mrp_workorder_views.xml index a3270e74811..7ac0c81ffd8 100644 --- a/addons/mrp/views/mrp_workorder_views.xml +++ b/addons/mrp/views/mrp_workorder_views.xml @@ -330,7 +330,7 @@ <field name="name">mrp.production.work.order.gantt.production</field> <field name="model">mrp.workorder</field> <field name="arch" type="xml"> - <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="production_id"> + <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="production_id" create="0"> </gantt> </field> </record> @@ -339,7 +339,7 @@ <field name="name">mrp.workorder.view.gantt</field> <field name="model">mrp.workorder</field> <field name="arch" type="xml"> - <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="workcenter_id"> + <gantt date_stop="date_planned_finished" date_start="date_planned_start" string="Operations" default_group_by="workcenter_id" create="0"> </gantt> </field> </record> 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 d7a7f77c7fc..18dc7ca5227 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -283,6 +283,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, default_partner_type='customer') + 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. @@ -290,7 +291,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() session.activity_unlink(['point_of_sale.mail_activity_old_session']) self.with_context(ctx)._confirm_orders() self.write({'state': 'closed'}) 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/product/models/product_template.py b/addons/product/models/product_template.py index 959a0de6e2e..c9fb51039c1 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -422,7 +422,7 @@ class ProductTemplate(models.Model): # we need to add the base _name_search to the results # FIXME awa: this is really not performant at all but after discussing with the team # we don't see another way to do it - if len(searched_ids) < limit: + if not limit or len(searched_ids) < limit: searched_ids |= set([template_id[0] for template_id in super(ProductTemplate, self)._name_search( name, 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 8711cc7a520..29c7c730da5 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -160,8 +160,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 2a3a4f54492..720039e214c 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -247,6 +247,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 2e43b590490..4c75b4b7d00 100644 --- a/addons/sale/views/sale_portal_templates.xml +++ b/addons/sale/views/sale_portal_templates.xml @@ -431,19 +431,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"> @@ -474,11 +475,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'"> @@ -486,8 +485,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"> @@ -501,14 +499,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/stock_account/models/stock.py b/addons/stock_account/models/stock.py index f8d6655a21d..5a6500ebf9d 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -155,24 +155,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 73158ecb4b5..c91944bd41f 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -3900,3 +3900,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 ec85b567cf7..dda3978635b 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 9f637c4fdc5..01f431589fc 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -2371,6 +2371,42 @@ QUnit.module('Views', { calendar.destroy(); }); + QUnit.test('form_view_id attribute works with popup (for creating events)', async function (assert) { + assert.expect(1); + + var calendar = await createCalendarView({ + 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"); + await testUtils.nextTick(); + calendar.destroy(); + }); + QUnit.test('calendar fallback to form view id in action if necessary', async function (assert) { assert.expect(1); diff --git a/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js index 0cb451c100d..3ab6a5f0317 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/keyboard.js @@ -969,13 +969,13 @@ var KeyboardPlugin = AbstractPlugin.extend({ var pointA = this.context.invoke('HelperPlugin.makePoint', startNode, 0); pointA = dom.nextPointUntil(pointA, function (point) { return self.context.invoke('HelperPlugin.isVisibleText', point.node); - }); + }) || pointA; var endNode = $contents.length ? $contents[$contents.length - 1] : unbreakable; var endOffset = $contents.length ? dom.nodeLength($contents[$contents.length - 1]) : 1; var pointB = this.context.invoke('HelperPlugin.makePoint', endNode, endOffset); pointB = dom.prevPointUntil(pointB, function (point) { return self.context.invoke('HelperPlugin.isVisibleText', point.node); - }); + }) || pointB; range.sc = pointA.node; range.so = pointA.offset; range.ec = pointB.node; diff --git a/addons/web_editor/static/src/scss/web_editor.common.scss b/addons/web_editor/static/src/scss/web_editor.common.scss index 76f167e0a7d..de51b9d8a37 100644 --- a/addons/web_editor/static/src/scss/web_editor.common.scss +++ b/addons/web_editor/static/src/scss/web_editor.common.scss @@ -272,3 +272,113 @@ a.o_underline { text-decoration: underline; } } + +// ACE EDITOR +.o_ace_view_editor { + background: $o-we-ace-color; + color: white; + display: flex; + flex-flow: column nowrap; + opacity: 0.97; + + .o_ace_view_editor_title { + flex: 0 0 auto; + display: flex; + align-items: center; + padding: $grid-gutter-width/4; + + >.o_ace_type_switcher>button::after { + @include o-caret-down; + margin-left: 4px; + } + + >* { + flex: 0 0 auto; + margin: 0 $grid-gutter-width/4; + + &.o_include_option { + display: flex; + align-items: center; + font-size: 11px; + + >.custom-control { + margin-right: $grid-gutter-width/4; + } + } + + &.o_res_list { + flex: 1 1 auto; + min-width: 60px; + } + } + } + + #ace-view-id { + flex: 0 0 auto; + padding: $grid-gutter-width/4 $grid-gutter-width/2; + background-color: lighten($o-we-ace-color, 10%); + } + + #ace-view-editor { + @mixin ace-line-error-mixin { + content: ""; + z-index: 1000; + display: block; + background-color: theme-color('danger'); + opacity: 0.5; + pointer-events: none; + } + + height: 70%; // in case flex is not supported + flex: 1 1 auto; + + .ace_gutter { + display: block !important; // display even with aria-hidden + cursor: ew-resize; + + .ace_gutter-cell.o_error { + position: relative; + + &::after { + @include o-position-absolute(-100%, 0, -100%, 0); + @include ace-line-error-mixin; + } + } + } + + .ace_resize_bar { + @include o-position-absolute($right: 0); + width: 25px; + height: 100%; + cursor: ew-resize; + } + + .ace_scroller.o_error::after { + @include o-position-absolute(0, auto, 0, 0); + width: 3px; + @include ace-line-error-mixin; + } + } +} + +.o_ace_select2_dropdown { + width: auto !important; + padding-top: 4px; + font-family: monospace !important; + + >.select2-results { + max-height: none; + max-height: 70vh; + + .select2-result-label { + padding-top: 1px; + padding-bottom: 2px; + + >.o_ace_select2_result { + padding: 0; + font-size: 12px; + white-space: nowrap; + } + } + } +} diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js index d177c8e9e42..83c16278b6d 100644 --- a/addons/website/static/src/js/backend/dashboard.js +++ b/addons/website/static/src/js/backend/dashboard.js @@ -471,7 +471,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/static/src/scss/website.editor.ui.scss b/addons/website/static/src/scss/website.editor.ui.scss index 422ac472f82..403b7929a92 100644 --- a/addons/website/static/src/scss/website.editor.ui.scss +++ b/addons/website/static/src/scss/website.editor.ui.scss @@ -51,113 +51,3 @@ } } } - -// ACE EDITOR -.o_ace_view_editor { - background: $o-we-ace-color; - color: white; - display: flex; - flex-flow: column nowrap; - opacity: 0.97; - - .o_ace_view_editor_title { - flex: 0 0 auto; - display: flex; - align-items: center; - padding: $grid-gutter-width/4; - - >.o_ace_type_switcher>button::after { - @include o-caret-down; - margin-left: 4px; - } - - >* { - flex: 0 0 auto; - margin: 0 $grid-gutter-width/4; - - &.o_include_option { - display: flex; - align-items: center; - font-size: 11px; - - >.custom-control { - margin-right: $grid-gutter-width/4; - } - } - - &.o_res_list { - flex: 1 1 auto; - min-width: 60px; - } - } - } - - #ace-view-id { - flex: 0 0 auto; - padding: $grid-gutter-width/4 $grid-gutter-width/2; - background-color: lighten($o-we-ace-color, 10%); - } - - #ace-view-editor { - @mixin ace-line-error-mixin { - content: ""; - z-index: 1000; - display: block; - background-color: theme-color('danger'); - opacity: 0.5; - pointer-events: none; - } - - height: 70%; // in case flex is not supported - flex: 1 1 auto; - - .ace_gutter { - display: block !important; // display even with aria-hidden - cursor: ew-resize; - - .ace_gutter-cell.o_error { - position: relative; - - &::after { - @include o-position-absolute(-100%, 0, -100%, 0); - @include ace-line-error-mixin; - } - } - } - - .ace_resize_bar { - @include o-position-absolute($right: 0); - width: 25px; - height: 100%; - cursor: ew-resize; - } - - .ace_scroller.o_error::after { - @include o-position-absolute(0, auto, 0, 0); - width: 3px; - @include ace-line-error-mixin; - } - } -} - -.o_ace_select2_dropdown { - width: auto !important; - padding-top: 4px; - font-family: monospace !important; - - >.select2-results { - max-height: none; - max-height: 70vh; - - .select2-result-label { - padding-top: 1px; - padding-bottom: 2px; - - >.o_ace_select2_result { - padding: 0; - font-size: 12px; - white-space: nowrap; - } - } - } -} 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 899d74e7439..f092cb5de57 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -110,11 +110,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, @@ -122,6 +138,7 @@ class SaleOrder(models.Model): 'order_id': order_id, 'product_uom': product.uom_id.id, 'price_unit': pu, + 'discount': discount, } @api.multi |
