[MERGE] forward port branch saas-12.1 up to f2d0995362

This commit is contained in:
Christophe Simonis
2019-04-05 10:50:56 +02:00
70 changed files with 1194 additions and 740 deletions
@@ -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,))
@@ -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 = {}
@@ -553,7 +553,10 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
type: 'boolean',
name: 'to_check',
}], {
account_id: {string: _t("Account")},
account_id: {
string: _t("Account"),
domain: [['deprecated', '=', false]],
},
label: {string: _t("Label")},
amount: {string: _t("Account")},
}).then(function (recordID) {
@@ -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},
+12 -19
View File
@@ -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>
@@ -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>
@@ -152,12 +152,12 @@ class AccountPayment(models.Model):
""" The stub is the summary of paid invoices. It may spill on several pages, in which case only the check on
first page is valid. This function returns a list of stub lines per page.
"""
if len(self.invoice_ids) == 0:
if len(self.reconciled_invoice_ids) == 0:
return None
multi_stub = self.company_id.account_check_printing_multi_stub
invoices = self.invoice_ids.sorted(key=lambda r: r.date_due)
invoices = self.reconciled_invoice_ids.sorted(key=lambda r: r.date_due)
debits = invoices.filtered(lambda r: r.type == 'in_invoice')
credits = invoices.filtered(lambda r: r.type == 'in_refund')
@@ -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
@@ -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):
@@ -786,6 +786,53 @@ QUnit.test('save two searches to dashboard', function (assert) {
actionManager.destroy();
});
QUnit.test('save a action domain to dashboard', function (assert) {
// View domains are to be added to the dashboard domain
assert.expect(1);
var view_domain = ["display_name", "ilike", "a"];
var filter_domain = ["display_name", "ilike", "b"];
// The filter domain already contains the view domain, but is always added by dashboard..,
var expected_domain = ['&', '&', view_domain, view_domain, filter_domain]
var actionManager = createActionManager({
data: this.data,
archs: {
'partner,false,list': '<list><field name="foo"/></list>',
'partner,false,search': '<search></search>',
},
mockRPC: function (route, args) {
if (route === '/board/add_to_dashboard') {
assert.deepEqual(args.domain, expected_domain,
"the correct domain should be sent");
return $.when(true);
}
return this._super.apply(this, arguments);
},
});
actionManager.doAction({
id: 1,
res_model: 'partner',
type: 'ir.actions.act_window',
views: [[false, 'list']],
domain: [view_domain],
});
// Add a filter
testUtils.dom.click(actionManager.$('.o_filters_menu_button'));
testUtils.dom.click(actionManager.$('.o_add_custom_filter'));
actionManager.$('.o_searchview_extended_prop_value .o_input').val('b');
testUtils.dom.click(actionManager.$('.o_apply_filter'));
// Add it to dashboard
testUtils.dom.click(actionManager.$('.o_favorites_menu_button'));
testUtils.dom.click(actionManager.$('.o_add_to_board'));
testUtils.dom.click(actionManager.$('.o_add_to_board_confirm_button'));
actionManager.destroy();
});
QUnit.test("Views should be loaded in the user's language", function (assert) {
assert.expect(2);
var form = createView({
+1 -2
View File
@@ -1,2 +1 @@
from . import driver
from . import loading
from . import driver
+362 -393
View File
@@ -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__)
#----------------------------------------------------------
# Helper
#----------------------------------------------------------
def get_mac_address():
try:
return netifaces.ifaddresses('eth0')[netifaces.AF_LINK][0]['addr']
except:
return netifaces.ifaddresses('wlan0')[netifaces.AF_LINK][0]['addr']
def get_ip():
try:
return netifaces.ifaddresses('eth0')[netifaces.AF_INET][0]['addr']
except:
return netifaces.ifaddresses('wlan0')[netifaces.AF_INET][0]['addr']
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_odoo_server_url():
return read_file_first_line('odoo-remote-server.conf')
def get_token():
return read_file_first_line('token')
def get_version():
return '19_04'
#----------------------------------------------------------
# Controllers
#----------------------------------------------------------
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/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/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/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'
@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()
#----------------------------------------------------------
# Driver common interface
# Drivers
#----------------------------------------------------------
class Driver(Thread):
ping_value = ""
def supported(self):
pass
drivers = []
bt_devices = {}
iot_devices = {}
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):
class DriverMetaClass(type):
def __new__(cls, clsname, bases, attrs):
newclass = super(UsbMetaClass, cls).__new__(cls, clsname, bases, attrs)
usbdrivers.append(newclass)
newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs)
drivers.append(newclass)
return newclass
class USBDriver(Driver,metaclass=UsbMetaClass):
def __init__(self, dev):
super(USBDriver, self).__init__()
class Driver(Thread, metaclass=DriverMetaClass):
"""
Hook to register the driver into the drivers list
"""
connection_type = ""
def __init__(self, device):
super(Driver, self).__init__()
self.dev = device
self.data = {'value': ''}
self.gatt_device = False
@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_message(self):
return ''
def action(self, data):
"""
On specific driver override this method to make a action with device (take picture, printing,...)
"""
raise NotImplementedError()
def disconnect(self):
del iot_devices[self.device_identifier]
#----------------------------------------------------------
# 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.value = ""
self.connection_type = connection_type
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 get_connection(self):
return 'direct'
def value(self):
return self.value
event_manager = EventManager()
#----------------------------------------------------------
# Bluetooth
# Manager
#----------------------------------------------------------
class GattBtManager(gatt.DeviceManager):
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
def run(self):
"""
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:
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)
d = driverclass(device = updated_devices[path].dev)
d.daemon = True
d.start()
iot_devices[path] = d
self.send_alldevices()
break
time.sleep(3)
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)
if path not in bt_devices:
device.manager = self
iot_device = IoTDevice(device, 'bluetooth')
bt_devices[path] = iot_device
class BtManager(Thread):
gatt_manager = False
def run(self):
dm = GattBtManager(adapter_name='hci0')
self.gatt_manager = dm
for device in [device_con for device_con in dm.devices() if device_con.is_connected()]:
device.disconnect()
dm.start_discovery()
dm.run()
#----------------------------------------------------------
# Bluetooth drivers
#----------------------------------------------------------
btdrivers = []
class BtMetaClass(type):
def __new__(cls, clsname, bases, attrs):
newclass = super(BtMetaClass, cls).__new__(cls, clsname, bases, attrs)
btdrivers.append(newclass)
return newclass
class BtDriver(Driver, metaclass=BtMetaClass):
def __init__(self, device, manager):
super(BtDriver, self).__init__()
self.dev = device
self.manager = manager
self.value = ''
self.gatt_device = False
def disconnect(self):
path = "bt_%s" % (self.dev.mac_address,)
del drivers[path]
def get_name(self):
return self.dev.alias()
def value(self):
return self.value
def action(self, action):
pass
def get_connection(self):
return 'bluetooth'
def connect(self):
pass
class USBDeviceManager(Thread):
devices = {}
def run(self):
first_time = True
send_iot_box_device(False)
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():
_logger.info('For device %s will be driven', path)
drivers[path] = d
# launch thread
d.daemon = True
d.start()
sendJSON = True
if sendJSON or first_time:
send_iot_box_device(send_printer = first_time)
first_time = False
time.sleep(3)
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()
-34
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

+60 -98
View File
@@ -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',
@@ -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>
@@ -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>
@@ -191,10 +191,10 @@
('user_type_id', '=', ref('account.data_account_type_expenses')),
('company_id', '=', obj().env['res.company']._company_default_get('account.account').id)]"/>
<field name="invoice_line_tax_ids" model="account.tax"
eval="[(6, 0, [obj().search([
eval="[(6, 0, obj().search([
('type_tax_use', '=', 'purchase'),
('amount', '&gt;', 0),
('company_id', '=', obj().env['res.company']._company_default_get('account.tax').id)], limit=1).id])]"/>
('company_id', '=', obj().env['res.company']._company_default_get('account.tax').id)], limit=1).ids)]"/>
</record>
<function model="account.invoice" name="_onchange_invoice_line_ids">
+4 -4
View File
@@ -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">
+2 -2
View File
@@ -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
1 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
2 tax_in_e 仮受消費税(外) 仮受消費税(外) l10n_jp1 sale percent 8 FALSE 1 FALSE A21807 A21809 A21807 A21809 tag_jp_03,tag_jp_12 tax_group_8
3 tax_in_i 仮受消費税(内) 仮受消費税(内) l10n_jp1 sale percent 8 TRUE 1 FALSE A21807 A21809 A21807 A21809 tag_jp_03,tag_jp_12 tax_group_8
4 tax_in_x 輸出免税 輸出免税 l10n_jp1 sale percent FALSE 1 FALSE tag_jp_04,tag_jp_13 tax_group_0
5 tax_in_o 非課税販売 非課税販売 l10n_jp1 sale percent FALSE 1 FALSE tag_jp_09,tag_jp_14 tax_group_0
6 tax_out_e 仮払消費税(外) 仮払消費税(外) l10n_jp1 purchase percent 8 FALSE 1 FALSE A11807 A11807 tag_jp_07,tag_jp_16 tax_group_8
+25 -11
View File
@@ -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'))
# 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
# 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_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'), ('name', 'in', list(in_xml_id_names))])
in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
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}
in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
return (in_ids, out_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"""
+4
View File
@@ -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>
+3 -3
View File
@@ -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,
+2 -3
View File
@@ -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('/');
+10 -10
View File
@@ -38,9 +38,9 @@
<t t-set="model" t-value="widget.modelName"/>
<div class="o_viewer-header">
<span class="o_image_caption">
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.type == 'image'" role="img" aria-label="Image" title="Image"/>
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.type == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.type == 'video'" role="img" aria-label="Video" title="Video"/>
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
<t t-esc="widget.activeAttachment.name"/>
<a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
</span>
@@ -48,19 +48,19 @@
</div>
<div class="o_viewer_img_wrapper">
<div class="o_viewer_zoomer">
<div t-if="widget.activeAttachment.type == 'image'" class="o_loading_img text-center">
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_loading_img text-center">
<i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
</div>
<img t-if="widget.activeAttachment.type === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&amp;signature=#{widget.activeAttachment.checksum}&amp;model=#{model}" alt="Viewer"/>
<iframe t-if="widget.activeAttachment.type == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
<iframe t-if="(widget.activeAttachment.type || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
<iframe t-if="widget.activeAttachment.type == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
<video t-if="widget.activeAttachment.type == 'video'" class="o_viewer_video" controls="controls">
<img t-if="widget.activeAttachment.fileType === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&amp;signature=#{widget.activeAttachment.checksum}&amp;model=#{model}" alt="Viewer"/>
<iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
<iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
<iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
<video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
<source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
</video>
</div>
</div>
<div t-if="widget.activeAttachment.type == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
<div class="btn-group ml0" role="group">
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
@@ -206,5 +206,24 @@ QUnit.module('DocumentViewer', {
assert.ok(viewer.isDestroyed(), 'viewer should be destroyed');
});
QUnit.test('fileType and integrity test', function (assert) {
assert.expect(3);
var viewer = createViewer({
attachmentID: 2,
attachments: this.attachments,
});
assert.strictEqual(this.attachments[1].type, 'url',
"the type should be url");
assert.strictEqual(this.attachments[1].fileType, 'youtu',
"there should be a fileType 'youtu'");
assert.strictEqual(this.attachments[1].youtube, 'FYqW0Gdwbzk',
"there should be a youtube token");
viewer.destroy();
});
});
});
+1
View File
@@ -743,6 +743,7 @@ class MrpProduction(models.Model):
})
if workorders:
workorders[-1].next_work_order_id = workorder.id
workorders[-1]._init_nextworkorder_states()
workorders += workorder
# assign moves; last operation receive all unassigned moves (which case ?)
+10 -2
View File
@@ -220,8 +220,7 @@ class MrpWorkorder(models.Model):
raise UserError(_('Please set the quantity you are currently producing. It should be different from zero.'))
# One a piece is produced, you can launch the next work order
if self.next_work_order_id.state == 'pending':
self.next_work_order_id.state = 'ready'
self._start_nextworkorder()
# If last work order, then post lots used
# TODO: should be same as checking if for every workorder something has been done?
@@ -257,6 +256,15 @@ class MrpWorkorder(models.Model):
self.button_finish()
return True
@api.multi
def _start_nextworkorder(self):
for record in self:
if record.next_work_order_id.state == 'pending':
record.next_work_order_id.state = 'ready'
def _init_nextworkorder_states(self):
return 'pending'
@api.multi
def button_start(self):
self.ensure_one()
+3 -2
View File
@@ -574,6 +574,9 @@ class TestMrpOrder(TestMrpCommon):
'active_ids': [mo.id],
}))
produce_form.qty_producing = 1.0
for i in range(len(produce_form.workorder_line_ids)):
with produce_form.workorder_line_ids.edit(i) as line:
line.qty_done += 1
product_produce = produce_form.save()
product_produce.final_lot_id = final_product_lot.id
# product 1 lot 1 shelf1
@@ -581,8 +584,6 @@ class TestMrpOrder(TestMrpCommon):
# product 1 lot 2
self.assertEqual(len(product_produce.workorder_line_ids), 4, 'You should have 4 produce lines. lot 1 shelf_1, lot 1 shelf_2, lot2 and for product which have tracking None')
for produce_line in product_produce.workorder_line_ids:
produce_line.qty_done = produce_line.qty_to_consume + 1
product_produce.do_produce()
move_1 = mo.move_raw_ids.filtered(lambda m: m.product_id == p1)
+4 -4
View File
@@ -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>
@@ -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()
+2 -1
View File
@@ -273,6 +273,7 @@ class PosSession(models.Model):
for session in self:
company_id = session.config_id.company_id.id
ctx = dict(self.env.context, force_company=company_id, company_id=company_id)
ctx_notrack = dict(ctx, mail_notrack=True)
for st in session.statement_ids:
if abs(st.difference) > st.journal_id.amount_authorized_diff:
# The pos manager can close statements with maximums.
@@ -280,7 +281,7 @@ class PosSession(models.Model):
raise UserError(_("Your ending balance is too different from the theoretical cash closing (%.2f), the maximum allowed is: %.2f. You can contact your manager to force it.") % (st.difference, st.journal_id.amount_authorized_diff))
if (st.journal_id.type not in ['bank', 'cash']):
raise UserError(_("The journal type for your payment method should be bank or cash."))
st.with_context(ctx).sudo().button_confirm_bank()
st.with_context(ctx_notrack).sudo().button_confirm_bank()
self.with_context(ctx)._confirm_orders()
self.write({'state': 'closed'})
return {
@@ -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
@@ -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;
}
}
@@ -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
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
@@ -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):
@@ -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
@@ -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}"
@@ -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'
+1 -1
View File
@@ -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
+2 -2
View File
@@ -157,8 +157,8 @@
</button>
</div>
<div class="oe_title">
<span class="o_form_label" string="Request for Quotation " attrs="{'invisible': [('state','not in',('draft','sent'))]}"/>
<span class="o_form_label" string="Purchase Order " attrs="{'invisible': [('state','in',('draft','sent'))]}"/>
<span class="o_form_label" attrs="{'invisible': [('state','not in',('draft','sent'))]}">Request for Quotation </span>
<span class="o_form_label" attrs="{'invisible': [('state','in',('draft','sent'))]}">Purchase Order </span>
<h1>
<field name="name" readonly="1"/>
</h1>
@@ -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 []
+6
View File
@@ -246,6 +246,12 @@ class PurchaseOrderLine(models.Model):
if move.location_dest_id.usage == "supplier":
if move.to_refund:
total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
elif move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned():
# Edge case: the dropship is returned to the stock, no to the supplier.
# In this case, the received quantity on the PO is set although we didn't
# receive the product physically in our stock. To avoid counting the
# quantity twice, we do nothing.
pass
else:
total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
line.qty_received = total
+13 -20
View File
@@ -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>
+13 -20
View File
@@ -430,19 +430,20 @@
<span>Discount</span>
</th>
<th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">Taxes</th>
<th class="text-right" groups="account.group_show_line_subtotals_tax_excluded">Amount</th>
<th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th>
<th class="text-right" >
<span groups="account.group_show_line_subtotals_tax_excluded">Amount</span>
<span groups="account.group_show_line_subtotals_tax_included">Total Price</span>
</th>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
<t t-set="current_subtotal_tax_included" t-value="0"/>
<t t-set="current_subtotal" t-value="0"/>
<t t-foreach="sale_order.order_line" t-as="line">
<t t-set="current_subtotal_tax_excluded" t-value="current_subtotal_tax_excluded + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
<t t-set="current_subtotal_tax_included" t-value="current_subtotal_tax_included + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
<t t-set="current_subtotal" t-value="current_subtotal + line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
<t t-set="current_subtotal" t-value="current_subtotal + line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
<tr t-att-class="'bg-200 font-weight-bold o_line_section' if line.display_type == 'line_section' else 'font-italic o_line_note' if line.display_type == 'line_note' else ''">
<t t-if="not line.display_type">
@@ -473,11 +474,9 @@
<td t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
</td>
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded">
<span t-field="line.price_subtotal"/>
</td>
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included">
<span t-field="line.price_total"/>
<td class="text-right">
<span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
<span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
</td>
</t>
<t t-if="line.display_type == 'line_section'">
@@ -485,8 +484,7 @@
<span t-field="line.name"/>
</td>
<t t-set="current_section" t-value="line"/>
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
<t t-set="current_subtotal_tax_included" t-value="0"/>
<t t-set="current_subtotal" t-value="0"/>
</t>
<t t-if="line.display_type == 'line_note'">
<td colspan="99">
@@ -500,14 +498,9 @@
<td colspan="99">
<strong class="mr16">Subtotal</strong>
<span
groups="account.group_show_line_subtotals_tax_excluded"
t-esc="current_subtotal_tax_excluded"
t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
t-esc="current_subtotal"
t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'
/>
<span groups="account.group_show_line_subtotals_tax_included"> | <span
t-esc="current_subtotal_tax_included"
t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
/></span>
</td>
</tr>
</t>
+2
View File
@@ -135,6 +135,8 @@ class SaleOrder(models.Model):
def _prepare_invoice(self):
invoice_vals = super(SaleOrder, self)._prepare_invoice()
invoice_vals['incoterms_id'] = self.incoterm.id or False
if self.incoterm.id:
invoice_vals['incoterm_id'] = self.incoterm.id
return invoice_vals
@api.model
@@ -7,7 +7,7 @@
<field name="arch" type="xml">
<data>
<xpath expr="//page[@name='other_info']//field[@name='origin']" position="after">
<field name="incoterms_id" widget="selection" groups="sale_stock.group_display_incoterm"/>
<field name="incoterms_id" widget="selection" groups="sale_stock.group_display_incoterm" invisible="1"/>
</xpath>
</data>
</field>
@@ -16,9 +16,9 @@
<template id="report_invoice_document_inherit_sale_stock" inherit_id="account.report_invoice_document">
<!-- HTML Preview for mobile responsive -->
<xpath expr="//div[@name='reference']" position="after">
<div t-att-class="o_grid_layout" t-if="o.incoterms_id" groups="sale_stock.group_display_incoterm">
<div t-att-class="o_grid_layout" t-if="o.incoterm_id" groups="sale_stock.group_display_incoterm">
<strong>Incoterms:</strong>
<p t-field="o.incoterms_id.code"/>
<p t-field="o.incoterm_id.code"/>
</div>
</xpath>
</template>
+30 -4
View File
@@ -156,24 +156,50 @@ class StockMove(models.Model):
@api.model
def _get_in_base_domain(self, company_id=False):
# Domain:
# - state is done
# - coming from a location without company, or an inventory location within the same company
# - going to a location within the same company
domain = [
('state', '=', 'done'),
('location_id.company_id', '=', False),
('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id)
'&',
'|',
('location_id.company_id', '=', False),
'&',
('location_id.usage', 'in', ['inventory', 'production']),
('location_id.company_id', '=', company_id or self.env.user.company_id.id),
('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
]
return domain
@api.model
def _get_all_base_domain(self, company_id=False):
# Domain:
# - state is done
# Then, either 'in' or 'out' moves.
# 'in' moves:
# - coming from a location without company, or an inventory location within the same company
# - going to a location within the same company
# 'out' moves:
# - coming from to a location within the same company
# - going to a location without company, or an inventory location within the same company
domain = [
('state', '=', 'done'),
'|',
'&',
('location_id.company_id', '=', False),
'|',
('location_id.company_id', '=', False),
'&',
('location_id.usage', 'in', ['inventory', 'production']),
('location_id.company_id', '=', company_id or self.env.user.company_id.id),
('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
'&',
('location_id.company_id', '=', company_id or self.env.user.company_id.id),
('location_dest_id.company_id', '=', False)
'|',
('location_dest_id.company_id', '=', False),
'&',
('location_dest_id.usage', '=', 'inventory'),
('location_dest_id.company_id', '=', company_id or self.env.user.company_id.id),
]
return domain
@@ -3899,3 +3899,48 @@ class TestStockValuation(TransactionCase):
self.assertEqual(self.product1.with_context(to_date=Datetime.to_string(date6)).stock_value, 100)
self.assertEqual(self.product1.qty_at_date, 10)
self.assertEqual(self.product1.stock_value, 100)
def test_inventory_fifo_1(self):
""" Make an inventory from a location with a company set, and ensure the product has a stock
value. When the product is sold, ensure there is no remaining quantity on the original move
and no stock value.
"""
self.product1.standard_price = 15
self.product1.product_tmpl_id.cost_method = 'fifo'
inventory_location = self.product1.property_stock_inventory
inventory_location.company_id = self.env.user.company_id.id
# Start Inventory: 12 units
move1 = self.env['stock.move'].create({
'name': 'Adjustment of 12 units',
'location_id': inventory_location.id,
'location_dest_id': self.stock_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 12.0,
})
move1._action_confirm()
move1._action_assign()
move1.move_line_ids.qty_done = 12.0
move1._action_done()
self.assertAlmostEqual(move1.value, 180.0)
self.assertAlmostEqual(move1.remaining_qty, 12.0)
self.assertAlmostEqual(self.product1.stock_value, 180.0)
# Sell the 12 units
move2 = self.env['stock.move'].create({
'name': 'Sell 12 units',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 12.0,
})
move2._action_confirm()
move2._action_assign()
move2.move_line_ids.qty_done = 12.0
move2._action_done()
self.assertAlmostEqual(move1.remaining_qty, 0.0)
self.assertAlmostEqual(self.product1.stock_value, 0.0)
+2 -2
View File
@@ -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;
}
@@ -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) {
@@ -82,3 +82,10 @@
}
}
}
/* special case for displaying report in iframe */
.o_in_iframe {
.o_background_header {
min-width: 0;
}
}
@@ -2381,6 +2381,41 @@ QUnit.module('Views', {
calendar.destroy();
});
QUnit.test('form_view_id attribute works with popup (for creating events)', function (assert) {
assert.expect(1);
var calendar = createView({
View: CalendarView,
model: 'event',
data: this.data,
arch: '<calendar class="o_calendar_test" '+
'date_start="start" '+
'date_stop="stop" '+
'mode="month" '+
'event_open_popup="true" ' +
'quick_add="false" ' +
'form_view_id="1">'+
'<field name="name"/>'+
'</calendar>',
archs: archs,
viewOptions: {
initialDate: initialDate,
},
mockRPC: function (route, args) {
if (args.method === "load_views") {
assert.strictEqual(args.kwargs.views[0][0], 1,
"should load view with id 1");
}
return this._super(route, args);
},
});
var $cell = calendar.$('.fc-day-grid .fc-row:eq(2) .fc-day:eq(2)');
testUtils.dom.triggerMouseEvent($cell, "mousedown");
testUtils.dom.triggerMouseEvent($cell, "mouseup");
calendar.destroy();
});
QUnit.test('calendar fallback to form view id in action if necessary', function (assert) {
assert.expect(1);
@@ -397,7 +397,8 @@ var Dashboard = AbstractAction.extend({
container: $analytics_chart_2[0],
options: {
title: 'All',
width: '100%'
width: '100%',
tooltip: {isHtml: true},
}
}
});
@@ -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)
@@ -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')
+22 -5
View File
@@ -119,11 +119,27 @@ class SaleOrder(models.Model):
'pricelist': order.pricelist_id.id,
})
product = self.env['product.product'].with_context(product_context).browse(product_id)
pu = product.price
if order.pricelist_id and order.partner_id:
order_line = order._cart_find_product_line(product.id)
if order_line:
pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
discount = 0
if order.pricelist_id.discount_policy == 'without_discount':
# This part is pretty much a copy-paste of the method '_onchange_discount' of
# 'sale.order.line'.
price, rule_id = order.pricelist_id.with_context(product_context).get_product_price_rule(product, qty or 1.0, order.partner_id)
pu, currency = request.env['sale.order.line'].with_context(product_context)._get_real_price_currency(product, rule_id, qty, product.uom_id, order.pricelist_id.id)
if pu != 0:
if order.pricelist_id.currency_id != currency:
# we need new_list_price in the same currency as price, which is in the SO's pricelist's currency
date = order.date_order or fields.Date.today()
pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date)
discount = (pu - price) / pu * 100
if discount < 0:
discount = 0
else:
pu = product.price
if order.pricelist_id and order.partner_id:
order_line = order._cart_find_product_line(product.id)
if order_line:
pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
return {
'product_id': product_id,
@@ -131,6 +147,7 @@ class SaleOrder(models.Model):
'order_id': order_id,
'product_uom': product.uom_id.id,
'price_unit': pu,
'discount': discount,
}
@api.multi
+17
View File
@@ -62,6 +62,23 @@ import time
if hasattr(time, 'tzset'):
time.tzset()
# ----------------------------------------------------------
# module babel hack
# make sure unicode [world] territory is "001"
# whereas "unitag" notation is "AA"
# http://www.unicode.org/reports/tr35/#unicode_region_subtag
# http://www.unicode.org/reports/tr35/#unicode_region_subtag_validity
# BCP47: https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
# ----------------------------------------------------------
import babel
_babelCoreParseLocale = babel.core.parse_locale
def _babelCoreParseLocale_unitag(identifier, sep='_'):
lang, territory, script, variant = _babelCoreParseLocale(identifier, sep)
territory = '001' if territory == 'AA' else territory
return lang, territory, script, variant
babel.core.parse_locale = _babelCoreParseLocale_unitag
#----------------------------------------------------------
# Shortcuts
#----------------------------------------------------------
+2 -2
View File
@@ -66,7 +66,7 @@ class ResPartnerBank(models.Model):
_name = 'res.partner.bank'
_rec_name = 'acc_number'
_description = 'Bank Accounts'
_order = 'sequence'
_order = 'sequence, id'
@api.model
def get_supported_account_types(self):
@@ -84,7 +84,7 @@ class ResPartnerBank(models.Model):
bank_id = fields.Many2one('res.bank', string='Bank')
bank_name = fields.Char(related='bank_id.name', readonly=False)
bank_bic = fields.Char(related='bank_id.bic', readonly=False)
sequence = fields.Integer()
sequence = fields.Integer(default=10)
currency_id = fields.Many2one('res.currency', string='Currency')
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.user.company_id, ondelete='cascade')
qr_code_valid = fields.Boolean(string="Has all required arguments", compute="_validate_qr_code_arguments")
+87 -16
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import collections
import datetime
import hashlib
import pytz
@@ -272,8 +273,32 @@ class Partner(models.Model):
@api.depends('is_company', 'parent_id.commercial_partner_id')
def _compute_commercial_partner(self):
self.env.cr.execute("""
WITH RECURSIVE cpid(id, parent_id, commercial_partner_id, final) AS (
SELECT
id, parent_id, id,
(coalesce(is_company, false) OR parent_id IS NULL) as final
FROM res_partner
WHERE id = ANY(%s)
UNION
SELECT
cpid.id, p.parent_id, p.id,
(coalesce(is_company, false) OR p.parent_id IS NULL) as final
FROM res_partner p
JOIN cpid ON (cpid.parent_id = p.id)
WHERE NOT cpid.final
)
SELECT cpid.id, cpid.commercial_partner_id
FROM cpid
WHERE final AND id = ANY(%s);
""", [self.ids, self.ids])
d = dict(self.env.cr.fetchall())
for partner in self:
if partner.is_company or not partner.parent_id:
fetched = d.get(partner.id)
if fetched is not None:
partner.commercial_partner_id = fetched
elif partner.is_company or not partner.parent_id:
partner.commercial_partner_id = partner
else:
partner.commercial_partner_id = partner.parent_id.commercial_partner_id
@@ -467,21 +492,25 @@ class Partner(models.Model):
self.update_address(onchange_vals)
# 2. To DOWNSTREAM: sync children
if self.child_ids:
# 2a. Commercial Fields: sync if commercial entity
if self.commercial_partner_id == self:
commercial_fields = self._commercial_fields()
if any(field in values for field in commercial_fields):
self._commercial_sync_to_children()
for child in self.child_ids.filtered(lambda c: not c.is_company):
if child.commercial_partner_id != self.commercial_partner_id :
self._commercial_sync_to_children()
break
# 2b. Address fields: sync if address changed
address_fields = self._address_fields()
if any(field in values for field in address_fields):
contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
contacts.update_address(values)
self._children_sync(values)
def _children_sync(self, values):
if not self.child_ids:
return
# 2a. Commercial Fields: sync if commercial entity
if self.commercial_partner_id == self:
commercial_fields = self._commercial_fields()
if any(field in values for field in commercial_fields):
self._commercial_sync_to_children()
for child in self.child_ids.filtered(lambda c: not c.is_company):
if child.commercial_partner_id != self.commercial_partner_id:
self._commercial_sync_to_children()
break
# 2b. Address fields: sync if address changed
address_fields = self._address_fields()
if any(field in values for field in address_fields):
contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
contacts.update_address(values)
@api.multi
def _handle_first_contact_creation(self):
@@ -558,11 +587,53 @@ class Partner(models.Model):
tools.image_resize_images(vals, return_big=False)
tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True)
partners = super(Partner, self).create(vals_list)
if self.env.context.get('_partners_skip_fields_sync'):
return partners
for partner, vals in zip(partners, vals_list):
partner._fields_sync(vals)
partner._handle_first_contact_creation()
return partners
def _load_records_create(self, vals_list):
partners = super(Partner, self.with_context(_partners_skip_fields_sync=True))._load_records_create(vals_list)
# batch up first part of _fields_sync
# group partners by commercial_partner_id (if not self) and parent_id (if type == contact)
groups = collections.defaultdict(list)
for partner, vals in zip(partners, vals_list):
cp_id = None
if vals.get('parent_id') and partner.commercial_partner_id != partner:
cp_id = partner.commercial_partner_id.id
add_id = None
if partner.parent_id and partner.type == 'contact':
add_id = partner.parent_id.id
groups[(cp_id, add_id)].append(partner.id)
for (cp_id, add_id), children in groups.items():
# values from parents (commercial, regular) written to their common children
to_write = {}
# commercial fields from commercial partner
if cp_id:
to_write = self.browse(cp_id)._update_fields_values(self._commercial_fields())
# address fields from parent
if add_id:
parent = self.browse(add_id)
for f in self._address_fields():
v = parent[f]
if v:
to_write[f] = v.id if isinstance(v, models.BaseModel) else v
if to_write:
self.browse(children).write(to_write)
# do the second half of _fields_sync the "normal" way
for partner, vals in zip(partners, vals_list):
partner._children_sync(vals)
partner._handle_first_contact_creation()
return partners
@api.multi
def create_company(self):
self.ensure_one()
+35
View File
@@ -252,6 +252,41 @@ class TestBase(TransactionCase):
self.assertEqual(leaf111.address_get([]),
{'contact': branch11.id}, 'Invalid address resolution, branch11 should now be contact')
def test_commercial_partner_nullcompany(self):
""" The commercial partner is the first/nearest ancestor-or-self which
is a company or doesn't have a parent
"""
P = self.env['res.partner']
p0 = P.create({'name': '0', 'email': '0'})
self.assertEqual(p0.commercial_partner_id, p0, "partner without a parent is their own commercial partner")
p1 = P.create({'name': '1', 'email': '1', 'parent_id': p0.id})
self.assertEqual(p1.commercial_partner_id, p0, "partner's parent is their commercial partner")
p12 = P.create({'name': '12', 'email': '12', 'parent_id': p1.id})
self.assertEqual(p12.commercial_partner_id, p0, "partner's GP is their commercial partner")
p2 = P.create({'name': '2', 'email': '2', 'parent_id': p0.id, 'is_company': True})
self.assertEqual(p2.commercial_partner_id, p2, "partner flagged as company is their own commercial partner")
p21 = P.create({'name': '21', 'email': '21', 'parent_id': p2.id})
self.assertEqual(p21.commercial_partner_id, p2, "commercial partner is closest ancestor with themselves as commercial partner")
p3 = P.create({'name': '3', 'email': '3', 'is_company': True})
self.assertEqual(p3.commercial_partner_id, p3, "being both parent-less and company should be the same as either")
notcompanies = p0 | p1 | p12 | p21
self.env.cr.execute('update res_partner set is_company=null where id = any(%s)', [notcompanies.ids])
for parent in notcompanies:
p = P.create({
'name': parent.name + '_sub',
'email': parent.email + '_sub',
'parent_id': parent.id,
})
self.assertEqual(
p.commercial_partner_id,
parent.commercial_partner_id,
"check that is_company=null is properly handled when looking for ancestor"
)
def test_50_res_partner_commercial_sync(self):
res_partner = self.env['res.partner']
p0 = res_partner.create({'name': 'Sigurd Sunknife',
+2 -1
View File
@@ -69,6 +69,7 @@
<form string="Bank account">
<group>
<group>
<field name="sequence" invisible="1"/>
<field name="acc_number"/>
<field name="acc_type"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
@@ -89,7 +90,7 @@
<field name="model">res.partner.bank</field>
<field name="arch" type="xml">
<tree string="Bank Accounts">
<field name="sequence" invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="acc_number"/>
<field name="bank_name"/>
<field name="company_id" groups="base.group_multi_company"/>
@@ -12,7 +12,10 @@ access_test_testing_utilities_parent,access_test_testing_utilities_parent,model_
access_test_testing_utilities_sub,access_test_testing_utilities_sub,model_test_testing_utilities_sub,,1,0,0,0
access_test_testing_utilities_sub2,access_test_testing_utilities_sub2,model_test_testing_utilities_sub2,,1,0,0,0
access_test_testing_utilities_sub3,access_test_testing_utilities_sub3,model_test_testing_utilities_sub3,,1,0,0,0
access_test_testing_utilities_recursive,access_test_testing_utilities_recursive,model_test_testing_utilities_recursive,,1,0,0,0
access_test_testing_utilities_onchange_parent,access_test_testing_utilities_onchange_parent,model_test_testing_utilities_onchange_parent,,1,0,0,0
access_test_testing_utilities_onchange_line,access_test_testing_utilities_onchange_line,model_test_testing_utilities_onchange_line,,1,0,0,0
access_model_test_testing_utilities_onchange_count,access_model_test_testing_utilities_onchange_count,model_test_testing_utilities_onchange_count,,1,1,1,1
access_test_testing_utilities_onchange_count_sub,access_test_testing_utilities_onchange_count_sub,model_test_testing_utilities_onchange_count_sub,,1,1,1,1
access_o2m_readonly_subfield_parent,access_o2m_readonly_subfield_parent,model_o2m_readonly_subfield_parent,,1,1,1,1
access_o2m_readonly_subfield_child,access_o2m_readonly_subfield_child,model_o2m_readonly_subfield_child,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_test_testing_utilities_sub access_test_testing_utilities_sub model_test_testing_utilities_sub 1 0 0 0
13 access_test_testing_utilities_sub2 access_test_testing_utilities_sub2 model_test_testing_utilities_sub2 1 0 0 0
14 access_test_testing_utilities_sub3 access_test_testing_utilities_sub3 model_test_testing_utilities_sub3 1 0 0 0
15 access_test_testing_utilities_recursive access_test_testing_utilities_recursive model_test_testing_utilities_recursive 1 0 0 0
16 access_test_testing_utilities_onchange_parent access_test_testing_utilities_onchange_parent model_test_testing_utilities_onchange_parent 1 0 0 0
17 access_test_testing_utilities_onchange_line access_test_testing_utilities_onchange_line model_test_testing_utilities_onchange_line 1 0 0 0
18 access_model_test_testing_utilities_onchange_count access_model_test_testing_utilities_onchange_count model_test_testing_utilities_onchange_count 1 1 1 1
19 access_test_testing_utilities_onchange_count_sub access_test_testing_utilities_onchange_count_sub model_test_testing_utilities_onchange_count_sub 1 1 1 1
20 access_o2m_readonly_subfield_parent access_o2m_readonly_subfield_parent model_o2m_readonly_subfield_parent 1 1 1 1
21 access_o2m_readonly_subfield_child access_o2m_readonly_subfield_child model_o2m_readonly_subfield_child 1 1 1 1
@@ -151,4 +151,17 @@
</form>
</field>
</record>
<record id="o2m_recursive_relation_view" model="ir.ui.view">
<field name="name">Recursive Relation Test View</field>
<field name="model">test_testing_utilities.recursive</field>
<field name="arch" type="xml">
<form>
<sheet>
<field name="one_to_many_id"/>
<field name="many_to_one_ids"/>
</sheet>
</form>
</field>
</record>
</odoo>
@@ -188,6 +188,13 @@ class O2MSub3(models.Model):
r.name = str(r.v)
class O2MRecursive(models.Model):
_name = _description = 'test_testing_utilities.recursive'
one_to_many_id = fields.Many2one('test_testing_utilities.recursive', readonly=True)
many_to_one_ids = fields.One2many('test_testing_utilities.recursive', 'one_to_many_id', readonly=True)
class O2MOnchangeParent(models.Model):
_name = 'test_testing_utilities.onchange_parent'
_description = 'Testing Utilities Onchange Parent'
@@ -233,3 +240,24 @@ class O2MChangeSub(models.Model):
parent = fields.Many2one('test_testing_utilities.onchange_count')
name = fields.Char()
class O2MReadonlySubfield(models.Model):
_name = 'o2m_readonly_subfield_parent'
_description = _name
line_ids = fields.One2many('o2m_readonly_subfield_child', 'parent_id')
class O2MReadonlySubfieldChild(models.Model):
_name = _description = 'o2m_readonly_subfield_child'
name = fields.Char()
parent_id = fields.Many2one('o2m_readonly_subfield_parent')
f = fields.Integer(compute='_compute_f', inverse='_inverse_f', readonly=True)
@api.depends('name')
def _compute_f(self):
for r in self:
r.f = len(r.name) if r.name else 0
def _inverse_f(self):
raise AssertionError("Inverse of f should not be called")
@@ -359,7 +359,7 @@ class TestO2M(TransactionCase):
self.assertEqual(
[get(s) for s in r.subs],
[("5", 2, 5)]
[("5", 0, 5)]
)
def test_o2m_inner_default(self):
@@ -409,7 +409,10 @@ class TestO2M(TransactionCase):
self.assertEqual(sub.value, 1)
self.assertEqual(sub.v, 1)
def test_m2o_readonly(self):
def test_readonly_o2m(self):
""" Tests that o2m fields flagged as readonly (readonly="1" in the
view) can't be written to
"""
r = self.env['test_testing_utilities.parent'].create({
'subs': [(0, 0, {})]
})
@@ -422,6 +425,20 @@ class TestO2M(TransactionCase):
with self.assertRaises(AssertionError):
f.subs.remove(index=0)
def test_o2m_readonly_subfield(self):
""" Tests that readonly is applied to the field of the o2m = not sent
as part of the create / write values
"""
f = Form(self.env['o2m_readonly_subfield_parent'])
with f.line_ids.new() as new_line:
new_line.name = "ok"
self.assertEqual(new_line.f, 2)
r = f.save()
self.assertEqual(
(r.line_ids.name, r.line_ids.f),
('ok', 2)
)
def test_o2m_dyn_onchange(self):
f = Form(self.env['test_testing_utilities.onchange_parent'], view='test_testing_utilities.m2o_onchange_view')
@@ -467,6 +484,9 @@ class TestO2M(TransactionCase):
self.assertEqual(commands(), [0, 2, 2], "should contain 1 '0' command and 2 deletions")
self.assertEqual(len(r.line_ids), 1)
def test_o2m_self_recursive(self):
Form(self.env['test_testing_utilities.recursive'], view='test_testing_utilities.o2m_recursive_relation_view')
class TestEdition(TransactionCase):
""" These use the context manager form as we don't need the record
post-save (we already have it) and it's easier to see what bits act on
+90 -41
View File
@@ -1110,7 +1110,7 @@ class Form(object):
else:
self._init_from_defaults(self._model)
def _o2m_set_edition_view(self, descr, node):
def _o2m_set_edition_view(self, descr, node, level):
default_view = next(
(m for m in node.get('mode', 'tree').split(',') if m != 'form'),
'tree'
@@ -1138,17 +1138,10 @@ class Form(object):
edition = views['tree']
edition['tree'] = subarch
self._process_fvg(submodel, edition)
# don't recursively process o2ms in o2ms
self._process_fvg(submodel, edition, level=level-1)
descr['views']['edition'] = edition
def _get_node(self, f):
""" Find etree node for the field ``f`` in the current arch
"""
return next(
n for n in self._view['tree'].iter('field')
if n.get('name') == f
)
def __str__(self):
return "<%s %s(%s)>" % (
type(self).__name__,
@@ -1156,16 +1149,16 @@ class Form(object):
self._values.get('id', False),
)
def _process_fvg(self, model, fvg):
def _process_fvg(self, model, fvg, level=2):
""" Post-processes to augment the fields_view_get with:
* an id field (may not be present if not in the view but needed)
* pre-processed modifiers (map of modifier name to json-loaded domain)
* pre-processed onchanges list
"""
fvg['fields']['id'] = {'type': 'id'}
fvg['fields'].setdefault('id', {'type': 'id'})
# pre-resolve modifiers & bind to arch toplevel
modifiers = fvg['modifiers'] = {}
modifiers = fvg['modifiers'] = {'id': {'required': False, 'readonly': True}}
contexts = fvg['contexts'] = {}
order = fvg['fields_ordered'] = []
for f in fvg['tree'].xpath('//field[not(ancestor::field)]'):
@@ -1181,10 +1174,9 @@ class Form(object):
contexts[fname] = ctx
descr = fvg['fields'].get(fname) or {'type': None}
if descr['type'] == 'one2many':
self._o2m_set_edition_view(descr, f)
if level and descr['type'] == 'one2many':
self._o2m_set_edition_view(descr, f, level)
fvg['modifiers']['id'] = {'required': False, 'readonly': True}
fvg['onchange'] = model._onchange_spec(fvg)
def _init_from_defaults(self, model):
@@ -1242,12 +1234,13 @@ class Form(object):
return O2MProxy(self, field)
return v
def _get_modifier(self, field, modifier, default=False):
d = self._view['modifiers'][field].get(modifier, default)
def _get_modifier(self, field, modifier, default=False, modmap=None, vals=None):
d = (modmap or self._view['modifiers'])[field].get(modifier, default)
if isinstance(d, bool):
return d
vals = self._values
if vals is None:
vals = self._values
stack = []
for it in reversed(d):
if it == '!':
@@ -1262,7 +1255,9 @@ class Form(object):
stack.append(e1 or e2)
elif isinstance(it, list):
f, op, val = it
field_val = vals[f]
# hack-ish handling of parent.<field> modifiers
f, n = re.subn(r'^parent\.', '', f, 1)
field_val = (vals['•parent•'] if n else vals)[f]
stack.append(self._OPS[op](field_val, val))
else:
raise ValueError("Unknown domain element %s" % it)
@@ -1361,7 +1356,8 @@ class Form(object):
load/save
"""
values = {}
for f in self._view['fields']:
fields = self._view['fields']
for f in fields:
v = self._values[f]
if self._get_modifier(f, 'required'):
assert v is not False, "{} is a required field".format(f)
@@ -1371,10 +1367,34 @@ class Form(object):
continue
if self._get_modifier(f, 'readonly'):
node = self._get_node(f)
node = _get_node(self._view, f)
if not node.get('force_save'):
continue
if fields[f]['type'] == 'one2many':
view = fields[f]['views']['edition']
modifiers = view['modifiers']
oldvals = v
v = []
nodes = {
n.get('name'): n
for n in view['tree'].iter('field')
}
nodes['id'] = etree.Element('field', attrib={'name': 'id'})
for (c, rid, vs) in oldvals:
if c in (0, 1):
items = list(getattr(vs, 'changed_items', vs.items)())
# FIXME: should be more extensive processing of o2m defaults
vs.setdefault('id', False)
vs['•parent•'] = self._values
vs = {
k: v for k, v in items
if nodes[k].get('force_save') or not self._get_modifier(k, 'readonly', modmap=modifiers, vals=vs)
}
v.append((c, rid, vs))
values[f] = v
return values
@@ -1409,11 +1429,15 @@ class Form(object):
values = {}
for k, v in self._values.items():
if f[k]['type'] == 'one2many':
# web client sends a 4 for unmodified o2m rows
values[k] = [
(4, rid, False) if (c == 1 and not vs) else (c, rid, vs)
for (c, rid, vs) in v
]
it = values[k] = []
for (c, rid, vs) in v:
if c == 1 and not vs:
# web client sends a 4 for unmodified o2m rows
it.append((4, rid, False))
elif c == 1 and isinstance(vs, UpdateDict):
it.append((1, rid, dict(vs.changed_items())))
else:
it.append((c, rid, vs))
else:
values[k] = v
return values
@@ -1512,9 +1536,16 @@ class O2MForm(Form):
if self._index is None:
commands.append((0, 0, values))
else:
(c, _, vs) = commands[proxy._command_index(self._index)]
assert c in (0, 1)
vs.update(values)
index = proxy._command_index(self._index)
(c, id_, vs) = commands[index]
if c == 0:
vs.update(values)
elif c == 1:
vs = UpdateDict(vs)
vs.update(values)
commands[index] = (1, id_, vs)
else:
raise AssertionError("Expected command type 0 or 1, found %s" % c)
# FIXME: should be called when performing on change => value needs to be serialised into parent every time?
proxy._parent._perform_onchange([proxy._field])
@@ -1523,20 +1554,31 @@ class O2MForm(Form):
""" Validates values and returns only fields modified since
load/save
"""
values = {}
for f in self._view['fields']:
v = self._values[f]
if self._get_modifier(f, 'required'):
assert v is not False, "{} is a required field".format(f)
values = UpdateDict(self._values)
values._changed.update(self._changed)
for f in self._view['fields']:
if self._get_modifier(f, 'required'):
assert self._values[f] is not False, "{} is a required field".format(f)
# skip unmodified fields
if f not in self._changed:
continue
# if self._get_modifier(f, 'readonly'):
# continue
values[f] = v
return values
class UpdateDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._changed = set()
def changed_items(self):
return (
(k, v) for k, v in self.items()
if k in self._changed
)
def update(self, *args, **kw):
super().update(*args, **kw)
if args and isinstance(args[0], UpdateDict):
self._changed.update(args[0]._changed)
class X2MProxy(object):
_parent = None
_field = None
@@ -1738,6 +1780,13 @@ def record_to_values(fields, record):
r[f] = v
return r
def _get_node(view, f, *arg):
""" Find etree node for the field ``f`` in the view's arch
"""
return next((
n for n in view['tree'].iter('field')
if n.get('name') == f
), *arg)
def tagged(*tags):
"""
+4
View File
@@ -73,6 +73,10 @@ def get_fiscal_year(date, day=31, month=12):
date_from = date_to + relativedelta(days=1)
max_day = calendar.monthrange(date_to.year + 1, date_to.month)[1]
date_to = type(date)(date.year + 1, month, min(day, max_day))
# Force at 29 February instead of 28 in case of leap year.
if date_to.month == 2 and date_to.day == 28 and max_day == 29:
date_to += relativedelta(days=1)
return date_from, date_to