summaryrefslogtreecommitdiff
path: root/addons
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2017-06-15 18:46:47 +0200
committerChristophe Simonis <chs@odoo.com>2017-06-15 18:46:47 +0200
commited2ddeccdfaac46acb4d4bfbdac5f1b570f0829c (patch)
tree5d0c41f089c8deddd753a7ec61217ee33d007451 /addons
parent156e07f6387b3e2b8915b1bd8624195d7110c3f5 (diff)
parent1b50c829ef383705647d0c2df7c89e9051ec40fb (diff)
[MERGE] forward port branch saas-16 up to 1b50c829ef3
Diffstat (limited to 'addons')
-rw-r--r--addons/account/models/account_move.py3
-rw-r--r--addons/account_asset/models/account_asset.py2
-rw-r--r--addons/anonymization/wizard/anonymize_wizard.py5
-rw-r--r--addons/auth_oauth/models/res_users.py2
-rw-r--r--addons/hw_screen/__manifest__.py (renamed from addons/hw_screen/__openerp__.py)0
-rw-r--r--addons/l10n_fr_certification/__init__.py33
-rw-r--r--addons/l10n_fr_certification/__manifest__.py19
-rw-r--r--addons/l10n_fr_certification/data/account_move.xml13
-rw-r--r--addons/l10n_fr_certification/models/__init__.py2
-rw-r--r--addons/l10n_fr_certification/models/account.py155
-rw-r--r--addons/l10n_fr_certification/models/res_company.py42
-rw-r--r--addons/l10n_fr_certification/views/no_cancel.xml36
-rw-r--r--addons/l10n_in_schedule6/__manifest__.py (renamed from addons/l10n_in_schedule6/__openerp__.py)0
-rw-r--r--addons/mail/models/mail_template.py16
-rw-r--r--addons/point_of_sale/models/pos_order.py17
-rw-r--r--addons/point_of_sale/models/pos_session.py12
-rw-r--r--addons/point_of_sale/static/src/js/models.js4
-rwxr-xr-xaddons/point_of_sale/tools/posbox/overwrite_after_init/etc/xdg/openbox/autostart9
-rw-r--r--addons/point_of_sale/views/pos_order_view.xml4
-rw-r--r--addons/pos_restaurant/static/src/js/floors.js5
-rw-r--r--addons/project/wizard/project_task_merge_wizard.py2
-rw-r--r--addons/resource/models/resource.py14
-rw-r--r--addons/resource/tests/test_resource.py23
-rw-r--r--addons/sale/models/sale.py11
-rw-r--r--addons/sale_margin/models/sale_order.py2
-rw-r--r--addons/sale_margin/tests/test_sale_margin.py26
-rw-r--r--addons/sale_timesheet/tests/test_timesheet_revenue.py9
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js43
-rw-r--r--addons/web/static/src/js/views/basic/basic_controller.js1
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js94
-rw-r--r--addons/web/static/src/js/views/form/form_controller.js10
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js12
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js62
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js3
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_controller.js34
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_model.js7
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js11
-rw-r--r--addons/web/static/src/js/views/view_dialogs.js5
-rw-r--r--addons/web/static/src/less/fields.less20
-rw-r--r--addons/web/static/src/less/kanban_dashboard.less2
-rw-r--r--addons/web/static/src/less/views.less20
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js81
-rw-r--r--addons/web/static/tests/views/basic_model_tests.js96
-rw-r--r--addons/web/static/tests/views/form_tests.js142
-rw-r--r--addons/web/static/tests/views/kanban_model_tests.js1
-rw-r--r--addons/web/static/tests/views/kanban_tests.js37
-rw-r--r--addons/web/static/tests/views/pivot_tests.js76
-rw-r--r--addons/web/static/tests/views/view_dialogs_tests.js79
-rw-r--r--addons/website/models/ir_http.py4
-rw-r--r--addons/website_sale/models/website.py5
-rw-r--r--addons/website_sale_options/static/src/js/website_sale.js5
51 files changed, 1187 insertions, 129 deletions
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index b0280238635..87966a761fe 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -1322,9 +1322,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 103f0595524..cc2a29a09a9 100644
--- a/addons/account_asset/models/account_asset.py
+++ b/addons/account_asset/models/account_asset.py
@@ -482,7 +482,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 880dd34617c..fb514fe778e 100644
--- a/addons/anonymization/wizard/anonymize_wizard.py
+++ b/addons/anonymization/wizard/anonymize_wizard.py
@@ -4,13 +4,14 @@
import base64
import os
import random
-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
@@ -204,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 2f9bf22c6ce..dd2496bdea7 100644
--- a/addons/auth_oauth/models/res_users.py
+++ b/addons/auth_oauth/models/res_users.py
@@ -9,6 +9,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
index 8ed5711b836..8ed5711b836 100644
--- a/addons/hw_screen/__openerp__.py
+++ b/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 @@
+<odoo>
+ <record model="ir.actions.server" id="action_check_hash_integrity">
+ <field name="name">Intégrité des données comptables</field>
+ <field name="model_id" ref="account.model_account_move"/>
+ <field name="type">ir.actions.server</field>
+ <field name="code">
+ action = env['account.move']._check_hash_integrity(env.user.company_id.id)
+ </field>
+ </record>
+
+ <menuitem action="action_check_hash_integrity" id="menu_check_move_integrity" parent="l10n_fr.account_reports_fr_statements_menu" sequence="90"/>
+
+</odoo>
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 @@
+<odoo>
+ <record id="bank_statement_draft_form_inherit" model="ir.ui.view">
+ <field name="name">bank.statement.draft.form.inherit</field>
+ <field name="model">account.bank.statement</field>
+ <field name="inherit_id" ref="account.view_bank_statement_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//button[@name='button_cancel']" position="attributes">
+ <attribute name="invisible">1</attribute>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="invoice_form_no_cancel_inherit" model="ir.ui.view">
+ <field name="name">invoice.form.cancel.inherit</field>
+ <field name="model">account.invoice</field>
+ <field name="inherit_id" ref="account.invoice_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//button[@name='action_invoice_cancel']" position="attributes">
+ <attribute name="invisible">1</attribute>
+ </xpath>
+ </field>
+ </record>
+
+ <record id="view_move_form_no_cancel_inherit" model="ir.ui.view">
+ <field name="name">account.move.form.nocancel.inherit</field>
+ <field name="model">account.move</field>
+ <field name="inherit_id" ref="account.view_move_form"/>
+ <field name="arch" type="xml">
+ <xpath expr="//button[@name='button_cancel']" position="attributes">
+ <attribute name="invisible">1</attribute>
+ </xpath>
+
+ </field>
+ </record>
+
+</odoo>
diff --git a/addons/l10n_in_schedule6/__openerp__.py b/addons/l10n_in_schedule6/__manifest__.py
index 20b803525d1..20b803525d1 100644
--- a/addons/l10n_in_schedule6/__openerp__.py
+++ b/addons/l10n_in_schedule6/__manifest__.py
diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py
index be73a374a45..988fed44727 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
@@ -360,6 +375,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 6b0cbef8d5d..f661505b962 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, pycompat
@@ -701,7 +702,7 @@ class PosOrder(models.Model):
pack_operation.write({'pack_lot_ids': [(0, 0, x) for x in pack_lots], 'qty_done': qty_done})
return has_wrong_lots
- 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'],
@@ -746,8 +747,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):
@@ -920,7 +929,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 aa1f44f9ea1..67d9ca973a0 100644
--- a/addons/point_of_sale/models/pos_session.py
+++ b/addons/point_of_sale/models/pos_session.py
@@ -250,16 +250,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/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index 01f58010c34..6d1779ce398 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -727,6 +727,7 @@ exports.PosModel = Backbone.Model.extend({
return $.when.apply($, get_image_deferreds).then(function () {
var rendered_order_lines = "";
var rendered_payment_lines = "";
+ var order_total_with_tax = self.chrome.format_currency(0);
if (order) {
rendered_order_lines = QWeb.render('CustomerFacingDisplayOrderLines', {
@@ -737,11 +738,12 @@ exports.PosModel = Backbone.Model.extend({
'order': order,
'widget': self.chrome,
});
+ order_total_with_tax = self.chrome.format_currency(order.get_total_with_tax());
}
var $rendered_html = $(rendered_html);
$rendered_html.find('.pos_orderlines_list').html(rendered_order_lines);
- $rendered_html.find('.pos-total').find('.pos_total-amount').html(self.chrome.format_currency(order.get_total_with_tax()));
+ $rendered_html.find('.pos-total').find('.pos_total-amount').html(order_total_with_tax);
var pos_change_title = $rendered_html.find('.pos-change_title').text();
$rendered_html.find('.pos-paymentlines').html(rendered_payment_lines);
$rendered_html.find('.pos-change_title').text(pos_change_title);
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 b405e5df3ab..8dac1cf74fd 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 @@
<field name="price_unit" widget="monetary"/>
<field name="discount" widget="monetary"/>
<field name="tax_ids_after_fiscal_position" widget="many2many_tags"/>
- <field name="tax_ids" invisible="1"/>
+ <field name="tax_ids" widget="many2many_tags" invisible="1"/>
<field name="price_subtotal" widget="monetary"/>
<field name="price_subtotal_incl" widget="monetary"/>
</tree>
@@ -51,7 +51,7 @@
<field name="price_subtotal" invisible="1" widget="monetary"/>
<field name="price_subtotal_incl" invisible="1" widget="monetary"/>
<field name="tax_ids_after_fiscal_position" widget="many2many_tags"/>
- <field name="tax_ids" invisible="1"/>
+ <field name="tax_ids" widget="many2many_tags" invisible="1"/>
<field name="notice"/>
</group>
</form>
diff --git a/addons/pos_restaurant/static/src/js/floors.js b/addons/pos_restaurant/static/src/js/floors.js
index bddc559804a..4ad760b8d0b 100644
--- a/addons/pos_restaurant/static/src/js/floors.js
+++ b/addons/pos_restaurant/static/src/js/floors.js
@@ -860,12 +860,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/project/wizard/project_task_merge_wizard.py b/addons/project/wizard/project_task_merge_wizard.py
index 02ad9b7b1c5..1389a9d4899 100644
--- a/addons/project/wizard/project_task_merge_wizard.py
+++ b/addons/project/wizard/project_task_merge_wizard.py
@@ -63,7 +63,7 @@ class ProjectTaskMergeWizard(models.TransientModel):
selected_tasks = self.env['project.task'].browse(self.env.context.get('active_ids', False))
assigned_tasks = selected_tasks.filtered(lambda task: task.user_id)
result.update({
- 'task_ids': [(4, task.id) for task in selected_tasks],
+ 'task_ids': selected_tasks.ids,
'user_id': assigned_tasks and assigned_tasks[0].user_id.id or False,
'target_project_id': selected_tasks[0].project_id.id,
'target_task_id': selected_tasks[0].id
diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py
index de4e5617a53..8e8f6e67908 100644
--- a/addons/resource/models/resource.py
+++ b/addons/resource/models/resource.py
@@ -527,7 +527,9 @@ class ResourceCalendar(models.Model):
backwards = (hours < 0)
intervals = []
remaining_hours, iterations = abs(hours * 1.0), 0
- current_datetime = day_dt
+
+ day_dt_tz = to_naive_user_tz(day_dt, self.env.user)
+ current_datetime = day_dt_tz
call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id)
@@ -562,7 +564,11 @@ class ResourceCalendar(models.Model):
def plan_hours(self, hours, day_dt, compute_leaves=False, resource_id=None):
""" Return datetime after having planned hours """
res = self._schedule_hours(hours, day_dt, compute_leaves, resource_id)
- return res and res[0][0] or False
+ if res and hours < 0.0:
+ return res[0][0]
+ elif res:
+ return res[-1][1]
+ return False
@api.multi
def _schedule_days(self, days, day_dt, compute_leaves=False, resource_id=None):
@@ -582,7 +588,9 @@ class ResourceCalendar(models.Model):
backwards = (days < 0)
intervals = []
planned_days, iterations = 0, 0
- current_datetime = day_dt.replace(hour=0, minute=0, second=0, microsecond=0)
+
+ day_dt_tz = to_naive_user_tz(day_dt, self.env.user)
+ current_datetime = day_dt_tz.replace(hour=0, minute=0, second=0, microsecond=0)
while planned_days < abs(days) and iterations < 100:
working_intervals = self._get_day_work_intervals(
diff --git a/addons/resource/tests/test_resource.py b/addons/resource/tests/test_resource.py
index 37e1d46fee7..b1a8ead484b 100644
--- a/addons/resource/tests/test_resource.py
+++ b/addons/resource/tests/test_resource.py
@@ -278,6 +278,10 @@ class ResourceWorkingHours(TestResourceCommon):
td += item[1] - item[0]
self.assertEqual(td.total_seconds() / 3600.0, 40.0)
+ 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')))
@@ -288,11 +292,25 @@ 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]
self.assertEqual(td.total_seconds() / 3600.0, 40.0)
+ res = self.calendar.plan_hours(40, day_dt=Datetime.from_string('2013-02-12 09:00:00'))
+ self.assertEqual(res, Datetime.from_string('2013-02-26 09:00:00'))
+
+ def test_calendar_hours_scheduling_timezone(self):
+ # user in timezone UTC-9 asks for work hours
+ self.env.user.tz = 'US/Alaska'
+ res = self.calendar.plan_hours(
+ 42,
+ to_naive_utc(Datetime.from_string('2013-02-12 09:25:00'), self.env.user))
+ self.assertEqual(res, to_naive_utc(Datetime.from_string('2013-02-26 11:25:00'), self.env.user))
+
def test_calendar_hours_scheduling_forward_leaves_resource(self):
res = self.calendar._schedule_hours(
40, day_dt=Datetime.from_string('2013-02-12 09:00:00'),
@@ -324,6 +342,11 @@ class ResourceWorkingHours(TestResourceCommon):
compute_leaves=True, resource_id=self.resource1_id)
self.assertEqual(res.date(), Datetime.from_string('2013-03-01 00:00:00').date(), 'resource_calendar: wrong days scheduling')
+ def test_calendar_days_scheduling_timezone(self):
+ self.env.user.tz = 'US/Alaska'
+ res = self.calendar.plan_days(5, to_naive_utc(Datetime.from_string('2013-02-12 09:08:07'), self.env.user))
+ self.assertEqual(to_naive_user_tz(res, self.env.user).date(), Datetime.from_string('2013-02-26 00:00:00').date())
+
WAR_START = date(1932, 11, 2)
WAR_END = date(1932, 12, 10)
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index ab4a64fa1a3..453430b5e48 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -712,14 +712,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 06b6a1a3874..94bddf50fb5 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/sale_timesheet/tests/test_timesheet_revenue.py b/addons/sale_timesheet/tests/test_timesheet_revenue.py
index f96dc67720a..ee2a2268f8a 100644
--- a/addons/sale_timesheet/tests/test_timesheet_revenue.py
+++ b/addons/sale_timesheet/tests/test_timesheet_revenue.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale.tests.test_sale_common import TestSale
+from odoo.tools import float_compare
class TestSaleTimesheet(TestSale):
@@ -280,10 +281,10 @@ class TestSaleTimesheet(TestSale):
invoice.action_invoice_open()
# check concrete revenue
- self.assertEquals(timesheet1.timesheet_revenue, 385.85, "Revenue computation on invoice validation does not return the correct revenue !")
- self.assertEquals(timesheet2.timesheet_revenue, 154.35, "Revenue computation on invoice validation does not return the correct revenue !")
- self.assertEquals(timesheet3.timesheet_revenue, 114.5, "Revenue computation on invoice validation does not return the correct revenue !")
- self.assertEquals(timesheet4.timesheet_revenue, 152.68, "Revenue computation on invoice validation does not return the correct revenue !")
+ self.assertEquals(float_compare(timesheet1.timesheet_revenue, 385.85, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !")
+ self.assertEquals(float_compare(timesheet2.timesheet_revenue, 154.35, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !")
+ self.assertEquals(float_compare(timesheet3.timesheet_revenue, 114.5, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !")
+ self.assertEquals(float_compare(timesheet4.timesheet_revenue, 152.68, precision_digits=2), 0, "Revenue computation on invoice validation does not return the correct revenue !")
# check the invoice is well set
self.assertEquals(timesheet1.timesheet_invoice_id, invoice)
diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index ae3b06d3a02..3b47b8c64b0 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -619,12 +619,15 @@ var FieldX2Many = AbstractField.extend({
* @returns {Deferred}
*/
commitChanges: function () {
+ var self = this;
var inEditionRecordID =
this.renderer
&& this.renderer.viewType === "list"
&& this.renderer.getEditableRecordID();
if (inEditionRecordID) {
- return this._saveLine(inEditionRecordID);
+ return this.renderer.commitChanges(inEditionRecordID).then(function () {
+ return self._saveLine(inEditionRecordID);
+ });
}
return this._super.apply(this, arguments);
},
@@ -767,21 +770,17 @@ var FieldX2Many = AbstractField.extend({
* did not agree to discard.
*/
_saveLine: function (recordID) {
- var self = this;
var def = $.Deferred();
- this.renderer.commitChanges(recordID).then(function () { // TODO wrong as no mutex protection
- var fieldNames = self.renderer.canBeSaved(recordID);
- if (fieldNames.length) {
- self.trigger_up('discard_changes', {
- recordID: recordID,
- onSuccess: def.resolve.bind(def),
- onFailure: def.reject.bind(def),
- });
- } else {
- self.renderer.setRowMode(recordID, 'readonly')
- .done(def.resolve.bind(def));
- }
- });
+ var fieldNames = this.renderer.canBeSaved(recordID);
+ if (fieldNames.length) {
+ this.trigger_up('discard_changes', {
+ recordID: recordID,
+ onSuccess: def.resolve.bind(def),
+ onFailure: def.reject.bind(def),
+ });
+ } else {
+ this.renderer.setRowMode(recordID, 'readonly').done(def.resolve.bind(def));
+ }
return def;
},
@@ -900,9 +899,15 @@ var FieldX2Many = AbstractField.extend({
ev.stopPropagation();
this.trigger_up('mutexify', {
action: function () {
- return self._saveLine(ev.data.recordID)
- .done(ev.data.onSuccess)
- .fail(ev.data.onFailure);
+ return self.renderer.commitChanges(ev.data.recordID).then(function () {
+ self.trigger_up('mutexify', {
+ action: function () {
+ return self._saveLine(ev.data.recordID)
+ .done(ev.data.onSuccess)
+ .fail(ev.data.onFailure);
+ },
+ });
+ });
},
});
},
@@ -922,7 +927,7 @@ var FieldX2Many = AbstractField.extend({
id: rowID,
data: data,
});
- })
+ });
},
/**
* Adds field name information to the event, so that the view upstream is
diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js
index 16a066db495..af4ca7d7a08 100644
--- a/addons/web/static/src/js/views/basic/basic_controller.js
+++ b/addons/web/static/src/js/views/basic/basic_controller.js
@@ -419,6 +419,7 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
var saveDef = this.model.save(recordID, { // Save then leave edit mode
reload: options.reload,
savePoint: options.savePoint,
+ viewType: options.viewType,
});
if (!options.stayInEdit) {
saveDef = saveDef.then(function (fieldNames) {
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 61c285d6581..d897a74c696 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -208,6 +208,7 @@ var BasicModel = AbstractModel.extend({
delete self.localData[record.id];
} else {
record.res_ids.splice(record.offset, 1);
+ record.offset = Math.min(record.offset, record.res_ids.length - 1);
record.res_id = record.res_ids[record.offset];
record.count--;
}
@@ -704,6 +705,8 @@ var BasicModel = AbstractModel.extend({
* @param {boolean} [options.savePoint=false] if true, the record will only
* be 'locally' saved: its changes written in a _savePoint key that can
* be restored later by call discardChanges with option rollback to true
+ * @param {string} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record
* @returns {Deferred}
* Resolved with the list of field names (whose value has been modified)
*/
@@ -718,6 +721,11 @@ var BasicModel = AbstractModel.extend({
if (newValue instanceof Array) {
rec._savePoint = newValue.slice(0);
} else {
+ // save the viewType of edition, so that the correct readonly modifiers
+ // can be evaluated when the record will be saved
+ for (var fieldName in (rec._changes || {})) {
+ rec._editionViewType[fieldName] = options.viewType;
+ }
rec._savePoint = _.extend({}, newValue);
}
});
@@ -728,7 +736,7 @@ var BasicModel = AbstractModel.extend({
// id never changes, and should not be written
delete record._changes.id;
}
- var changes = self._generateChanges(record);
+ var changes = self._generateChanges(record, {viewType: options.viewType});
if (method === 'create') {
var fieldNames = record.getFieldNames();
@@ -1869,12 +1877,36 @@ var BasicModel = AbstractModel.extend({
*
* @private
* @param {Object} record
+ * @param {Object} [options]
+ * @param {boolean} [options.changesOnly=true] if true, only generates
+ * commands for fields that have changed (concerns x2many fields only)
+ * @param {boolean} [options.withReadonly=false] if false, doesn't generate
+ * changes for readonly fields
+ * @param {string} [options.viewType] current viewType. If not set, we will
+ * assume main viewType from the record. Note that if an editionViewType is
+ * specified for a field, it will take the priority over the viewType arg.
* @returns {Object} a map from changed fields to their new value
*/
- _generateChanges: function (record) {
+ _generateChanges: function (record, options) {
+ options = options || {};
+ var viewType = options.viewType || record.viewType;
var changes = _.extend({}, record._changes);
- var commands = this._generateX2ManyCommands(record, true);
+ var withReadonly = options.withReadonly || false;
+ var commands = this._generateX2ManyCommands(record, {
+ changesOnly: 'changesOnly' in options ? options.changesOnly : true,
+ withReadonly: withReadonly,
+ });
for (var fieldName in record.fields) {
+ // remove readonly fields from the list of changes
+ if (!withReadonly && fieldName in changes || fieldName in commands) {
+ var editionViewType = record._editionViewType[fieldName] || viewType;
+ if (this._isFieldReadonly(record, fieldName, editionViewType)) {
+ delete changes[fieldName];
+ continue;
+ }
+ }
+
+ // process relational fields and handle the null case
var type = record.fields[fieldName].type;
if (type === 'one2many' || type === 'many2many') {
if (commands[fieldName].length) { // replace localId by commands
@@ -1901,7 +1933,7 @@ var BasicModel = AbstractModel.extend({
* @returns {Object} the data
*/
_generateOnChangeData: function (record) {
- var commands = this._generateX2ManyCommands(record, false);
+ var commands = this._generateX2ManyCommands(record, {withReadonly: true});
var data = _.extend(this.get(record.id, {raw: true}).data, commands);
// one2many records have a parentID
@@ -1924,12 +1956,16 @@ var BasicModel = AbstractModel.extend({
* or write them...
*
* @param {Object} record
+ * @param {Object} [options]
* @param {boolean} [changesOnly=false] if true, only generates commands for
* fields that have changed
+ * @param {boolean} [options.withReadonly=false] if false, doesn't generate
+ * changes for readonly fields in commands
* @returns {Object} a map from some field names to commands
*/
- _generateX2ManyCommands: function (record, changesOnly) {
+ _generateX2ManyCommands: function (record, options) {
var self = this;
+ options = options || {};
var commands = {};
var data = _.extend({}, record.data, record._changes);
var type;
@@ -1943,7 +1979,7 @@ var BasicModel = AbstractModel.extend({
continue;
}
var list = this.localData[data[fieldName]];
- if (changesOnly && !list._changes) {
+ if (options.changesOnly && !list._changes) {
// if only changes are requested, skip if there is no change
continue;
}
@@ -1961,7 +1997,7 @@ var BasicModel = AbstractModel.extend({
// generate update commands for records that have been
// updated (it may happen with editable lists)
_.each(relData, function (relRecord) {
- var changes = self._generateChanges(relRecord);
+ var changes = self._generateChanges(relRecord, options);
if (!_.isEmpty(changes)) {
var command = x2ManyCommands.update(relRecord.res_id, changes);
commands[fieldName].push(command);
@@ -1980,7 +2016,7 @@ var BasicModel = AbstractModel.extend({
if (_.contains(keptIds, relIds[i])) {
// this is an id that already existed
relRecord = _.findWhere(relData, {res_id: relIds[i]});
- changes = this._generateChanges(relRecord);
+ changes = this._generateChanges(relRecord, options);
if (!_.isEmpty(changes)) {
command = x2ManyCommands.update(relRecord.res_id, changes);
didChange = true;
@@ -1991,11 +2027,11 @@ var BasicModel = AbstractModel.extend({
} else if (_.contains(addedIds, relIds[i])) {
// this is a new id
relRecord = _.findWhere(relData, {res_id: relIds[i]});
- changes = this._generateChanges(relRecord);
+ changes = this._generateChanges(relRecord, options);
commands[fieldName].push(x2ManyCommands.create(changes));
}
}
- if (changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) {
+ if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) {
// in this situation, we have no changed ids, no added
// ids and no removed ids, so we can safely ignore the
// last changes
@@ -2155,7 +2191,7 @@ var BasicModel = AbstractModel.extend({
if (context[fieldName] === null) {
context[fieldName] = false;
}
- if (!field) {
+ if (!field || field.name === 'id') {
continue;
}
if (field.type === 'float' ||
@@ -2200,6 +2236,31 @@ var BasicModel = AbstractModel.extend({
return context;
},
/**
+ * Returns true if the field is readonly (checking first in the modifiers,
+ * and if there is no readonly modifier, checking the readonly attribute of
+ * the field).
+ *
+ * @private
+ * @param {Object} record an element from the localData
+ * @param {string} fieldName
+ * @param {string} [viewType] current viewType. If not set, we will assume
+ * main viewType from the record
+ * @returns {boolean}
+ */
+ _isFieldReadonly: function (record, fieldName, viewType) {
+ var fieldInfo = record.fieldsInfo[viewType || record.viewType][fieldName];
+ var modifiers;
+ if (fieldInfo) {
+ var rawModifiers = JSON.parse(fieldInfo.modifiers || "{}");
+ modifiers = this._evalModifiers(record, rawModifiers);
+ }
+ if (modifiers && 'readonly' in modifiers) {
+ return modifiers.readonly;
+ } else {
+ return record.fields[fieldName].readonly;
+ }
+ },
+ /**
* Returns true iff value is considered to be set for the given field's type.
*
* @private
@@ -2330,6 +2391,12 @@ var BasicModel = AbstractModel.extend({
viewType: params.viewType,
};
+ // _editionViewType is a dict whose keys are field names and which is populated when a field
+ // is edited with the viewType as value. This is useful for one2manys to determine whether
+ // or not a field is readonly (using the readonly modifiers of the view in which the field
+ // has been edited)
+ dataPoint._editionViewType = {};
+
dataPoint.evalModifiers = this._evalModifiers.bind(this, dataPoint);
dataPoint.getContext = this._getContext.bind(this, dataPoint);
dataPoint.getDomain = this._getDomain.bind(this, dataPoint);
@@ -2507,7 +2574,7 @@ var BasicModel = AbstractModel.extend({
.then(function () {
// save initial changes, so they can be restored later,
// if we need to discard.
- self.save(record.id, {savePoint: true})
+ self.save(record.id, {savePoint: true});
return record.id;
});
@@ -2582,8 +2649,7 @@ var BasicModel = AbstractModel.extend({
* @param {string[]} fields changed fields
* @param {string} [viewType] current viewType. If not set, we will assume
* main viewType from the record
- * @returns {Deferred} The returned deferred can fail, in which case the
- * fail value will be the warning message received from the server
+ * @returns {Deferred}
*/
_performOnChange: function (record, fields, viewType) {
var self = this;
diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js
index 1f0247b732b..0b85ee8cf42 100644
--- a/addons/web/static/src/js/views/form/form_controller.js
+++ b/addons/web/static/src/js/views/form/form_controller.js
@@ -151,7 +151,7 @@ var FormController = BasicController.extend({
if (this.is_action_enabled('delete')) {
otherItems.push({
label: _t('Delete'),
- callback: this._deleteRecords.bind(this, [this.handle]),
+ callback: this._onDeleteRecord.bind(this),
});
}
if (this.is_action_enabled('create') && this.is_action_enabled('duplicate')) {
@@ -395,6 +395,14 @@ var FormController = BasicController.extend({
this.createRecord();
},
/**
+ * Deletes the current record
+ *
+ * @private
+ */
+ _onDeleteRecord: function () {
+ this._deleteRecords([this.handle]);
+ },
+ /**
* Called when the user wants to discard the changes made to the current
* record -> @see discardChanges
*
diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js
index 80d48a78774..99c9500d210 100644
--- a/addons/web/static/src/js/views/kanban/kanban_column.js
+++ b/addons/web/static/src/js/views/kanban/kanban_column.js
@@ -45,7 +45,6 @@ var KanbanColumn = Widget.extend({
this.folded = !data.isOpen;
this.has_active_field = 'active' in data.fields;
this.size = data.count;
- this.values = data.values;
this.fields = data.fields;
this.records = [];
this.modelName = data.model;
@@ -63,13 +62,12 @@ var KanbanColumn = Widget.extend({
this.record_options = _.clone(recordOptions);
- if (data.options && data.options.group_by_tooltip) {
- var self = this;
- this.tooltip_info = _.map(data.options.group_by_tooltip, function (key, value) {
- return (self.values && self.values[value] && "<div>" +key + "<br>" + self.values[value] + "</div>") || '';
+ if (options.group_by_tooltip) {
+ this.tooltipInfo = _.map(options.group_by_tooltip, function (help, field) {
+ return (data.tooltipData && data.tooltipData[field] && "<div>" + help + "<br>" + data.tooltipData[field] + "</div>") || '';
}).join('');
} else {
- this.tooltip_info = "";
+ this.tooltipInfo = "";
}
},
/**
@@ -217,7 +215,7 @@ var KanbanColumn = Widget.extend({
this.$el.toggleClass('o_column_folded', this.folded);
var tooltip = this.size + _t(' records');
- tooltip = '<p>' + tooltip + '</p>' + this.tooltip_info;
+ tooltip = '<p>' + tooltip + '</p>' + this.tooltipInfo;
this.$header.tooltip({html: true}).attr('data-original-title', tooltip);
if (!this.remaining) {
this.$('.o_kanban_load_more').remove();
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
index c606776ccc0..cd69889bda9 100644
--- a/addons/web/static/src/js/views/kanban/kanban_model.js
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -90,6 +90,21 @@ var KanbanModel = BasicModel.extend({
});
},
/**
+ * Add the key `tooltipData` (kanban specific) when performing a `geŧ`.
+ *
+ * @override
+ * @see _readTooltipFields
+ * @returns {Object}
+ */
+ get: function () {
+ var result = this._super.apply(this, arguments);
+ var dp = result && this.localData[result.id];
+ if (dp && dp.tooltipData) {
+ result.tooltipData = $.extend(true, {}, dp.tooltipData);
+ }
+ return result;
+ },
+ /**
* @override
*/
load: function (params) {
@@ -203,10 +218,55 @@ var KanbanModel = BasicModel.extend({
* @param {Object} list valid resource object
*/
_readGroup: function (list) {
+ var self = this;
if (list.groupedBy.length > 1) {
list.groupedBy = [list.groupedBy[0]];
}
- return this._super.apply(this, arguments);
+ return this._super.apply(this, arguments).then(function (result) {
+ return self._readTooltipFields(list).then(_.constant(result));
+ });
+ },
+ /**
+ * Fetches tooltip specific fields on the group by relation and stores it in
+ * the column datapoint in a special key `tooltipData`.
+ * Data for the tooltips (group_by_tooltip) are fetched in batch for all
+ * groups, to avoid doing multiple calls.
+ * Data are stored in a special key `tooltipData` on the datapoint.
+ * Note that the option `group_by_tooltip` is only for m2o fields.
+ *
+ * @private
+ * @param {Object} list a list of groups
+ * @returns {Deferred}
+ */
+ _readTooltipFields: function (list) {
+ var self = this;
+ var groupedByField = list.fields[list.groupedBy[0]];
+ if (groupedByField.type !== 'many2one') {
+ return $.when();
+ }
+ var groupIds = _.map(list.data, function (id) {
+ return self.get(id, {raw: true}).res_id;
+ });
+ var tooltipFields = [];
+ var groupedByFieldInfo = list.fieldsInfo.kanban[list.groupedBy[0]];
+ if (groupedByFieldInfo && groupedByFieldInfo.options) {
+ tooltipFields = Object.keys(groupedByFieldInfo.options.group_by_tooltip || {});
+ }
+ if (groupIds.length && tooltipFields.length) {
+ var fieldNames = _.union(['display_name'], tooltipFields);
+ return this._rpc({
+ model: groupedByField.relation,
+ method: 'read',
+ args: [groupIds, fieldNames],
+ context: list.context,
+ }).then(function (result) {
+ _.each(list.data, function (id) {
+ var dp = self.localData[id];
+ dp.tooltipData = _.findWhere(result, {id: dp.res_id});
+ });
+ });
+ }
+ return $.when();
},
});
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
index 7bfe21e66a9..87275260b01 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -188,6 +188,7 @@ var KanbanRenderer = BasicRenderer.extend({
_renderGrouped: function (fragment) {
var self = this;
var groupByFieldAttrs = this.state.fields[this.state.groupedBy[0]];
+ var groupByFieldInfo = this.state.fieldsInfo.kanban[this.state.groupedBy[0]];
// Deactivate the drag'n'drop if the groupedBy field:
// - is a date or datetime since we group by month or
// - is readonly
@@ -201,8 +202,10 @@ var KanbanRenderer = BasicRenderer.extend({
}
var groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one');
var grouped_by_field = groupedByM2O && groupByFieldAttrs.relation;
+ var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip;
this.columnOptions = _.extend(this.columnOptions, {
draggable: draggable,
+ group_by_tooltip: groupByTooltip,
grouped_by_m2o: groupedByM2O,
relation: grouped_by_field,
});
diff --git a/addons/web/static/src/js/views/pivot/pivot_controller.js b/addons/web/static/src/js/views/pivot/pivot_controller.js
index fb175aa1934..78640a97124 100644
--- a/addons/web/static/src/js/views/pivot/pivot_controller.js
+++ b/addons/web/static/src/js/views/pivot/pivot_controller.js
@@ -45,13 +45,15 @@ var PivotController = AbstractController.extend({
this.groupableFields = params.groupableFields;
this.title = params.title;
this.enableLinking = params.enableLinking;
+ // views to use in the action triggered when a data cell is clicked
+ this.views = params.views;
this.lastHeaderSelected = null;
},
/**
* @override
*/
start: function () {
- this.$el.toggleClass('o_enableLinking', this.enableLinking);
+ this.$el.toggleClass('o_enable_linking', this.enableLinking);
this.$fieldSelection = this.$('.o_field_selection');
core.bus.on('click', this, function () {
this.$fieldSelection.empty();
@@ -253,33 +255,23 @@ var PivotController = AbstractController.extend({
!this.enableLinking) {
return;
}
- var row_id = $target.data('id');
- var col_id = $target.data('col_id');
var state = this.model.get(this.handle);
- var row_domain = state.data.headers[row_id].domain;
- var col_domain = state.data.headers[col_id].domain;
- var context = _.omit(_.clone(state.context), 'group_by');
-
- function _find_view_info (views, view_type) {
- return _.find(views, function (view) {
- return view[1] === view_type;
- }) || [false, view_type];
- }
- var views = [
- _find_view_info(this.options.action.views, "list"),
- _find_view_info(this.options.action.views, "form"),
- ];
+ var colDomain = this.model.getHeader($target.data('col_id')).domain;
+ var rowDomain = this.model.getHeader($target.data('id')).domain;
+ var context = _.omit(state.context, function (val, key) {
+ return key === 'group_by' || _.str.startsWith(key, 'search_default_');
+ });
this.do_action({
type: 'ir.actions.act_window',
name: this.title,
- res_model: this.model,
- views: views,
- view_type : "list",
- view_mode : "list",
+ res_model: this.modelName,
+ views: this.views,
+ view_type: 'list',
+ view_mode: 'list',
target: 'current',
context: context,
- domain: state.domain.concat(row_domain, col_domain),
+ domain: state.domain.concat(rowDomain, colDomain),
});
},
/**
diff --git a/addons/web/static/src/js/views/pivot/pivot_model.js b/addons/web/static/src/js/views/pivot/pivot_model.js
index 6bbadd56ed8..49c00663d47 100644
--- a/addons/web/static/src/js/views/pivot/pivot_model.js
+++ b/addons/web/static/src/js/views/pivot/pivot_model.js
@@ -16,9 +16,10 @@ odoo.define('web.PivotModel', function (require) {
* @todo add a full description/specification of the data layout
*/
+var AbstractModel = require('web.AbstractModel');
var core = require('web.core');
+var session = require('web.session');
var utils = require('web.utils');
-var AbstractModel = require('web.AbstractModel');
var _t = core._t;
@@ -189,6 +190,8 @@ var PivotModel = AbstractModel.extend({
}
return {
colGroupBys: this.data.main_col.groupbys,
+ context: this.data.context,
+ domain: this.data.domain,
fields: this.fields,
headers: !isRaw && this._computeHeaders(),
has_data: true,
@@ -225,7 +228,7 @@ var PivotModel = AbstractModel.extend({
this.modelName = params.modelName;
this.data = {
domain: params.domain,
- context: params.context,
+ context: _.extend({}, session.user_context, params.context),
groupedBy: params.rowGroupBys,
colGroupBys: params.colGroupBys,
measures: params.measures,
diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js
index d90c0d21780..356672081d9 100644
--- a/addons/web/static/src/js/views/pivot/pivot_view.js
+++ b/addons/web/static/src/js/views/pivot/pivot_view.js
@@ -96,6 +96,17 @@ var PivotView = AbstractView.extend({
this.controllerParams.enableLinking = !arch.attrs.disable_linking;
this.controllerParams.measures = measures;
this.controllerParams.groupableFields = groupableFields;
+ // retrieve form and list view ids from the action to open those views
+ // when a data cell of the pivot view is clicked
+ this.controllerParams.views = [
+ _findView(params.action && params.action.views, 'list'),
+ _findView(params.action && params.action.views, 'form'),
+ ];
+ function _findView(views, viewType) {
+ return _.find(views, function (view) {
+ return view[1] === viewType;
+ }) || [false, viewType];
+ }
},
});
diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js
index a19e440913e..d38400c2e84 100644
--- a/addons/web/static/src/js/views/view_dialogs.js
+++ b/addons/web/static/src/js/views/view_dialogs.js
@@ -222,6 +222,7 @@ var FormViewDialog = ViewDialog.extend({
stayInEdit: true,
reload: false,
savePoint: this.shouldSaveLocally,
+ viewType: 'form',
});
}
return $.when(def).then(function () {
@@ -326,7 +327,6 @@ var SelectCreateDialog = ViewDialog.extend({
var searchview = new SearchView(this, this.dataset, fields_views.search, options);
searchview.prependTo($header).done(function () {
var d = searchview.build_search_data();
- d.domains = d.domains.concat([self.domain]);
if (self.initial_ids) {
d.domains.push([["id", "in", self.initial_ids]]);
self.initial_ids = undefined;
@@ -387,8 +387,9 @@ var SelectCreateDialog = ViewDialog.extend({
contexts: [this.context].concat(contexts),
group_by_seq: groupbys || []
});
+ var context = _.omit(results.context, function (value, key) { return key.indexOf('search_default_') === 0; });
return {
- context: results.context,
+ context: context,
domain: results.domain,
groupBy: results.group_by,
};
diff --git a/addons/web/static/src/less/fields.less b/addons/web/static/src/less/fields.less
index b070a8f4cc8..02885ef3514 100644
--- a/addons/web/static/src/less/fields.less
+++ b/addons/web/static/src/less/fields.less
@@ -33,7 +33,7 @@
}
// Block fields
- &.note-editor, &.oe_form_field_html, &.oe_form_field_html_text, &.oe_form_field_text, &.o_view_manager_content, &.o_field_domain {
+ &.note-editor, &.oe_form_field_html, &.oe_form_field_html_text, &.oe_form_field_text, &.o_view_manager_content, &.o_field_domain, &.o_graph_linechart, &.o_graph_barchart {
display: block;
}
@@ -180,24 +180,6 @@
}
}
- // Status
- &.o_selection .o_status {
- display: inline-block;
- height: 12px;
- width: 12px;
- border-radius: 6px;
- vertical-align: middle;
-
- background-color: @odoo-brand-secondary;
-
- &.o_status_green {
- background-color: @brand-success;
- }
- &.o_status_red {
- background-color: @brand-danger;
- }
- }
-
// Specific success color for toggle_button widget
.fa.o_toggle_button_success {
color: @brand-success;
diff --git a/addons/web/static/src/less/kanban_dashboard.less b/addons/web/static/src/less/kanban_dashboard.less
index fd544445f6d..e86350f6b49 100644
--- a/addons/web/static/src/less/kanban_dashboard.less
+++ b/addons/web/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;
}
diff --git a/addons/web/static/src/less/views.less b/addons/web/static/src/less/views.less
index db5fe8f814c..102d50e7590 100644
--- a/addons/web/static/src/less/views.less
+++ b/addons/web/static/src/less/views.less
@@ -21,3 +21,23 @@
.o_invisible_modifier {
display: none!important;
}
+
+// Status
+// This should normally be put in fields.less but these classes are used outside
+// of `.o_field_widget` so it needs to be placed at an upper level.
+.o_status {
+ display: inline-block;
+ height: 12px;
+ width: 12px;
+ border-radius: 6px;
+ vertical-align: middle;
+
+ background-color: @odoo-brand-secondary;
+
+ &.o_status_green {
+ background-color: @brand-success;
+ }
+ &.o_status_red {
+ background-color: @brand-danger;
+ }
+}
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 559a9387baa..a1f851eb91f 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -659,6 +659,87 @@ QUnit.module('relational_fields', {
});
});
+ QUnit.test('list in form: quick create then add a new line directly', function (assert) {
+ // required many2one inside a one2many list: directly after quick creating
+ // a new many2one value (before the name_create returns), click on add an item:
+ // at this moment, the many2one has still no value, and as it is required,
+ // the row is discarded if a saveLine is requested. However, it should
+ // wait for the name_create to return before trying to save the line.
+ var done = assert.async();
+ assert.expect(8);
+
+ this.data.partner.onchanges = {
+ trululu: function () {},
+ };
+
+ var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ var def = $.Deferred();
+ var newRecordID;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form>' +
+ '<sheet>' +
+ '<field name="p">' +
+ '<tree editable="bottom">' +
+ '<field name="trululu" required="1"/>' +
+ '</tree>' +
+ '</field>' +
+ '</sheet>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'name_create') {
+ return def.then(_.constant(result)).then(function (nameGet) {
+ newRecordID = nameGet[0];
+ return nameGet;
+ });
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0].p[0][2].trululu, newRecordID);
+ }
+ return result;
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ var $dropdown = form.$('.o_field_many2one input').autocomplete('widget');
+ form.$('.o_field_many2one input').val('b').trigger('keydown');
+ concurrency.delay(0).then(function () {
+ $dropdown.find('li:first()').click(); // quick create 'b'
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should still be only one row");
+ assert.ok(form.$('.o_data_row').hasClass('o_selected_row'),
+ "the row should still be in edition");
+
+ def.resolve();
+
+ assert.strictEqual(form.$('.o_data_row:first .o_data_cell').text(), 'b',
+ "first row should have the correct m2o value");
+ assert.strictEqual(form.$('.o_data_row').length, 2,
+ "there should now be 2 rows");
+ assert.ok(form.$('.o_data_row:nth(1)').hasClass('o_selected_row'),
+ "the second row should be in edition");
+
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('.o_data_row').length, 1,
+ "there should be 1 row saved (the second one was empty and invalid)");
+ assert.strictEqual(form.$('.o_data_row .o_data_cell').text(), 'b',
+ "should have the correct m2o value");
+
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
+ form.destroy();
+ done();
+ });
+ });
+
QUnit.test('autocompletion in a many2one, in form view with a domain', function (assert) {
assert.expect(1);
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
index fefc7231e5b..ca2f6f37f3e 100644
--- a/addons/web/static/tests/views/basic_model_tests.js
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -572,6 +572,20 @@ QUnit.module('Views', {
this.params.fieldNames = ['product_id', 'category', 'product_ids'];
this.params.res_id = undefined;
this.params.type = 'record';
+ this.params.fieldsInfo = {
+ form: {
+ category: {},
+ product_id: {},
+ product_ids: {
+ fieldsInfo: {
+ default: { name: {} },
+ },
+ relatedFields: this.data.product.fields,
+ viewType: 'default',
+ },
+ },
+ };
+ this.params.viewType = 'form';
var model = createModel({
Model: BasicModel,
@@ -1015,6 +1029,19 @@ QUnit.module('Views', {
this.params.fieldNames = ['total', 'product_ids'];
this.params.res_id = undefined;
this.params.type = 'record';
+ this.params.fieldsInfo = {
+ form: {
+ product_ids: {
+ fieldsInfo: {
+ default: { name: {} },
+ },
+ relatedFields: this.data.product.fields,
+ viewType: 'default',
+ },
+ total: {},
+ },
+ };
+ this.params.viewType = 'form';
var o2mRecordParams = {
fields: this.data.product.fields,
@@ -1277,6 +1304,63 @@ QUnit.module('Views', {
model.destroy();
});
+ QUnit.test('dont write on readonly fields (write and create)', function (assert) {
+ assert.expect(6);
+
+ this.params.fieldNames = ['foo', 'bar'];
+ this.data.partner.onchanges.foo = function (obj) {
+ obj.bar = obj.foo.length;
+ };
+ this.data.partner.fields.bar.readonly = true;
+
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1], {foo: "verylongstring"},
+ "should only save foo field");
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {foo: "anotherverylongstring"},
+ "should only save foo field");
+ }
+ return this._super(route, args);
+ },
+ });
+
+ model.load(this.params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 2,
+ "should be initialized with correct value");
+
+ model.notifyChanges(resultID, {foo: "verylongstring"});
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 14,
+ "should be changed with correct value");
+
+ model.save(resultID);
+ });
+
+ // start again, but with a new record
+ delete this.params.res_id;
+ model.load(this.params).then(function (resultID) {
+ var record = model.get(resultID);
+ assert.strictEqual(record.data.bar, false,
+ "should be initialized with correct value");
+
+ model.notifyChanges(resultID, {foo: "anotherverylongstring"});
+
+ record = model.get(resultID);
+ assert.strictEqual(record.data.bar, 21,
+ "should be changed with correct value");
+
+ model.save(resultID);
+ });
+ model.destroy();
+ });
+
QUnit.test('default_get with one2many values', function (assert) {
assert.expect(1);
@@ -1297,6 +1381,18 @@ QUnit.module('Views', {
fields: this.data.partner.fields,
modelName: 'partner',
type: 'record',
+ fieldsInfo: {
+ form: {
+ product_ids: {
+ fieldsInfo: {
+ default: { name: {} },
+ },
+ relatedFields: this.data.product.fields,
+ viewType: 'default',
+ },
+ },
+ },
+ viewType: 'form',
};
model.load(params).then(function (resultID) {
assert.strictEqual(typeof resultID, 'string', "result should be a valid id");
diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js
index 7ed63d3bece..ab304e62ebc 100644
--- a/addons/web/static/tests/views/form_tests.js
+++ b/addons/web/static/tests/views/form_tests.js
@@ -5013,5 +5013,147 @@ QUnit.module('Views', {
form.destroy();
});
+
+ QUnit.test('readonly fields are not sent when saving', function (assert) {
+ assert.expect(6);
+
+ // define an onchange on display_name to check that the value of readonly
+ // fields is correctly sent for onchanges
+ this.data.partner.onchanges = {
+ display_name: function () {},
+ p: function () {},
+ };
+ var checkOnchange = false;
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="p">' +
+ '<tree>' +
+ '<field name="display_name"/>' +
+ '</tree>' +
+ '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'display_name\', \'=\', \'readonly\']]}"/>' +
+ '</form>' +
+ '</field>' +
+ '</form>',
+ mockRPC: function (route, args) {
+ if (checkOnchange && args.method === 'onchange') {
+ if (args.args[2] === 'display_name') { // onchange on field display_name
+ assert.strictEqual(args.args[1].foo, 'foo value',
+ "readonly fields value should be sent for onchanges");
+ } else { // onchange on field p
+ assert.deepEqual(args.args[1].p, [
+ [0, false, {display_name: 'readonly', foo: 'foo value'}]
+ ], "readonly fields value should be sent for onchanges");
+ }
+ }
+ if (args.method === 'create') {
+ assert.deepEqual(args.args[0], {
+ p: [[0, false, {display_name: 'readonly'}]]
+ }, "should not have sent the value of the readonly field");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ form.$('.o_field_x2many_list_row_add a').click();
+ assert.strictEqual($('.modal input.o_field_widget[name=foo]').length, 1,
+ 'foo should be editable');
+ checkOnchange = true;
+ $('.modal .o_field_widget[name=foo]').val('foo value').trigger('input');
+ $('.modal .o_field_widget[name=display_name]').val('readonly').trigger('input');
+ assert.strictEqual($('.modal span.o_field_widget[name=foo]').length, 1,
+ 'foo should be readonly');
+ $('.modal .modal-footer .btn-primary').click(); // close the modal
+ checkOnchange = false;
+
+ form.$('.o_data_row').click(); // re-open previous record
+ assert.strictEqual($('.modal .o_field_widget[name=foo]').text(), 'foo value',
+ "the edited value should have been kept");
+ $('.modal .modal-footer .btn-primary').click(); // close the modal
+
+ form.$buttons.find('.o_form_button_save').click(); // save the record
+
+ form.destroy();
+ });
+
+ QUnit.test('id is False in evalContext for new records', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="id"/>' +
+ '<field name="foo" attrs="{\'readonly\': [[\'id\', \'=\', False]]}"/>' +
+ '</form>',
+ });
+
+ assert.ok(form.$('.o_field_widget[name=foo]').hasClass('o_readonly_modifier'),
+ "foo should be readonly in 'Create' mode");
+
+ form.$buttons.find('.o_form_button_save').click();
+ form.$buttons.find('.o_form_button_edit').click();
+
+ assert.notOk(form.$('.o_field_widget[name=foo]').hasClass('o_readonly_modifier'),
+ "foo should not be readonly anymore");
+
+ form.destroy();
+ });
+
+ QUnit.test('delete a duplicated record', function (assert) {
+ assert.expect(5);
+
+ var newRecordID;
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '<form string="Partners">' +
+ '<field name="display_name"/>' +
+ '</form>',
+ res_id: 1,
+ viewOptions: {sidebar: true},
+ mockRPC: function (route, args) {
+ if (args.method === 'search_read' && args.model === 'ir.attachment') {
+ // rpcs done by the sidebar
+ return $.when([]);
+ }
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'copy') {
+ return result.then(function (id) {
+ newRecordID = id;
+ return id;
+ });
+ }
+ if (args.method === 'unlink') {
+ assert.deepEqual(args.args[0], [newRecordID],
+ "should delete the newly created record");
+ }
+ return result;
+ },
+ });
+
+ form.sidebar.$('a:contains(Duplicate)').click(); // duplicate record 1
+ assert.strictEqual(form.$('.o_form_editable').length, 1,
+ "form should be in edit mode");
+ assert.strictEqual(form.$('.o_field_widget').val(), 'first record (copy)',
+ "duplicated record should have correct name");
+ form.$buttons.find('.o_form_button_save').click(); // save duplicated record
+
+ form.sidebar.$('a:contains(Delete)').click(); // delete duplicated record
+ assert.strictEqual($('.modal').length, 1, "should have opened a confirm dialog");
+ $('.modal .modal-footer .btn-primary').click(); // confirm
+
+ assert.strictEqual(form.$('.o_field_widget').text(), 'first record',
+ "should have come back to previous record");
+
+ form.destroy();
+ });
});
});
diff --git a/addons/web/static/tests/views/kanban_model_tests.js b/addons/web/static/tests/views/kanban_model_tests.js
index eeb07c17358..1a3391c720d 100644
--- a/addons/web/static/tests/views/kanban_model_tests.js
+++ b/addons/web/static/tests/views/kanban_model_tests.js
@@ -55,6 +55,7 @@ QUnit.module('Views', {
limit: 40,
modelName: 'partner',
openGroupByDefault: true,
+ viewType: 'kanban',
};
}
}, function () {
diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js
index a6c0e049a17..3d375e7be0a 100644
--- a/addons/web/static/tests/views/kanban_tests.js
+++ b/addons/web/static/tests/views/kanban_tests.js
@@ -1503,6 +1503,43 @@ QUnit.module('Views', {
"Create button should now be highlighted");
kanban.destroy();
});
+
+ QUnit.test('group_by_tooltip option when grouping on a many2one', function (assert) {
+ assert.expect(5);
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban default_group_by="bar">' +
+ '<field name="bar"/>' +
+ '<field name="product_id" '+
+ 'options=\'{"group_by_tooltip": {"name": "Kikou"}}\'/>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div><field name="foo"/></div>' +
+ '</t></templates></kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/product/read') {
+ assert.strictEqual(args.args[0].length, 2,
+ "read on two groups");
+ assert.deepEqual(args.args[1], ['display_name', 'name'],
+ "should read on specified fields on the group by relation");
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.strictEqual(kanban.$('.o_kanban_group').length, 2, "should have " + 2 + " columns");
+
+ // simulate an update coming from the searchview, with another groupby given
+ kanban.update({groupBy: ['product_id']});
+ assert.ok(kanban.$('.o_kanban_group:first span.o_column_title:contains(hello)').length,
+ "first column should have a title with a value from the many2one");
+ assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_header').data('original-title'),
+ "<p>2 records</p><div>Kikou<br>hello</div>",
+ "first column should have a tooltip with the number of records, the group_by_tooltip title and many2one field value");
+
+ kanban.destroy();
+ });
});
});
diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js
index d74e29204d7..c3acdb52399 100644
--- a/addons/web/static/tests/views/pivot_tests.js
+++ b/addons/web/static/tests/views/pivot_tests.js
@@ -1,6 +1,7 @@
odoo.define('web.pivot_tests', function (require) {
"use strict";
+var Context = require('web.Context');
var core = require('web.core');
var PivotView = require('web.PivotView');
var testUtils = require('web.test_utils');
@@ -81,7 +82,7 @@ QUnit.module('Views', {
QUnit.module('PivotView');
QUnit.test('simple pivot rendering', function (assert) {
- assert.expect(2);
+ assert.expect(3);
var pivot = createView({
View: PivotView,
@@ -97,6 +98,8 @@ QUnit.module('Views', {
},
});
+ assert.ok(pivot.$el.hasClass('o_enable_linking'),
+ "root node should have classname 'o_enable_linking'");
assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32)').length, 1,
"should contain a pivot cell with the sum of all records");
pivot.destroy();
@@ -119,6 +122,77 @@ QUnit.module('Views', {
pivot.destroy();
});
+ QUnit.test('clicking on a cell triggers a do_action', function (assert) {
+ assert.expect(2);
+
+ var pivot = createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot>' +
+ '<field name="product_id" type="row"/>' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ intercepts: {
+ do_action: function (ev) {
+ assert.deepEqual(ev.data.action, {
+ context: {someKey: true, userContextKey: true},
+ domain: [['product_id', '=', 37]],
+ name: 'Partners',
+ res_model: 'partner',
+ target: 'current',
+ type: 'ir.actions.act_window',
+ view_mode: 'list',
+ view_type: 'list',
+ views: [[false, 'list'], [2, 'form']],
+ }, "should trigger do_action with the correct args");
+ },
+ },
+ session: {
+ user_context: {userContextKey: true},
+ },
+ viewOptions: {
+ action: {
+ views: [[2, 'form'], [5, 'kanban'], [false, 'list'], [false, 'pivot']],
+ },
+ context: {someKey: true, search_default_test: 3},
+ title: 'Partners',
+ }
+ });
+
+ assert.ok(pivot.$el.hasClass('o_enable_linking'),
+ "root node should have classname 'o_enable_linking'");
+ pivot.$('.o_pivot_cell_value:contains(12)').click(); // should trigger a do_action
+
+ pivot.destroy();
+ });
+
+ QUnit.test('pivot view with disable_linking="True"', function (assert) {
+ assert.expect(2);
+
+ var pivot = createView({
+ View: PivotView,
+ model: "partner",
+ data: this.data,
+ arch: '<pivot disable_linking="True">' +
+ '<field name="foo" type="measure"/>' +
+ '</pivot>',
+ intercepts: {
+ do_action: function () {
+ assert.ok(false, "should not trigger do_action");
+ },
+ },
+ });
+
+ assert.notOk(pivot.$el.hasClass('o_enable_linking'),
+ "root node should not have classname 'o_enable_linking'");
+ assert.strictEqual(pivot.$('.o_pivot_cell_value').length, 1,
+ "should have one cell");
+ pivot.$('.o_pivot_cell_value').click(); // should not trigger a do_action
+
+ pivot.destroy();
+ });
+
QUnit.test('pivot view grouped by date field', function (assert) {
assert.expect(2);
diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js
index 963b3c1552e..8c46d8fff46 100644
--- a/addons/web/static/tests/views/view_dialogs_tests.js
+++ b/addons/web/static/tests/views/view_dialogs_tests.js
@@ -10,10 +10,14 @@ QUnit.module('Views', {
this.data = {
partner: {
fields: {
+ display_name: { string: "Displayed name", type: "char" },
foo: {string: "Foo", type: 'char'},
+ bar: {string: "Bar", type: "boolean"},
},
records: [
- {id: 1, foo: 'blip',},
+ {id: 1, foo: 'blip', display_name: 'blipblip', bar: true},
+ {id: 2, foo: 'ta tata ta ta', display_name: 'macgyver', bar: false},
+ {id: 3, foo: 'piou piou', display_name: "Jack O'Neill", bar: true},
],
},
};
@@ -63,6 +67,79 @@ QUnit.module('Views', {
dialog.destroy();
});
+ QUnit.test('SelectCreateDialog use domain, group_by and search default', function (assert) {
+ assert.expect(3);
+
+ var search = 0;
+ var parent = createParent({
+ data: this.data,
+ archs: {
+ 'partner,false,list':
+ '<tree string="Partner">' +
+ '<field name="display_name"/>' +
+ '<field name="foo"/>' +
+ '</tree>',
+ 'partner,false,search':
+ '<search>' +
+ '<field name="foo" filter_domain="[(\'display_name\',\'ilike\',self), (\'foo\',\'ilike\',self)]"/>' +
+ '<group expand="0" string="Group By">' +
+ '<filter name="groupby_bar" context="{\'group_by\' : \'bar\'}"/>' +
+ '</group>' +
+ '</search>',
+ },
+ mockRPC: function (route, args) {
+ search++;
+ if (search === 1 && args.method === 'read_group') {
+ assert.deepEqual(args.kwargs, {
+ context: {group_by: "bar"},
+ domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]],
+ fields:["display_name","foo","bar"],
+ groupby:["bar"],
+ lazy: true
+ }, "should search with the complete domain (domain + search), and group by 'bar'");
+ }
+ if (search === 2 && route === '/web/dataset/search_read') {
+ assert.deepEqual(args, {
+ context: {},
+ domain: [["display_name","like","a"], ["display_name","ilike","piou"], ["foo","ilike","piou"]],
+ fields:["display_name","foo"],
+ model: "partner",
+ limit: 80,
+ sort: ""
+ }, "should search with the complete domain (domain + search)");
+ }
+ if (search === 3 && route === '/web/dataset/search_read') {
+ assert.deepEqual(args, {
+ context: {},
+ domain: [["display_name","like","a"]],
+ fields:["display_name","foo"],
+ model: "partner",
+ limit: 80,
+ sort: ""
+ }, "should search with the domain");
+ }
+
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ var dialog = new dialogs.SelectCreateDialog(parent, {
+ no_create: true,
+ readonly: true,
+ res_model: 'partner',
+ domain: [['display_name', 'like', 'a']],
+ context: {
+ search_default_groupby_bar: true,
+ search_default_foo: 'piou',
+ },
+ }).open();
+
+ dialog.$('.o_searchview_facet:contains(groupby_bar) .o_facet_remove').click();
+ dialog.$('.o_searchview_facet .o_facet_remove').click();
+
+ dialog.destroy();
+ });
+
});
}); \ No newline at end of file
diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py
index 0c0497ef843..a97cebe76d5 100644
--- a/addons/website/models/ir_http.py
+++ b/addons/website/models/ir_http.py
@@ -175,8 +175,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/models/website.py b/addons/website_sale/models/website.py
index e7415b137fa..6cc12e947d4 100644
--- a/addons/website_sale/models/website.py
+++ b/addons/website_sale/models/website.py
@@ -149,7 +149,7 @@ class Website(models.Model):
@api.model
def sale_get_payment_term(self, partner):
DEFAULT_PAYMENT_TERM = 'account.account_payment_term_immediate'
- return self.env.ref(DEFAULT_PAYMENT_TERM, False).id or partner.property_payment_term_id.id
+ return partner.property_payment_term_id.id or self.env.ref(DEFAULT_PAYMENT_TERM, False).id
@api.multi
def _prepare_sale_order_values(self, partner, pricelist):
@@ -157,6 +157,7 @@ class Website(models.Model):
affiliate_id = request.session.get('affiliate_id')
salesperson_id = affiliate_id if self.env['res.users'].sudo().browse(affiliate_id).exists() else request.website.salesperson_id.id
addr = partner.address_get(['delivery', 'invoice'])
+ default_user_id = partner.parent_id.user_id.id or partner.user_id.id
values = {
'partner_id': partner.id,
'pricelist_id': pricelist.id,
@@ -164,7 +165,7 @@ class Website(models.Model):
'team_id': self.salesteam_id.id,
'partner_invoice_id': addr['invoice'],
'partner_shipping_id': addr['delivery'],
- 'user_id': salesperson_id or self.salesperson_id.id,
+ 'user_id': salesperson_id or self.salesperson_id.id or default_user_id,
}
company = self.company_id or pricelist.company_id
if company:
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');