diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index c3001c510dd..0deff04529b 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -161,6 +161,7 @@ class AccountMove(models.Model): if not move.journal_id.update_posted: raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.')) if self.ids: + self._check_lock_date() self._cr.execute('UPDATE account_move '\ 'SET state=%s '\ 'WHERE id IN %s', ('draft', tuple(self.ids),)) diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index d7043ccb832..ba876652eb4 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -283,7 +283,7 @@ - + diff --git a/addons/hw_blackbox_be/controllers/main.py b/addons/hw_blackbox_be/controllers/main.py index 13579ee7b9b..a21ddbc2c80 100644 --- a/addons/hw_blackbox_be/controllers/main.py +++ b/addons/hw_blackbox_be/controllers/main.py @@ -14,6 +14,8 @@ import openerp.addons.hw_proxy.controllers.main as hw_proxy _logger = logging.getLogger(__name__) +DRIVER_NAME = 'fiscal_data_module' + class Blackbox(Thread): def __init__(self): Thread.__init__(self) @@ -39,26 +41,31 @@ class Blackbox(Thread): # request. The first device to give an answer that makes sense # wins. def _find_device_path_by_probing(self): - path = "/dev/serial/by-path/" - probe_message = self._wrap_low_level_message_around("S000") + with hw_proxy.rs232_lock: + path = "/dev/serial/by-path/" + probe_message = self._wrap_low_level_message_around("S000") - try: - devices = listdir(path) - except OSError: - _logger.warning(path + " doesn't exist") - else: - for device in listdir(path): - path_to_device = path + device - _logger.debug("Probing " + device) + try: + devices = listdir(path) + except OSError: + _logger.warning(path + " doesn't exist") + self.set_status("disconnected", ["No RS-232 device (or emulated ones) found"]) + else: + for device in devices: + if device in hw_proxy.rs232_devices: + continue + path_to_device = path + device + _logger.debug("Probing " + device) - if self._send_to_blackbox(probe_message, 21, path_to_device, just_wait_for_ack=True): - _logger.info(device + " will be used as the blackbox") - self.set_status("connected", [device]) - return path_to_device + if self._send_to_blackbox(probe_message, 21, path_to_device, just_wait_for_ack=True): + _logger.info(device + " will be used as the blackbox") + self.set_status("connected", [device]) + hw_proxy.rs232_devices[device] = DRIVER_NAME + return path_to_device - _logger.warning("Blackbox could not be found") - self.set_status("error", ["Couldn't find the Fiscal Data Module"]) - return "" + _logger.warning("Blackbox could not be found") + self.set_status("disconnected", ["Couldn't find the Fiscal Data Module"]) + return "" def _lrc(self, msg): lrc = 0 @@ -152,7 +159,7 @@ class Blackbox(Thread): if isfile("/home/pi/registered_blackbox_be"): blackbox_thread = Blackbox() - hw_proxy.drivers['fiscal_data_module'] = blackbox_thread + hw_proxy.drivers[DRIVER_NAME] = blackbox_thread class BlackboxDriver(hw_proxy.Proxy): @http.route('/hw_proxy/request_blackbox/', type='json', auth='none', cors='*') diff --git a/addons/hw_escpos/controllers/main.py b/addons/hw_escpos/controllers/main.py index 84e77065736..c5273154e8c 100644 --- a/addons/hw_escpos/controllers/main.py +++ b/addons/hw_escpos/controllers/main.py @@ -104,8 +104,12 @@ class EscposDriver(Thread): printers = self.connected_usb_devices() if len(printers) > 0: - self.set_status('connected','Connected to '+printers[0]['name']) - return Usb(printers[0]['vendor'], printers[0]['product']) + print_dev = Usb(printers[0]['vendor'], printers[0]['product']) + self.set_status( + 'connected', + "Connected to %s (in=0x%02x,out=0x%02x)" % (printers[0]['name'], print_dev.in_ep, print_dev.out_ep) + ) + return print_dev else: self.set_status('disconnected','Printer Not Found') return None diff --git a/addons/hw_escpos/escpos/printer.py b/addons/hw_escpos/escpos/printer.py index 1f32875a5cb..d52668b50f9 100644 --- a/addons/hw_escpos/escpos/printer.py +++ b/addons/hw_escpos/escpos/printer.py @@ -13,7 +13,7 @@ from time import sleep class Usb(Escpos): """ Define USB printer """ - def __init__(self, idVendor, idProduct, interface=0, in_ep=0x82, out_ep=0x01): + def __init__(self, idVendor, idProduct, interface=0, in_ep=None, out_ep=None): """ @param idVendor : Vendor ID @param idProduct : Product ID @@ -42,6 +42,23 @@ class Usb(Escpos): self.device.detach_kernel_driver(self.interface) self.device.set_configuration() usb.util.claim_interface(self.device, self.interface) + + cfg = self.device.get_active_configuration() + intf = cfg[(0,0)] # first interface + if self.in_ep is None: + # Attempt to detect IN/OUT endpoint addresses + try: + is_IN = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN + is_OUT = lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT + endpoint_in = usb.util.find_descriptor(intf, custom_match=is_IN) + endpoint_out = usb.util.find_descriptor(intf, custom_match=is_OUT) + self.in_ep = endpoint_in.bEndpointAddress + self.out_ep = endpoint_out.bEndpointAddress + except usb.core.USBError: + # default values for officially supported printers + self.in_ep = 0x82 + self.out_ep = 0x01 + except usb.core.USBError as e: raise HandleDeviceError(e) diff --git a/addons/hw_posbox_homepage/controllers/main.py b/addons/hw_posbox_homepage/controllers/main.py index 188bce6ee5a..85a51077d33 100644 --- a/addons/hw_posbox_homepage/controllers/main.py +++ b/addons/hw_posbox_homepage/controllers/main.py @@ -37,10 +37,10 @@ index_template = """

