[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')
|
statements = self.filtered(lambda r: r.state == 'open')
|
||||||
for statement in statements:
|
for statement in statements:
|
||||||
moves = self.env['account.move']
|
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:
|
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
|
#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.
|
#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()
|
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.'))
|
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 = statement.mapped('line_ids.journal_entry_ids.move_id')
|
||||||
moves |= aml.move_id
|
|
||||||
if moves:
|
if moves:
|
||||||
moves.filtered(lambda m: m.state != 'posted').post()
|
moves.filtered(lambda m: m.state != 'posted').post()
|
||||||
statement.message_post(body=_('Statement %s confirmed, journal items were created.') % (statement.name,))
|
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
|
WHERE st_line.id IN %s
|
||||||
'''
|
'''
|
||||||
params = [tuple(st_lines.ids)]
|
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)
|
self._cr.execute(query, params)
|
||||||
|
|
||||||
result = {}
|
result = {}
|
||||||
|
|||||||
@@ -553,7 +553,10 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
name: 'to_check',
|
name: 'to_check',
|
||||||
}], {
|
}], {
|
||||||
account_id: {string: _t("Account")},
|
account_id: {
|
||||||
|
string: _t("Account"),
|
||||||
|
domain: [['deprecated', '=', false]],
|
||||||
|
},
|
||||||
label: {string: _t("Label")},
|
label: {string: _t("Label")},
|
||||||
amount: {string: _t("Account")},
|
amount: {string: _t("Account")},
|
||||||
}).then(function (recordID) {
|
}).then(function (recordID) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ var db = {
|
|||||||
code: {string: "code", type: 'integer'},
|
code: {string: "code", type: 'integer'},
|
||||||
name: {string: "Displayed name", type: 'char'},
|
name: {string: "Displayed name", type: 'char'},
|
||||||
company_id: {string: "Company", type: 'many2one', relation: 'res.company'},
|
company_id: {string: "Company", type: 'many2one', relation: 'res.company'},
|
||||||
|
deprecated: {string: "Deprecated", type: 'boolean'},
|
||||||
},
|
},
|
||||||
records: [
|
records: [
|
||||||
{id: 282, code: 100000, name: "100000 Fixed Asset Account", company_id: 1},
|
{id: 282, code: 100000, name: "100000 Fixed Asset Account", company_id: 1},
|
||||||
|
|||||||
@@ -59,18 +59,19 @@
|
|||||||
<span>Disc.(%)</span>
|
<span>Disc.(%)</span>
|
||||||
</th>
|
</th>
|
||||||
<th t-attf-class="text-left {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Taxes</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">
|
||||||
<th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="invoice_tbody">
|
<tbody class="invoice_tbody">
|
||||||
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
|
<t t-set="current_subtotal" t-value="0"/>
|
||||||
<t t-set="current_subtotal_tax_included" t-value="0"/>
|
|
||||||
|
|
||||||
<t t-foreach="o.invoice_line_ids" t-as="line">
|
<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" t-value="current_subtotal + 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_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 ''">
|
<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">
|
<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 '' }}">
|
<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))"/>
|
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.invoice_line_tax_ids))"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded">
|
<td class="text-right o_price_total">
|
||||||
<span t-field="line.price_subtotal"/>
|
<span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
|
||||||
</td>
|
<span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
|
||||||
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included">
|
|
||||||
<span t-field="line.price_total"/>
|
|
||||||
</td>
|
</td>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="line.display_type == 'line_section'">
|
<t t-if="line.display_type == 'line_section'">
|
||||||
@@ -101,8 +100,7 @@
|
|||||||
<span t-field="line.name"/>
|
<span t-field="line.name"/>
|
||||||
</td>
|
</td>
|
||||||
<t t-set="current_section" t-value="line"/>
|
<t t-set="current_section" t-value="line"/>
|
||||||
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
|
<t t-set="current_subtotal" t-value="0"/>
|
||||||
<t t-set="current_subtotal_tax_included" t-value="0"/>
|
|
||||||
</t>
|
</t>
|
||||||
<t t-if="line.display_type == 'line_note'">
|
<t t-if="line.display_type == 'line_note'">
|
||||||
<td colspan="99">
|
<td colspan="99">
|
||||||
@@ -116,14 +114,9 @@
|
|||||||
<td colspan="99">
|
<td colspan="99">
|
||||||
<strong class="mr16">Subtotal</strong>
|
<strong class="mr16">Subtotal</strong>
|
||||||
<span
|
<span
|
||||||
groups="account.group_show_line_subtotals_tax_excluded"
|
t-esc="current_subtotal"
|
||||||
t-esc="current_subtotal_tax_excluded"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": o.currency_id}'
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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.date_invoice"/></td>
|
||||||
<td><span t-field="inv.number"/></td>
|
<td><span t-field="inv.number"/></td>
|
||||||
<td class="text-right"><span t-field="inv.amount_total"/></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
|
""" 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.
|
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
|
return None
|
||||||
|
|
||||||
multi_stub = self.company_id.account_check_printing_multi_stub
|
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')
|
debits = invoices.filtered(lambda r: r.type == 'in_invoice')
|
||||||
credits = invoices.filtered(lambda r: r.type == 'in_refund')
|
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",
|
street_number2 = fields.Char('Door', compute='_split_street', help="Door Number",
|
||||||
inverse='_set_street', store=True)
|
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):
|
def get_street_fields(self):
|
||||||
"""Returns the fields that can be used in a street format.
|
"""Returns the fields that can be used in a street format.
|
||||||
Overwrite this function if you want to add your own fields."""
|
Overwrite this function if you want to add your own fields."""
|
||||||
@@ -144,7 +139,7 @@ class Partner(models.Model):
|
|||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
res = super(Partner, self).write(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()
|
self._set_street()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,12 @@ class GeoCoder(models.AbstractModel):
|
|||||||
if result['status'] != 'OK':
|
if result['status'] != 'OK':
|
||||||
_logger.debug('Invalid Gmaps call: %s - %s',
|
_logger.debug('Invalid Gmaps call: %s - %s',
|
||||||
result['status'], result.get('error_message', ''))
|
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']
|
geo = result['results'][0]['geometry']['location']
|
||||||
return float(geo['lat']), float(geo['lng'])
|
return float(geo['lat']), float(geo['lng'])
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
|
|||||||
@@ -786,6 +786,53 @@ QUnit.test('save two searches to dashboard', function (assert) {
|
|||||||
actionManager.destroy();
|
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) {
|
QUnit.test("Views should be loaded in the user's language", function (assert) {
|
||||||
assert.expect(2);
|
assert.expect(2);
|
||||||
var form = createView({
|
var form = createView({
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
from . import driver
|
from . import driver
|
||||||
from . import loading
|
|
||||||
@@ -1,432 +1,401 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from threading import Thread
|
from threading import Thread, Event
|
||||||
import usb
|
from usb import core
|
||||||
import gatt
|
from gatt import DeviceManager as Gatt_DeviceManager
|
||||||
import subprocess
|
import subprocess
|
||||||
import netifaces as ni
|
import netifaces
|
||||||
import json
|
import json
|
||||||
import re
|
from re import sub
|
||||||
from odoo import http
|
|
||||||
import urllib3
|
import urllib3
|
||||||
from odoo.http import request as httprequest
|
import os
|
||||||
import datetime
|
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')
|
from odoo import http, _
|
||||||
_logger = logging.getLogger('dispatcher')
|
from odoo.modules.module import get_resource_path
|
||||||
|
|
||||||
owner_dict = {}
|
_logger = logging.getLogger(__name__)
|
||||||
last_ping = {}
|
|
||||||
|
|
||||||
|
#----------------------------------------------------------
|
||||||
|
# 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):
|
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)
|
@http.route('/hw_drivers/event', type='json', auth='none', cors='*', csrf=False, save_session=False)
|
||||||
def check_cantakeowner(self): #, devices, tab
|
def event(self, listener):
|
||||||
data = httprequest.jsonrequest
|
"""
|
||||||
for device in data['devices']:
|
listener is a dict in witch there are a sessions_id and a dict of device_id to listen
|
||||||
if owner_dict.get(device) and owner_dict[device] != data['tab']:
|
"""
|
||||||
before_date = datetime.datetime.now() - datetime.timedelta(seconds=10)
|
req = event_manager.add_request(listener)
|
||||||
if last_ping.get(owner_dict[device]) and last_ping.get(owner_dict[device]) > before_date:
|
if req['event'].wait(50):
|
||||||
return 'no'
|
req['event'].clear()
|
||||||
else:
|
req['result']['session_id'] = req['session_id']
|
||||||
old_tab = owner_dict[device]
|
return req['result']
|
||||||
for dev2 in owner_dict:
|
|
||||||
if owner_dict[dev2] == old_tab:
|
|
||||||
owner_dict[dev2] = ''
|
|
||||||
return 'yes'
|
|
||||||
|
|
||||||
@http.route('/hw_drivers/owner/take', type='json', auth='none', cors='*', csrf=False)
|
@http.route('/hw_drivers/box/connect', type='http', auth='none', cors='*', csrf=False, save_session=False)
|
||||||
def take_ownership(self): #, devices, tab
|
def connect_box(self, token):
|
||||||
data = httprequest.jsonrequest
|
"""
|
||||||
for device in data['devices']:
|
This route is called when we want that a IoT Box will be connected to a Odoo DB
|
||||||
owner_dict[device] = data['tab']
|
token is a base 64 encoded string and have 2 argument separate by |
|
||||||
last_ping[data['tab']] = datetime.datetime.now()
|
1 - url of odoo DB
|
||||||
return data['tab']
|
2 - token. This token will be compared to the token of Odoo. He have 1 hour lifetime
|
||||||
|
"""
|
||||||
@http.route('/hw_drivers/owner/ping', type='json', auth='none', cors='*', csrf=False)
|
server = get_odoo_server_url()
|
||||||
def ping_trigger(self): #, tab
|
image = get_resource_path('hw_drivers', 'static/img', 'False.jpg')
|
||||||
data = httprequest.jsonrequest
|
if server == '':
|
||||||
ping_dict = {}
|
token = b64decode(token).decode('utf-8')
|
||||||
last_ping[data['tab']] = datetime.datetime.now()
|
url, token = token.split('|')
|
||||||
for dev in data['devices']:
|
try:
|
||||||
if owner_dict.get(dev) and owner_dict[dev] == data['tab']:
|
subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, '', token, 'noreboot'])
|
||||||
for driver_path in drivers:
|
m.send_alldevices()
|
||||||
if driver_path.find(dev) == 0 and drivers[driver_path].ping_value:
|
image = get_resource_path('hw_drivers', 'static/img', 'True.jpg')
|
||||||
ping_dict[dev] = drivers[driver_path].ping_value
|
except subprocess.CalledProcessError as e:
|
||||||
drivers[driver_path].ping_value = '' # or set it to nothing
|
_logger.error('A error encountered : %s ' % e.output)
|
||||||
else:
|
if os.path.isfile(image):
|
||||||
ping_dict[dev] = 'STOP'
|
with open(image, 'rb') as f:
|
||||||
return ping_dict
|
return f.read()
|
||||||
|
|
||||||
@http.route('/hw_drivers/box/connect', type='json', auth='none', cors='*', csrf=False)
|
|
||||||
def connect_box(self):
|
|
||||||
data = httprequest.jsonrequest
|
|
||||||
server = "" # read from file
|
|
||||||
try:
|
|
||||||
f = open('/home/pi/odoo-remote-server.conf', 'r')
|
|
||||||
for line in f:
|
|
||||||
server += line
|
|
||||||
f.close()
|
|
||||||
server = server.split('\n')[0]
|
|
||||||
except:
|
|
||||||
server = ''
|
|
||||||
if server:
|
|
||||||
return 'This IoTBox has already been connected'
|
|
||||||
else:
|
|
||||||
iotname = ''
|
|
||||||
token = data['token'].split('|')[1]
|
|
||||||
url = data['token'].split('|')[0]
|
|
||||||
reboot = 'noreboot'
|
|
||||||
subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server.sh', url, iotname, token, reboot])
|
|
||||||
send_iot_box_device(False)
|
|
||||||
return 'IoTBox connected'
|
|
||||||
|
|
||||||
@http.route('/hw_drivers/drivers/status', type='http', auth='none', cors='*')
|
|
||||||
def status(self):
|
|
||||||
result = "<html><head></head><body>List of drivers and values: <br/> <ul>"
|
|
||||||
for path in drivers:
|
|
||||||
result += "<li>" + path + ":" + str(drivers[path].value) + "</li>"
|
|
||||||
result += "</ul>"
|
|
||||||
result +=" </body></html>"
|
|
||||||
return result
|
|
||||||
|
|
||||||
@http.route('/hw_drivers/driverdetails/<string:identifier>', type='http', auth='none', cors='*')
|
|
||||||
def statusdetail(self, identifier):
|
|
||||||
for device in drivers:
|
|
||||||
if device.find(identifier) != -1:
|
|
||||||
return str(drivers[device].value)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
@http.route('/hw_drivers/driveraction/<string:identifier>', type='json', auth='none', cors='*', csrf=False)
|
|
||||||
def driveraction(self, identifier):
|
|
||||||
data = httprequest.jsonrequest
|
|
||||||
result = 'device not found'
|
|
||||||
if data.get('action') == 'print':
|
|
||||||
with open('/tmp/toprinter', 'w') as file:
|
|
||||||
file.write(data['data'])
|
|
||||||
subprocess.call("cat /tmp/toprinter | base64 -d | lp -d " + identifier, shell=True)
|
|
||||||
result = 'ok'
|
|
||||||
if data.get('action') == 'camera':
|
|
||||||
cameras = subprocess.check_output("v4l2-ctl --list-devices", shell=True).decode('utf-8').split('\n\n')
|
|
||||||
adrress = '/dev/video0'
|
|
||||||
for camera in cameras:
|
|
||||||
if camera:
|
|
||||||
camera = camera.split('\n\t')
|
|
||||||
serial = re.sub('[^a-zA-Z0-9 ]+', '', camera[0].split(': ')[0]).replace(' ','_')
|
|
||||||
if serial == data.get('identifier'):
|
|
||||||
adrress = camera[1]
|
|
||||||
picture = subprocess.check_output("v4l2-ctl --list-formats-ext|grep 'Size'|awk '{print $3}'|sort -rn|awk NR==1", shell=True).decode('utf-8')
|
|
||||||
subprocess.call("fswebcam -d " + adrress + " /tmp/testimage -r " + picture, shell=True)
|
|
||||||
image_bytes = subprocess.check_output('cat /tmp/testimage | base64',shell=True)
|
|
||||||
result = {'image': image_bytes}
|
|
||||||
return result
|
|
||||||
|
|
||||||
@http.route('/hw_drivers/send_iot_box', type='http', auth='none', cors='*')
|
|
||||||
def send_iot_box(self):
|
|
||||||
send_iot_box_device(False)
|
|
||||||
return 'ok'
|
|
||||||
|
|
||||||
#----------------------------------------------------------
|
#----------------------------------------------------------
|
||||||
# Driver common interface
|
# Drivers
|
||||||
#----------------------------------------------------------
|
#----------------------------------------------------------
|
||||||
class Driver(Thread):
|
|
||||||
ping_value = ""
|
|
||||||
|
|
||||||
def supported(self):
|
drivers = []
|
||||||
pass
|
bt_devices = {}
|
||||||
|
iot_devices = {}
|
||||||
|
|
||||||
def value(self):
|
class DriverMetaClass(type):
|
||||||
pass
|
|
||||||
|
|
||||||
def get_name(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_connection(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def action(self, action):
|
|
||||||
pass
|
|
||||||
|
|
||||||
#----------------------------------------------------------
|
|
||||||
# Usb drivers
|
|
||||||
#----------------------------------------------------------
|
|
||||||
usbdrivers = []
|
|
||||||
drivers = {}
|
|
||||||
|
|
||||||
class UsbMetaClass(type):
|
|
||||||
def __new__(cls, clsname, bases, attrs):
|
def __new__(cls, clsname, bases, attrs):
|
||||||
newclass = super(UsbMetaClass, cls).__new__(cls, clsname, bases, attrs)
|
newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs)
|
||||||
usbdrivers.append(newclass)
|
drivers.append(newclass)
|
||||||
return newclass
|
return newclass
|
||||||
|
|
||||||
class USBDriver(Driver,metaclass=UsbMetaClass):
|
class Driver(Thread, metaclass=DriverMetaClass):
|
||||||
def __init__(self, dev):
|
"""
|
||||||
super(USBDriver, self).__init__()
|
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.dev = dev
|
||||||
self.value = ""
|
self.connection_type = connection_type
|
||||||
|
|
||||||
def get_name(self):
|
event_manager = EventManager()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
#----------------------------------------------------------
|
#----------------------------------------------------------
|
||||||
# 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):
|
def device_discovered(self, device):
|
||||||
# TODO: need some kind of updated_devices mechanism or not?
|
|
||||||
path = "bt_%s" % (device.mac_address,)
|
path = "bt_%s" % (device.mac_address,)
|
||||||
if path not in drivers:
|
if path not in bt_devices:
|
||||||
for driverclass in btdrivers:
|
device.manager = self
|
||||||
d = driverclass(device = device, manager=self)
|
iot_device = IoTDevice(device, 'bluetooth')
|
||||||
if d.supported():
|
bt_devices[path] = iot_device
|
||||||
drivers[path] = d
|
|
||||||
d.connect()
|
|
||||||
send_iot_box_device(False)
|
|
||||||
|
|
||||||
|
|
||||||
class BtManager(Thread):
|
class BtManager(Thread):
|
||||||
gatt_manager = False
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
dm = GattBtManager(adapter_name='hci0')
|
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.start_discovery()
|
||||||
dm.run()
|
dm.run()
|
||||||
|
|
||||||
#----------------------------------------------------------
|
conn = cups_connection()
|
||||||
# Bluetooth drivers
|
PPDs = conn.getPPDs()
|
||||||
#----------------------------------------------------------
|
printers = conn.getPrinters()
|
||||||
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)
|
|
||||||
|
|
||||||
|
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 subprocess
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import netifaces as ni
|
import netifaces
|
||||||
import odoo
|
import odoo
|
||||||
from odoo import http
|
from odoo import http
|
||||||
import zipfile
|
import zipfile
|
||||||
@@ -15,12 +15,28 @@ import io
|
|||||||
import os
|
import os
|
||||||
from odoo.tools import misc
|
from odoo.tools import misc
|
||||||
import urllib3
|
import urllib3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from uuid import getnode as get_mac
|
from uuid import getnode as get_mac
|
||||||
from odoo.addons.hw_proxy.controllers import main as hw_proxy
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
#----------------------------------------------------------
|
||||||
|
# Helper
|
||||||
|
#----------------------------------------------------------
|
||||||
|
|
||||||
|
def access_point():
|
||||||
|
return get_ip() == '10.11.12.1'
|
||||||
|
|
||||||
|
#----------------------------------------------------------
|
||||||
|
# Controllers
|
||||||
|
#----------------------------------------------------------
|
||||||
|
|
||||||
if hasattr(sys, 'frozen'):
|
if hasattr(sys, 'frozen'):
|
||||||
# When running on compiled windows binary, we don't have access to package loader.
|
# 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'))
|
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')
|
remote_connect_template = jinja_env.get_template('remote_connect.html')
|
||||||
configure_wizard_template = jinja_env.get_template('configure_wizard.html')
|
configure_wizard_template = jinja_env.get_template('configure_wizard.html')
|
||||||
|
|
||||||
class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
class IoTboxHomepage(web.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
|
|
||||||
|
|
||||||
def get_hw_screen_message(self):
|
def get_hw_screen_message(self):
|
||||||
return """
|
return """
|
||||||
@@ -67,18 +72,6 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
statuses[driver] = hw_proxy.drivers[driver].get_status()
|
statuses[driver] = hw_proxy.drivers[driver].get_status()
|
||||||
return statuses
|
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):
|
def get_homepage_data(self):
|
||||||
hostname = str(socket.gethostname())
|
hostname = str(socket.gethostname())
|
||||||
mac = get_mac()
|
mac = get_mac()
|
||||||
@@ -88,7 +81,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
if wired == 'up':
|
if wired == 'up':
|
||||||
network = 'Ethernet'
|
network = 'Ethernet'
|
||||||
elif ssid:
|
elif ssid:
|
||||||
if self.get_ip_iotbox() == '10.11.12.1':
|
if access_point():
|
||||||
network = 'Wifi access point'
|
network = 'Wifi access point'
|
||||||
else:
|
else:
|
||||||
network = 'Wifi : ' + ssid
|
network = 'Wifi : ' + ssid
|
||||||
@@ -105,70 +98,33 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
'message': ' '.join(pos_device[status]['messages'])
|
'message': ' '.join(pos_device[status]['messages'])
|
||||||
})
|
})
|
||||||
|
|
||||||
hdmi_name = subprocess.check_output('tvservice -n', shell=True).decode('utf-8')
|
for device in iot_devices:
|
||||||
if hdmi_name.find('=') != -1:
|
|
||||||
hdmi_name = hdmi_name.split('=')[1]
|
|
||||||
hdmi_message = subprocess.check_output('tvservice -s', shell=True).decode('utf-8')
|
|
||||||
iot_device.append({
|
iot_device.append({
|
||||||
'name': 'display : ' + hdmi_name,
|
'name': iot_devices[device].device_name + ' : ' + str(iot_devices[device].data['value']),
|
||||||
'type': 'device',
|
'type': iot_devices[device].device_type,
|
||||||
'message': hdmi_message
|
'message': iot_devices[device].device_identifier + iot_devices[device].get_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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'hostname': hostname,
|
'hostname': hostname,
|
||||||
'ip': self.get_ip_iotbox(),
|
'ip': get_ip(),
|
||||||
'mac': ":".join(i + next(h) for i in h),
|
'mac': ":".join(i + next(h) for i in h),
|
||||||
'iot_device_status': iot_device,
|
'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,
|
'network_status': network,
|
||||||
}
|
}
|
||||||
|
|
||||||
@http.route('/', type='http', auth='none')
|
@http.route('/', type='http', auth='none')
|
||||||
def index(self):
|
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({
|
return configure_wizard_template.render({
|
||||||
'title': 'Configure IoT Box',
|
'title': 'Configure IoT Box',
|
||||||
'breadcrumb': 'Configure IoT Box',
|
'breadcrumb': 'Configure IoT Box',
|
||||||
'loading_message': 'Configuring your IoT Box',
|
'loading_message': 'Configuring your IoT Box',
|
||||||
'ssid': self.get_wifi_essid(),
|
'ssid': self.get_wifi_essid(),
|
||||||
'server': self.get_server_status(),
|
'server': get_odoo_server_url(),
|
||||||
'hostname': subprocess.check_output('hostname').decode('utf-8'),
|
'hostname': subprocess.check_output('hostname').decode('utf-8'),
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -177,7 +133,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
@http.route('/list_drivers', type='http', auth='none', website=True)
|
@http.route('/list_drivers', type='http', auth='none', website=True)
|
||||||
def list_drivers(self):
|
def list_drivers(self):
|
||||||
drivers_list = []
|
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__':
|
if driver != '__pycache__':
|
||||||
drivers_list.append(driver)
|
drivers_list.append(driver)
|
||||||
return driver_list_template.render({
|
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)
|
@http.route('/load_drivers', type='http', auth='none', website=True)
|
||||||
def load_drivers(self):
|
def load_drivers(self):
|
||||||
subprocess.call("sudo mount -o remount,rw /", shell=True)
|
subprocess.check_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 /root_bypass_ramdisks", shell=True)
|
||||||
|
|
||||||
mac = subprocess.check_output("/sbin/ifconfig eth0 |grep -Eo ..\(\:..\){5}", shell=True).decode('utf-8').split('\n')[0]
|
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)
|
#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:
|
if server:
|
||||||
urllib3.disable_warnings()
|
urllib3.disable_warnings()
|
||||||
pm = urllib3.PoolManager(cert_reqs='CERT_NONE')
|
pm = urllib3.PoolManager(cert_reqs='CERT_NONE')
|
||||||
@@ -205,17 +161,16 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
server,
|
server,
|
||||||
fields={'mac': mac})
|
fields={'mac': mac})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
req = ''
|
_logger.error('Could not reach configured server')
|
||||||
_logger.warning('Could not reach configured server')
|
|
||||||
_logger.error('A error encountered : %s ' % e)
|
_logger.error('A error encountered : %s ' % e)
|
||||||
if resp and resp.data:
|
if resp and resp.data:
|
||||||
zip_file = zipfile.ZipFile(io.BytesIO(resp.data))
|
zip_file = zipfile.ZipFile(io.BytesIO(resp.data))
|
||||||
zip_file.extractall("/home/pi/odoo/addons/hw_drivers/drivers")
|
zip_file.extractall(get_resource_path('hw_drivers', 'drivers'))
|
||||||
subprocess.call("sudo service odoo restart", shell=True)
|
subprocess.check_call("sudo service odoo restart", shell=True)
|
||||||
subprocess.call("sudo mount -o remount,ro /", shell=True)
|
subprocess.check_call("sudo mount -o remount,ro /", shell=True)
|
||||||
subprocess.call("sudo mount -o remount,ro /root_bypass_ramdisks", 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):
|
def get_wifi_essid(self):
|
||||||
wifi_options = []
|
wifi_options = []
|
||||||
@@ -247,8 +202,8 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
else:
|
else:
|
||||||
persistent = ""
|
persistent = ""
|
||||||
|
|
||||||
subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_wifi.sh', essid, password, persistent])
|
subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_wifi.sh'), essid, password, persistent])
|
||||||
server = self.get_server_status()
|
server = get_odoo_server_url()
|
||||||
res_payload = {
|
res_payload = {
|
||||||
'message': 'Connecting to ' + essid,
|
'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)
|
@http.route('/wifi_clear', type='http', auth='none', cors='*', csrf=False)
|
||||||
def clear_wifi_configuration(self):
|
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)
|
@http.route('/server_clear', type='http', auth='none', cors='*', csrf=False)
|
||||||
def clear_server_configuration(self):
|
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)
|
@http.route('/drivers_clear', type='http', auth='none', cors='*', csrf=False)
|
||||||
def clear_drivers_list(self):
|
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)
|
@http.route('/server_connect', type='http', auth='none', cors='*', csrf=False)
|
||||||
def connect_to_server(self, token, iotname):
|
def connect_to_server(self, token, iotname):
|
||||||
url = token.split('|')[0]
|
url = token.split('|')[0]
|
||||||
token = token.split('|')[1]
|
token = token.split('|')[1]
|
||||||
reboot = 'reboot'
|
reboot = 'reboot'
|
||||||
subprocess.call(['/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/connect_to_server.sh', url, iotname, token, reboot])
|
subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, iotname, token, reboot])
|
||||||
return 'http://' + self.get_ip_iotbox() + ':8069'
|
return 'http://' + get_ip() + ':8069'
|
||||||
|
|
||||||
@http.route('/steps', type='http', auth='none', cors='*', csrf=False)
|
@http.route('/steps', type='http', auth='none', cors='*', csrf=False)
|
||||||
def step_by_step_configure_page(self):
|
def step_by_step_configure_page(self):
|
||||||
@@ -293,7 +248,7 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
'breadcrumb': 'Configure IoT Box',
|
'breadcrumb': 'Configure IoT Box',
|
||||||
'loading_message': 'Configuring your IoT Box',
|
'loading_message': 'Configuring your IoT Box',
|
||||||
'ssid': self.get_wifi_essid(),
|
'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'),
|
'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]
|
token = token.split('|')[1]
|
||||||
else:
|
else:
|
||||||
url = ''
|
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
|
return url
|
||||||
|
|
||||||
# Set server address
|
# Set server address
|
||||||
@@ -314,12 +269,19 @@ class IoTboxHomepage(odoo.addons.web.controllers.main.Home):
|
|||||||
'title': 'IoT -> Odoo server configuration',
|
'title': 'IoT -> Odoo server configuration',
|
||||||
'breadcrumb': 'Configure Odoo Server',
|
'breadcrumb': 'Configure Odoo Server',
|
||||||
'hostname': subprocess.check_output('hostname').decode('utf-8').strip('\n'),
|
'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'
|
'loading_message': 'Configure Domain Server'
|
||||||
})
|
})
|
||||||
|
|
||||||
@http.route('/remote_connect', type='http', auth='none', cors='*')
|
@http.route('/remote_connect', type='http', auth='none', cors='*')
|
||||||
def remote_connect(self):
|
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({
|
return remote_connect_template.render({
|
||||||
'title': 'Remote debugging',
|
'title': 'Remote debugging',
|
||||||
'breadcrumb': 'Remote Debugging',
|
'breadcrumb': 'Remote Debugging',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
}
|
}
|
||||||
.device-status .message {
|
.device-status .message {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
max-width: 350px;
|
||||||
}
|
}
|
||||||
.device-status .indicator {
|
.device-status .indicator {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
@@ -90,7 +91,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="heading">Version</td>
|
<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>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="heading">IP Address</td>
|
<td class="heading">IP Address</td>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h2 class="text-center">Remote Debugging</h2>
|
<h2 class="text-center">Remote Debugging</h2>
|
||||||
<p class='text-red'>
|
<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
|
thus your entire local network. Only enable this for someone
|
||||||
you trust.
|
you trust.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -191,10 +191,10 @@
|
|||||||
('user_type_id', '=', ref('account.data_account_type_expenses')),
|
('user_type_id', '=', ref('account.data_account_type_expenses')),
|
||||||
('company_id', '=', obj().env['res.company']._company_default_get('account.account').id)]"/>
|
('company_id', '=', obj().env['res.company']._company_default_get('account.account').id)]"/>
|
||||||
<field name="invoice_line_tax_ids" model="account.tax"
|
<field name="invoice_line_tax_ids" model="account.tax"
|
||||||
eval="[(6, 0, [obj().search([
|
eval="[(6, 0, obj().search([
|
||||||
('type_tax_use', '=', 'purchase'),
|
('type_tax_use', '=', 'purchase'),
|
||||||
('amount', '>', 0),
|
('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>
|
</record>
|
||||||
|
|
||||||
<function model="account.invoice" name="_onchange_invoice_line_ids">
|
<function model="account.invoice" name="_onchange_invoice_line_ids">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
<xpath expr="//table[@name='invoice_line_table']/thead/tr" position="inside">
|
<xpath expr="//table[@name='invoice_line_table']/thead/tr" position="inside">
|
||||||
<t t-if="o.company_id.country_id.code == 'IN'">
|
<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">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_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>
|
<th class="text-right" t-if="is_igst">IGST<t t-set="colspan" t-value="colspan+1"/></th>
|
||||||
@@ -35,11 +36,10 @@
|
|||||||
</t>
|
</t>
|
||||||
</xpath>
|
</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">
|
<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'">
|
<t t-if="o.company_id.country_id.code == 'IN'">
|
||||||
<td class="text-right" t-if="is_cgst">
|
<td class="text-right" t-if="is_cgst">
|
||||||
<t t-foreach="tax_datas[line.id]" t-as="tax_data">
|
<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
|
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_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,A21807,A21807,"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_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_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
|
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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from odoo import api, models
|
from odoo import api, models
|
||||||
@@ -88,20 +89,33 @@ class AccountChartTemplate(models.Model):
|
|||||||
|
|
||||||
def _get_template_from_model(self, company_id, model):
|
def _get_template_from_model(self, company_id, model):
|
||||||
""" Find the records and their matching template """
|
""" Find the records and their matching template """
|
||||||
# genereated records have an external id with the format <company id>_<template id>
|
# generated records have an external id with the format <company id>_<template xml id>
|
||||||
out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')])
|
grouped_out_data = defaultdict(lambda: self.env['ir.model.data'])
|
||||||
out_ids = self.env[model].browse(out_data.mapped('res_id'))
|
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
|
in_records = self.env[model + '.template']
|
||||||
# reorder them based on external id names
|
out_records = self.env[model]
|
||||||
in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')]
|
for module, out_data in grouped_out_data.items():
|
||||||
in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)}
|
# 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 = self.env['ir.model.data'].search([
|
||||||
in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
|
('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'))
|
for name, xml_id in expected_in_xml_id_names.items():
|
||||||
return (in_ids, out_ids)
|
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):
|
class BaseLanguageInstall(models.TransientModel):
|
||||||
""" Install Language"""
|
""" Install Language"""
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
<field name="name">DIOT: 8%</field>
|
<field name="name">DIOT: 8%</field>
|
||||||
<field name="applicability">taxes</field>
|
<field name="applicability">taxes</field>
|
||||||
</record>
|
</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">
|
<record id="tag_diot_ret" model="account.account.tag">
|
||||||
<field name="name">DIOT: Retención</field>
|
<field name="name">DIOT: Retención</field>
|
||||||
<field name="applicability">taxes</field>
|
<field name="applicability">taxes</field>
|
||||||
|
|||||||
@@ -162,14 +162,14 @@ class MailMail(models.Model):
|
|||||||
failed = self.env['mail.notification']
|
failed = self.env['mail.notification']
|
||||||
if failure_type:
|
if failure_type:
|
||||||
failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids)
|
failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids)
|
||||||
failed.write({
|
failed.sudo().write({
|
||||||
'email_status': 'exception',
|
'email_status': 'exception',
|
||||||
'failure_type': failure_type,
|
'failure_type': failure_type,
|
||||||
'failure_reason': failure_reason,
|
'failure_reason': failure_reason,
|
||||||
})
|
})
|
||||||
messages = notifications.mapped('mail_message_id').filtered(lambda m: m.res_id and m.model)
|
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
|
messages._notify_failure_update() # notify user that we have a failure
|
||||||
(notifications - failed).write({
|
(notifications - failed).sudo().write({
|
||||||
'email_status': 'sent',
|
'email_status': 'sent',
|
||||||
})
|
})
|
||||||
if not failure_type or failure_type == 'RECIPIENT': # if we have another error, we want to keep the mail.
|
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:
|
if notifs:
|
||||||
notif_msg = _('Error without exception. Probably due do concurrent access update of notification records. Please see with an administrator.')
|
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',
|
'email_status': 'exception',
|
||||||
'failure_type': 'UNKNOWN',
|
'failure_type': 'UNKNOWN',
|
||||||
'failure_reason': notif_msg,
|
'failure_reason': notif_msg,
|
||||||
|
|||||||
@@ -44,11 +44,10 @@ var DocumentViewer = Widget.extend({
|
|||||||
this._super.apply(this, arguments);
|
this._super.apply(this, arguments);
|
||||||
this.attachment = _.filter(attachments, function (attachment) {
|
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)");
|
var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)");
|
||||||
|
|
||||||
if (match) {
|
if (match) {
|
||||||
attachment.type = match[1];
|
attachment.fileType = match[1];
|
||||||
if (match[1].match("(.png|.jpg|.gif)")) {
|
if (match[1].match("(.png|.jpg|.gif)")) {
|
||||||
attachment.type = 'image';
|
attachment.fileType = 'image';
|
||||||
}
|
}
|
||||||
if (match[1] === 'youtu') {
|
if (match[1] === 'youtu') {
|
||||||
var youtube_array = attachment.url.split('/');
|
var youtube_array = attachment.url.split('/');
|
||||||
|
|||||||
@@ -38,9 +38,9 @@
|
|||||||
<t t-set="model" t-value="widget.modelName"/>
|
<t t-set="model" t-value="widget.modelName"/>
|
||||||
<div class="o_viewer-header">
|
<div class="o_viewer-header">
|
||||||
<span class="o_image_caption">
|
<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-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.type == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
|
<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.type == 'video'" role="img" aria-label="Video" title="Video"/>
|
<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"/>
|
<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>
|
<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>
|
</span>
|
||||||
@@ -48,19 +48,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="o_viewer_img_wrapper">
|
<div class="o_viewer_img_wrapper">
|
||||||
<div class="o_viewer_zoomer">
|
<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"/>
|
<i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
|
||||||
</div>
|
</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"/>
|
<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.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.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.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.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.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}"/>
|
<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.type == 'video'" class="o_viewer_video" controls="controls">
|
<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"/>
|
<source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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_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>
|
<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');
|
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:
|
if workorders:
|
||||||
workorders[-1].next_work_order_id = workorder.id
|
workorders[-1].next_work_order_id = workorder.id
|
||||||
|
workorders[-1]._init_nextworkorder_states()
|
||||||
workorders += workorder
|
workorders += workorder
|
||||||
|
|
||||||
# assign moves; last operation receive all unassigned moves (which case ?)
|
# 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.'))
|
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
|
# One a piece is produced, you can launch the next work order
|
||||||
if self.next_work_order_id.state == 'pending':
|
self._start_nextworkorder()
|
||||||
self.next_work_order_id.state = 'ready'
|
|
||||||
|
|
||||||
# If last work order, then post lots used
|
# If last work order, then post lots used
|
||||||
# TODO: should be same as checking if for every workorder something has been done?
|
# 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()
|
self.button_finish()
|
||||||
return True
|
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
|
@api.multi
|
||||||
def button_start(self):
|
def button_start(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -574,6 +574,9 @@ class TestMrpOrder(TestMrpCommon):
|
|||||||
'active_ids': [mo.id],
|
'active_ids': [mo.id],
|
||||||
}))
|
}))
|
||||||
produce_form.qty_producing = 1.0
|
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 = produce_form.save()
|
||||||
product_produce.final_lot_id = final_product_lot.id
|
product_produce.final_lot_id = final_product_lot.id
|
||||||
# product 1 lot 1 shelf1
|
# product 1 lot 1 shelf1
|
||||||
@@ -581,8 +584,6 @@ class TestMrpOrder(TestMrpCommon):
|
|||||||
# product 1 lot 2
|
# 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')
|
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()
|
product_produce.do_produce()
|
||||||
|
|
||||||
move_1 = mo.move_raw_ids.filtered(lambda m: m.product_id == p1)
|
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">
|
<record model="note.stage" id="note_stage_00">
|
||||||
<field name="name">New</field>
|
<field name="name">New</field>
|
||||||
<field name="sequence">0</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>
|
||||||
|
|
||||||
<record model="note.stage" id="note_stage_01">
|
<record model="note.stage" id="note_stage_01">
|
||||||
<field name="name">Meeting Minutes</field>
|
<field name="name">Meeting Minutes</field>
|
||||||
<field name="sequence">5</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>
|
||||||
|
|
||||||
<record model="note.stage" id="note_stage_02">
|
<record model="note.stage" id="note_stage_02">
|
||||||
<field name="name">Notes</field>
|
<field name="name">Notes</field>
|
||||||
<field name="sequence">10</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>
|
||||||
|
|
||||||
<record model="note.stage" id="note_stage_03">
|
<record model="note.stage" id="note_stage_03">
|
||||||
<field name="name">Todo</field>
|
<field name="name">Todo</field>
|
||||||
<field name="sequence">50</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>
|
</record>
|
||||||
</data>
|
</data>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ class AuthorizeAPI():
|
|||||||
"""
|
"""
|
||||||
root = self._base_tree('createCustomerProfileRequest')
|
root = self._base_tree('createCustomerProfileRequest')
|
||||||
profile = etree.SubElement(root, "profile")
|
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 ''
|
etree.SubElement(profile, "email").text = partner.email or ''
|
||||||
payment_profile = etree.SubElement(profile, "paymentProfiles")
|
payment_profile = etree.SubElement(profile, "paymentProfiles")
|
||||||
etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual'
|
etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual'
|
||||||
@@ -220,7 +221,8 @@ class AuthorizeAPI():
|
|||||||
root = self._base_tree('createCustomerProfileFromTransactionRequest')
|
root = self._base_tree('createCustomerProfileFromTransactionRequest')
|
||||||
etree.SubElement(root, "transId").text = transaction_id
|
etree.SubElement(root, "transId").text = transaction_id
|
||||||
customer = etree.SubElement(root, "customer")
|
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 ''
|
etree.SubElement(customer, "email").text = partner.email or ''
|
||||||
response = self._authorize_request(root)
|
response = self._authorize_request(root)
|
||||||
res = dict()
|
res = dict()
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ class PosSession(models.Model):
|
|||||||
for session in self:
|
for session in self:
|
||||||
company_id = session.config_id.company_id.id
|
company_id = session.config_id.company_id.id
|
||||||
ctx = dict(self.env.context, force_company=company_id, company_id=company_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:
|
for st in session.statement_ids:
|
||||||
if abs(st.difference) > st.journal_id.amount_authorized_diff:
|
if abs(st.difference) > st.journal_id.amount_authorized_diff:
|
||||||
# The pos manager can close statements with maximums.
|
# 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))
|
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']):
|
if (st.journal_id.type not in ['bank', 'cash']):
|
||||||
raise UserError(_("The journal type for your payment method should be bank or 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.with_context(ctx)._confirm_orders()
|
||||||
self.write({'state': 'closed'})
|
self.write({'state': 'closed'})
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ Listen /run/cups/cups.sock
|
|||||||
Browsing On
|
Browsing On
|
||||||
BrowseLocalProtocols dnssd
|
BrowseLocalProtocols dnssd
|
||||||
|
|
||||||
|
# Local printers are not shared by default
|
||||||
|
DefaultShared No
|
||||||
|
|
||||||
# Default authentication type, when authentication is required...
|
# Default authentication type, when authentication is required...
|
||||||
DefaultAuthType Basic
|
DefaultAuthType Basic
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -85,7 +85,7 @@ server {
|
|||||||
root /var/www/;
|
root /var/www/;
|
||||||
index index.html;
|
index index.html;
|
||||||
#
|
#
|
||||||
# location / {
|
location /hw_drivers/ {
|
||||||
# try_files $uri $uri/ =404;
|
proxy_pass http://127.0.0.1:8069;
|
||||||
# }
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ if [ -e /home/pi/odoo-remote-server.conf ]
|
|||||||
var=0
|
var=0
|
||||||
while [ ${var} -lt 30 ]; do
|
while [ ${var} -lt 30 ]; do
|
||||||
var=$((var+1))
|
var=$((var+1))
|
||||||
|
sleep 1
|
||||||
status_code=$(wget ${URL_SERVER} --server-response 2>&1|grep 'HTTP/'|awk '{print $2}')
|
status_code=$(wget ${URL_SERVER} --server-response 2>&1|grep 'HTTP/'|awk '{print $2}')
|
||||||
if [ ${status_code} -eq 200 ]
|
if [ ${status_code} -eq 200 ]
|
||||||
then
|
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 \
|
cups \
|
||||||
printer-driver-all \
|
printer-driver-all \
|
||||||
cups-ipp-utils \
|
cups-ipp-utils \
|
||||||
|
libcups2-dev \
|
||||||
|
pcscd \
|
||||||
localepurge \
|
localepurge \
|
||||||
vim \
|
vim \
|
||||||
mc \
|
mc \
|
||||||
@@ -37,6 +39,7 @@ PKGS_TO_INSTALL="
|
|||||||
hostapd \
|
hostapd \
|
||||||
git \
|
git \
|
||||||
rsync \
|
rsync \
|
||||||
|
swig \
|
||||||
console-data \
|
console-data \
|
||||||
lightdm \
|
lightdm \
|
||||||
xserver-xorg-video-fbdev \
|
xserver-xorg-video-fbdev \
|
||||||
@@ -49,7 +52,9 @@ PKGS_TO_INSTALL="
|
|||||||
rpi-update \
|
rpi-update \
|
||||||
adduser \
|
adduser \
|
||||||
postgresql \
|
postgresql \
|
||||||
|
python-cups \
|
||||||
python3 \
|
python3 \
|
||||||
|
python3-pyscard \
|
||||||
python3-urllib3 \
|
python3-urllib3 \
|
||||||
python3-dateutil \
|
python3-dateutil \
|
||||||
python3-decorator \
|
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
|
# 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.
|
# so we install the latest pyusb that works with this libusb.
|
||||||
# Even in stretch, we had an error with langid (but worked otherwise)
|
# Even in stretch, we had an error with langid (but worked otherwise)
|
||||||
pip3 install pyusb==1.0.0b1
|
PIP_TO_INSTALL="
|
||||||
pip3 install evdev
|
pyusb==1.0.0b1 \
|
||||||
pip3 install gatt
|
evdev \
|
||||||
|
gatt \
|
||||||
|
v4l2 \
|
||||||
|
pycups"
|
||||||
|
|
||||||
|
pip3 install ${PIP_TO_INSTALL}
|
||||||
|
|
||||||
|
|
||||||
groupadd usbusers
|
groupadd usbusers
|
||||||
@@ -148,6 +158,7 @@ if [ $SYSTEMD -eq 1 ]; then
|
|||||||
systemctl set-default graphical.target
|
systemctl set-default graphical.target
|
||||||
ln -fs /etc/systemd/system/autologin@.service /etc/systemd/system/getty.target.wants/getty@tty1.service
|
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/sysinit.target.wants/systemd-timesyncd.service
|
||||||
|
rm /etc/systemd/system/hostapd.service
|
||||||
else
|
else
|
||||||
update-rc.d lightdm enable 2
|
update-rc.d lightdm enable 2
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ qemu-system-arm "${QEMU_OPTS[@]}"
|
|||||||
mount "${LOOP_MAPPER_PATH}" "${MOUNT_POINT}"
|
mount "${LOOP_MAPPER_PATH}" "${MOUNT_POINT}"
|
||||||
cp -av "${OVERWRITE_FILES_AFTER_INIT_DIR}"/* "${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
|
# cleanup
|
||||||
sleep 2
|
sleep 2
|
||||||
umount "${MOUNT_POINT}"
|
umount "${MOUNT_POINT}"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/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
|
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'
|
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:
|
if not self.partner_id:
|
||||||
self.fiscal_position_id = False
|
self.fiscal_position_id = False
|
||||||
self.payment_term_id = False
|
self.payment_term_id = False
|
||||||
self.currency_id = False
|
self.currency_id = self.env.user.company_id.currency_id.id
|
||||||
else:
|
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.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
|
self.payment_term_id = self.partner_id.property_supplier_payment_term_id.id
|
||||||
|
|||||||
@@ -157,8 +157,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="oe_title">
|
<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" attrs="{'invisible': [('state','not in',('draft','sent'))]}">Request for Quotation </span>
|
||||||
<span class="o_form_label" string="Purchase Order " attrs="{'invisible': [('state','in',('draft','sent'))]}"/>
|
<span class="o_form_label" attrs="{'invisible': [('state','in',('draft','sent'))]}">Purchase Order </span>
|
||||||
<h1>
|
<h1>
|
||||||
<field name="name" readonly="1"/>
|
<field name="name" readonly="1"/>
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class AccountInvoice(models.Model):
|
|||||||
diff_line = {
|
diff_line = {
|
||||||
'type': 'src',
|
'type': 'src',
|
||||||
'name': i_line.name[:64],
|
'name': i_line.name[:64],
|
||||||
'price_unit': inv.currency_id.round(price_unit_val_dif),
|
'price_unit': price_unit_val_dif,
|
||||||
'quantity': line_quantity,
|
'quantity': line_quantity,
|
||||||
'price': inv.currency_id.round(price_val_dif),
|
'price': inv.currency_id.round(price_val_dif),
|
||||||
'account_id': acc,
|
'account_id': acc,
|
||||||
@@ -133,8 +133,9 @@ class AccountInvoice(models.Model):
|
|||||||
'tax_ids': tax_ids,
|
'tax_ids': tax_ids,
|
||||||
}
|
}
|
||||||
# We update the original line accordingly
|
# 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'] = inv.currency_id.round(line['quantity'] * line['price_unit'])
|
||||||
|
line['price_unit'] = inv.currency_id.round(line['price_unit'])
|
||||||
diff_res.append(diff_line)
|
diff_res.append(diff_line)
|
||||||
return diff_res
|
return diff_res
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -246,6 +246,12 @@ class PurchaseOrderLine(models.Model):
|
|||||||
if move.location_dest_id.usage == "supplier":
|
if move.location_dest_id.usage == "supplier":
|
||||||
if move.to_refund:
|
if move.to_refund:
|
||||||
total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
|
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:
|
else:
|
||||||
total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
|
total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom)
|
||||||
line.qty_received = total
|
line.qty_received = total
|
||||||
|
|||||||
@@ -77,19 +77,20 @@
|
|||||||
<span>Disc.(%)</span>
|
<span>Disc.(%)</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="text-right">Taxes</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">
|
||||||
<th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="sale_tbody">
|
<tbody class="sale_tbody">
|
||||||
|
|
||||||
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
|
<t t-set="current_subtotal" t-value="0"/>
|
||||||
<t t-set="current_subtotal_tax_included" t-value="0"/>
|
|
||||||
|
|
||||||
<t t-foreach="doc.order_line" t-as="line">
|
<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" t-value="current_subtotal + 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_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 ''">
|
<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">
|
<t t-if="not line.display_type">
|
||||||
@@ -107,11 +108,9 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
|
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded">
|
<td class="text-right o_price_total">
|
||||||
<span t-field="line.price_subtotal"/>
|
<span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
|
||||||
</td>
|
<span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
|
||||||
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included">
|
|
||||||
<span t-field="line.price_total"/>
|
|
||||||
</td>
|
</td>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="line.display_type == 'line_section'">
|
<t t-if="line.display_type == 'line_section'">
|
||||||
@@ -119,8 +118,7 @@
|
|||||||
<span t-field="line.name"/>
|
<span t-field="line.name"/>
|
||||||
</td>
|
</td>
|
||||||
<t t-set="current_section" t-value="line"/>
|
<t t-set="current_section" t-value="line"/>
|
||||||
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
|
<t t-set="current_subtotal" t-value="0"/>
|
||||||
<t t-set="current_subtotal_tax_included" t-value="0"/>
|
|
||||||
</t>
|
</t>
|
||||||
<t t-if="line.display_type == 'line_note'">
|
<t t-if="line.display_type == 'line_note'">
|
||||||
<td colspan="99">
|
<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')">
|
<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">
|
<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>
|
<strong class="mr16">Subtotal</strong>
|
||||||
<span
|
<span
|
||||||
groups="account.group_show_line_subtotals_tax_excluded"
|
t-esc="current_subtotal"
|
||||||
t-esc="current_subtotal_tax_excluded"
|
|
||||||
t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -430,19 +430,20 @@
|
|||||||
<span>Discount</span>
|
<span>Discount</span>
|
||||||
</th>
|
</th>
|
||||||
<th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">Taxes</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" >
|
||||||
<th class="text-right" groups="account.group_show_line_subtotals_tax_included">Total Price</th>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="sale_tbody">
|
<tbody class="sale_tbody">
|
||||||
|
|
||||||
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
|
<t t-set="current_subtotal" t-value="0"/>
|
||||||
<t t-set="current_subtotal_tax_included" t-value="0"/>
|
|
||||||
|
|
||||||
<t t-foreach="sale_order.order_line" t-as="line">
|
<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" t-value="current_subtotal + 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_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 ''">
|
<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">
|
<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 '' }}">
|
<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))"/>
|
<span t-esc="', '.join(map(lambda x: (x.description or x.name), line.tax_id))"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_excluded">
|
<td class="text-right">
|
||||||
<span t-field="line.price_subtotal"/>
|
<span t-field="line.price_subtotal" groups="account.group_show_line_subtotals_tax_excluded"/>
|
||||||
</td>
|
<span t-field="line.price_total" groups="account.group_show_line_subtotals_tax_included"/>
|
||||||
<td class="text-right o_price_total" groups="account.group_show_line_subtotals_tax_included">
|
|
||||||
<span t-field="line.price_total"/>
|
|
||||||
</td>
|
</td>
|
||||||
</t>
|
</t>
|
||||||
<t t-if="line.display_type == 'line_section'">
|
<t t-if="line.display_type == 'line_section'">
|
||||||
@@ -485,8 +484,7 @@
|
|||||||
<span t-field="line.name"/>
|
<span t-field="line.name"/>
|
||||||
</td>
|
</td>
|
||||||
<t t-set="current_section" t-value="line"/>
|
<t t-set="current_section" t-value="line"/>
|
||||||
<t t-set="current_subtotal_tax_excluded" t-value="0"/>
|
<t t-set="current_subtotal" t-value="0"/>
|
||||||
<t t-set="current_subtotal_tax_included" t-value="0"/>
|
|
||||||
</t>
|
</t>
|
||||||
<t t-if="line.display_type == 'line_note'">
|
<t t-if="line.display_type == 'line_note'">
|
||||||
<td colspan="99">
|
<td colspan="99">
|
||||||
@@ -500,14 +498,9 @@
|
|||||||
<td colspan="99">
|
<td colspan="99">
|
||||||
<strong class="mr16">Subtotal</strong>
|
<strong class="mr16">Subtotal</strong>
|
||||||
<span
|
<span
|
||||||
groups="account.group_show_line_subtotals_tax_excluded"
|
t-esc="current_subtotal"
|
||||||
t-esc="current_subtotal_tax_excluded"
|
t-options='{"widget": "monetary", "display_currency": sale_order.pricelist_id.currency_id}'
|
||||||
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</t>
|
</t>
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ class SaleOrder(models.Model):
|
|||||||
def _prepare_invoice(self):
|
def _prepare_invoice(self):
|
||||||
invoice_vals = super(SaleOrder, self)._prepare_invoice()
|
invoice_vals = super(SaleOrder, self)._prepare_invoice()
|
||||||
invoice_vals['incoterms_id'] = self.incoterm.id or False
|
invoice_vals['incoterms_id'] = self.incoterm.id or False
|
||||||
|
if self.incoterm.id:
|
||||||
|
invoice_vals['incoterm_id'] = self.incoterm.id
|
||||||
return invoice_vals
|
return invoice_vals
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<data>
|
<data>
|
||||||
<xpath expr="//page[@name='other_info']//field[@name='origin']" position="after">
|
<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>
|
</xpath>
|
||||||
</data>
|
</data>
|
||||||
</field>
|
</field>
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
<template id="report_invoice_document_inherit_sale_stock" inherit_id="account.report_invoice_document">
|
<template id="report_invoice_document_inherit_sale_stock" inherit_id="account.report_invoice_document">
|
||||||
<!-- HTML Preview for mobile responsive -->
|
<!-- HTML Preview for mobile responsive -->
|
||||||
<xpath expr="//div[@name='reference']" position="after">
|
<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>
|
<strong>Incoterms:</strong>
|
||||||
<p t-field="o.incoterms_id.code"/>
|
<p t-field="o.incoterm_id.code"/>
|
||||||
</div>
|
</div>
|
||||||
</xpath>
|
</xpath>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -156,24 +156,50 @@ class StockMove(models.Model):
|
|||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _get_in_base_domain(self, company_id=False):
|
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 = [
|
domain = [
|
||||||
('state', '=', 'done'),
|
('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
|
return domain
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _get_all_base_domain(self, company_id=False):
|
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 = [
|
domain = [
|
||||||
('state', '=', 'done'),
|
('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_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_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
|
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.with_context(to_date=Datetime.to_string(date6)).stock_value, 100)
|
||||||
self.assertEqual(self.product1.qty_at_date, 10)
|
self.assertEqual(self.product1.qty_at_date, 10)
|
||||||
self.assertEqual(self.product1.stock_value, 100)
|
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;
|
break;
|
||||||
case 'previous_year':
|
case 'previous_year':
|
||||||
leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years-- : -1;
|
leftBoundaryParams.years = leftBoundaryParams.years ? leftBoundaryParams.years - 1 : -1;
|
||||||
rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years-- : -1;
|
rightBoundaryParams.years = rightBoundaryParams.years ? rightBoundaryParams.years - 1 : -1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ var CalendarController = AbstractController.extend({
|
|||||||
res_model: this.modelName,
|
res_model: this.modelName,
|
||||||
context: context,
|
context: context,
|
||||||
title: title,
|
title: title,
|
||||||
|
view_id: this.formViewId || false,
|
||||||
disable_multiple_selection: true,
|
disable_multiple_selection: true,
|
||||||
on_saved: function () {
|
on_saved: function () {
|
||||||
if (event.data.on_save) {
|
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();
|
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) {
|
QUnit.test('calendar fallback to form view id in action if necessary', function (assert) {
|
||||||
assert.expect(1);
|
assert.expect(1);
|
||||||
|
|
||||||
|
|||||||
@@ -397,7 +397,8 @@ var Dashboard = AbstractAction.extend({
|
|||||||
container: $analytics_chart_2[0],
|
container: $analytics_chart_2[0],
|
||||||
options: {
|
options: {
|
||||||
title: 'All',
|
title: 'All',
|
||||||
width: '100%'
|
width: '100%',
|
||||||
|
tooltip: {isHtml: true},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from odoo import api, fields, models, _
|
from odoo import api, fields, models, _
|
||||||
from odoo.exceptions import AccessDenied
|
from odoo.exceptions import AccessDenied, AccessError
|
||||||
|
|
||||||
|
|
||||||
class CrmLead(models.Model):
|
class CrmLead(models.Model):
|
||||||
@@ -270,3 +270,35 @@ class CrmLead(models.Model):
|
|||||||
return {
|
return {
|
||||||
'id': lead.id
|
'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.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.')
|
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,
|
'pricelist': order.pricelist_id.id,
|
||||||
})
|
})
|
||||||
product = self.env['product.product'].with_context(product_context).browse(product_id)
|
product = self.env['product.product'].with_context(product_context).browse(product_id)
|
||||||
pu = product.price
|
discount = 0
|
||||||
if order.pricelist_id and order.partner_id:
|
|
||||||
order_line = order._cart_find_product_line(product.id)
|
if order.pricelist_id.discount_policy == 'without_discount':
|
||||||
if order_line:
|
# This part is pretty much a copy-paste of the method '_onchange_discount' of
|
||||||
pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
|
# '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 {
|
return {
|
||||||
'product_id': product_id,
|
'product_id': product_id,
|
||||||
@@ -131,6 +147,7 @@ class SaleOrder(models.Model):
|
|||||||
'order_id': order_id,
|
'order_id': order_id,
|
||||||
'product_uom': product.uom_id.id,
|
'product_uom': product.uom_id.id,
|
||||||
'price_unit': pu,
|
'price_unit': pu,
|
||||||
|
'discount': discount,
|
||||||
}
|
}
|
||||||
|
|
||||||
@api.multi
|
@api.multi
|
||||||
|
|||||||
@@ -62,6 +62,23 @@ import time
|
|||||||
if hasattr(time, 'tzset'):
|
if hasattr(time, 'tzset'):
|
||||||
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
|
# Shortcuts
|
||||||
#----------------------------------------------------------
|
#----------------------------------------------------------
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class ResPartnerBank(models.Model):
|
|||||||
_name = 'res.partner.bank'
|
_name = 'res.partner.bank'
|
||||||
_rec_name = 'acc_number'
|
_rec_name = 'acc_number'
|
||||||
_description = 'Bank Accounts'
|
_description = 'Bank Accounts'
|
||||||
_order = 'sequence'
|
_order = 'sequence, id'
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def get_supported_account_types(self):
|
def get_supported_account_types(self):
|
||||||
@@ -84,7 +84,7 @@ class ResPartnerBank(models.Model):
|
|||||||
bank_id = fields.Many2one('res.bank', string='Bank')
|
bank_id = fields.Many2one('res.bank', string='Bank')
|
||||||
bank_name = fields.Char(related='bank_id.name', readonly=False)
|
bank_name = fields.Char(related='bank_id.name', readonly=False)
|
||||||
bank_bic = fields.Char(related='bank_id.bic', 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')
|
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')
|
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")
|
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.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import collections
|
||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import pytz
|
import pytz
|
||||||
@@ -272,8 +273,32 @@ class Partner(models.Model):
|
|||||||
|
|
||||||
@api.depends('is_company', 'parent_id.commercial_partner_id')
|
@api.depends('is_company', 'parent_id.commercial_partner_id')
|
||||||
def _compute_commercial_partner(self):
|
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:
|
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
|
partner.commercial_partner_id = partner
|
||||||
else:
|
else:
|
||||||
partner.commercial_partner_id = partner.parent_id.commercial_partner_id
|
partner.commercial_partner_id = partner.parent_id.commercial_partner_id
|
||||||
@@ -467,21 +492,25 @@ class Partner(models.Model):
|
|||||||
self.update_address(onchange_vals)
|
self.update_address(onchange_vals)
|
||||||
|
|
||||||
# 2. To DOWNSTREAM: sync children
|
# 2. To DOWNSTREAM: sync children
|
||||||
if self.child_ids:
|
self._children_sync(values)
|
||||||
# 2a. Commercial Fields: sync if commercial entity
|
|
||||||
if self.commercial_partner_id == self:
|
def _children_sync(self, values):
|
||||||
commercial_fields = self._commercial_fields()
|
if not self.child_ids:
|
||||||
if any(field in values for field in commercial_fields):
|
return
|
||||||
self._commercial_sync_to_children()
|
# 2a. Commercial Fields: sync if commercial entity
|
||||||
for child in self.child_ids.filtered(lambda c: not c.is_company):
|
if self.commercial_partner_id == self:
|
||||||
if child.commercial_partner_id != self.commercial_partner_id :
|
commercial_fields = self._commercial_fields()
|
||||||
self._commercial_sync_to_children()
|
if any(field in values for field in commercial_fields):
|
||||||
break
|
self._commercial_sync_to_children()
|
||||||
# 2b. Address fields: sync if address changed
|
for child in self.child_ids.filtered(lambda c: not c.is_company):
|
||||||
address_fields = self._address_fields()
|
if child.commercial_partner_id != self.commercial_partner_id:
|
||||||
if any(field in values for field in address_fields):
|
self._commercial_sync_to_children()
|
||||||
contacts = self.child_ids.filtered(lambda c: c.type == 'contact')
|
break
|
||||||
contacts.update_address(values)
|
# 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
|
@api.multi
|
||||||
def _handle_first_contact_creation(self):
|
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_big=False)
|
||||||
tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True)
|
tools.image_resize_images(vals, return_medium=False, return_small=False, preserve_aspect_ratio=True)
|
||||||
partners = super(Partner, self).create(vals_list)
|
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):
|
for partner, vals in zip(partners, vals_list):
|
||||||
partner._fields_sync(vals)
|
partner._fields_sync(vals)
|
||||||
partner._handle_first_contact_creation()
|
partner._handle_first_contact_creation()
|
||||||
return partners
|
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
|
@api.multi
|
||||||
def create_company(self):
|
def create_company(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -252,6 +252,41 @@ class TestBase(TransactionCase):
|
|||||||
self.assertEqual(leaf111.address_get([]),
|
self.assertEqual(leaf111.address_get([]),
|
||||||
{'contact': branch11.id}, 'Invalid address resolution, branch11 should now be contact')
|
{'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):
|
def test_50_res_partner_commercial_sync(self):
|
||||||
res_partner = self.env['res.partner']
|
res_partner = self.env['res.partner']
|
||||||
p0 = res_partner.create({'name': 'Sigurd Sunknife',
|
p0 = res_partner.create({'name': 'Sigurd Sunknife',
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
<form string="Bank account">
|
<form string="Bank account">
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
|
<field name="sequence" invisible="1"/>
|
||||||
<field name="acc_number"/>
|
<field name="acc_number"/>
|
||||||
<field name="acc_type"/>
|
<field name="acc_type"/>
|
||||||
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
|
<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="model">res.partner.bank</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<tree string="Bank Accounts">
|
<tree string="Bank Accounts">
|
||||||
<field name="sequence" invisible="1"/>
|
<field name="sequence" widget="handle"/>
|
||||||
<field name="acc_number"/>
|
<field name="acc_number"/>
|
||||||
<field name="bank_name"/>
|
<field name="bank_name"/>
|
||||||
<field name="company_id" groups="base.group_multi_company"/>
|
<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_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_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_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_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_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_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_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>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ class O2MSub3(models.Model):
|
|||||||
r.name = str(r.v)
|
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):
|
class O2MOnchangeParent(models.Model):
|
||||||
_name = 'test_testing_utilities.onchange_parent'
|
_name = 'test_testing_utilities.onchange_parent'
|
||||||
_description = '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')
|
parent = fields.Many2one('test_testing_utilities.onchange_count')
|
||||||
name = fields.Char()
|
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(
|
self.assertEqual(
|
||||||
[get(s) for s in r.subs],
|
[get(s) for s in r.subs],
|
||||||
[("5", 2, 5)]
|
[("5", 0, 5)]
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_o2m_inner_default(self):
|
def test_o2m_inner_default(self):
|
||||||
@@ -409,7 +409,10 @@ class TestO2M(TransactionCase):
|
|||||||
self.assertEqual(sub.value, 1)
|
self.assertEqual(sub.value, 1)
|
||||||
self.assertEqual(sub.v, 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({
|
r = self.env['test_testing_utilities.parent'].create({
|
||||||
'subs': [(0, 0, {})]
|
'subs': [(0, 0, {})]
|
||||||
})
|
})
|
||||||
@@ -422,6 +425,20 @@ class TestO2M(TransactionCase):
|
|||||||
with self.assertRaises(AssertionError):
|
with self.assertRaises(AssertionError):
|
||||||
f.subs.remove(index=0)
|
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):
|
def test_o2m_dyn_onchange(self):
|
||||||
f = Form(self.env['test_testing_utilities.onchange_parent'], view='test_testing_utilities.m2o_onchange_view')
|
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(commands(), [0, 2, 2], "should contain 1 '0' command and 2 deletions")
|
||||||
self.assertEqual(len(r.line_ids), 1)
|
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):
|
class TestEdition(TransactionCase):
|
||||||
""" These use the context manager form as we don't need the record
|
""" 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
|
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:
|
else:
|
||||||
self._init_from_defaults(self._model)
|
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(
|
default_view = next(
|
||||||
(m for m in node.get('mode', 'tree').split(',') if m != 'form'),
|
(m for m in node.get('mode', 'tree').split(',') if m != 'form'),
|
||||||
'tree'
|
'tree'
|
||||||
@@ -1138,17 +1138,10 @@ class Form(object):
|
|||||||
edition = views['tree']
|
edition = views['tree']
|
||||||
edition['tree'] = subarch
|
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
|
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):
|
def __str__(self):
|
||||||
return "<%s %s(%s)>" % (
|
return "<%s %s(%s)>" % (
|
||||||
type(self).__name__,
|
type(self).__name__,
|
||||||
@@ -1156,16 +1149,16 @@ class Form(object):
|
|||||||
self._values.get('id', False),
|
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:
|
""" Post-processes to augment the fields_view_get with:
|
||||||
|
|
||||||
* an id field (may not be present if not in the view but needed)
|
* 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 modifiers (map of modifier name to json-loaded domain)
|
||||||
* pre-processed onchanges list
|
* pre-processed onchanges list
|
||||||
"""
|
"""
|
||||||
fvg['fields']['id'] = {'type': 'id'}
|
fvg['fields'].setdefault('id', {'type': 'id'})
|
||||||
# pre-resolve modifiers & bind to arch toplevel
|
# pre-resolve modifiers & bind to arch toplevel
|
||||||
modifiers = fvg['modifiers'] = {}
|
modifiers = fvg['modifiers'] = {'id': {'required': False, 'readonly': True}}
|
||||||
contexts = fvg['contexts'] = {}
|
contexts = fvg['contexts'] = {}
|
||||||
order = fvg['fields_ordered'] = []
|
order = fvg['fields_ordered'] = []
|
||||||
for f in fvg['tree'].xpath('//field[not(ancestor::field)]'):
|
for f in fvg['tree'].xpath('//field[not(ancestor::field)]'):
|
||||||
@@ -1181,10 +1174,9 @@ class Form(object):
|
|||||||
contexts[fname] = ctx
|
contexts[fname] = ctx
|
||||||
|
|
||||||
descr = fvg['fields'].get(fname) or {'type': None}
|
descr = fvg['fields'].get(fname) or {'type': None}
|
||||||
if descr['type'] == 'one2many':
|
if level and descr['type'] == 'one2many':
|
||||||
self._o2m_set_edition_view(descr, f)
|
self._o2m_set_edition_view(descr, f, level)
|
||||||
|
|
||||||
fvg['modifiers']['id'] = {'required': False, 'readonly': True}
|
|
||||||
fvg['onchange'] = model._onchange_spec(fvg)
|
fvg['onchange'] = model._onchange_spec(fvg)
|
||||||
|
|
||||||
def _init_from_defaults(self, model):
|
def _init_from_defaults(self, model):
|
||||||
@@ -1242,12 +1234,13 @@ class Form(object):
|
|||||||
return O2MProxy(self, field)
|
return O2MProxy(self, field)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
def _get_modifier(self, field, modifier, default=False):
|
def _get_modifier(self, field, modifier, default=False, modmap=None, vals=None):
|
||||||
d = self._view['modifiers'][field].get(modifier, default)
|
d = (modmap or self._view['modifiers'])[field].get(modifier, default)
|
||||||
if isinstance(d, bool):
|
if isinstance(d, bool):
|
||||||
return d
|
return d
|
||||||
|
|
||||||
vals = self._values
|
if vals is None:
|
||||||
|
vals = self._values
|
||||||
stack = []
|
stack = []
|
||||||
for it in reversed(d):
|
for it in reversed(d):
|
||||||
if it == '!':
|
if it == '!':
|
||||||
@@ -1262,7 +1255,9 @@ class Form(object):
|
|||||||
stack.append(e1 or e2)
|
stack.append(e1 or e2)
|
||||||
elif isinstance(it, list):
|
elif isinstance(it, list):
|
||||||
f, op, val = it
|
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))
|
stack.append(self._OPS[op](field_val, val))
|
||||||
else:
|
else:
|
||||||
raise ValueError("Unknown domain element %s" % it)
|
raise ValueError("Unknown domain element %s" % it)
|
||||||
@@ -1361,7 +1356,8 @@ class Form(object):
|
|||||||
load/save
|
load/save
|
||||||
"""
|
"""
|
||||||
values = {}
|
values = {}
|
||||||
for f in self._view['fields']:
|
fields = self._view['fields']
|
||||||
|
for f in fields:
|
||||||
v = self._values[f]
|
v = self._values[f]
|
||||||
if self._get_modifier(f, 'required'):
|
if self._get_modifier(f, 'required'):
|
||||||
assert v is not False, "{} is a required field".format(f)
|
assert v is not False, "{} is a required field".format(f)
|
||||||
@@ -1371,10 +1367,34 @@ class Form(object):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if self._get_modifier(f, 'readonly'):
|
if self._get_modifier(f, 'readonly'):
|
||||||
node = self._get_node(f)
|
node = _get_node(self._view, f)
|
||||||
if not node.get('force_save'):
|
if not node.get('force_save'):
|
||||||
continue
|
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
|
values[f] = v
|
||||||
return values
|
return values
|
||||||
|
|
||||||
@@ -1409,11 +1429,15 @@ class Form(object):
|
|||||||
values = {}
|
values = {}
|
||||||
for k, v in self._values.items():
|
for k, v in self._values.items():
|
||||||
if f[k]['type'] == 'one2many':
|
if f[k]['type'] == 'one2many':
|
||||||
# web client sends a 4 for unmodified o2m rows
|
it = values[k] = []
|
||||||
values[k] = [
|
for (c, rid, vs) in v:
|
||||||
(4, rid, False) if (c == 1 and not vs) else (c, rid, vs)
|
if c == 1 and not vs:
|
||||||
for (c, rid, vs) in v
|
# 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:
|
else:
|
||||||
values[k] = v
|
values[k] = v
|
||||||
return values
|
return values
|
||||||
@@ -1512,9 +1536,16 @@ class O2MForm(Form):
|
|||||||
if self._index is None:
|
if self._index is None:
|
||||||
commands.append((0, 0, values))
|
commands.append((0, 0, values))
|
||||||
else:
|
else:
|
||||||
(c, _, vs) = commands[proxy._command_index(self._index)]
|
index = proxy._command_index(self._index)
|
||||||
assert c in (0, 1)
|
(c, id_, vs) = commands[index]
|
||||||
vs.update(values)
|
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?
|
# FIXME: should be called when performing on change => value needs to be serialised into parent every time?
|
||||||
proxy._parent._perform_onchange([proxy._field])
|
proxy._parent._perform_onchange([proxy._field])
|
||||||
@@ -1523,20 +1554,31 @@ class O2MForm(Form):
|
|||||||
""" Validates values and returns only fields modified since
|
""" Validates values and returns only fields modified since
|
||||||
load/save
|
load/save
|
||||||
"""
|
"""
|
||||||
values = {}
|
values = UpdateDict(self._values)
|
||||||
for f in self._view['fields']:
|
values._changed.update(self._changed)
|
||||||
v = self._values[f]
|
|
||||||
if self._get_modifier(f, 'required'):
|
for f in self._view['fields']:
|
||||||
assert v is not False, "{} is a required field".format(f)
|
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
|
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):
|
class X2MProxy(object):
|
||||||
_parent = None
|
_parent = None
|
||||||
_field = None
|
_field = None
|
||||||
@@ -1738,6 +1780,13 @@ def record_to_values(fields, record):
|
|||||||
r[f] = v
|
r[f] = v
|
||||||
return r
|
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):
|
def tagged(*tags):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ def get_fiscal_year(date, day=31, month=12):
|
|||||||
date_from = date_to + relativedelta(days=1)
|
date_from = date_to + relativedelta(days=1)
|
||||||
max_day = calendar.monthrange(date_to.year + 1, date_to.month)[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))
|
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
|
return date_from, date_to
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user