diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index d87e3eeeea5..4a4f1d39133 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -1331,9 +1331,8 @@ class AccountMoveLine(models.Model):
""" Create analytic items upon validation of an account.move.line having an analytic account. This
method first remove any existing analytic item related to the line before creating any new one.
"""
+ self.mapped('analytic_line_ids').unlink()
for obj_line in self:
- if obj_line.analytic_line_ids:
- obj_line.analytic_line_ids.unlink()
if obj_line.analytic_account_id:
vals_line = obj_line._prepare_analytic_line()[0]
self.env['account.analytic.line'].create(vals_line)
diff --git a/addons/account_asset/models/account_asset.py b/addons/account_asset/models/account_asset.py
index 83639b5f3a5..d6bc83b9087 100644
--- a/addons/account_asset/models/account_asset.py
+++ b/addons/account_asset/models/account_asset.py
@@ -477,7 +477,7 @@ class AccountAssetDepreciationLine(models.Model):
depreciation_date = self.env.context.get('depreciation_date') or line.depreciation_date or fields.Date.context_today(self)
company_currency = line.asset_id.company_id.currency_id
current_currency = line.asset_id.currency_id
- amount = current_currency.compute(line.amount, company_currency)
+ amount = current_currency.with_context(date=depreciation_date).compute(line.amount, company_currency)
asset_name = line.asset_id.name + ' (%s/%s)' % (line.sequence, len(line.asset_id.depreciation_line_ids))
move_line_1 = {
'name': asset_name,
diff --git a/addons/anonymization/wizard/anonymize_wizard.py b/addons/anonymization/wizard/anonymize_wizard.py
index a0b355d4330..0d0ecfaaa63 100644
--- a/addons/anonymization/wizard/anonymize_wizard.py
+++ b/addons/anonymization/wizard/anonymize_wizard.py
@@ -4,16 +4,14 @@
import base64
import os
import random
-try:
- import cPickle as pickle
-except ImportError:
- import pickle
+
from lxml import etree
from operator import itemgetter
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.release import version_info
+from odoo.tools import pickle
from odoo.tools.safe_eval import safe_eval
from odoo.addons.anonymization.models.anonymization import group
@@ -207,7 +205,7 @@ class IrModelFieldsAnonymizeWizard(models.TransientModel):
# save pickle:
fn = open(abs_filepath, 'w')
- pickle.dump(data, fn, pickle.HIGHEST_PROTOCOL)
+ pickle.dump(data, fn, protocol=-1)
# update the anonymization fields:
ano_fields.write({'state': 'anonymized'})
diff --git a/addons/auth_oauth/models/res_users.py b/addons/auth_oauth/models/res_users.py
index f48802c2310..b10f19a28be 100644
--- a/addons/auth_oauth/models/res_users.py
+++ b/addons/auth_oauth/models/res_users.py
@@ -10,6 +10,8 @@ from odoo import api, fields, models
from odoo.exceptions import AccessDenied
from odoo.addons.auth_signup.models.res_users import SignupError
+from odoo.addons import base
+base.res.res_users.USER_PRIVATE_FIELDS.append('oauth_access_token')
class ResUsers(models.Model):
_inherit = 'res.users'
diff --git a/addons/hw_screen/__openerp__.py b/addons/hw_screen/__manifest__.py
similarity index 100%
rename from addons/hw_screen/__openerp__.py
rename to addons/hw_screen/__manifest__.py
diff --git a/addons/l10n_fr_certification/__init__.py b/addons/l10n_fr_certification/__init__.py
new file mode 100644
index 00000000000..24644871f1e
--- /dev/null
+++ b/addons/l10n_fr_certification/__init__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import models
+from openerp import api, SUPERUSER_ID
+
+def _setup_inalterability(cr, registry):
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ # enable ping for this module
+ env['publisher_warranty.contract'].update_notification(cron_mode=True)
+
+ # make sure account_cancel is not usable at the same time as l10n_fr
+ # FORWARD PORT NOTICE
+ # In master as of March 2017, RCO-ODOO coded an exclusive field on modules to flag incompatibility
+
+ wanted_states = ['installed', 'to upgrade', 'to install']
+ account_cancel_module = env['ir.module.module'].search([('name', '=', 'account_cancel')], limit=1)
+
+ if account_cancel_module and account_cancel_module.state in wanted_states:
+ views_xml_id = env['ir.model.data'].search([('module', '=', 'account_cancel'), ('model', '=', 'ir.ui.view')])
+ ir_views = env['ir.ui.view'].browse([v.res_id for v in views_xml_id])
+ for cancel_view in ir_views:
+ cancel_view.write({'active': False})
+
+ fr_companies = env['res.company'].search([('partner_id.country_id.code', '=', 'FR')])
+ if fr_companies:
+ # create the securisation sequence per company
+ fr_companies._create_secure_sequence()
+
+ #reset the update_posted field on journals
+ journals = env['account.journal'].search([('company_id', 'in', fr_companies.ids)])
+ for journal in journals:
+ journal.write({'update_posted': False})
diff --git a/addons/l10n_fr_certification/__manifest__.py b/addons/l10n_fr_certification/__manifest__.py
new file mode 100644
index 00000000000..e83e9102914
--- /dev/null
+++ b/addons/l10n_fr_certification/__manifest__.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'France - Accounting - Certified CGI 286 I-3 bis',
+ 'version': '1.0',
+ 'category': 'Localization',
+ 'description': """
+""",
+ 'depends': ['l10n_fr'],
+ 'installable': True,
+ 'auto_install': True,
+ 'application': True,
+ 'data': [
+ 'views/no_cancel.xml',
+ 'data/account_move.xml',
+ ],
+ 'post_init_hook': '_setup_inalterability',
+}
diff --git a/addons/l10n_fr_certification/data/account_move.xml b/addons/l10n_fr_certification/data/account_move.xml
new file mode 100644
index 00000000000..95adc8d8c2e
--- /dev/null
+++ b/addons/l10n_fr_certification/data/account_move.xml
@@ -0,0 +1,13 @@
+
+
+ Intégrité des données comptables
+
+ ir.actions.server
+
+ action = env['account.move']._check_hash_integrity(env.user.company_id.id)
+
+
+
+
+
+
diff --git a/addons/l10n_fr_certification/models/__init__.py b/addons/l10n_fr_certification/models/__init__.py
new file mode 100644
index 00000000000..17a7d5cf526
--- /dev/null
+++ b/addons/l10n_fr_certification/models/__init__.py
@@ -0,0 +1,2 @@
+import res_company
+import account
diff --git a/addons/l10n_fr_certification/models/account.py b/addons/l10n_fr_certification/models/account.py
new file mode 100644
index 00000000000..ecb0f5aff8b
--- /dev/null
+++ b/addons/l10n_fr_certification/models/account.py
@@ -0,0 +1,155 @@
+# -*- coding: utf-8 -*-
+from hashlib import sha256
+from json import dumps
+
+from openerp import models, api, fields
+from openerp.tools.translate import _
+from openerp.exceptions import UserError
+
+ERR_MSG = _("According to the french law, you cannot modify a %s in order for its posted data to be updated or deleted. Field: %s")
+
+#forbidden fields
+MOVE_FIELDS = ['date', 'journal_id', 'company_id']
+LINE_FIELDS = ['debit', 'credit', 'account_id', 'move_id', 'partner_id']
+
+
+class AccountMove(models.Model):
+ _inherit = "account.move"
+
+ l10n_fr_secure_sequence_number = fields.Integer(readonly=True, copy=False)
+ l10n_fr_hash = fields.Char(readonly=True, copy=False)
+ l10n_fr_string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True, store=False)
+
+ def _get_new_hash(self, secure_seq_number):
+ """ Returns the hash to write on journal entries when they get posted"""
+ self.ensure_one()
+ #get the only one exact previous move in the securisation sequence
+ prev_move = self.search([('state', '=', 'posted'),
+ ('company_id', '=', self.company_id.id),
+ ('l10n_fr_secure_sequence_number', '=', int(secure_seq_number) - 1)])
+ if prev_move and len(prev_move) != 1:
+ raise UserError(
+ _('Error occured when computing the hash. Impossible to get the unique previous posted move'))
+
+ #build and return the hash
+ return self._compute_hash(prev_move.l10n_fr_hash if prev_move else '')
+
+ def _compute_hash(self, previous_hash):
+ """ Computes the hash of the browse_record given as self, based on the hash
+ of the previous record in the company's securisation sequence given as parameter"""
+ self.ensure_one()
+ hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash)
+ return hash_string.hexdigest()
+
+ def _compute_string_to_hash(self):
+ def _getattrstring(obj, field_str):
+ field_value = obj[field_str]
+ if obj._fields[field_str].type == 'many2one':
+ field_value = field_value.id
+ return str(field_value)
+
+ for move in self:
+ values = {}
+ for field in MOVE_FIELDS:
+ values[field] = _getattrstring(move, field)
+
+ for line in move.line_ids:
+ for field in LINE_FIELDS:
+ values[field] = _getattrstring(line, field)
+ #make the json serialization canonical
+ # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00)
+ move.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8",
+ ensure_ascii=True, indent=None,
+ separators=(',',':'))
+
+ @api.multi
+ def write(self, vals):
+ has_been_posted = False
+ for move in self:
+ if move.company_id.country_id.code == 'FR':
+ # write the hash and the secure_sequence_number when posting an account.move
+ if vals.get('state') == 'posted':
+ has_been_posted = True
+
+ # restrict the operation in case we are trying to write a forbidden field
+ if (move.state == "posted" and set(vals).intersection(MOVE_FIELDS)):
+ raise UserError(ERR_MSG % (self._name, ', '.join(MOVE_FIELDS)))
+ # restrict the operation in case we are trying to overwrite existing hash
+ if (move.l10n_fr_hash and 'l10n_fr_hash' in vals) or (move.l10n_fr_secure_sequence_number and 'l10n_fr_secure_sequence_number' in vals):
+ raise UserError(_('You cannot overwrite the values ensuring the inalterability of the accounting.'))
+ res = super(AccountMove, self).write(vals)
+ # write the hash and the secure_sequence_number when posting an account.move
+ if has_been_posted:
+ for move in self.filtered(lambda m: m.company_id.country_id.code == 'FR' and
+ not (m.l10n_fr_secure_sequence_number or m.l10n_fr_hash)):
+ new_number = move.company_id.l10n_fr_secure_sequence_id.next_by_id()
+ vals_hashing = {'l10n_fr_secure_sequence_number': new_number,
+ 'l10n_fr_hash': move._get_new_hash(new_number)}
+ res |= super(AccountMove, move).write(vals_hashing)
+ return res
+
+ def button_cancel(self):
+ #by-pass the normal behavior/message that tells people can cancel a posted journal entry
+ #if the journal allows it.
+ if self.company_id.country_id.code == 'FR':
+ raise UserError(_('You cannot modify a posted entry of a journal.'))
+ super(AccountMove, self).button_cancel()
+
+ @api.model
+ def _check_hash_integrity(self, company_id):
+ """Checks that all posted moves have still the same data as when they were posted
+ and raises an error with the result.
+ """
+ moves = self.search([('state', '=', 'posted'),
+ ('company_id', '=', company_id),
+ ('l10n_fr_secure_sequence_number', '!=', False)],
+ order="l10n_fr_secure_sequence_number ASC")
+
+ if not moves:
+ raise UserError(_('Warning: impossible to find any move with a hash.'))
+ previous_hash = ''
+ start_move_info = []
+ for move in moves:
+ if move.l10n_fr_hash != move._compute_hash(previous_hash=previous_hash):
+ raise UserError(_('Corrupted Data on move %s.') % move.id)
+ if not previous_hash:
+ #save the date and sequence number of the first move hashed
+ start_move_info = [move.date, move.l10n_fr_secure_sequence_number]
+ previous_hash = move.l10n_fr_hash
+ end_move_info = [move.date, move.l10n_fr_secure_sequence_number]
+ raise UserError(_('''Successfully checked the integrity of account moves.
+
+ The account moves are guaranteed to be in their original and inalterable state
+ - since: %s (Sequence Number: %s)
+ - to: %s (Sequence Number: %s)'''
+ ) % (start_move_info[0], start_move_info[1], end_move_info[0], end_move_info[1]))
+
+
+class AccountMoveLine(models.Model):
+ _inherit = "account.move.line"
+
+ @api.multi
+ def write(self, vals):
+ # restrict the operation in case we are trying to write a forbidden field
+ if set(vals).intersection(LINE_FIELDS):
+ if any(l.company_id.country_id.code == 'FR' and l.move_id.state == 'posted' for l in self):
+ raise UserError(ERR_MSG % (self._name, ', '.join(LINE_FIELDS)))
+ return super(AccountMoveLine, self).write(vals)
+
+
+class AccountJournal(models.Model):
+ _inherit = "account.journal"
+
+ @api.multi
+ def write(self, vals):
+ # restrict the operation in case we are trying to write a forbidden field
+ if self.company_id.country_id.code == 'FR' and vals.get('update_posted'):
+ raise UserError(ERR_MSG % (self._name, 'update_posted'))
+ return super(AccountJournal, self).write(vals)
+
+ @api.model
+ def create(self, vals):
+ # restrict the operation in case we are trying to set a forbidden field
+ if self.company_id.country_id.code == 'FR' and vals.get('update_posted'):
+ raise UserError(ERR_MSG % (self._name, 'update_posted'))
+ return super(AccountJournal, self).create(vals)
diff --git a/addons/l10n_fr_certification/models/res_company.py b/addons/l10n_fr_certification/models/res_company.py
new file mode 100644
index 00000000000..66a2a633164
--- /dev/null
+++ b/addons/l10n_fr_certification/models/res_company.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from openerp import fields, models, api
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ l10n_fr_secure_sequence_id = fields.Many2one('ir.sequence', 'Sequence to use to ensure the securisation of data', readonly=True)
+
+ @api.model
+ def create(self, vals):
+ company = super(ResCompany, self).create(vals)
+ #when creating a new french company, create the securisation sequence as well
+ if company.country_id == self.env.ref('base.fr'):
+ company._create_secure_sequence()
+ return company
+
+ @api.multi
+ def write(self, vals):
+ res = super(ResCompany, self).write(vals)
+ #if country changed to fr, create the securisation sequence
+ if vals.get('country_id') and vals.get('country_id') == self.env.ref('base.fr').id:
+ self.filtered(lambda c: not c.l10n_fr_secure_sequence_id)._create_secure_sequence()
+ return res
+
+ def _create_secure_sequence(self):
+ """This function creates a no_gap sequence on each companies in self that will ensure
+ a unique number is given to all posted account.move in such a way that we can always
+ find the previous move of a journal entry.
+ """
+ for company in self:
+ vals = {
+ 'name': 'French Securisation of account_move_line - ' + company.name,
+ 'code': 'FRSECUR',
+ 'implementation': 'no_gap',
+ 'prefix': '',
+ 'suffix': '',
+ 'padding': 0,
+ 'company_id': company.id}
+ seq = self.env['ir.sequence'].create(vals)
+ company.write({'l10n_fr_secure_sequence_id': seq.id})
diff --git a/addons/l10n_fr_certification/views/no_cancel.xml b/addons/l10n_fr_certification/views/no_cancel.xml
new file mode 100644
index 00000000000..43bc66d6ee3
--- /dev/null
+++ b/addons/l10n_fr_certification/views/no_cancel.xml
@@ -0,0 +1,36 @@
+
+
+ bank.statement.draft.form.inherit
+ account.bank.statement
+
+
+
+ 1
+
+
+
+
+
+ invoice.form.cancel.inherit
+ account.invoice
+
+
+
+ 1
+
+
+
+
+
+ account.move.form.nocancel.inherit
+ account.move
+
+
+
+ 1
+
+
+
+
+
+
diff --git a/addons/l10n_in_schedule6/__openerp__.py b/addons/l10n_in_schedule6/__manifest__.py
similarity index 100%
rename from addons/l10n_in_schedule6/__openerp__.py
rename to addons/l10n_in_schedule6/__manifest__.py
diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py
index 1fc922a0d55..17539e1023b 100644
--- a/addons/mail/models/mail_template.py
+++ b/addons/mail/models/mail_template.py
@@ -58,6 +58,21 @@ def format_tz(env, dt, tz=False, format=False):
ftime = ts.strftime(format_time).decode('utf-8')
return "%s %s%s" % (fdate, ftime, (' (%s)' % tz) if tz else '')
+def format_amount(env, amount, currency):
+ fmt = "%.{0}f".format(currency.decimal_places)
+ lang = env['res.lang']._lang_get(env.context.get('lang') or 'en_US')
+
+ formatted_amount = lang.format(fmt, currency.round(amount), grouping=True, monetary=True)\
+ .replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'\u2011')
+
+ pre = post = u''
+ if currency.position == 'before':
+ pre = u'{symbol}\N{NO-BREAK SPACE}'.format(symbol=currency.symbol or '')
+ else:
+ post = u'\N{NO-BREAK SPACE}{symbol}'.format(symbol=currency.symbol or '')
+
+ return u'{pre}{0}{post}'.format(formatted_amount, pre=pre, post=post)
+
try:
# We use a jinja2 sandboxed environment to render mako templates.
# Note that the rendering does not cover all the mako syntax, in particular
@@ -362,6 +377,7 @@ class MailTemplate(models.Model):
variables = {
'format_date': lambda date, format=False, context=self._context: format_date(self.env, date, format),
'format_tz': lambda dt, tz=False, format=False, context=self._context: format_tz(self.env, dt, tz, format),
+ 'format_amount': lambda amount, currency, context=self._context: format_amount(self.env, amount, currency),
'user': self.env.user,
'ctx': self._context, # context kw would clash with mako internals
}
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index 1bec12c7eb1..48cf795bed1 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -5,6 +5,7 @@ from datetime import timedelta
from functools import partial
import psycopg2
+import pytz
from odoo import api, fields, models, tools, _
from odoo.tools import float_is_zero
@@ -699,7 +700,7 @@ class PosOrder(models.Model):
qty_done = pack_operation.product_qty
pack_operation.write({'pack_lot_ids': map(lambda x: (0, 0, x), pack_lots), 'qty_done': qty_done})
- def add_payment(self, data):
+ def _prepare_bank_statement_line_payment_values(self, data):
"""Create a new payment for the order"""
args = {
'amount': data['amount'],
@@ -744,8 +745,16 @@ class PosOrder(models.Model):
'journal_id': journal_id,
'ref': self.session_id.name,
})
+
+ return args
+
+ def add_payment(self, data):
+ """Create a new payment for the order"""
+ args = self._prepare_bank_statement_line_payment_values(data)
+ context = dict(self.env.context)
+ context.pop('pos_session_id', False)
self.env['account.bank.statement.line'].with_context(context).create(args)
- return statement_id
+ return args.get('statement_id', False)
@api.multi
def refund(self):
@@ -918,7 +927,9 @@ class ReportSaleDetails(models.AbstractModel):
if not configs:
configs = self.env['pos.config'].search([])
- today = fields.Datetime.from_string(fields.Date.context_today(self))
+ user_tz = pytz.timezone(self.env.context.get('tz') or self.env.user.tz or 'UTC')
+ today = user_tz.localize(fields.Datetime.from_string(fields.Date.context_today(self)))
+ today = today.astimezone(pytz.timezone('UTC'))
if date_start:
date_start = fields.Datetime.from_string(date_start)
else:
diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py
index 735d5e3e599..535db66e093 100644
--- a/addons/point_of_sale/models/pos_session.py
+++ b/addons/point_of_sale/models/pos_session.py
@@ -246,16 +246,22 @@ class PosSession(models.Model):
@api.multi
def action_pos_session_closing_control(self):
+ self._check_pos_session_balance()
for session in self:
- for statement in session.statement_ids:
- if (statement != session.cash_register_id) and (statement.balance_end != statement.balance_end_real):
- statement.write({'balance_end_real': statement.balance_end})
session.write({'state': 'closing_control', 'stop_at': fields.Datetime.now()})
if not session.config_id.cash_control:
session.action_pos_session_close()
+ @api.multi
+ def _check_pos_session_balance(self):
+ for session in self:
+ for statement in session.statement_ids:
+ if (statement != session.cash_register_id) and (statement.balance_end != statement.balance_end_real):
+ statement.write({'balance_end_real': statement.balance_end})
+
@api.multi
def action_pos_session_validate(self):
+ self._check_pos_session_balance()
self.action_pos_session_close()
@api.multi
diff --git a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart
index e2b61e2f591..15908b4c5aa 100755
--- a/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart
+++ b/addons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart
@@ -3,4 +3,11 @@ xset s off
xset -dpms
export HOME=/tmp
-/usr/bin/firefox http://localhost:8069/point_of_sale/display &
+
+DISPLAY_URL='http://localhost:8069/point_of_sale/display'
+
+# wait until odoo is ready, otherwise you will have to plug a
+# keyboard into the posbox just to refresh firefox
+until wget -q "${DISPLAY_URL}" -O /dev/null ; do sleep 1 ; done
+
+/usr/bin/firefox "${DISPLAY_URL}" &
diff --git a/addons/point_of_sale/views/pos_order_view.xml b/addons/point_of_sale/views/pos_order_view.xml
index 2e6a6d0a5bd..4c7698b5cac 100644
--- a/addons/point_of_sale/views/pos_order_view.xml
+++ b/addons/point_of_sale/views/pos_order_view.xml
@@ -38,7 +38,7 @@
-
+
@@ -51,7 +51,7 @@
-
+
diff --git a/addons/pos_restaurant/static/src/js/floors.js b/addons/pos_restaurant/static/src/js/floors.js
index ec118ad4637..27f5c98f9ae 100644
--- a/addons/pos_restaurant/static/src/js/floors.js
+++ b/addons/pos_restaurant/static/src/js/floors.js
@@ -840,12 +840,13 @@ models.PosModel = models.PosModel.extend({
add_new_order: function() {
if (this.config.iface_floorplan) {
if (this.table) {
- _super_posmodel.add_new_order.call(this);
+ return _super_posmodel.add_new_order.call(this);
} else {
console.warn("WARNING: orders cannot be created when there is no active table in restaurant mode");
+ return undefined;
}
} else {
- _super_posmodel.add_new_order.apply(this,arguments);
+ return _super_posmodel.add_new_order.apply(this,arguments);
}
},
diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py
index 5a76a14c64f..5415f85e5f4 100644
--- a/addons/resource/tests/test_resource.py
+++ b/addons/resource/tests/test_resource.py
@@ -279,6 +279,7 @@ class ResourceWorkingHours(TestResourceCommon):
res = self.calendar.plan_hours(-40, day_dt=Datetime.from_string('2013-02-12 09:00:00'))
self.assertEqual(res, Datetime.from_string('2013-01-29 09:00:00'))
+
def test_calendar_hours_scheduling_forward(self):
res = self.calendar._schedule_hours(40, day_dt=Datetime.from_string('2013-02-12 09:00:00'))
self.assertEqual(res[0][:2], (Datetime.from_string('2013-02-12 09:00:00'), Datetime.from_string('2013-02-12 16:00:00')))
@@ -289,6 +290,9 @@ class ResourceWorkingHours(TestResourceCommon):
self.assertEqual(res[5][:2], (Datetime.from_string('2013-02-22 16:00:00'), Datetime.from_string('2013-02-22 23:00:00')))
self.assertEqual(res[6][:2], (Datetime.from_string('2013-02-26 08:00:00'), Datetime.from_string('2013-02-26 09:00:00')))
+ res = self.calendar.schedule_hours_get_date(40, day_dt=self.date1.replace(minute=0, second=0))
+ self.assertEqual(res, Datetime.from_string('2013-02-26 09:00:00'))
+
td = timedelta()
for item in res:
td += item[1] - item[0]
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 3ca93dd3b90..f35bc650838 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -685,14 +685,21 @@ class SaleOrderLine(models.Model):
return {}
@api.model
- def create(self, values):
+ def _prepare_add_missing_fields(self, values):
+ """ Deduce missing required fields from the onchange """
+ res = {}
onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_id']
if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields):
line = self.new(values)
line.product_id_change()
for field in onchange_fields:
if field not in values:
- values[field] = line._fields[field].convert_to_write(line[field], line)
+ res[field] = line._fields[field].convert_to_write(line[field], line)
+ return res
+
+ @api.model
+ def create(self, values):
+ values.update(self._prepare_add_missing_fields(values))
line = super(SaleOrderLine, self).create(values)
if line.state == 'sale':
line._action_procurement_create()
diff --git a/addons/sale_margin/models/sale_order.py b/addons/sale_margin/models/sale_order.py
index 63dc3f872f0..dfb24840f2d 100644
--- a/addons/sale_margin/models/sale_order.py
+++ b/addons/sale_margin/models/sale_order.py
@@ -42,6 +42,8 @@ class SaleOrderLine(models.Model):
@api.model
def create(self, vals):
+ vals.update(self._prepare_add_missing_fields(vals))
+
# Calculation of the margin for programmatic creation of a SO line. It is therefore not
# necessary to call product_id_change_margin manually
if 'purchase_price' not in vals:
diff --git a/addons/sale_margin/tests/test_sale_margin.py b/addons/sale_margin/tests/test_sale_margin.py
index 46cf3561aec..8252fd7f390 100644
--- a/addons/sale_margin/tests/test_sale_margin.py
+++ b/addons/sale_margin/tests/test_sale_margin.py
@@ -23,14 +23,22 @@ class TestSaleMargin(common.TransactionCase):
sale_order_so11 = self.SaleOrder.create({
'date_order': datetime.today(),
'name': 'Test_SO011',
- 'order_line': [(0, 0, {
- 'name': '[CARD] Graphics Card',
- 'purchase_price': 700.0,
- 'price_unit': 1000.0,
- 'product_uom': self.product_uom_id,
- 'product_uom_qty': 10.0,
- 'state': 'draft',
- 'product_id': self.product_id})],
+ 'order_line': [
+ (0, 0, {
+ 'name': '[CARD] Graphics Card',
+ 'purchase_price': 700.0,
+ 'price_unit': 1000.0,
+ 'product_uom': self.product_uom_id,
+ 'product_uom_qty': 10.0,
+ 'state': 'draft',
+ 'product_id': self.product_id}),
+ (0, 0, {
+ 'name': 'Line without product_uom',
+ 'price_unit': 1000.0,
+ 'purchase_price': 700.0,
+ 'product_uom_qty': 10.0,
+ 'state': 'draft',
+ 'product_id': self.product_id})],
'partner_id': self.partner_id,
'partner_invoice_id': self.partner_invoice_address_id,
'partner_shipping_id': self.partner_invoice_address_id,
@@ -38,4 +46,4 @@ class TestSaleMargin(common.TransactionCase):
# Confirm the sales order.
sale_order_so11.action_confirm()
# Verify that margin field gets bind with the value.
- self.assertEqual(sale_order_so11.margin, 3000.00, "Sales order margin should be 3000.00")
+ self.assertEqual(sale_order_so11.margin, 6000.00, "Sales order margin should be 6000.00")
diff --git a/addons/web_kanban/static/src/less/kanban_dashboard.less b/addons/web_kanban/static/src/less/kanban_dashboard.less
index eb036d0c0bd..9c7c9c76658 100644
--- a/addons/web_kanban/static/src/less/kanban_dashboard.less
+++ b/addons/web_kanban/static/src/less/kanban_dashboard.less
@@ -39,7 +39,7 @@
// If invisible, elem will no take more than its original height
a, span {
- .o-text-overflow(block);
+ .o-text-overflow(inline-block);
position: relative;
}
@@ -88,9 +88,10 @@
.o_kanban_manage_button_section {
.o-flex(1, 0, 0);
- padding: @odoo-horizontal-padding;
.o_kanban_manage_toggle_button {
+ display: block;
+ padding: @odoo-horizontal-padding;
text-align: right;
}
}
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 1e65fd256a8..c62486d34a0 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -179,8 +179,8 @@ class Http(models.AbstractModel):
nearest_lang = not func and cls.get_nearest_lang(path[1])
url_lang = nearest_lang and path[1]
preferred_lang = ((cook_lang if cook_lang in langs else False)
- or request.website.default_lang_code
- or (not is_a_bot and cls.get_nearest_lang(request.lang)))
+ or (not is_a_bot and cls.get_nearest_lang(request.lang))
+ or request.website.default_lang_code)
request.lang = context['lang'] = nearest_lang or preferred_lang
# if lang in url but not the displayed or default language --> change or remove
diff --git a/addons/website_sale_options/static/src/js/website_sale.js b/addons/website_sale_options/static/src/js/website_sale.js
index e206184b52e..7de8faff3ca 100644
--- a/addons/website_sale_options/static/src/js/website_sale.js
+++ b/addons/website_sale_options/static/src/js/website_sale.js
@@ -52,6 +52,11 @@ $('.oe_website_sale #add_to_cart, .oe_website_sale #products_grid .a-submit')
$modal.modal('hide');
});
+ $modal.on('click', '.css_attribute_color input', function (event) {
+ $modal.find('.css_attribute_color').removeClass("active");
+ $modal.find('.css_attribute_color:has(input:checked)').addClass("active");
+ });
+
$modal.on("click", "a.js_add, a.js_remove", function (event) {
event.preventDefault();
var $parent = $(this).parents('.js_product:first');
diff --git a/doc/_static/banners/mobile.jpg b/doc/_static/banners/mobile.jpg
new file mode 100644
index 00000000000..56d29b6c529
Binary files /dev/null and b/doc/_static/banners/mobile.jpg differ
diff --git a/doc/cla/corporate/akretion.md b/doc/cla/corporate/akretion.md
index 156b24ff4d5..6743d21653c 100644
--- a/doc/cla/corporate/akretion.md
+++ b/doc/cla/corporate/akretion.md
@@ -33,6 +33,7 @@ Cilene Oliveira cilene.oliveira@akretion.com https://github.com/cileneoliveira
David Beal david.beal@akretion.com https://github.com/bealdav
Florian da Costa florian.dacosta@akretion.com https://github.com/florian-dacosta
Magno Barcelo da Costa magno.costa@akretion.com.br https://github.com/mbcosta
+Mourad El Hadj Mimoune mourad.elhadj.mimoune@akretion.com https://github.com/mourad-ehm
Raphaël Reverdy raphael.reverdy@akretion.com https://github.com/hparfr
Raphaël Valyi raphael.valyi@akretion.com https://github.com/rvalyi
Renato Lima renato.lima@akretion.com https://github.com/renatonlima
diff --git a/doc/cla/corporate/fairhall.md b/doc/cla/corporate/fairhall.md
new file mode 100644
index 00000000000..b075820986c
--- /dev/null
+++ b/doc/cla/corporate/fairhall.md
@@ -0,0 +1,15 @@
+Spain, 2017-06-12
+
+Fairhall Solutions, S.L. agrees to the terms of the Odoo Corporate Contributor License
+Agreement v1.0.
+
+I declare that I am authorized and able to make this agreement and sign this
+declaration.
+
+Signed,
+
+Harsh Sharma harsh@fairhall.es https://github.com/fairhall
+
+List of contributors:
+
+Carlos Mayo carlos.mayo@fairhall.es https://github.com/cmayo
\ No newline at end of file
diff --git a/doc/reference.rst b/doc/reference.rst
index 07efea05ffa..d83ef3bb476 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -22,3 +22,4 @@ Reference
reference/mixins
reference/guidelines
reference/upgrade_api
+ reference/mobile
diff --git a/doc/reference/images/mobile_contact_create.png b/doc/reference/images/mobile_contact_create.png
new file mode 100644
index 00000000000..37a326e25fe
Binary files /dev/null and b/doc/reference/images/mobile_contact_create.png differ
diff --git a/doc/reference/images/mobile_notification.png b/doc/reference/images/mobile_notification.png
new file mode 100644
index 00000000000..7fb37839af8
Binary files /dev/null and b/doc/reference/images/mobile_notification.png differ
diff --git a/doc/reference/images/mobile_switch_account.png b/doc/reference/images/mobile_switch_account.png
new file mode 100644
index 00000000000..d13759e272f
Binary files /dev/null and b/doc/reference/images/mobile_switch_account.png differ
diff --git a/doc/reference/images/mobile_working.jpg b/doc/reference/images/mobile_working.jpg
new file mode 100644
index 00000000000..b657adb2c06
Binary files /dev/null and b/doc/reference/images/mobile_working.jpg differ
diff --git a/doc/reference/images/odoo_mobile_api.png b/doc/reference/images/odoo_mobile_api.png
new file mode 100644
index 00000000000..65672f62168
Binary files /dev/null and b/doc/reference/images/odoo_mobile_api.png differ
diff --git a/doc/reference/images/snackbar.png b/doc/reference/images/snackbar.png
new file mode 100644
index 00000000000..99e4dd42bbb
Binary files /dev/null and b/doc/reference/images/snackbar.png differ
diff --git a/doc/reference/images/toast.png b/doc/reference/images/toast.png
new file mode 100644
index 00000000000..15a252d381d
Binary files /dev/null and b/doc/reference/images/toast.png differ
diff --git a/doc/reference/mobile.rst b/doc/reference/mobile.rst
new file mode 100644
index 00000000000..74bda5997ab
--- /dev/null
+++ b/doc/reference/mobile.rst
@@ -0,0 +1,190 @@
+:banner: banners/mobile.jpg
+:types: api
+
+.. _reference/mobile:
+
+==================
+Mobile JavaScript
+==================
+
+Introduction
+============
+
+In Odoo 10.0 we released a mobile app, allows you to access all **Odoo apps** (even your customized modules).
+
+The application is a combination of **Odoo Web** and **Native Mobile components**, in other words it is a Odoo Web instance loaded inside native WebView container of mobile.
+
+This page documents how you can access mobile native components like Camera, Vibration, Notification, Toast through Odoo Web (via JavaScript). For this, you do not need to be a mobile developer, if you know Odoo JavaScript API you can access all available mobile features.
+
+.. warning:: This features works with **Odoo Enterprise 10.0+** only
+
+How does it work?
+=================
+
+Internal working of Mobile application:
+
+.. image:: images/mobile_working.jpg
+ :align: left
+
+Of course, it is a web page that loads on a Mobile Native Web container. But it is integrated in such a way that you can access native resources from your web JavaScript.
+
+WebPages (Odoo Web) is on the top of each layer, where second layer is a Bridge between Odoo Web (JS) and Native component of mobile.
+
+When any call from JavaScript triggered it passes through Bridge and Bridge pass it to native invoker to perform that action.
+
+When the native component has done their work, it again is passed to Bridge and you get the output in JavaScript.
+
+Process time taken by the Native component depends on what you are requesting from Native resources. Like Camera or GPS Location.
+
+How to use it?
+==============
+
+As Odoo Web Framework, Mobile api can be used anywhere by getting object of **web_mobile.rpc**
+
+.. image:: images/odoo_mobile_api.png
+ :align: left
+
+Mobile rpc object provide list of methods available (Works only with Mobile App).
+
+Just check for method availability and execute it.
+
+Methods
+-------
+
+.. note:: Each of the methods returns JQuery Deffered object with returning data JSON dictionary
+
+Show Toast in device
+.....................
+
+.. js:function:: showToast
+
+ :param object args: **message** text to display
+
+A toast provides simple feedback about an operation in a small popup. It only fills the amount of space required for the message and the current activity remains visible and interactive
+
+.. code-block:: javascript
+
+ mobile.methods.showToast({'message': 'Message sent'});
+
+.. image:: images/toast.png
+
+
+Vibrating device
+................
+
+
+.. js:function:: vibrate
+
+ :param object args: Vibrates constantly for the specified period of time (in milliseconds).
+
+Vibrate mobile device with given duration.
+
+.. code-block:: javascript
+
+ mobile.methods.vibrate({'duration': 100});
+
+Show snackbar with action
+.........................
+
+.. js:function:: showSnackBar
+
+ :param object args: (*required*) **Message** to show in snackbar and action **button label** in Snackbar (optional)
+ :returns: ``True`` if User click on Action button, ``False`` if SnackBar auto dismissed after some time
+
+Snackbars provide lightweight feedback about an operation. They show a brief message at the bottom of the screen on mobile and lower left on larger devices. Snackbars appear above all other elements on screen and only one can be displayed at a time.
+
+.. code-block:: javascript
+
+ mobile.methods.showSnackBar({'message': 'Message is deleted', 'btn_text': 'Undo'}).then(function(result){
+ if(result){
+ // Do undo operation
+ }else{
+ // Snack Bar dismissed
+ }
+ });
+
+.. image:: images/snackbar.png
+
+Showing notification
+.....................
+
+.. js:function:: showNotification
+
+ :param object args: **title** (first row) of the notification, **message** (second row) of the notification, in a standard notification.
+
+A notification is a message you can display to the user outside of your application's normal UI. When you tell the system to issue a notification, it first appears as an icon in the notification area. To see the details of the notification, the user opens the notification drawer. Both the notification area and the notification drawer are system-controlled areas that the user can view at any time.
+
+.. code-block:: javascript
+
+ mobile.showNotification({'title': 'Simple Notification', 'message': 'This is test of simple notification'})
+
+.. image:: images/mobile_notification.png
+
+
+Create contact in device
+.........................
+
+.. js:function:: addContact
+
+ :param object args: Dictionary with contact details. Possible keys (name, mobile, phone, fax, email, website, street, street2, country_id, state_id, city, zip, parent_id, function and image)
+
+Create device contact with given contact details.
+
+.. code-block:: javascript
+
+ var contact = {
+ 'name': 'Michel Fletcher',
+ 'mobile': '9999999999',
+ 'phone': '7954856587',
+ 'fax': '765898745',
+ 'email': 'michel.fletcher@agrolait.example.com',
+ 'website': 'http://www.agrolait.com',
+ 'street': '69 rue de Namur',
+ 'street2': false,
+ 'country_id': [21, 'Belgium'],
+ 'state_id': false,
+ 'city': 'Wavre',
+ 'zip': '1300',
+ 'parent_id': [8, 'Agrolait'],
+ 'function': 'Analyst',
+ 'image': '<>'
+ }
+
+ mobile.methods.addContact(contact);
+
+.. image:: images/mobile_contact_create.png
+
+Scanning barcodes
+..................
+
+.. js:function:: scanBarcode
+
+ :returns: Scanned ``code`` from any barcodes
+
+The Barcode API detects barcodes in real-time, on device, in any orientation.
+
+It reads the following barcode formats:
+
+* 1D barcodes: EAN-13, EAN-8, UPC-A, UPC-E, Code-39, Code-93, Code-128, ITF, Codabar
+* 2D barcodes: QR Code, Data Matrix, PDF-417, AZTEC
+
+.. code-block:: javascript
+
+ mobile.methods.scanBarcode().then(function(code){
+ if(code){
+ // Perform operation with code scanned
+ }
+ });
+
+Switching account in device
+...........................
+
+.. js:function:: switchAccount
+
+Use to switch device account.
+
+.. code-block:: javascript
+
+ mobile.methods.switchAccount();
+
+.. image:: images/mobile_switch_account.png
diff --git a/odoo/addons/base/ir/ir_translation.py b/odoo/addons/base/ir/ir_translation.py
index 2cebb72e5fa..4ac6f4213da 100644
--- a/odoo/addons/base/ir/ir_translation.py
+++ b/odoo/addons/base/ir/ir_translation.py
@@ -538,16 +538,18 @@ class IrTranslation(models.Model):
record = trans.env[mname].browse(trans.res_id)
field = record._fields[fname]
if callable(field.translate):
- # check whether applying (trans.src -> trans.value) then
- # (trans.value -> trans.src) gives the original value back
+ src = trans.src
+ val = trans.value.strip()
+ # check whether applying (src -> val) then (val -> src)
+ # gives the original value back
value0 = field.translate(lambda term: None, record[fname])
- value1 = field.translate({trans.src: trans.value}.get, value0)
+ value1 = field.translate({src: val}.get, value0)
# don't check the reverse if no translation happened
if value0 == value1:
continue
- value2 = field.translate({trans.value: trans.src}.get, value1)
+ value2 = field.translate({val: src}.get, value1)
if value2 != value0:
- raise ValidationError(_("Translation is not valid:\n%s") % trans.value)
+ raise ValidationError(_("Translation is not valid:\n%s") % val)
@api.model
def create(self, vals):
diff --git a/odoo/addons/base/tests/test_translate.py b/odoo/addons/base/tests/test_translate.py
index de607798a82..328906c89c2 100644
--- a/odoo/addons/base/tests/test_translate.py
+++ b/odoo/addons/base/tests/test_translate.py
@@ -270,3 +270,72 @@ class TestTranslation(TransactionCase):
categories = padawans_fr.search([('id', 'in', [self.customers.id, padawans.id])], order='name')
self.assertEqual(categories.ids, [padawans.id, self.customers.id],
"Search ordered by translated name should return Padawans (Apprentis) before Customers (Clients)")
+
+
+class TestXMLTranslation(TransactionCase):
+ def setUp(self):
+ super(TestXMLTranslation, self).setUp()
+ self.env['ir.translation'].load_module_terms(['base'], ['fr_FR'])
+
+ def test_copy(self):
+ """ Create a simple view, fill in translations, and copy it. """
+ env_en = self.env(context={})
+ env_fr = self.env(context={'lang': 'fr_FR'})
+
+ archf = '
'
+ terms_en = ('Knife', 'Fork', 'Spoon')
+ terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
+ view0 = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'model': 'res.partner',
+ 'arch': archf % terms_en,
+ })
+ for src, value in zip(terms_en, terms_fr):
+ self.env['ir.translation'].create({
+ 'type': 'model',
+ 'name': 'ir.ui.view,arch_db',
+ 'lang': 'fr_FR',
+ 'res_id': view0.id,
+ 'src': src,
+ 'value': value,
+ })
+
+ # check translated field
+ self.assertEqual(view0.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view0.with_env(env_fr).arch_db, archf % terms_fr)
+
+ # copy without lang
+ view1 = view0.with_env(env_en).copy({})
+ self.assertEqual(view1.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view1.with_env(env_fr).arch_db, archf % terms_fr)
+
+ # copy with lang='fr_FR'
+ view2 = view0.with_env(env_fr).copy({})
+ self.assertEqual(view2.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view2.with_env(env_fr).arch_db, archf % terms_fr)
+
+ # copy with lang='fr_FR' and translate=html_translate
+ self.patch(type(self.env['ir.ui.view']).arch_db, 'translate', html_translate)
+ view3 = view0.with_env(env_fr).copy({})
+ self.assertEqual(view3.with_env(env_en).arch_db, archf % terms_en)
+ self.assertEqual(view3.with_env(env_fr).arch_db, archf % terms_fr)
+
+ def test_spaces(self):
+ """ Create translations where value has surrounding spaces. """
+ archf = ''
+ terms_en = ('Knife', 'Fork', 'Spoon')
+ terms_fr = (' Couteau', 'Fourchette ', ' Cuiller ')
+ view0 = self.env['ir.ui.view'].create({
+ 'name': 'test',
+ 'model': 'res.partner',
+ 'arch': archf % terms_en,
+ })
+ for src, value in zip(terms_en, terms_fr):
+ self.env['ir.translation'].create({
+ 'type': 'model',
+ 'name': 'ir.ui.view,arch_db',
+ 'lang': 'fr_FR',
+ 'res_id': view0.id,
+ 'src': src,
+ 'value': value,
+ })
diff --git a/odoo/http.py b/odoo/http.py
index cef63e8f639..94ec3b8c752 100644
--- a/odoo/http.py
+++ b/odoo/http.py
@@ -163,6 +163,8 @@ def redirect_with_hash(url, code=303):
# See extensive test page at http://greenbytes.de/tech/tc/httpredirects/
if request.httprequest.user_agent.browser in ('firefox',):
return werkzeug.utils.redirect(url, code)
+ if urlparse.urlparse(url, scheme='http').scheme not in ('http', 'https'):
+ url = 'http://' + url
url = url.replace("'", "%27").replace("<", "%3C")
return "" % url
diff --git a/odoo/models.py b/odoo/models.py
index 882805f2836..7dbeaaad68f 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -3813,16 +3813,18 @@ class BaseModel(object):
# for translatable fields we copy their translations
trans_name, source_id, target_id = get_trans(field, old, new)
domain = [('name', '=', trans_name), ('res_id', '=', source_id)]
+ new_val = new_wo_lang[name]
+ if old.env.lang:
+ # the new value *without lang* must be the old value without lang
+ new_wo_lang[name] = old_wo_lang[name]
for vals in Translation.search_read(domain):
del vals['id']
del vals['source'] # remove source to avoid triggering _set_src
del vals['module'] # duplicated vals is not linked to any module
vals['res_id'] = target_id
if vals['lang'] == old.env.lang and field.translate is True:
- # 'source' to force the call to _set_src
- # 'value' needed if value is changed in copy(), want to see the new_value
- vals['source'] = old_wo_lang[name]
- vals['value'] = new_wo_lang[name]
+ # the value should be the new value (given by copy())
+ vals['value'] = new_val
Translation.create(vals)
@api.multi
diff --git a/odoo/sql_db.py b/odoo/sql_db.py
index a7c7d6fc54d..607bb9ba2b8 100644
--- a/odoo/sql_db.py
+++ b/odoo/sql_db.py
@@ -47,6 +47,19 @@ psycopg2.extensions.register_type(psycopg2.extensions.new_type((700, 701, 1700,)
import tools
+
+from tools import parse_version as pv
+if pv(psycopg2.__version__) < pv('2.7'):
+ from psycopg2._psycopg import QuotedString
+ def adapt_string(adapted):
+ """Python implementation of psycopg/psycopg2#459 from v2.7"""
+ if '\x00' in adapted:
+ raise ValueError("A string literal cannot contain NUL (0x00) characters.")
+ return QuotedString(adapted)
+
+ psycopg2.extensions.register_adapter(str, adapt_string)
+ psycopg2.extensions.register_adapter(unicode, adapt_string)
+
from tools.func import frame_codeinfo
from datetime import timedelta
import threading