Your PosBox is up and running

- The PosBox is an hardware adapter that allows you to use + The PosBox is a hardware adapter that allows you to use receipt printers and barcode scanners with Odoo's Point of Sale, version 8.0 or later. You can start an online free trial, - or download and install it yourself. + or download and install it yourself.

For more information on how to setup the Point of Sale with @@ -55,7 +55,10 @@ index_template = """ Wi-Fi can be configured by visiting the Wi-Fi configuration page.

- The PosBox software installed on this posbox is version 14, + If you need to grant remote debugging access to a developer, you can do it here. +

+

+ The PosBox software installed on this posbox is version 15, the posbox version number is independent from Odoo. You can upgrade the software on the upgrade page.

diff --git a/addons/hw_proxy/controllers/main.py b/addons/hw_proxy/controllers/main.py index 0a75d114edb..d7dada8c1e1 100644 --- a/addons/hw_proxy/controllers/main.py +++ b/addons/hw_proxy/controllers/main.py @@ -11,6 +11,8 @@ import subprocess import json import werkzeug import werkzeug.wrappers +from threading import Lock + _logger = logging.getLogger(__name__) @@ -30,6 +32,11 @@ BANNED_DEVICES = set([ # so that 'status' can return the status of all active drivers drivers = {} +# keep a list of RS-232 devices that have been recognized by a driver, +# so other drivers can skip them during probes +rs232_devices = {} # {'/path/to/device': 'driver'} +rs232_lock = Lock() # must be held to update `rs232_devices` + class Proxy(http.Controller): def get_status(self): @@ -47,7 +54,7 @@ class Proxy(http.Controller): return True @http.route('/hw_proxy/status', type='http', auth='none', cors='*') - def status_http(self): + def status_http(self, debug=None, **kwargs): resp = """ @@ -95,6 +102,8 @@ class Proxy(http.Controller):

Connected Devices

The list of connected USB devices as seen by the posbox

""" + if debug is None: + resp += """(debug version)""" devices = commands.getoutput("lsusb").split('\n') count = 0 resp += "
\n" @@ -110,6 +119,17 @@ class Proxy(http.Controller): resp += "
\n\n\n\n" + if debug is not None: + resp += """ + +

Debug version

+

lsusb -v output:

