[MERGE] forward port branch saas-16 up to 1b50c829ef
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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})
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
import res_company
|
||||
import account
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}" &
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' ||
|
||||
@@ -2199,6 +2235,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.
|
||||
*
|
||||
@@ -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;
|
||||
|
||||
@@ -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')) {
|
||||
@@ -394,6 +394,14 @@ var FormController = BasicController.extend({
|
||||
_onCreate: function () {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -89,6 +89,21 @@ var KanbanModel = BasicModel.extend({
|
||||
return newGroup.id;
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -16,9 +16,10 @@ odoo.define('web.PivotModel', function (require) {
|
||||
* @todo add a full description/specification of the data layout
|
||||
*/
|
||||
|
||||
var core = require('web.core');
|
||||
var utils = require('web.utils');
|
||||
var AbstractModel = require('web.AbstractModel');
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var utils = require('web.utils');
|
||||
|
||||
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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ QUnit.module('Views', {
|
||||
limit: 40,
|
||||
modelName: 'partner',
|
||||
openGroupByDefault: true,
|
||||
viewType: 'kanban',
|
||||
};
|
||||
}
|
||||
}, function () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
After Width: | Height: | Size: 300 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -22,3 +22,4 @@ Reference
|
||||
reference/mixins
|
||||
reference/guidelines
|
||||
reference/upgrade_api
|
||||
reference/mobile
|
||||
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
@@ -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': '<<BASE 64 Image Data>>'
|
||||
}
|
||||
|
||||
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
|
||||
@@ -544,16 +544,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):
|
||||
|
||||
@@ -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 = '<form string="%s"><div>%s</div><div>%s</div></form>'
|
||||
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 = '<form string="%s"><div>%s</div><div>%s</div></form>'
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -162,6 +162,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 "<html><head><script>window.location = '%s' + location.hash;</script></head></html>" % url
|
||||
|
||||
|
||||
@@ -3802,16 +3802,18 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
|
||||
# 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
|
||||
|
||||
@@ -50,6 +50,19 @@ psycopg2.extensions.register_type(psycopg2.extensions.new_type((700, 701, 1700,)
|
||||
|
||||
from . import tools
|
||||
from .tools.func import frame_codeinfo
|
||||
|
||||
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 datetime import timedelta
|
||||
import threading
|
||||
from inspect import currentframe
|
||||
|
||||