[MERGE] forward port branch saas-12.1 up to f2d0995362
This commit is contained in:
@@ -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},
|
||||
|
||||
@@ -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 +1 @@
|
||||
from . import driver
|
||||
from . import loading
|
||||
from . import driver
|
||||
@@ -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()
|
||||
|
||||
@@ -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 |
@@ -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', '>', 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
<t t-set="model" t-value="widget.modelName"/>
|
||||
<div class="o_viewer-header">
|
||||
<span class="o_image_caption">
|
||||
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.type == 'image'" role="img" aria-label="Image" title="Image"/>
|
||||
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.type == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
|
||||
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.type == 'video'" role="img" aria-label="Video" title="Video"/>
|
||||
<i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
|
||||
<i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
|
||||
<i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
|
||||
<t t-esc="widget.activeAttachment.name"/>
|
||||
<a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
|
||||
</span>
|
||||
@@ -48,19 +48,19 @@
|
||||
</div>
|
||||
<div class="o_viewer_img_wrapper">
|
||||
<div class="o_viewer_zoomer">
|
||||
<div t-if="widget.activeAttachment.type == 'image'" class="o_loading_img text-center">
|
||||
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_loading_img text-center">
|
||||
<i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
|
||||
</div>
|
||||
<img t-if="widget.activeAttachment.type === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&signature=#{widget.activeAttachment.checksum}&model=#{model}" alt="Viewer"/>
|
||||
<iframe t-if="widget.activeAttachment.type == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="(widget.activeAttachment.type || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="widget.activeAttachment.type == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
|
||||
<video t-if="widget.activeAttachment.type == 'video'" class="o_viewer_video" controls="controls">
|
||||
<img t-if="widget.activeAttachment.fileType === 'image'" class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=1&signature=#{widget.activeAttachment.checksum}&model=#{model}" alt="Viewer"/>
|
||||
<iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
|
||||
<iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
|
||||
<video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
|
||||
<source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="widget.activeAttachment.type == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
|
||||
<div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
|
||||
<div class="btn-group ml0" role="group">
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
|
||||
<a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
+20
@@ -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'
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
#----------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user