+
+                %s
+                
+ + """ % subprocess.check_output('lsusb -v', shell=True) + return request.make_response(resp,{ 'Cache-Control': 'no-cache', 'Content-Type': 'text/html; charset=utf-8', diff --git a/addons/hw_scale/controllers/main.py b/addons/hw_scale/controllers/main.py index 2437b7829ea..33f423e33a3 100644 --- a/addons/hw_scale/controllers/main.py +++ b/addons/hw_scale/controllers/main.py @@ -1,21 +1,20 @@ # -*- coding: utf-8 -*- import logging import os +import re import time -from os import listdir -from os.path import join -from threading import Thread, Lock -from select import select -from Queue import Queue, Empty -import openerp +from collections import namedtuple +from os import listdir +from threading import Thread, Lock + import openerp.addons.hw_proxy.controllers.main as hw_proxy from openerp import http -from openerp.http import request -from openerp.tools.translate import _ _logger = logging.getLogger(__name__) +DRIVER_NAME = 'scale' + try: import serial except ImportError: @@ -23,6 +22,99 @@ except ImportError: serial = None +def _toledo8217StatusParse(status): + """ Parse a scale's status, returning a `(weight, weight_info)` pair. """ + weight, weight_info = None, None + stat = ord(status[status.index('?') + 1]) + if stat == 0: + weight_info = 'ok' + else: + weight_info = [] + if stat & 1 : + weight_info.append('moving') + if stat & 1 << 1: + weight_info.append('over_capacity') + if stat & 1 << 2: + weight_info.append('negative') + weight = 0.0 + if stat & 1 << 3: + weight_info.append('outside_zero_capture_range') + if stat & 1 << 4: + weight_info.append('center_of_zero') + if stat & 1 << 5: + weight_info.append('net_weight') + return weight, weight_info + +ScaleProtocol = namedtuple( + 'ScaleProtocol', + "name baudrate bytesize stopbits parity timeout writeTimeout weightRegexp statusRegexp " + "statusParse commandTerminator commandDelay weightDelay newWeightDelay " + "weightCommand zeroCommand tareCommand clearCommand emptyAnswerValid autoResetWeight") + +# 8217 Mettler-Toledo (Weight-only) Protocol, as described in the scale's Service Manual. +# e.g. here: https://www.manualslib.com/manual/861274/Mettler-Toledo-Viva.html?page=51#manual +# Our recommended scale, the Mettler-Toledo "Ariva-S", supports this protocol on +# both the USB and RS232 ports, it can be configured in the setup menu as protocol option 3. +# We use the default serial protocol settings, the scale's settings can be configured in the +# scale's menu anyway. +Toledo8217Protocol = ScaleProtocol( + name='Toledo 8217', + baudrate=9600, + bytesize=serial.SEVENBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_EVEN, + timeout=1, + writeTimeout=1, + weightRegexp="\x02\\s*([0-9.]+)N?\\r", + statusRegexp="\x02\\s*(\\?.)\\r", + statusParse=_toledo8217StatusParse, + commandDelay=0.2, + weightDelay=0.5, + newWeightDelay=0.2, + commandTerminator='', + weightCommand='W', + zeroCommand='Z', + tareCommand='T', + clearCommand='C', + emptyAnswerValid=False, + autoResetWeight=False, +) + +# The ADAM scales have their own RS232 protocol, usually documented in the scale's manual +# e.g at https://www.adamequipment.com/media/docs/Print%20Publications/Manuals/PDF/AZEXTRA/AZEXTRA-UM.pdf +# https://www.manualslib.com/manual/879782/Adam-Equipment-Cbd-4.html?page=32#manual +# Only the baudrate and label format seem to be configurable in the AZExtra series. +ADAMEquipmentProtocol = ScaleProtocol( + name='Adam Equipment', + baudrate=4800, + bytesize=serial.EIGHTBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_NONE, + timeout=0.2, + writeTimeout=0.2, + weightRegexp=r"\s*([0-9.]+)kg", # LABEL format 3 + KG in the scale settings, but Label 1/2 should work + statusRegexp=None, + statusParse=None, + commandTerminator="\r\n", + commandDelay=0.2, + weightDelay=0.5, + newWeightDelay=5, # AZExtra beeps every time you ask for a weight that was previously returned! + # Adding an extra delay gives the operator a chance to remove the products + # before the scale starts beeping. Could not find a way to disable the beeps. + weightCommand='P', + zeroCommand='Z', + tareCommand='T', + clearCommand=None, # No clear command -> Tare again + emptyAnswerValid=True, # AZExtra does not answer unless a new non-zero weight has been detected + autoResetWeight=True, # AZExtra will not return 0 after removing products +) + + +SCALE_PROTOCOLS = ( + Toledo8217Protocol, + ADAMEquipmentProtocol, # must be listed last, as it supports no probing! +) + class Scale(Thread): def __init__(self): Thread.__init__(self) @@ -33,8 +125,8 @@ class Scale(Thread): self.weight = 0 self.weight_info = 'ok' self.device = None - self.probed_device_paths = [] self.path_to_scale = '' + self.protocol = None def lockedstart(self): with self.lock: @@ -42,15 +134,15 @@ class Scale(Thread): self.daemon = True self.start() - def set_status(self, status, message = None): + def set_status(self, status, message=None): if status == self.status['status']: - if message != None and message != self.status['messages'][-1]: + if message is not None and message != self.status['messages'][-1]: self.status['messages'].append(message) if status == 'error' and message: - _logger.error('Scale Error: '+message) + _logger.error('Scale Error: '+ message) elif status == 'disconnected' and message: - _logger.warning('Disconnected Scale: '+message) + _logger.warning('Disconnected Scale: '+ message) else: self.status['status'] = status if message: @@ -59,61 +151,105 @@ class Scale(Thread): self.status['messages'] = [] if status == 'error' and message: - _logger.error('Scale Error: '+message) + _logger.error('Scale Error: '+ message) elif status == 'disconnected' and message: _logger.info('Disconnected Scale: %s', message) def _get_raw_response(self, connection): - response = "" + answer = [] while True: - byte = connection.read(1) - - if byte: - response += byte + char = connection.read(1) # may return `bytes` or `str` + if not char: + break else: - return response + answer.append(char) + return ''.join(answer) + + def _parse_weight_answer(self, protocol, answer): + """ Parse a scale's answer to a weighing request, returning + a `(weight, weight_info, status)` pair. + """ + weight, weight_info, status = None, None, None + try: + _logger.debug("Parsing weight [%r]", answer) + if not answer and protocol.emptyAnswerValid: + # Some scales do not return the same value again, but we + # should not clear the weight data, POS may still be reading it + return weight, weight_info, status + + if protocol.statusRegexp and re.search(protocol.statusRegexp, answer): + # parse status to set weight_info - we'll try weighing again later + weight, weight_info = protocol.statusParse(answer) + else: + match = re.search(protocol.weightRegexp, answer) + if match: + weight_text = match.group(1) + try: + weight = float(weight_text) + _logger.info('Weight: %s', weight) + except ValueError: + _logger.exception("Cannot parse weight [%r]", weight_text) + status = 'Invalid weight, please power-cycle the scale' + else: + _logger.error("Cannot parse scale answer [%r]", answer) + status = 'Invalid scale answer, please power-cycle the scale' + except Exception as e: + _logger.exception("Cannot parse scale answer [%r]", answer) + status = ("Could not weigh on scale %s with protocol %s: %s" % + (self.path_to_scale, protocol.name, e)) + return weight, weight_info, status def get_device(self): - try: - if not os.path.exists(self.input_dir): - self.set_status('disconnected','Scale Not Found') - return None - devices = [ device for device in listdir(self.input_dir)] + if self.device: + return self.device + + with hw_proxy.rs232_lock: + try: + if not os.path.exists(self.input_dir): + self.set_status('disconnected', 'No RS-232 device found') + return None + + devices = [device for device in listdir(self.input_dir)] - if len(devices) > 0: for device in devices: + driver = hw_proxy.rs232_devices.get(device) + if driver and driver != DRIVER_NAME: + # belongs to another driver + _logger.info('Ignoring %s, belongs to %s', device, driver) + continue path = self.input_dir + device - - # don't keep probing devices that are not a scale, - # only keep probing if in the past the device was - # confirmed to be a scale - if path not in self.probed_device_paths or path == self.path_to_scale: - _logger.debug('Probing: ' + path) + for protocol in SCALE_PROTOCOLS: + _logger.info('Probing %s with protocol %s', path, protocol) connection = serial.Serial(path, - baudrate = 9600, - bytesize = serial.SEVENBITS, - stopbits = serial.STOPBITS_ONE, - parity = serial.PARITY_EVEN, - timeout = 1, - writeTimeout = 1) - - connection.write("W") - self.probed_device_paths.append(path) - - if self._get_raw_response(connection): - _logger.debug(path + ' is scale') + baudrate=protocol.baudrate, + bytesize=protocol.bytesize, + stopbits=protocol.stopbits, + parity=protocol.parity, + timeout=1, # longer timeouts for probing + writeTimeout=1) # longer timeouts for probing + connection.write(protocol.weightCommand + protocol.commandTerminator) + time.sleep(protocol.commandDelay) + answer = self._get_raw_response(connection) + weight, weight_info, status = self._parse_weight_answer(protocol, answer) + if status: + _logger.info('Probing %s: no valid answer to protocol %s', path, protocol.name) + else: + _logger.info('Probing %s: answer looks ok for protocol %s', path, protocol.name) self.path_to_scale = path - self.set_status('connected','Connected to '+device) - connection.timeout = 0.02 - connection.writeTimeout = 0.02 + self.protocol = protocol + self.set_status( + 'connected', + 'Connected to %s with %s protocol' % (device, protocol.name) + ) + connection.timeout = protocol.timeout + connection.writeTimeout = protocol.writeTimeout + hw_proxy.rs232_devices[path] = DRIVER_NAME return connection - else: - _logger.debug('Already probed: ' + path) - self.set_status('disconnected','Scale Not Found') - return None - except Exception as e: - self.set_status('error',str(e)) + self.set_status('disconnected', 'No supported RS-232 scale found') + except Exception as e: + _logger.exception('Failed probing for scales') + self.set_status('error', 'Failed probing for scales: %s' % e) return None def get_weight(self): @@ -123,109 +259,110 @@ class Scale(Thread): def get_weight_info(self): self.lockedstart() return self.weight_info - + def get_status(self): self.lockedstart() return self.status def read_weight(self): with self.scalelock: - if self.device: - try: - self.device.write('W') - time.sleep(0.2) - answer = [] - - while True: - char = self.device.read(1) - if not char: - break - else: - answer.append(char) - - if '?' in answer: - stat = ord(answer[answer.index('?')+1]) - if stat == 0: - self.weight_info = 'ok' - else: - self.weight_info = [] - if stat & 1 : - self.weight_info.append('moving') - if stat & 1 << 1: - self.weight_info.append('over_capacity') - if stat & 1 << 2: - self.weight_info.append('negative') - self.weight = 0.0 - if stat & 1 << 3: - self.weight_info.append('outside_zero_capture_range') - if stat & 1 << 4: - self.weight_info.append('center_of_zero') - if stat & 1 << 5: - self.weight_info.append('net_weight') - else: - answer = answer[1:-1] - if 'N' in answer: - answer = answer[0:-1] - try: - self.weight = float(''.join(answer)) - except ValueError as v: - self.set_status('error','No data Received, please power-cycle the scale'); - self.device = None - - except Exception as e: - self.set_status('error',str(e)) + p = self.protocol + try: + self.device.write(p.weightCommand + p.commandTerminator) + time.sleep(p.commandDelay) + answer = self._get_raw_response(self.device) + weight, weight_info, status = self._parse_weight_answer(p, answer) + if status: + self.set_status('error', status) self.device = None + else: + if weight is not None: + self.weight = weight + if weight_info is not None: + self.weight_info = weight_info + except Exception as e: + self.set_status( + 'error', + "Could not weigh on scale %s with protocol %s: %s" % + (self.path_to_scale, p.name, e)) + self.device = None def set_zero(self): with self.scalelock: if self.device: - try: - self.device.write('Z') + try: + self.device.write(self.protocol.zeroCommand + self.protocol.commandTerminator) + time.sleep(self.protocol.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not zero scale %s with protocol %s: %s" % + (self.path_to_scale, self.protocol.name, e)) self.device = None def set_tare(self): with self.scalelock: if self.device: - try: - self.device.write('T') + try: + self.device.write(self.protocol.tareCommand + self.protocol.commandTerminator) + time.sleep(self.protocol.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not tare scale %s with protocol %s: %s" % + (self.path_to_scale, self.protocol.name, e)) self.device = None def clear_tare(self): with self.scalelock: if self.device: - try: - self.device.write('C') + p = self.protocol + try: + # if the protocol has no clear, we can just tare again + clearCommand = p.clearCommand or p.tareCommand + self.device.write(clearCommand + p.commandTerminator) + time.sleep(p.commandDelay) except Exception as e: - self.set_status('error',str(e)) + self.set_status( + 'error', + "Could not clear tare on scale %s with protocol %s: %s" % + (self.path_to_scale, p.name, e)) self.device = None def run(self): - self.device = None + self.device = None - while True: + while True: if self.device: + old_weight = self.weight self.read_weight() - time.sleep(0.15) + if self.weight != old_weight: + _logger.info('New Weight: %s, sleeping %ss', self.weight, self.protocol.newWeightDelay) + time.sleep(self.protocol.newWeightDelay) + if self.weight and self.protocol.autoResetWeight: + self.weight = 0 + else: + _logger.info('Weight: %s, sleeping %ss', self.weight, self.protocol.weightDelay) + time.sleep(self.protocol.weightDelay) else: with self.scalelock: self.device = self.get_device() if not self.device: - time.sleep(5) + # retry later to support "plug and play" + time.sleep(10) scale_thread = None if serial: scale_thread = Scale() - hw_proxy.drivers['scale'] = scale_thread + hw_proxy.drivers[DRIVER_NAME] = scale_thread class ScaleDriver(hw_proxy.Proxy): @http.route('/hw_proxy/scale_read/', type='json', auth='none', cors='*') def scale_read(self): if scale_thread: - return {'weight': scale_thread.get_weight(), 'unit':'kg', 'info': scale_thread.get_weight_info()} + return {'weight': scale_thread.get_weight(), + 'unit': 'kg', + 'info': scale_thread.get_weight_info()} return None @http.route('/hw_proxy/scale_zero/', type='json', auth='none', cors='*') @@ -245,5 +382,3 @@ class ScaleDriver(hw_proxy.Proxy): if scale_thread: scale_thread.clear_tare() return True - - 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 ba01ef09da5..1e4c9d1277c 100755 --- a/addons/point_of_sale/tools/posbox/posbox_create_image.sh +++ b/addons/point_of_sale/tools/posbox/posbox_create_image.sh @@ -13,6 +13,14 @@ file_exists() { [[ -f $1 ]]; } +require_command () { + type "$1" &> /dev/null || { echo "Command $1 is missing. Install it e.g. with 'apt-get install $1'. Aborting." >&2; exit 1; } +} + +require_command kpartx +require_command qemu-system-arm +require_command zerofree + __dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" __file="${__dir}/$(basename "${BASH_SOURCE[0]}")" __base="$(basename ${__file} .sh)" @@ -28,6 +36,7 @@ fi cp -a *raspbian*.img posbox.img CLONE_DIR="${OVERWRITE_FILES_BEFORE_INIT_DIR}/home/pi/odoo" +rm -rf "${CLONE_DIR}" mkdir "${CLONE_DIR}" git clone -b 8.0 --no-checkout --depth 1 https://github.com/odoo/odoo.git "${CLONE_DIR}" cd "${CLONE_DIR}" @@ -44,7 +53,7 @@ cd "${__dir}" USR_BIN="${OVERWRITE_FILES_BEFORE_INIT_DIR}/usr/bin/" mkdir -p "${USR_BIN}" cd "/tmp" -curl 'https://dl.ngrok.com/ngrok_2.0.19_linux_arm.zip' > ngrok.zip +curl 'https://bin.equinox.io/c/4VmDzA7iaHb/ngrok-stable-linux-arm.zip' > ngrok.zip unzip ngrok.zip rm ngrok.zip cd "${__dir}" @@ -68,6 +77,7 @@ START_OF_ROOT_PARTITION=$(fdisk -l posbox.img | tail -n 1 | awk '{print $2}') LOOP_MAPPER_PATH=$(kpartx -av posbox.img | tail -n 1 | cut -d ' ' -f 3) LOOP_MAPPER_PATH="/dev/mapper/${LOOP_MAPPER_PATH}" +sleep 5 # resize filesystem e2fsck -f "${LOOP_MAPPER_PATH}" # resize2fs requires clean fs 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 b500094058d..09bd79b2103 100755 --- a/addons/point_of_sale/tools/posbox/posbox_download_images.sh +++ b/addons/point_of_sale/tools/posbox/posbox_download_images.sh @@ -2,4 +2,4 @@ wget 'https://downloads.raspberrypi.org/raspbian_lite_latest' -O raspbian.img.zip unzip raspbian.img.zip -wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.1.13-jessie' -O kernel-qemu +wget 'https://github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.4.13-jessie' -O kernel-qemu diff --git a/addons/sale/sale.py b/addons/sale/sale.py index a96a42ed245..032e1381f91 100644 --- a/addons/sale/sale.py +++ b/addons/sale/sale.py @@ -54,7 +54,7 @@ class SaleOrder(models.Model): refund is not directly linked to the SO. """ for order in self: - invoice_ids = order.order_line.mapped('invoice_lines').mapped('invoice_id') + invoice_ids = order.order_line.mapped('invoice_lines').mapped('invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund']) # Search for invoices which have been 'cancelled' (filter_refund = 'modify' in # 'account.invoice.refund') # use like as origin may contains multiple references (e.g. 'SO01, SO02') diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 67643c8754a..51b22be1e8e 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -725,7 +725,7 @@ class Picking(models.Model): # check if the quant is matching the operation details if ops.package_id: - flag = quant.package_id and bool(QuantPackage.search([('id', 'child_of', [ops.package_id.id])])) or False + flag = quant.package_id == ops.package_id else: flag = not quant.package_id.id flag = flag and (ops.owner_id.id == quant.owner_id.id) diff --git a/addons/stock/tests/common.py b/addons/stock/tests/common.py index e836c93a0d6..ec11da4eafc 100644 --- a/addons/stock/tests/common.py +++ b/addons/stock/tests/common.py @@ -37,6 +37,7 @@ class TestStockCommon(common.TransactionCase): self.productB = self.ProductObj.create({'name': 'Product B', 'type': 'product'}) self.productC = self.ProductObj.create({'name': 'Product C', 'type': 'product'}) self.productD = self.ProductObj.create({'name': 'Product D', 'type': 'product'}) + self.productE = self.ProductObj.create({'name': 'Product E', 'type': 'product'}) # Configure unit of measure. self.uom_kg = self.UomObj.create({ diff --git a/addons/stock/tests/test_stock_flow.py b/addons/stock/tests/test_stock_flow.py index b7bcad76539..7c701372f31 100644 --- a/addons/stock/tests/test_stock_flow.py +++ b/addons/stock/tests/test_stock_flow.py @@ -1410,3 +1410,129 @@ class TestStockFlow(TestStockCommon): self.assertEqual(quant.qty, 3.0, 'The quant in customer location for productA has not a quantity of 3.0') # Check that the parent package of the quant is the picking_in_package self.assertEqual(quant.package_id.id, picking_in_package.id, 'The quant in customer location is not in its package created in picking in') + + def test_50_create_in_out_with_product_pack_lines(self): + picking_in = self.PickingObj.create({ + 'partner_id': self.partner_delta_id, + 'picking_type_id': self.picking_type_in, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 10, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_in.id, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + + picking_in.action_confirm() + picking_in.action_assign() + pack_obj = self.env['stock.quant.package'] + pack1 = pack_obj.create({'name': 'PACKINOUTTEST1'}) + pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'}) + picking_in.pack_operation_ids[0].result_package_id = pack1 + picking_in.pack_operation_ids[0].product_qty = 4 + packop2 = picking_in.pack_operation_ids[0].copy() + packop2.product_qty = 6 + packop2.result_package_id = pack2 + picking_in.do_transfer() + self.assertEqual(sum([x.qty for x in picking_in.move_lines[0].quant_ids]), 10.0, 'Expecting 10 pieces in stock') + #check the quants are in the package + self.assertEqual(sum(x.qty for x in pack1.quant_ids), 4.0, 'Pack 1 should have 4 pieces') + self.assertEqual(sum(x.qty for x in pack2.quant_ids), 6.0, 'Pack 2 should have 6 pieces') + picking_out = self.PickingObj.create({ + 'partner_id': self.partner_agrolite_id, + 'picking_type_id': self.picking_type_out, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 3, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_out.id, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + picking_out.action_confirm() + picking_out.action_assign() + packout1 = picking_out.pack_operation_ids[0] + packout2 = picking_out.pack_operation_ids[0].copy() + packout1.product_qty = 2 + packout1.package_id = pack1 + packout2.package_id = pack2 + packout2.product_qty = 1 + picking_out.do_transfer() + #Check there are no negative quants + neg_quants = self.env['stock.quant'].search([('product_id', '=', self.productE.id), ('qty', '<', 0.0)]) + self.assertEqual(len(neg_quants), 0, 'There are negative quants!') + self.assertEqual(len(picking_out.move_lines[0].linked_move_operation_ids), 2, 'We should have 2 links in the matching between the move and the operations') + self.assertEqual(len(picking_out.move_lines[0].quant_ids), 2, 'We should have exactly 2 quants in the end') + + def test_60_create_in_out_with_product_pack_lines(self): + picking_in = self.PickingObj.create({ + 'partner_id': self.partner_delta_id, + 'picking_type_id': self.picking_type_in, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 200, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_in.id, + 'location_id': self.supplier_location, + 'location_dest_id': self.stock_location}) + + picking_in.action_confirm() + picking_in.action_assign() + pack_obj = self.env['stock.quant.package'] + pack1 = pack_obj.create({'name': 'PACKINOUTTEST1'}) + pack2 = pack_obj.create({'name': 'PACKINOUTTEST2'}) + picking_in.pack_operation_ids[0].result_package_id = pack1 + picking_in.pack_operation_ids[0].product_qty = 120 + packop2 = picking_in.pack_operation_ids[0].copy() + packop2.product_qty = 80 + packop2.result_package_id = pack2 + picking_in.do_transfer() + self.assertEqual(sum([x.qty for x in picking_in.move_lines[0].quant_ids]), 200.0, 'Expecting 200 pieces in stock') + #check the quants are in the package + self.assertEqual(sum(x.qty for x in pack1.quant_ids), 120, 'Pack 1 should have 120 pieces') + self.assertEqual(sum(x.qty for x in pack2.quant_ids), 80, 'Pack 2 should have 80 pieces') + picking_out = self.PickingObj.create({ + 'partner_id': self.partner_agrolite_id, + 'picking_type_id': self.picking_type_out, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + self.MoveObj.create({ + 'name': self.productE.name, + 'product_id': self.productE.id, + 'product_uom_qty': 200, + 'product_uom': self.productE.uom_id.id, + 'picking_id': picking_out.id, + 'location_id': self.stock_location, + 'location_dest_id': self.customer_location}) + picking_out.action_confirm() + picking_out.action_assign() + #Convert entire packs into taking out of packs + packout0 = picking_out.pack_operation_ids[0] + packout1 = picking_out.pack_operation_ids[1] + packout0.write({ + 'package_id': pack1.id, + 'product_id': self.productE.id, + 'product_qty': 120.0, + 'product_uom_id': self.productE.uom_id.id, + }) + packout1.write({ + 'package_id': pack2.id, + 'product_id': self.productE.id, + 'product_qty': 80.0, + 'product_uom_id': self.productE.uom_id.id, + }) + picking_out.do_transfer() + #Check there are no negative quants + neg_quants = self.env['stock.quant'].search([('product_id', '=', self.productE.id), ('qty', '<', 0.0)]) + self.assertEqual(len(neg_quants), 0, 'There are negative quants!') + # We should also make sure that when matching stock moves with pack operations, it takes the correct + self.assertEqual(len(picking_out.move_lines[0].linked_move_operation_ids), 2, 'We should only have 2 links beween the move and the 2 operations') + self.assertEqual(len(picking_out.move_lines[0].quant_ids), 2, 'We should have exactly 2 quants in the end') diff --git a/addons/web/static/src/js/views/graph_widget.js b/addons/web/static/src/js/views/graph_widget.js index 80a575d0505..b2cd726c95c 100644 --- a/addons/web/static/src/js/views/graph_widget.js +++ b/addons/web/static/src/js/views/graph_widget.js @@ -98,7 +98,8 @@ return Widget.extend({ "there is no active filter in the search bar."), })); } else { - this['display_' + this.mode](); + var chart = this['display_' + this.mode](); + chart.tooltip.chartContainer(this.$el[0]); } }, display_bar: function () { @@ -182,6 +183,8 @@ return Widget.extend({ chart(svg); this.to_remove = chart.update; nv.utils.onWindowResize(chart.update); + + return chart; }, display_pie: function () { var data = [], @@ -232,6 +235,8 @@ return Widget.extend({ chart(svg); this.to_remove = chart.update; nv.utils.onWindowResize(chart.update); + + return chart; }, display_line: function () { if (this.data.length < 2) { @@ -309,7 +314,9 @@ return Widget.extend({ chart(svg); this.to_remove = chart.update; - nv.utils.onWindowResize(chart.update); + nv.utils.onWindowResize(chart.update); + + return chart; }, destroy: function () { nv.utils.offWindowResize(this.to_remove); diff --git a/addons/web/static/src/js/views/list_view.js b/addons/web/static/src/js/views/list_view.js index e97b4eac93f..e43e8acdfd6 100644 --- a/addons/web/static/src/js/views/list_view.js +++ b/addons/web/static/src/js/views/list_view.js @@ -554,8 +554,11 @@ var ListView = View.extend({ if (self.display_nocontent_helper()) { self.no_result(); } else { - // Load previous page if the current one is empty - if (self.records.length === 0 && self.dataset.size() > 0) { + if (self.records.length && self.current_min === 1) { + // Reload the list view if we delete all the records of the first page + self.reload(); + } else if (self.records.length && self.dataset.size() > 0) { + // Load previous page if the current one is empty self.pager.previous(); } // Reload the list view if we are not on the last page diff --git a/addons/web/static/src/less/graph_view.less b/addons/web/static/src/less/graph_view.less index c0d460ab3f1..f77b4faa9d0 100644 --- a/addons/web/static/src/less/graph_view.less +++ b/addons/web/static/src/less/graph_view.less @@ -10,9 +10,13 @@ } } +.nvtooltip thead { + background-color: transparent; +} + // ------------------------------------------------------------------ // Control panel buttons // ------------------------------------------------------------------ .o_graph_measures_list { .o-selected-li; -} \ No newline at end of file +} diff --git a/addons/web_calendar/static/src/js/web_calendar.js b/addons/web_calendar/static/src/js/web_calendar.js index 6f1be2bd5c4..f8f7317e93c 100644 --- a/addons/web_calendar/static/src/js/web_calendar.js +++ b/addons/web_calendar/static/src/js/web_calendar.js @@ -911,9 +911,9 @@ var CalendarView = View.extend({ var index = this.dataset.get_id_index(id); this.dataset.index = index; if (this.write_right) { - this.do_switch_view('form', null, { mode: "edit" }); + this.do_switch_view('form', { mode: "edit" }); } else { - this.do_switch_view('form', null, { mode: "view" }); + this.do_switch_view('form', { mode: "view" }); } } else { @@ -927,7 +927,7 @@ var CalendarView = View.extend({ buttons: [ {text: _t("Edit"), classes: 'btn-primary', close: true, click: function() { self.dataset.index = self.dataset.get_id_index(id); - self.do_switch_view('form', null, { mode: "edit" }); + self.do_switch_view('form', { mode: "edit" }); }}, {text: _t("Delete"), close: true, click: function() { diff --git a/addons/web_editor/static/src/js/backend.js b/addons/web_editor/static/src/js/backend.js index 17ce8c3c1a1..3b414d45cb5 100644 --- a/addons/web_editor/static/src/js/backend.js +++ b/addons/web_editor/static/src/js/backend.js @@ -161,6 +161,11 @@ var FieldTextHtmlSimple = widget.extend({ return !this.get('value') || this.get('value') === "


