summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2016-11-17 14:48:43 +0100
committerChristophe Simonis <chs@odoo.com>2016-11-17 14:48:43 +0100
commit1ef4061e68a6a9f07effe5efcdbd3fb3e5054cf6 (patch)
tree32918613ae611e9b55ec5a08904a9a649bc6fd54
parentd7004d7dc0859056f982052184d5500846c13716 (diff)
parent3b2f4e244f51b37050f61629bf01e6dc38cc4817 (diff)
[MERGE] forward port branch saas-11 up to 3b2f4e2
-rw-r--r--addons/account/models/account_move.py1
-rw-r--r--addons/account/views/account_view.xml2
-rw-r--r--addons/hw_blackbox_be/controllers/main.py49
-rw-r--r--addons/hw_escpos/controllers/main.py8
-rw-r--r--addons/hw_escpos/escpos/printer.py19
-rw-r--r--addons/hw_posbox_homepage/controllers/main.py9
-rw-r--r--addons/hw_proxy/controllers/main.py22
-rw-r--r--addons/hw_scale/controllers/main.py361
-rwxr-xr-xaddons/point_of_sale/tools/posbox/posbox_create_image.sh12
-rwxr-xr-xaddons/point_of_sale/tools/posbox/posbox_download_images.sh2
-rw-r--r--addons/sale/sale.py2
-rw-r--r--addons/stock/models/stock_picking.py2
-rw-r--r--addons/stock/tests/common.py1
-rw-r--r--addons/stock/tests/test_stock_flow.py126
-rw-r--r--addons/web/static/src/js/views/graph_widget.js11
-rw-r--r--addons/web/static/src/js/views/list_view.js7
-rw-r--r--addons/web/static/src/less/graph_view.less6
-rw-r--r--addons/web_calendar/static/src/js/web_calendar.js6
-rw-r--r--addons/web_editor/static/src/js/backend.js10
-rw-r--r--addons/web_editor/static/src/js/rte.summernote.js2
-rw-r--r--addons/web_kanban/static/src/js/kanban_view.js2
-rw-r--r--addons/website_sale/static/src/js/website_sale.js2
-rw-r--r--openerp/addons/base/tests/test_api.py7
-rw-r--r--openerp/fields.py7
24 files changed, 517 insertions, 159 deletions
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 @@
<field name="code"/>
<field name="refund_sequence" attrs="{'invisible': [('type', 'not in', ['sale', 'purchase'])]}" groups="base.group_no_one"/>
<field name="sequence_id" required="0" attrs="{'readonly': 1}" groups="base.group_no_one"/>
- <field name="refund_sequence_id" attrs="{'invisible': ['|',('type', 'not in', ['sale', 'purchase']), ('refund_sequence', '!=', True)], 'required': [('type', 'in', ['sale', 'purchase']), ('refund_sequence', '=', True)]}" groups="base.group_no_one"/>
+ <field name="refund_sequence_id" attrs="{'invisible': ['|',('type', 'not in', ['sale', 'purchase']), ('refund_sequence', '!=', True)], 'required': [('type', 'in', ['sale', 'purchase']), ('refund_sequence', '=', True)]}"/>
</group>
<group>
<field name="default_debit_account_id" domain="[('deprecated', '=', False)]" />
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")
-
- 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)
-
- 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
-
- _logger.warning("Blackbox could not be found")
- self.set_status("error", ["Couldn't find the Fiscal Data Module"])
- return ""
+ 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")
+ 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])
+ hw_proxy.rs232_devices[device] = DRIVER_NAME
+ return path_to_device
+
+ _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 = """
<body>
<h1>Your PosBox is up and running</h1>
<p>
- 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, <b>version 8.0 or later</b>. You can start an <a href='https://www.odoo.com/start'>online free trial</a>,
- or <a href='https://www.odoo.com/start?download'>download and install</a> it yourself.
+ or <a href='https://www.odoo.com/page/download'>download and install</a> it yourself.
</p>
<p>
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 <a href='/wifi'>Wi-Fi configuration page</a>.
</p>
<p>
- The PosBox software installed on this posbox is <b>version 14</b>,
+ If you need to grant remote debugging access to a developer, you can do it <a href='/remote_connect'>here</a>.
+ </p>
+ <p>
+ The PosBox software installed on this posbox is <b>version 15</b>,
the posbox version number is independent from Odoo. You can upgrade
the software on the <a href='/hw_proxy/upgrade/'>upgrade page</a>.
</p>
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 = """
<!DOCTYPE HTML>
<html>
@@ -95,6 +102,8 @@ class Proxy(http.Controller):
<h2>Connected Devices</h2>
<p>The list of connected USB devices as seen by the posbox</p>
"""
+ if debug is None:
+ resp += """(<a href="/hw_proxy/status?debug">debug version</a>)"""
devices = commands.getoutput("lsusb").split('\n')
count = 0
resp += "<div class='devices'>\n"
@@ -110,6 +119,17 @@ class Proxy(http.Controller):
resp += "</div>\n</body>\n</html>\n\n"
+ if debug is not None:
+ resp += """
+
+ <h3>Debug version</h3>
+ <p><tt>lsusb -v</tt> output:</p>
+ <pre>
+ %s
+ </pre>
+
+ """ % 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 collections import namedtuple
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
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') === "<p><br/></p>" || !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 ())