" || !this.get('value').match(/\S/); }, commit_value: function () { + // leave code view before saving + if (this.$content.prev('textarea').length === 1) { + var layoutInfo = { 'editor': function() { return this.$content; }.bind(this) }; + $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo); + } if (this.options['style-inline']) { transcoder.class_to_style(this.$content); transcoder.font_to_img(this.$content); @@ -374,6 +379,11 @@ var FieldTextHtml = widget.extend({ this._dirty_flag = false; return this.editor.save(); } else if (this._dirty_flag && this.editor && this.editor.buildingBlock) { + // leave code view before saving + if (this.$content.prev('textarea').length === 1) { + var layoutInfo = { 'editor': function() { return this.$content; }.bind(this) }; + $.summernote.pluginEvents.codeview(undefined, undefined, layoutInfo); + } this.editor.buildingBlock.clean_for_save(); this.internal_set_value( this.$content.html() ); } diff --git a/addons/web_editor/static/src/js/rte.summernote.js b/addons/web_editor/static/src/js/rte.summernote.js index a83e9136a06..5c621ebbe70 100644 --- a/addons/web_editor/static/src/js/rte.summernote.js +++ b/addons/web_editor/static/src/js/rte.summernote.js @@ -538,6 +538,8 @@ function prettify_html(html) { } return result; } + +// Change Warning: called with dummy arguments to quit code view before saving $.summernote.pluginEvents.codeview = function (event, editor, layoutInfo) { if (layoutInfo.toolbar) { return eventHandler.modules.codeview.toggle(layoutInfo); diff --git a/addons/web_kanban/static/src/js/kanban_view.js b/addons/web_kanban/static/src/js/kanban_view.js index cbfa11ad8c3..f216ac84597 100644 --- a/addons/web_kanban/static/src/js/kanban_view.js +++ b/addons/web_kanban/static/src/js/kanban_view.js @@ -517,7 +517,7 @@ var KanbanView = View.extend({ open_record: function (event, options) { if (this.dataset.select_id(event.data.id)) { - this.do_switch_view('form', null, options); //, null, { mode: "edit" }); + this.do_switch_view('form', options); } else { this.do_warn("Kanban: could not find id#" + event.data.id); } diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js index bb4a6078c0a..c6ab7952701 100644 --- a/addons/website_sale/static/src/js/website_sale.js +++ b/addons/website_sale/static/src/js/website_sale.js @@ -349,7 +349,7 @@ odoo.define('website_sale.website_sale', function (require) { //$("input[name='phone']").attr('placeholder', data.phone_code !== 0 ? '+'+ data.phone_code : ''); // populate states and display - var selectStates = $("select[name='state_id']"); + var selectStates = $("select[name='state_id']:visible"); // dont reload state at first loading (done in qweb) if (selectStates.data('init')===1) { selectStates.data('init', 0); diff --git a/openerp/addons/base/tests/test_api.py b/openerp/addons/base/tests/test_api.py index 22a42cac389..ca26886f7ca 100644 --- a/openerp/addons/base/tests/test_api.py +++ b/openerp/addons/base/tests/test_api.py @@ -325,6 +325,13 @@ class TestAPI(common.TransactionCase): self.assertEqual(set(partner2.child_ids), set(children2)) self.env.check_cache() + # convert from the cache format to the write format + partner = partner1 + partner.country_id, partner.child_ids + data = partner._convert_to_write(partner._cache) + self.assertEqual(data['country_id'], partner.country_id.id) + self.assertEqual(data['child_ids'], [(6, 0, partner.child_ids.ids)]) + @mute_logger('openerp.models') def test_60_prefetch(self): """ Check the record cache prefetching """ diff --git a/openerp/fields.py b/openerp/fields.py index acc5aacd6ec..6a915a5808a 100644 --- a/openerp/fields.py +++ b/openerp/fields.py @@ -1729,7 +1729,8 @@ class Many2one(_Relational): return process(value._ids) raise ValueError("Wrong value for %s: %r" % (self, value)) elif isinstance(value, tuple): - return process((value[0],)) + # value is either a pair (id, name), or a tuple of ids + return process(value[:1]) elif isinstance(value, dict): return process(record.env[self.comodel_name].new(value)._ids) else: @@ -1803,8 +1804,8 @@ class _RelationalMulti(_Relational): if isinstance(value, BaseModel): if not validate or (value._name == self.comodel_name): return process(value._ids) - elif isinstance(value, list): - # value is a list of record ids or commands + elif isinstance(value, (list, tuple)): + # value is a list/tuple of commands, dicts or record ids comodel = record.env[self.comodel_name] # determine the value ids; by convention empty on new records ids = OrderedSet(record[self.name].ids if record.id else ())