[MERGE] forward port branch saas-16 up to dc2a6c6cd2

This commit is contained in:
Christophe Simonis
2017-10-20 19:27:04 +02:00
82 changed files with 1466 additions and 442 deletions
+4 -4
View File
@@ -72,12 +72,12 @@ from ${object.company_id.name}.
<div>
% set record = ctx.get('record')
% set company = record and record.company_id or user.company_id
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification">
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size:20px; color:white; font-weight: bold;">
@@ -94,7 +94,7 @@ from ${object.company_id.name}.
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;">
<tbody>
<td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;">
${object.body | safe}
@@ -106,7 +106,7 @@ from ${object.company_id.name}.
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;">
${company.name}<br/>
+127 -38
View File
@@ -848,6 +848,7 @@ class AccountTax(models.Model):
price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include)
"""
self.ensure_one()
price_include = self._context.get('force_price_include', self.price_include)
if self.amount_type == 'fixed':
# Use copysign to take into account the sign of the base amount which includes the sign
# of the quantity and the sign of the price_unit
@@ -861,11 +862,11 @@ class AccountTax(models.Model):
return math.copysign(quantity, base_amount) * self.amount
else:
return quantity * self.amount
if (self.amount_type == 'percent' and not self.price_include) or (self.amount_type == 'division' and self.price_include):
if (self.amount_type == 'percent' and not price_include) or (self.amount_type == 'division' and self.price_include):
return base_amount * self.amount / 100
if self.amount_type == 'percent' and self.price_include:
if self.amount_type == 'percent' and price_include:
return base_amount - (base_amount / (1 + self.amount / 100))
if self.amount_type == 'division' and not self.price_include:
if self.amount_type == 'division' and not price_include:
return base_amount / (1 - self.amount / 100) - base_amount
@api.multi
@@ -899,13 +900,58 @@ class AccountTax(models.Model):
'analytic': boolean,
}]
} """
# 1) Flatten the taxes.
def collect_taxes(self, all_taxes=None):
# Collect all the taxes recursively ordered by the sequence.
# Example:
# group | seq | sub-group |
# ------------|-----------|
# | 1 | |
# ------------|-----------|
# t | 2 | | seq | |
# | | | 4 | |
# | | | 5 | |
# | | | 6 | |
# | | |
# ------------|-----------|
# | 3 | |
# ------------|-----------|
# Result: 1-4-5-6-3
if not all_taxes:
all_taxes = self.env['account.tax']
for tax in self.sorted(key=lambda r: r.sequence):
if tax.amount_type == 'group':
all_taxes = collect_taxes(tax.children_tax_ids, all_taxes)
else:
all_taxes += tax
return all_taxes
taxes = collect_taxes(self)
# 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True
# with price_include=True
base_excluded_flag = False # price_include=False && include_base_amount=True
included_flag = False # price_include=True
for tax in taxes:
if tax.price_include:
included_flag = True
elif tax.include_base_amount:
base_excluded_flag = True
if base_excluded_flag and included_flag:
raise UserError(_('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.'))
# 3) Deal with the rounding methods
if len(self) == 0:
company_id = self.env.user.company_id
else:
company_id = self[0].company_id
if not currency:
currency = company_id.currency_id
taxes = []
# By default, for each tax, tax amount will first be computed
# and rounded at the 'Account' decimal precision for each
# PO/SO/invoice line and then these rounded amounts will be
@@ -930,50 +976,93 @@ class AccountTax(models.Model):
if not round_tax:
prec += 5
base_values = self.env.context.get('base_values')
if not base_values:
total_excluded = total_included = base = round(price_unit * quantity, prec)
# 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
# tax | base | amount |
# /\ ----------------------------
# || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded
# || tax_2 | | |
# || tax_3 | | |
# || ... | .. | .. |
# ----------------------------
def recompute_base(base_amount, fixed_amount, percent_amount):
# Recompute the new base amount based on included fixed/percent amount and the current base amount.
# Example:
# tax | amount |
# ------------------
# tax_1 | 10% |
# tax_2 | 15 |
# tax_3 | 20% |
# ------------------
# if base_amount = 145, the new base is computed as:
# (145 - 15) / (1.0 + ((10 + 20) / 100.0)) = 130 / 1.3 = 100
if fixed_amount == 0.0 and percent_amount == 0.0:
return base_amount
return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0)
base = round(price_unit * quantity, prec)
# For the computation of move lines, we could have a negative base value.
# In this case, compute all with positive values and negative them at the end.
if base < 0:
base = -base
sign = -1
else:
total_excluded, total_included, base = base_values
sign = 1
# Sorting key is mandatory in this case. When no key is provided, sorted() will perform a
# search. However, the search method is overridden in account.tax in order to add a domain
# depending on the context. This domain might filter out some taxes from self, e.g. in the
# case of group taxes.
for tax in self.sorted(key=lambda r: r.sequence):
if tax.amount_type == 'group':
children = tax.children_tax_ids.with_context(base_values=(total_excluded, total_included, base))
ret = children.compute_all(price_unit, currency, quantity, product, partner)
total_excluded = ret['total_excluded']
base = ret['base'] if tax.include_base_amount else base
total_included = ret['total_included']
tax_amount = total_included - total_excluded
taxes += ret['taxes']
continue
# Keep track of the accumulated included fixed/percent amount.
incl_fixed_amount = incl_percent_amount = 0
for tax in reversed(taxes):
if tax.include_base_amount:
base = recompute_base(base, incl_fixed_amount, incl_percent_amount)
incl_fixed_amount = incl_percent_amount = 0
if tax.price_include:
if tax.amount_type == 'fixed':
incl_fixed_amount += tax.amount
elif tax.amount_type == 'percent':
incl_percent_amount += tax.amount
# Start the computation of accumulated amounts at the total_excluded value.
total_excluded = total_included = base = recompute_base(base, incl_fixed_amount, incl_percent_amount)
tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner)
# 5) Iterate the taxes in the sequence order to fill missing base/amount values.
# tax | base | amount |
# || ----------------------------
# || tax_1 | OK | XXXX |
# || tax_2 | XXXX | XXXX |
# || tax_3 | XXXX | XXXX |
# \/ ... | .. | .. |
# ----------------------------
taxes_vals = []
for tax in taxes:
# Compute the amount of the tax but don't deal with the price_include because it's already
# took into account on the base amount except for 'division' tax:
# (tax.amount_type == 'percent' && not tax.price_include)
# == (tax.amount_type == 'division' && tax.price_include)
tax_amount = tax.with_context(force_price_include=False)._compute_amount(base, price_unit, quantity, product, partner)
if not round_tax:
tax_amount = round(tax_amount, prec)
else:
tax_amount = currency.round(tax_amount)
if tax.price_include:
total_excluded -= tax_amount
base -= tax_amount
else:
total_included += tax_amount
# Keep base amount used for the current tax
# Suppose:
# seq | amount | incl | incl_base | base | amount
# -----------------------------------------------
# 1 | 10 % | t | t | 100.0 | 10.0
# -----------------------------------------------
# ... the next computation must be done using 100.0 + 10.0 = 110.0 as base but
# the tax base of this tax will be 100.0.
tax_base = base
if tax.include_base_amount:
base += tax_amount
taxes.append({
# The total_included amount is computed as the sum of total_excluded with all tax_amount
total_included += tax_amount
taxes_vals.append({
'id': tax.id,
'name': tax.with_context(**{'lang': partner.lang} if partner else {}).name,
'amount': tax_amount,
'base': tax_base,
'amount': sign * tax_amount,
'base': round(sign * tax_base, prec),
'sequence': tax.sequence,
'account_id': tax.account_id.id,
'refund_account_id': tax.refund_account_id.id,
@@ -981,10 +1070,10 @@ class AccountTax(models.Model):
})
return {
'taxes': sorted(taxes, key=lambda k: k['sequence']),
'total_excluded': currency.round(total_excluded) if round_total else total_excluded,
'total_included': currency.round(total_included) if round_total else total_included,
'base': base,
'taxes': taxes_vals,
'total_excluded': sign * (currency.round(total_excluded) if round_total else total_excluded),
'total_included': sign * (currency.round(total_included) if round_total else total_included),
'base': round(sign * base, prec),
}
@api.model
@@ -500,10 +500,10 @@ class AccountBankStatementLine(models.Model):
if self.amount_currency and self.currency_id:
amount = self.amount_currency
amount_currency = self.amount
amount_currency_str = amount_currency > 0 and amount_currency or -amount_currency
amount_currency_str = formatLang(self.env, amount_currency_str, currency_obj=statement_currency)
amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=statement_currency)
else:
amount = self.amount
amount_currency = amount
amount_currency_str = ""
amount_str = formatLang(self.env, abs(amount), currency_obj=self.currency_id or statement_currency)
@@ -525,6 +525,7 @@ class AccountBankStatementLine(models.Model):
'partner_name': self.partner_id.name,
'communication_partner_name': self.partner_name,
'amount_currency_str': amount_currency_str, # Amount in the statement currency
'amount_currency': amount_currency, # Amount in the statement currency
'has_no_partner': not self.partner_id.id,
}
if self.partner_id:
+3
View File
@@ -1588,6 +1588,9 @@ class AccountInvoiceLine(models.Model):
self.price_unit = 0.0
domain['uom_id'] = []
else:
# Use the purchase uom by default
self.uom_id = self.product_id.uom_po_id
if part.lang:
product = self.product_id.with_context(lang=part.lang)
else:
@@ -175,7 +175,7 @@ class account_journal(models.Model):
# optimization to read sum of balance from account_move_line
account_ids = tuple(ac for ac in [self.default_debit_account_id.id, self.default_credit_account_id.id] if ac)
if account_ids:
amount_field = 'balance' if not self.currency_id else 'amount_currency'
amount_field = 'balance' if (not self.currency_id or self.currency_id == self.company_id.currency_id) else 'amount_currency'
query = """SELECT sum(%s) FROM account_move_line WHERE account_id in %%s AND date <= %%s;""" % (amount_field,)
self.env.cr.execute(query, (account_ids, fields.Date.today(),))
query_results = self.env.cr.dictfetchall()
+37 -17
View File
@@ -698,19 +698,36 @@ class AccountMoveLine(models.Model):
'|', ('date_maturity', 'like', str),
'&', ('name', '!=', '/'), ('name', 'ilike', str)
]
try:
amount = float(str)
amount_domain = [
'|', ('amount_residual', '=', amount),
'|', ('amount_residual_currency', '=', amount),
'|', ('amount_residual', '=', -amount),
'|', ('amount_residual_currency', '=', -amount),
'&', ('account_id.internal_type', '=', 'liquidity'),
'|', '|', ('debit', '=', amount), ('credit', '=', amount), ('amount_currency', '=', amount),
]
str_domain = expression.OR([str_domain, amount_domain])
except:
pass
if str[0] in ['-', '+']:
try:
amounts_str = str.split('|')
for amount_str in amounts_str:
amount = amount_str[0] == '-' and float(amount_str) or float(amount_str[1:])
amount_domain = [
'|', ('amount_residual', '=', amount),
'|', ('amount_residual_currency', '=', amount),
'|', (amount_str[0] == '-' and 'credit' or 'debit', '=', float(amount_str[1:])),
('amount_currency', '=', amount),
]
str_domain = expression.OR([str_domain, amount_domain])
except:
pass
else:
try:
amount = float(str)
amount_domain = [
'|', ('amount_residual', '=', amount),
'|', ('amount_residual_currency', '=', amount),
'|', ('amount_residual', '=', -amount),
'|', ('amount_residual_currency', '=', -amount),
'&', ('account_id.internal_type', '=', 'liquidity'),
'|', '|', '|', ('debit', '=', amount), ('credit', '=', amount),
('amount_currency', '=', amount),
('amount_currency', '=', -amount),
]
str_domain = expression.OR([str_domain, amount_domain])
except:
pass
return str_domain
def _domain_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, str=False):
@@ -753,6 +770,7 @@ class AccountMoveLine(models.Model):
for line in self:
company_currency = line.account_id.company_id.currency_id
line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
ret_line = {
'id': line.id,
'name': line.name and line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name,
@@ -769,7 +787,7 @@ class AccountMoveLine(models.Model):
'journal_id': [line.journal_id.id, line.journal_id.display_name],
'partner_id': line.partner_id.id,
'partner_name': line.partner_id.name,
'currency_id': (line.currency_id and line.amount_currency) and line.currency_id.id or False,
'currency_id': line_currency.id,
}
debit = line.debit
@@ -784,7 +802,7 @@ class AccountMoveLine(models.Model):
# Get right debit / credit:
target_currency = target_currency or company_currency
line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
amount_currency = debit - credit
amount_currency_str = ""
total_amount_currency_str = ""
if line_currency != company_currency and target_currency == line_currency:
@@ -809,9 +827,10 @@ class AccountMoveLine(models.Model):
actual_debit = debit > 0 and amount or 0.0
actual_credit = credit > 0 and -amount or 0.0
currency = company_currency
if line_currency != target_currency:
target_currency.compute(total_amount, line_currency)
amount_currency_str = formatLang(self.env, target_currency.compute(abs(actual_debit or actual_credit), line_currency), currency_obj=line_currency)
amount_currency = target_currency.compute(total_amount, line_currency)
amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=line_currency)
total_amount_currency_str = formatLang(self.env, target_currency.compute(total_amount, line_currency), currency_obj=line_currency)
if currency != target_currency:
ctx = context.copy()
@@ -827,6 +846,7 @@ class AccountMoveLine(models.Model):
ret_line['amount_str'] = amount_str
ret_line['total_amount_str'] = total_amount_str
ret_line['amount_currency_str'] = amount_currency_str
ret_line['amount_currency'] = amount_currency
ret_line['total_amount_currency_str'] = total_amount_currency_str
ret.append(ret_line)
return ret
@@ -20,6 +20,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
change_offset: '_onAction',
change_partner: '_onAction',
add_proposition: '_onAction',
search_balance_amount: '_onAction',
remove_proposition: '_onAction',
update_proposition: '_onAction',
create_proposition: '_onAction',
@@ -38,7 +39,11 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
// used to instanciate the action interface
ActionRenderer: ReconciliationRenderer.StatementRenderer,
// used to instanciate each widget line
LineRenderer: ReconciliationRenderer.LineRenderer
LineRenderer: ReconciliationRenderer.LineRenderer,
// used context params
params: ['statement_ids'],
// number of moves lines displayed in 'match' mode
limitMoveLines: 5,
},
/**
@@ -51,11 +56,25 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
this._super.apply(this, arguments);
this.action_manager = parent;
this.params = params;
this.model = new this.config.Model(this, {modelName: "account.bank.statement.line"});
this.model = new this.config.Model(this, {
modelName: "account.bank.statement.line",
limitMoveLines: params.params && params.params.limitMoveLines || this.config.limitMoveLines,
});
this.widgets = [];
if (!this.action_manager) {
this.set_cp_bus(new Widget());
}
// Adding values from the context is necessary to put this information in the url via the action manager so that
// you can retrieve it if the person shares his url or presses f5
_.each(params.params, function (value, name) {
params.context[name] = name.indexOf('_ids') !== -1 ? _.map((value+'').split(), parseFloat) : value;
});
params.params = {};
_.each(this.config.params, function (name) {
if (params.context[name]) {
params.params[name] = name.indexOf('_ids') !== -1 && _.isArray(params.context[name]) ? params.context[name].join() : params.context[name];
}
});
},
/**
@@ -305,8 +324,15 @@ var ManualAction = StatementAction.extend({
config: {
Model: ReconciliationModel.ManualModel,
ActionRenderer: ReconciliationRenderer.ManualRenderer,
LineRenderer: ReconciliationRenderer.ManualLineRenderer
LineRenderer: ReconciliationRenderer.ManualLineRenderer,
params: ['company_ids', 'mode', 'partner_ids', 'account_ids'],
limitMoveLines: 10,
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* call 'validate' or 'autoReconciliation' model method then destroy the
* reconcilied lines, update the not reconcilied and update the action
@@ -66,6 +66,7 @@ var _t = core._t;
* }
* mv_lines: object - idem than reconciliation_proposition
* offset: integer
* limitMoveLines: integer
* filter: string
* [createForm]: {
* account_id: {
@@ -95,8 +96,11 @@ var StatementModel = BasicModel.extend({
/**
* @override
*
* @param {Widget} parent
* @param {object} options
*/
init: function () {
init: function (parent, options) {
this._super.apply(this, arguments);
this.reconcileModels = [];
this.lines = {};
@@ -104,6 +108,7 @@ var StatementModel = BasicModel.extend({
this.valuemax = 0;
this.alreadyDisplayed = [];
this.defaultDisplayQty = 10;
this.limitMoveLines = options && options.limitMoveLines || 5;
},
//--------------------------------------------------------------------------
@@ -162,7 +167,9 @@ var StatementModel = BasicModel.extend({
* @returns {Deferred}
*/
changeFilter: function (handle, filter) {
this.getLine(handle).filter = filter;
var line = this.getLine(handle);
line.filter = filter;
line.offset = 0;
return this._performMoveLine(handle);
},
/**
@@ -219,7 +226,7 @@ var StatementModel = BasicModel.extend({
* @returns {Deferred}
*/
changeOffset: function (handle, offset) {
this.getLine(handle).offset += offset;
this.getLine(handle).offset += (offset > 0 ? 1 : -1) * this.limitMoveLines;
return this._performMoveLine(handle);
},
/**
@@ -360,14 +367,14 @@ var StatementModel = BasicModel.extend({
self.context.journal_id = statement.journal_id;
_.each(statement.st_lines_ids, function (id) {
self.lines[_.uniqueId('rline')] = {
'id': id,
'reconciled': false,
'mode': 'inactive',
'mv_lines': [],
'offset': 0,
'filter': "",
'reconciliation_proposition': [],
'reconcileModels': []
id: id,
reconciled: false,
mode: 'inactive',
mv_lines: [],
offset: 0,
filter: "",
reconciliation_proposition: [],
reconcileModels: [],
};
});
});
@@ -489,6 +496,25 @@ var StatementModel = BasicModel.extend({
}
return def;
},
searchBalanceAmount: function (handle) {
var line = this.getLine(handle);
var amount = line.balance.amount;
var amount_str = _.str.sprintf('%.2f', Math.abs(amount));
amount_str = (amount > '0' ? '-' : '+') + amount_str;
if (line.balance.amount_currency) {
var amount_currency = line.balance.amount_currency;
var amount_currency_str = _.str.sprintf('%.2f', Math.abs(amount_currency));
amount_str += '|' + (amount_currency > '0' ? '-' : '+') + amount_currency_str;
}
if (amount_str === line.filter) {
line.filter = '';
line.offset = 0;
return this.changeMode(handle, 'create');
}
line.filter = amount_str;
line.offset = 0;
return this.changeMode(handle, 'match');
},
/**
* Force the partial reconciliation to display the reconciliate button.
* This method should only be called when there is onely one proposition.
@@ -728,7 +754,7 @@ var StatementModel = BasicModel.extend({
return !p.is_tax || p.link !== prop.id;
});
var args = [[prop.tax_id.id], prop.base_amount, line.st_line.currency_id];
var args = [[prop.tax_id.id], prop.base_amount, formatOptions.currency_id];
tax_defs.push(self._rpc({
model: 'account.tax',
method: 'json_friendly_compute_all',
@@ -758,11 +784,7 @@ var StatementModel = BasicModel.extend({
});
}));
} else {
var currencyData = {
currency_id: line.st_line.currency_id
};
prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, currencyData);
prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, formatOptions);
prop.display = self._isDisplayedProposition(prop);
prop.invalid = !self._isValid(prop);
}
@@ -774,19 +796,32 @@ var StatementModel = BasicModel.extend({
});
line.reconciliation_proposition = reconciliation_proposition;
var amount_currency = 0;
var total = line.st_line.amount || 0;
_.each(line.reconciliation_proposition, function (prop) {
var isOtherCurrencyId = _.uniq(_.pluck(_.reject(reconciliation_proposition, 'invalid'), 'currency_id'));
isOtherCurrencyId = isOtherCurrencyId.length === 1 && !total && isOtherCurrencyId[0] !== formatOptions.currency_id ? isOtherCurrencyId[0] : false;
_.each(reconciliation_proposition, function (prop) {
if (!prop.invalid) {
total -= prop.amount;
if (isOtherCurrencyId) {
amount_currency -= prop.amount_currency;
}
}
});
total = Math.round(total*1000)/1000 || 0;
line.balance = {
'amount': total,
'amount_str': field_utils.format.monetary(Math.abs(total), {}, formatOptions),
'account_code': self.accounts[line.st_line.open_balance_account_id],
amount: total,
amount_str: field_utils.format.monetary(Math.abs(total), {}, formatOptions),
currency_id: isOtherCurrencyId,
amount_currency: isOtherCurrencyId ? amount_currency : false,
amount_currency_str: isOtherCurrencyId ? field_utils.format.monetary(Math.abs(amount_currency), {}, {
currency_id: isOtherCurrencyId
}) : false,
account_code: self.accounts[line.st_line.open_balance_account_id],
};
line.balance.type = line.balance.amount ? (line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1;
line.balance.type = (isOtherCurrencyId && amount_currency || line.balance.amount) ?
(line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1;
});
},
/**
@@ -851,6 +886,7 @@ var StatementModel = BasicModel.extend({
return l.id === data.st_line.id;
});
line.visible = true;
line.limitMoveLines = self.limitMoveLines;
_.extend(line, data);
self._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
@@ -989,7 +1025,7 @@ var StatementModel = BasicModel.extend({
})));
var filter = line.filter || "";
var offset = line.offset;
var limit = 6;
var limit = this.limitMoveLines+1;
return this._rpc({
model: 'account.bank.statement.line',
method: 'get_move_lines_for_reconciliation_widget',
@@ -1196,6 +1232,8 @@ var ManualModel = StatementModel.extend({
if (line.reconciled) {
return;
}
line.filter = "";
line.offset = 0;
defs.push(self._performMoveLine(handle).then(function () {
if(!line.mv_lines.length) {
self.valuenow++;
@@ -1243,7 +1281,7 @@ var ManualModel = StatementModel.extend({
*/
_computeLine: function (line) {
return this._super(line).then(function () {
var props = line.reconciliation_proposition;
var props = _.reject(line.reconciliation_proposition, 'invalid');
line.balance.type = -1;
if (!line.balance.amount && props.length) {
line.balance.type = 1;
@@ -1265,15 +1303,16 @@ var ManualModel = StatementModel.extend({
*/
_formatLine: function (type, data) {
var line = this.lines[_.uniqueId('rline')] = _.extend(data, {
'type': type,
'reconciled': false,
'mode': 'inactive',
'offset': 0,
'filter': "",
'reconcileModels': [],
'account_id': this._formatNameGet([data.account_id, data.account_name]),
'st_line': data,
'visible': true
type: type,
reconciled: false,
mode: 'inactive',
offset: 0,
limitMoveLines: this.limitMoveLines,
filter: "",
reconcileModels: [],
account_id: this._formatNameGet([data.account_id, data.account_name]),
st_line: data,
visible: true
});
this._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
@@ -1337,7 +1376,7 @@ var ManualModel = StatementModel.extend({
})));
var filter = line.filter || "";
var offset = line.offset;
var limit = 6;
var limit = this.limitMoveLines+1;
var args = [line.account_id.id, line.partner_id, excluded_ids, filter, offset, limit];
return this._rpc({
model: 'account.move.line',
@@ -250,7 +250,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
events: {
'click .accounting_view caption .o_buttons button': '_onValidate',
'click .accounting_view thead td': '_onTogglePanel',
'click .accounting_view tfoot td': '_onShowPanel',
'click .accounting_view tfoot td:not(.cell_left,.cell_right)': '_onShowPanel',
'click tfoot .cell_left, tfoot .cell_right': '_onSearchBalanceAmount',
'input input.filter': '_onFilterChange',
'click .match_controls .fa-chevron-left:not(.disabled)': '_onPrevious',
'click .match_controls .fa-chevron-right:not(.disabled)': '_onNext',
@@ -386,7 +387,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
// mv_lines
var $mv_lines = this.$('.match table tbody').empty();
_.each(state.mv_lines.slice(0,5), function (line) {
_.each(state.mv_lines.slice(0, state.limitMoveLines), function (line) {
var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state}));
if (!isNaN(line.id)) {
$('<span class="line_info_button fa fa-info-circle"/>')
@@ -395,13 +396,19 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
}
$mv_lines.append($line);
});
this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= 5);
this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= state.limitMoveLines);
this.$('.match .fa-chevron-left').toggleClass('disabled', !state.offset);
this.$('.match').css('max-height', !state.mv_lines.length && !state.filter.length ? '0px' : '');
// balance
this.$('.popover').remove();
this.$('table tfoot').html(qweb.render("reconciliation.line.balance", {'state': state}));
// filter
if (_.str.strip(this.$('input.filter').val()) !== state.filter) {
this.$('input.filter').val(state.filter);
}
// create form
if (state.createForm) {
if (!this.fields.account_id) {
@@ -590,6 +597,12 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
var mode = this.$el.data('mode') === 'inactive' ? 'match' : 'inactive';
this.trigger_up('change_mode', {'data': mode});
},
/**
* @private
*/
_onSearchBalanceAmount: function () {
this.trigger_up('search_balance_amount');
},
/**
* @private
*/
@@ -637,13 +650,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
* @private
*/
_onPrevious: function () {
this.trigger_up('change_offset', {'data': -5});
this.trigger_up('change_offset', {'data': -1});
},
/**
* @private
*/
_onNext: function () {
this.trigger_up('change_offset', {'data': 5});
this.trigger_up('change_offset', {'data': 1});
},
/**
* @private
@@ -25,7 +25,7 @@
<div class="notification_area"/>
</div>
<div class="o_reconciliation_lines"/>
<div t-if="widget._initialState.valuemax > widget._initialState.defaultDisplayQty">
<div t-if="widget._initialState.valuemax &gt; widget._initialState.defaultDisplayQty">
<button class="btn btn-default js_load_more">Load more</button>
</div>
<div class="pull-right text-muted">Tip: Hit CTRL-Enter to reconcile all the balanced items in the sheet.</div>
@@ -129,8 +129,8 @@
<td class="cell_account_code"><t t-esc="state.balance.account_code"/></td>
<td class="cell_due_date"></td>
<td class="cell_label"><t t-if="state.balance.amount &lt; 0">Create Write-off</t><t t-elif="state.st_line.partner_id">Open balance</t><t t-else="">Choose counterpart</t></td>
<td class="cell_left"><t t-if="state.balance.amount &lt; 0"><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_right"><t t-if="state.balance.amount &gt; 0"><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_left"><t t-if="state.balance.amount &lt; 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_right"><t t-if="state.balance.amount &gt; 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_info_popover"></td>
</tr>
</t>
@@ -475,7 +475,10 @@ var session = {
var options = {
context: {
statement_ids: [4]
}
},
params: {
limitMoveLines: 5,
},
};
Datas.params = {
@@ -1051,7 +1054,7 @@ QUnit.module('account', {
var clientAction = new ReconciliationClientAction.ManualAction(null, this.params.options);
testUtils.addMockEnvironment(clientAction, {
'data': this.params.data,
data: this.params.data,
session: this.params.session,
});
clientAction.appendTo($('#qunit-fixture'));
+204 -30
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.account_test_users import AccountTestUsers
from odoo.tests.common import TransactionCase
from odoo.tools import float_compare
import time
@@ -27,6 +28,12 @@ class TestTax(AccountTestUsers):
'amount': 10,
'sequence': 3,
})
self.percent_tax_bis = self.tax_model.create({
'name': "Percent tax bis",
'amount_type': 'percent',
'amount': 21,
'sequence': 3,
})
self.division_tax = self.tax_model.create({
'name': "Division tax",
'amount_type': 'division',
@@ -67,47 +74,125 @@ class TestTax(AccountTestUsers):
self.bank_account = self.bank_journal.default_debit_account_id
self.expense_account = self.env['account.account'].search([('user_type_id.type', '=', 'payable')], limit=1) #Should be done by onchange later
def _check_compute_all_results(self, base, total_included, total_excluded, taxes, res):
self.assertAlmostEqual(res['base'], base)
self.assertAlmostEqual(res['total_included'], total_included)
self.assertAlmostEqual(res['total_excluded'], total_excluded)
for i in range(0, len(taxes)):
self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0])
self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1])
def test_tax_group_of_group_tax(self):
self.fixed_tax.include_base_amount = True
self.group_tax.include_base_amount = True
self.group_of_group_tax.include_base_amount = True
res = self.group_of_group_tax.compute_all(200.0)
self.assertEquals(res['total_excluded'], 200.0)
# After calculation of first group
# base = 210
# total_included = 231
# Base of the first grouped is passed
# Base after the second group (220) is dropped.
# Base of the group of groups is passed out,
# so we obtain base as after first group
self.assertEquals(res['base'], 210.0)
self.assertEquals(res['total_included'], 263.0)
self._check_compute_all_results(
220, # 'base'
263, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 10.0), # | 1 | 10 | | t
(210.0, 21.0), # | 3 | 10% | |
(210.0, 10.0), # | 1 | 10 | | t
(220.0, 22.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_group(self):
res = self.group_tax.compute_all(200.0)
self.assertEquals(res['total_excluded'], 200.0)
self.assertEquals(res['total_included'], 230.0)
self.assertEquals(len(res['taxes']), 2)
self.assertEquals(res['taxes'][0]['amount'], 10.0)
self.assertEquals(res['taxes'][1]['amount'], 20.0)
self._check_compute_all_results(
200, # 'base'
230, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 10.0), # | 1 | 10 | |
(200.0, 20.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_percent_division(self):
self.division_tax.price_include = True
self.division_tax.include_base_amount = True
res_division = self.division_tax.compute_all(200.0)
self._check_compute_all_results(
220, # 'base'
220, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 20.0), # | 4 | 10/ | t | t
# ---------------------------------------------------
],
res_division
)
self.percent_tax.price_include = False
self.percent_tax.include_base_amount = False
res_division = self.division_tax.compute_all(200.0)
res_percent = self.percent_tax.compute_all(200.0)
self.assertEquals(res_division['taxes'][0]['amount'], 20.0)
self.assertEquals(res_percent['taxes'][0]['amount'], 20.0)
self._check_compute_all_results(
200, # 'base'
220, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 20.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res_percent
)
self.division_tax.price_include = False
self.division_tax.include_base_amount = False
res_division = self.division_tax.compute_all(200.0)
self._check_compute_all_results(
200, # 'base'
222.22, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 22.22), # | 4 | 10/ | |
# ---------------------------------------------------
],
res_division
)
self.percent_tax.price_include = True
self.percent_tax.include_base_amount = True
res_division = self.division_tax.compute_all(200.0)
res_percent = self.percent_tax.compute_all(200.0)
self.assertEquals(res_division['taxes'][0]['amount'], 22.22)
self.assertEquals(res_percent['taxes'][0]['amount'], 18.18)
self._check_compute_all_results(
200, # 'base'
200, # 'total_included'
181.82, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(181.82, 18.18), # | 3 | 10% | t | t
# ---------------------------------------------------
],
res_percent
)
self.percent_tax_bis.price_include = True
self.percent_tax_bis.include_base_amount = True
res_percent = self.percent_tax_bis.compute_all(7.0)
self._check_compute_all_results(
7.0, # 'base'
7.0, # 'total_included'
5.79, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(5.79, 1.21), # | 3 | 21% | t | t
# ---------------------------------------------------
],
res_percent
)
def test_tax_sequence_normalized_set(self):
self.division_tax.sequence = 1
@@ -115,19 +200,53 @@ class TestTax(AccountTestUsers):
self.percent_tax.sequence = 3
taxes_set = (self.group_tax | self.division_tax)
res = taxes_set.compute_all(200.0)
self.assertEquals(res['taxes'][0]['amount'], 22.22)
self.assertEquals(res['taxes'][1]['amount'], 10.0)
self.assertEquals(res['taxes'][2]['amount'], 20.0)
self._check_compute_all_results(
200, # 'base'
252.22, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 22.22), # | 1 | 10/ | |
(200.0, 10.0), # | 2 | 10 | |
(200.0, 20.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_include_base_amount(self):
self.fixed_tax.include_base_amount = True
res = self.group_tax.compute_all(200.0)
self.assertEquals(res['total_included'], 231.0)
self._check_compute_all_results(
210, # 'base'
231, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 10.0), # | 1 | 10 | | t
(210.0, 21.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_currency(self):
self.division_tax.amount = 15.0
res = self.division_tax.compute_all(200.0, currency=self.env.ref('base.VEF'))
self.assertAlmostEqual(res['total_included'], 235.2941)
self._check_compute_all_results(
200, # 'base'
235.2941, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 35.2941), # | 4 | 15/ | |
# ---------------------------------------------------
],
res
)
def test_tax_move_lines_creation(self):
""" Test that creating a move.line with tax_ids generates the tax move lines and adjust line amount when a tax is price_include """
@@ -172,3 +291,58 @@ class TestTax(AccountTestUsers):
aml_with_taxes = move.line_ids.filtered(lambda l: set(l.tax_ids.ids) == set([self.group_tax.id, self.fixed_tax_bis.id]))
self.assertEquals(len(aml_with_taxes), 1)
self.assertEquals(aml_with_taxes.credit, 190)
def test_advanced_taxes_computation_0(self):
'''Test more advanced taxes computation (see issue 34471).'''
tax_1 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_1',
'amount_type': 'percent',
'amount': 10,
'price_include': True,
'include_base_amount': True,
'sequence': 1,
})
tax_2 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_2',
'amount_type': 'percent',
'amount': 10,
'sequence': 2,
})
tax_3 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_3',
'amount_type': 'percent',
'amount': 10,
'price_include': True,
'sequence': 3,
})
tax_4 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_4',
'amount_type': 'percent',
'amount': 10,
'sequence': 4,
})
tax_5 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_5',
'amount_type': 'percent',
'amount': 10,
'price_include': True,
'sequence': 5,
})
taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5
res = taxes.compute_all(132.0)
self._check_compute_all_results(
110, # 'base'
154, # 'total_included'
100, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(100.0, 10.0), # | 1 | 10% | t | t
(110.0, 11.0), # | 3 | 10% | |
(110.0, 11.0), # | 3 | 10% | t |
(110.0, 11.0), # | 3 | 10% | |
(110.0, 11.0), # | 3 | 10% | t |
# ---------------------------------------------------
],
res
)
@@ -12,6 +12,16 @@ from odoo.addons import decimal_precision as dp
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
@api.model
def _refund_cleanup_lines(self, lines):
result = super(AccountInvoice, self)._refund_cleanup_lines(lines)
for i, line in enumerate(lines):
for name, field in line._fields.items():
if name == 'asset_category_id':
result[i][2][name] = False
break
return result
@api.multi
def action_cancel(self):
res = super(AccountInvoice, self).action_cancel()
+6 -1
View File
@@ -6,6 +6,7 @@ import logging
import json
import werkzeug.urls
import werkzeug.utils
from werkzeug.exceptions import BadRequest
@@ -148,7 +149,11 @@ class OAuthController(http.Controller):
url = '/web#action=%s' % action
elif menu:
url = '/web#menu_id=%s' % menu
return login_and_redirect(*credentials, redirect_url=url)
resp = login_and_redirect(*credentials, redirect_url=url)
# Since /web is hardcoded, verify user has right to land on it
if werkzeug.urls.url_parse(resp.location).path == '/web' and not request.env.user.has_group('base.group_user'):
resp.location = '/'
return resp
except AttributeError:
# auth_signup is not installed
_logger.error("auth_signup not installed on database %s: oauth sign up cancelled." % (dbname,))
+15 -2
View File
@@ -597,9 +597,18 @@ class Import(models.TransientModel):
@api.multi
def _parse_import_data(self, data, import_fields, options):
""" Lauch first call to _parse_import_data_recursive with an
empty prefix. _parse_import_data_recursive will be run
recursively for each relational field.
"""
return self._parse_import_data_recursive(self.res_model, '', data, import_fields, options)
@api.multi
def _parse_import_data_recursive(self, model, prefix, data, import_fields, options):
# Get fields of type date/datetime
all_fields = self.env[self.res_model].fields_get()
all_fields = self.env[model].fields_get()
for name, field in all_fields.items():
name = prefix + name
if field['type'] in ('date', 'datetime') and name in import_fields:
# Parse date
index = import_fields.index(name)
@@ -618,7 +627,11 @@ class Import(models.TransientModel):
raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e))
except Exception as e:
raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e))
# Check if the field is in import_field and is a relational (followed by /)
# Also verify that the field name exactly match the import_field at the correct level.
elif any(name + '/' in import_field and name == import_field.split('/')[prefix.count('/')] for import_field in import_fields):
# Recursive call with the relational as new model and add the field name to the prefix
self._parse_import_data_recursive(field['relation'], name + '/', data, import_fields, options)
elif field['type'] in ('float', 'monetary') and name in import_fields:
# Parse float, sometimes float values from file have currency symbol or () to denote a negative value
# We should be able to manage both case
@@ -400,7 +400,33 @@ class test_convert_import_data(TransactionCase):
# if results empty, no errors
self.assertItemsEqual(results, [])
def test_parse_relational_fields(self):
""" Ensure that relational fields float and date are correctly
parsed during the import call.
"""
import_wizard = self.env['base_import.import'].create({
'res_model': 'res.partner',
'file': u'name,parent_id/id,parent_id/date,parent_id/credit_limit\n'
u'"foo","__export__.res_partner_1","2017年10月12日","5,69"\n'.encode('utf-8'),
'file_type': 'text/csv'
})
options = {
'date_format': '%Y年%m月%d日',
'quoting': '"',
'separator': ',',
'float_decimal_separator': ',',
'float_thousand_separator': '.',
'headers': True
}
data, import_fields = import_wizard._convert_import_data(
['name', 'parent_id/.id', 'parent_id/date', 'parent_id/credit_limit'],
options
)
result = import_wizard._parse_import_data(data, import_fields, options)
# Check if the data 5,69 as been correctly parsed.
self.assertEqual(float(result[0][-1]), 5.69)
self.assertEqual(str(result[0][-2]), '2017-10-12')
def test_filtered(self):
""" If ``False`` is provided as field mapping for a column,
+20
View File
@@ -18,6 +18,12 @@ from odoo import api, models, _
from odoo.tools.misc import ustr
from odoo.exceptions import ValidationError
_eu_country_vat = {
'GR': 'EL'
}
_eu_country_vat_inverse = {v: k for k, v in _eu_country_vat.items()}
_ref_vat = {
'at': 'ATU12345675',
'be': 'BE0477472701',
@@ -79,6 +85,7 @@ class ResPartner(models.Model):
# Foreign companies that trade with non-enterprises in the EU
# may have a VATIN starting with "EU" instead of a country code.
return True
country_code = _eu_country_vat_inverse.get(country_code, country_code)
return bool(self.env['res.country'].search([('code', '=ilike', country_code)]))
return check_func(vat_number)
@@ -96,6 +103,19 @@ class ResPartner(models.Model):
# country code or empty VAT number), so we fall back to the simple check.
return self.simple_vat_check(country_code, vat_number)
@api.model
def fix_eu_vat_number(self, country_id, vat):
europe = self.env.ref('base.europe')
country = self.env["res.country"].browse(country_id)
if not europe:
europe = self.env["res.country.group"].search([('name', '=', 'Europe')], limit=1)
if europe and country and country.id in europe.country_ids.ids:
vat = re.sub('[^A-Za-z0-9]', '', vat).upper()
country_code = _eu_country_vat.get(country.code, country.code).upper()
if vat[:2] != country_code:
vat = country_code + vat
return vat
@api.constrains('vat', 'commercial_partner_country_id')
def check_vat(self):
if self.env.context.get('company_id'):
+8 -2
View File
@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo.addons.mail.controllers.main import MailController
from odoo import http
_logger = logging.getLogger(__name__)
class CrmController(http.Controller):
@@ -12,8 +15,9 @@ class CrmController(http.Controller):
comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token)
if comparison and record:
try:
record.case_mark_won()
record.action_set_won()
except Exception:
_logger.exception("Could not mark crm.lead as won")
return MailController._redirect_to_messaging()
return redirect
@@ -22,8 +26,9 @@ class CrmController(http.Controller):
comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token)
if comparison and record:
try:
record.case_mark_lost()
record.action_set_lost()
except Exception:
_logger.exception("Could not mark crm.lead as lost")
return MailController._redirect_to_messaging()
return redirect
@@ -34,5 +39,6 @@ class CrmController(http.Controller):
try:
record.convert_opportunity(record.partner_id.id)
except Exception:
_logger.exception("Could not convert crm.lead to opportunity")
return MailController._redirect_to_messaging()
return redirect
+8
View File
@@ -267,6 +267,14 @@ class Lead(models.Model):
# ORM override (CRUD, fields_view_get, ...)
# ----------------------------------------
@api.model
def name_create(self, name):
res = super(Lead, self).name_create(name)
# update the probability of the lead if the stage is set to update it automatically
self.browse(res[0])._onchange_stage_id()
return res
@api.model
def create(self, vals):
# set up context used to find the lead's sales channel which is needed
@@ -116,17 +116,10 @@
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
<group expand="0" string="Extended Filters">
<field name="partner_id" filter_domain="[('partner_id','child_of',self)]"/>
<field name="stage_id" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/>
<field name="campaign_id"/>
<field name="medium_id"/>
<field name="source_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<separator orientation="vertical"/>
<field name="stage_id" widget="selection" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/>
<field name="campaign_id" widget="selection"/>
<field name="medium_id" widget="selection"/>
<field name="source_id" widget="selection"/>
<separator orientation="vertical"/>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
<newline/>
<field name="create_date"/>
+1 -1
View File
@@ -43,7 +43,7 @@
<p class="oe_mt32 oe_mb32 text-justify">
Automate activities based on your sales scripts: calls, meetings, mails and quotations. Get all information you need, right on the opportunity: page viewed, mails received, etc.
</p>
<img src="crm_sc_02b.png">
<img class='img-responsive' src="crm_sc_02b.png">
</div>
</div>
</section>
+3 -3
View File
@@ -384,7 +384,7 @@
domain="[('activity_ids.user_id', '=', uid)]"/>
<separator/>
<filter string="Late Activities" name="activities_overdue"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all records which has next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
@@ -629,7 +629,7 @@
domain="[('activity_ids.user_id', '=', uid)]"/>
<separator/>
<filter string="Late Activities" name="activities_overdue"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all opportunities for which the next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
@@ -639,7 +639,7 @@
<separator/>
<filter string="Overdue Opportunities" name="overdue_opp"
domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all opportunities with a deadline for which the next action date is before today"/>
<separator/>
<filter string="Lost" domain="[('active', '=', False)]"/>
@@ -42,6 +42,7 @@ class TimesheetAttendance(models.Model):
ts.unit_amount AS timesheet,
date_trunc('day', ts.date) AS date
FROM account_analytic_line AS ts
WHERE ts.project_id IS NOT NULL
) AS t
GROUP BY t.user_id, t.date
ORDER BY t.date
@@ -29,6 +29,7 @@
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('hr_timesheet.hr_timesheet_line_tree')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('hr_timesheet.hr_timesheet_line_form')})]"/>
<field name="domain">[('project_id','!=',False)]</field>
</record>
<record id="hr_timesheet_sheet_form" model="ir.ui.view">
+3 -3
View File
@@ -18,9 +18,9 @@ _logger = logging.getLogger(__name__)
# Those are the builtin raspberry pi USB modules, they should
# not appear in the list of connected devices.
BANNED_DEVICES = {
"0424:9514", # Standard Microsystem Corp. Builtin Ethernet module
"1d6b:0002", # Linux Foundation 2.0 root hub
"0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module
"0424:9514", # Standard Microsystem Corp. Builtin Ethernet module
"1d6b:0002", # Linux Foundation 2.0 root hub
"0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module
}
+2 -1
View File
@@ -71,6 +71,7 @@
<field name="model_id" ref="mail.model_mail_message"/>
<field name="auto_delete" eval="True"/>
<field name="body_html"><![CDATA[
% set company = ctx.get('company', user.company_id)
<div>
% if ctx.get('has_button_access'):
<div itemscope itemtype="http://schema.org/EmailMessage">
@@ -110,7 +111,7 @@
% endif
</td>
<td valign="center" align="right">
<img src="/logo.png" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${user.company_id.name}">
<img src="/logo.png?company=${company.id}" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${company.name}">
</td>
</tr>
<tr>
+1 -1
View File
@@ -251,7 +251,7 @@ class Channel(models.Model):
author = self.env['res.partner'].browse(message_dict.get('author_id', False))
if not author or author not in self.channel_partner_ids:
return {
'error_mesage': _('restricted to channel members'),
'error_message': _('restricted to channel members'),
}
return True
return super(Channel, self)._alias_check_contact(message, message_dict, alias)
+6 -4
View File
@@ -829,11 +829,13 @@ class MailThread(models.AbstractModel):
def _routing_warn(self, error_message, warn_suffix, message_id, route, raise_exception):
""" Tools method used in message_route_verify: whether to log a warning or raise an error """
full_message = _('Routing mail with Message-Id %s: route %s: %s') % (message_id, route, error_message)
short_message = _("Mailbox unavailable - %s") % error_message
full_message = ('Routing mail with Message-Id %s: route %s: %s' %
(message_id, route, error_message))
_logger.info(full_message + (warn_suffix and '; %s' % warn_suffix or ''))
if raise_exception:
raise ValueError(full_message)
else:
_logger.info(full_message + warn_suffix and '; %s' % warn_suffix or '')
# sender should not see private diagnostics info, just the error
raise ValueError(short_message)
def _routing_create_bounce_email(self, email_from, body_html, message):
bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from
+6 -1
View File
@@ -54,7 +54,6 @@ class Partner(models.Model):
website_url = 'http://%s' % user.company_id.website if not user.company_id.website.lower().startswith(('http:', 'https:')) else user.company_id.website
else:
website_url = False
company_name = user.company_id.name
model_name = False
if message.model:
@@ -74,9 +73,15 @@ class Partner(models.Model):
if message.res_id and message.model in self.env:
record = self.env[message.model].browse(message.res_id)
company = user.company_id;
if record and hasattr(record, 'company_id'):
company = record.company_id;
company_name = company.name;
return {
'signature': signature,
'website_url': website_url,
'company': company,
'company_name': company_name,
'model_name': model_name,
'record': record,
@@ -28,6 +28,9 @@
<field name="body_html" widget="html" readonly="1"
nolabel="1" options='{"safe": True}'/>
<field name="attachment_ids" widget="many2many_binary" readonly="1"/>
<footer>
<button string="Discard" class="btn-default" special="cancel"/>
</footer>
</form>
</field>
</record>
+2 -2
View File
@@ -17,10 +17,10 @@ class MassMailController(http.Controller):
if mailing.exists():
res_id = res_id and int(res_id)
res_ids = []
if mailing.mailing_model_name == 'mail.mass_mailing.contact':
if mailing.mailing_model_name == 'mail.mass_mailing.list':
contacts = request.env['mail.mass_mailing.contact'].sudo().search([
('email', '=', email),
('list_id', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids])
('list_ids', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids])
])
res_ids = contacts.ids
else:
@@ -404,4 +404,15 @@ odoo_top[callback+"_updown"] = function (value, fields_values, field_name) {
if ($editable_area.html().indexOf('on_change_model_and_list') !== -1) {
$editable_area.empty();
}
// Adding compatibility for the outlook compliance of mailings.
// Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42
options.registry.background.include({
start: function() {
this._super();
var $table_target = this.$target.find('table:first');
if ($table_target) {
this.$target = $table_target;
}
}
});
});
+10 -3
View File
@@ -53,9 +53,16 @@ class StripeController(http.Controller):
""" Create a payment transaction
Expects the result from the user input from checkout.js popup"""
tx = request.env['payment.transaction'].sudo().browse(
int(request.session.get('sale_transaction_id') or request.session.get('website_payment_tx_id', False))
)
TX = request.env['payment.transaction']
tx = None
if post.get('tx_ref'):
tx = TX.sudo().search([('reference', '=', post['tx_ref'])])
if not tx:
tx_id = (post.get('tx_id') or request.session.get('sale_transaction_id') or
request.session.get('website_payment_tx_id'))
tx = TX.sudo().browse(int(tx_id))
if not tx:
raise werkzeug.exceptions.NotFound()
response = tx._create_stripe_charge(tokenid=post['tokenid'], email=post['email'])
_logger.info('Stripe: entering form_feedback with post data %s', pprint.pformat(response))
if response:
+26 -15
View File
@@ -43,13 +43,13 @@ class PaymentAcquirerStripe(models.Model):
'amount': tx_values.get('amount'),
'currency': tx_values.get('currency') and tx_values.get('currency').name or '',
'currency_id': tx_values.get('currency') and tx_values.get('currency').id or '',
'address_line1': tx_values['partner_address'],
'address_city': tx_values['partner_city'],
'address_country': tx_values['partner_country'] and tx_values['partner_country'].name or '',
'email': tx_values['partner_email'],
'address_zip': tx_values['partner_zip'],
'name': tx_values['partner_name'],
'phone': tx_values['partner_phone'],
'address_line1': tx_values.get('partner_address'),
'address_city': tx_values.get('partner_city'),
'address_country': tx_values.get('partner_country') and tx_values['partner_country'].name or '',
'email': tx_values.get('partner_email'),
'address_zip': tx_values.get('partner_zip'),
'name': tx_values.get('partner_name'),
'phone': tx_values.get('partner_phone'),
}
temp_stripe_tx_values['returndata'] = stripe_tx_values.pop('return_url', '')
@@ -107,14 +107,15 @@ class PaymentTransactionStripe(models.Model):
charge_params = {
'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else self.amount*100),
'currency': self.currency_id.name,
'metadata[reference]': self.reference
'metadata[reference]': self.reference,
'description': self.reference,
}
if acquirer_ref:
charge_params['customer'] = acquirer_ref
if tokenid:
charge_params['card'] = str(tokenid)
if email:
charge_params['receipt_email'] = email
charge_params['receipt_email'] = email.strip()
r = requests.post(api_url_charge,
auth=(self.acquirer_id.stripe_secret_key, ''),
params=charge_params,
@@ -156,12 +157,17 @@ class PaymentTransactionStripe(models.Model):
transaction record. """
reference = data.get('metadata', {}).get('reference')
if not reference:
error_msg = _(
'Stripe: invalid reply received from provider, missing reference. Additional message: %s'
% data.get('error', {}).get('message', '')
)
_logger.error(error_msg)
stripe_error = data.get('error', {}).get('message', '')
_logger.error('Stripe: invalid reply received from stripe API, looks like '
'the transaction failed. (error: %s)', stripe_error or 'n/a')
error_msg = _("We're sorry to report that the transaction has failed.")
if stripe_error:
error_msg += " " + (_("Stripe gave us the following info about the problem: '%s'") %
stripe_error)
error_msg += " " + _("Perhaps the problem can be solved by double-checking your "
"credit card details, or contacting your bank?")
raise ValidationError(error_msg)
tx = self.search([('reference', '=', reference)])
if not tx:
error_msg = (_('Stripe: no order found for reference %s') % reference)
@@ -231,6 +237,7 @@ class PaymentTokenStripe(models.Model):
'card[exp_month]': str(values['cc_expiry'][:2]),
'card[exp_year]': str(values['cc_expiry'][-2:]),
'card[cvc]': values['cvc'],
'card[name]': values['cc_holder_name'],
}
r = requests.post(url_token,
auth=(payment_acquirer.stripe_secret_key, ''),
@@ -239,8 +246,12 @@ class PaymentTokenStripe(models.Model):
token = r.json()
if token.get('id'):
customer_params = {
'source': token['id']
'source': token['id'],
'description': values['cc_holder_name']
}
if values.get('partner_id'):
partner = self.env['res.partner'].browse(values['partner_id'])
customer_params['email'] = partner.email and partner.email.strip()
r = requests.post(url_customer,
auth=(payment_acquirer.stripe_secret_key, ''),
params=customer_params,
+101 -110
View File
@@ -14,120 +14,111 @@ odoo.define('payment_stripe.stripe', function(require) {
'RWF', 'KRW', 'VUV', 'VND', 'XOF'
];
function getStripeHandler(parentElement)
{
var handler = StripeCheckout.configure({
key: $("input[name='stripe_key']", parentElement).val(),
image: $("input[name='stripe_image']", parentElement).val(),
locale: 'auto',
token: function(token, args) {
handler.isTokenGenerate = true;
ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
tokenid: token.id,
email: token.email,
amount: $("input[name='amount']", parentElement).val(),
acquirer_id: $("#acquirer_stripe", parentElement).val(),
currency: $("input[name='currency']", parentElement).val(),
invoice_num: $("input[name='invoice_num']", parentElement).val(),
return_url: $("input[name='return_url']", parentElement).val()
}).done(function(data){
handler.isTokenGenerate = false;
window.location.href = data;
}).fail(function(){
var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.message;
var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
wizard.appendTo($('body')).modal({'keyboard': true});
});
},
});
return handler;
if ($.blockUI) {
// our message needs to appear above the modal dialog
$.blockUI.defaults.baseZ = 2147483647; //same z-index as StripeCheckout
$.blockUI.defaults.css.border = '0';
$.blockUI.defaults.css["background-color"] = '';
$.blockUI.defaults.overlayCSS["opacity"] = '0.9';
}
$(document).ready(function (){
if (!$('.o_payment_form').length) {
return $.Deferred().reject("DOM doesn't contain '.o_payment_form'");
}
$('#o_payment_form_pay').on('click', function(ev){
// we retrieve the payment form
var parent_form = ev.target.form;
// then the checked radio
var checked_radio = $('input[type="radio"]:checked', parent_form);
// check if there's one checked radio
if(checked_radio.length != 1) {
return;
}
// if there's a checked radio, we retrieve the usefull data
var acquirer_id = checked_radio.data('acquirerId');
var provider = checked_radio.data('provider');
var is_form_payment = checked_radio.data('form-payment') === "True";
// now we check if the user has clicked on stripe radio button and wants to pay via the checkout form
if(provider != "stripe" || is_form_payment !== true) {
return;
}
// from here we retrieve the inputs that contains the data we need
var provider_form = $("#o_payment_form_acq_" + acquirer_id, parent_form);
var so_id = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)/) || undefined;
var access_token = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)\/([0-9a-zA-Z\-]+)/) || undefined;
if (so_id) {
so_id = parseInt(so_id[1]);
}
if(access_token) {
access_token = access_token[2];
}
if ($('.o_website_payment').length !== 0) {
var currency = $("input[name='currency']", provider_form).val();
var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0');
if (!_.contains(int_currencies, currency)) {
amount = amount*100;
}
ajax.jsonRpc('/website_payment/transaction', 'call', {
reference: $("input[name='invoice_num']", provider_form).val(),
amount: amount,
currency_id: currency,
acquirer_id: acquirer_id
})
var handler = getStripeHandler(provider_form);
handler.open({
name: $("input[name='merchant']", provider_form).val(),
description: $("input[name='invoice_num']", provider_form).val(),
currency: currency,
amount: amount,
});
} else {
var currency = $("input[name='currency']", provider_form).val();
var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0');
if (!_.contains(int_currencies, currency)) {
amount = amount*100;
}
var url = '/shop/payment/transaction/';
if(so_id) {
url += so_id + '/';
if(access_token) {
url += access_token;
}
}
ajax.jsonRpc(url, 'call', {
acquirer_id: acquirer_id
}, {'async': false}).then(function (data) {
try {
provider_form.html(data);
}
catch(err) { } // here it will catch an error saying that payment_stripe.stripe is already started
var handler = getStripeHandler(provider_form);
handler.open({
name: $("input[name='merchant']", provider_form).val(),
description: $("input[name='invoice_num']", provider_form).val(),
currency: currency,
amount: amount,
});
var handler = StripeCheckout.configure({
key: $("input[name='stripe_key']").val(),
image: $("input[name='stripe_image']").val(),
locale: 'auto',
token: function(token, args) {
handler.isTokenGenerate = true;
if ($.blockUI) {
var msg = _t("Just one more second, confirming your payment...");
$.blockUI({
'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' +
' <br />' + msg +
'</h2>'
});
}
ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
tokenid: token.id,
email: token.email,
amount: $("input[name='amount']").val(),
acquirer_id: $("#acquirer_stripe").val(),
currency: $("input[name='currency']").val(),
invoice_num: $("input[name='invoice_num']").val(),
tx_ref: $("input[name='invoice_num']").val(),
return_url: $("input[name='return_url']").val()
}).always(function(){
if ($.blockUI) {
$.unblockUI();
}
}).done(function(data){
handler.isTokenGenerate = false;
window.location.href = data;
}).fail(function(){
var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.arguments && arguments[1].data.arguments[0];
var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
wizard.appendTo($('body')).modal({'keyboard': true});
});
},
});
$('#pay_stripe').on('click', function(e) {
// Open Checkout with further options
if(!$(this).find('i').length)
$(this).append('<i class="fa fa-spinner fa-spin"/>');
$(this).attr('disabled','disabled');
var $form = $(e.currentTarget).parents('form');
var acquirer_id = $(e.currentTarget).closest('div.oe_sale_acquirer_button,div.oe_quote_acquirer_button,div.o_website_payment_new_payment');
acquirer_id = acquirer_id.data('id') || acquirer_id.data('acquirer_id');
if (! acquirer_id) {
return false;
}
var so_token = $("input[name='token']").val();
var so_id = $("input[name='return_url']").val().match(/quote\/([0-9]+)/) || undefined;
if (so_id) {
so_id = parseInt(so_id[1]);
}
e.preventDefault();
var currency = $("input[name='currency']").val();
var currency_id = $("input[name='currency_id']").val();
var amount = parseFloat($("input[name='amount']").val() || '0.0');
if ($('.o_website_payment').length !== 0) {
var create_tx = ajax.jsonRpc('/website_payment/transaction', 'call', {
reference: $("input[name='invoice_num']").val(),
amount: amount, // exact amount, not stripe cents
currency_id: currency_id,
acquirer_id: acquirer_id
});
}
else if ($('.o_website_quote').length !== 0) {
var url = _.str.sprintf("/quote/%s/transaction/%s/%s", so_id, acquirer_id, so_token);
var create_tx = ajax.jsonRpc(url, 'call', {}).then(function (data) {
try { $form.html(data); } catch (e) {};
});
}
else {
var create_tx = ajax.jsonRpc('/shop/payment/transaction/' + acquirer_id, 'call', {
so_id: so_id,
so_token: so_token
}).then(function (data) {
try { $form.html(data); } catch (e) {};
});
}
create_tx.done(function () {
if (!_.contains(int_currencies, currency)) {
amount = amount*100;
}
handler.open({
name: $("input[name='merchant']").val(),
description: $("input[name='invoice_num']").val(),
email: $("input[name='email']").val(),
currency: currency,
amount: amount,
});
});
});
});
+2 -2
View File
@@ -223,7 +223,7 @@ class PosOrder(models.Model):
# We convert a new id object back to a dictionary to write to
# bridge between old and new api
inv_line = invoice_line._convert_to_write({name: invoice_line[name] for name in invoice_line._cache})
inv_line.update(price_unit=line.price_unit, discount=line.discount)
inv_line.update(price_unit=line.price_unit, discount=line.discount, name=inv_name)
return InvoiceLine.sudo().create(inv_line)
def _create_account_move_line(self, session=None, move=None):
@@ -650,7 +650,7 @@ class PosOrder(models.Model):
return_picking = Picking.create(return_vals)
return_picking.message_post(body=message)
for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_digits=l.product_id.uom_id.rounding)):
for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_rounding=l.product_id.uom_id.rounding)):
moves |= Move.create({
'name': line.name,
'product_uom': line.product_id.uom_id.id,
+110 -45
View File
@@ -205,7 +205,7 @@ exports.PosModel = Backbone.Model.extend({
},
},{
model: 'account.tax',
fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'],
fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids', 'sequence'],
domain: null,
loaded: function(self, taxes){
self.taxes = taxes;
@@ -1645,72 +1645,137 @@ exports.Orderline = Backbone.Model.extend({
return tax;
},
_compute_all: function(tax, base_amount, quantity) {
/**
* Mirror JS method of:
* _compute_amount in addons/account/models/account.py
*/
_compute_all: function(tax, base_amount, quantity, price_include) {
if(price_include === undefined)
price_include = tax.price_include;
if (tax.amount_type === 'fixed') {
var sign_base_amount = base_amount >= 0 ? 1 : -1;
return (Math.abs(tax.amount) * sign_base_amount) * quantity;
}
if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type === 'division' && tax.price_include)){
if ((tax.amount_type === 'percent' && !price_include) || (tax.amount_type === 'division' && tax.price_include)){
return base_amount * tax.amount / 100;
}
if (tax.amount_type === 'percent' && tax.price_include){
if (tax.amount_type === 'percent' && price_include){
return base_amount - (base_amount / (1 + tax.amount / 100));
}
if (tax.amount_type === 'division' && !tax.price_include) {
if (tax.amount_type === 'division' && !price_include) {
return base_amount / (1 - tax.amount / 100) - base_amount;
}
return false;
},
/**
* Mirror JS method of:
* compute_all in addons/account/models/account.py
*
* Read comments in the python side method for more details about each sub-methods.
*/
compute_all: function(taxes, price_unit, quantity, currency_rounding, no_map_tax) {
var self = this;
var list_taxes = [];
// 1) Flatten the taxes.
var _collect_taxes = function(taxes, all_taxes){
taxes.sort(function (tax1, tax2) {
return tax1.sequence - tax2.sequence;
});
_(taxes).each(function(tax){
if(tax.amount_type === 'group')
all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes);
else
all_taxes.push(tax);
});
return all_taxes;
}
var collect_taxes = function(taxes){
return _collect_taxes(taxes, []);
}
taxes = collect_taxes(taxes);
// 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True
// with price_include=True
var base_excluded_flag = false; // price_include=False && include_base_amount=True
var included_flag = false; // price_include=True
_(taxes).each(function(tax){
if(tax.price_include)
included_flag = true;
else if(tax.include_base_amount)
base_excluded_flag = true
if(base_excluded_flag && included_flag)
throw new Error('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.');
});
// 3) Deal with the rounding methods
var currency_rounding_bak = currency_rounding;
if (this.pos.company.tax_calculation_rounding_method == "round_globally"){
currency_rounding = currency_rounding * 0.00001;
}
var total_excluded = round_pr(price_unit * quantity, currency_rounding);
var total_included = total_excluded;
var base = total_excluded;
_(taxes).each(function(tax) {
if (!no_map_tax){
tax = self._map_tax_fiscal_position(tax);
}
if (!tax){
return;
}
if (tax.amount_type === 'group'){
var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity, currency_rounding);
total_excluded = ret.total_excluded;
base = ret.total_excluded;
total_included = ret.total_included;
list_taxes = list_taxes.concat(ret.taxes);
}
else {
var tax_amount = self._compute_all(tax, base, quantity);
tax_amount = round_pr(tax_amount, currency_rounding);
if (tax_amount){
if (tax.price_include) {
total_excluded -= tax_amount;
base -= tax_amount;
}
else {
total_included += tax_amount;
}
if (tax.include_base_amount) {
base += tax_amount;
}
var data = {
id: tax.id,
amount: tax_amount,
name: tax.name,
};
list_taxes.push(data);
}
var recompute_base = function(base_amount, fixed_amount, percent_amount){
if(fixed_amount === 0.0 && percent_amount === 0.0)
return base_amount;
return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0);
}
// 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
var base = round_pr(price_unit * quantity, currency_rounding);
var sign = 1;
if(base < 0){
base = -base;
var sign = -1;
}
var incl_fixed_amount = 0.0;
var incl_percent_amount = 0.0;
_(taxes.reverse()).each(function(tax){
if(tax.include_base_amount){
base = recompute_base(base, incl_fixed_amount, incl_percent_amount);
incl_fixed_amount = 0.0;
incl_percent_amount = 0.0;
}
if(tax.price_include){
if(tax.amount_type === 'fixed')
incl_fixed_amount += tax.amount;
else if(tax.amount_type === 'percent')
incl_percent_amount += tax.amount;
}
});
base = recompute_base(base, incl_fixed_amount, incl_percent_amount);
var total_excluded = base;
var total_included = base;
// 5) Iterate the taxes in the sequence order to fill missing base/amount values.
var taxes_vals = [];
_(taxes.reverse()).each(function(tax){
var tax_amount = self._compute_all(tax, base, quantity, false);
tax_amount = round_pr(tax_amount, currency_rounding);
var tax_base = base;
if(tax.include_base_amount)
base += tax_amount;
total_included += tax_amount;
var tax_vals = {
id: tax.id,
amount: sign * tax_amount,
name: tax.name,
};
taxes_vals.push(tax_vals);
});
return {
taxes: list_taxes,
taxes: taxes_vals,
total_excluded: round_pr(total_excluded, currency_rounding_bak),
total_included: round_pr(total_included, currency_rounding_bak)
};
+2 -1
View File
@@ -283,7 +283,8 @@ class ProductProduct(models.Model):
for value in product.attribute_value_ids:
if value.attribute_id in attributes:
raise ValidationError(_('Error! It is not allowed to choose more than one value for a given attribute.'))
attributes |= value.attribute_id
if value.attribute_id.create_variant:
attributes |= value.attribute_id
return True
@api.onchange('uom_id', 'uom_po_id')
+1 -1
View File
@@ -721,7 +721,7 @@ class ResourceCalendarLeaves(models.Model):
date_from = fields.Datetime('Start Date', required=True)
date_to = fields.Datetime('End Date', required=True)
tz = fields.Selection(
_tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz),
_tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz or 'UTC'),
help="Timezone used when encoding the leave. It is used to correctly"
"localize leave hours when computing time intervals.")
resource_id = fields.Many2one(
+2 -2
View File
@@ -20,6 +20,6 @@ def uninstall_hook(cr, registry):
if 'crm.team' in env:
recs = env['crm.team'].search([])
for rec in recs:
rec._onchange_team_type()
rec._onchange_team_type()
cr.after("commit", partial(update_dashboard_graph_model, cr.dbname))
cr.after("commit", partial(update_dashboard_graph_model, cr.dbname))
+4 -4
View File
@@ -72,13 +72,13 @@ from ${object.company_id.name}.
% set record = ctx.get('record')
% set company = record and record.company_id or user.company_id
<body style="margin: 0; padding: 0;">
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification">
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size:20px; color:white; font-weight: bold;">
@@ -96,7 +96,7 @@ from ${object.company_id.name}.
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;">
<tbody>
<td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;">
${object.body | safe}
@@ -109,7 +109,7 @@ from ${object.company_id.name}.
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;">
${company.name}<br/>
+2 -1
View File
@@ -973,7 +973,8 @@ class SaleOrderLine(models.Model):
return price
else:
from_currency = self.order_id.company_id.currency_id
return from_currency.compute(product.lst_price, self.order_id.pricelist_id.currency_id)
product_price = product[pricelist_item.base] if (pricelist_item and pricelist_item.base != 'pricelist') else product.lst_price
return from_currency.compute(product_price, self.order_id.pricelist_id.currency_id)
@api.multi
@api.onchange('product_id')
+7 -5
View File
@@ -138,11 +138,13 @@ class SaleOrderLine(models.Model):
@api.multi
@api.depends('product_id')
def _compute_qty_delivered_updateable(self):
for line in self:
if line.product_id.type not in ('consu', 'product'):
super(SaleOrderLine, line)._compute_qty_delivered_updateable()
else:
line.qty_delivered_updateable = False
# prefetch field before filtering
self.mapped('product_id')
# on consumable or stockable products, qty_delivered_updateable defaults
# to False; on other lines use the original computation
lines = self.filtered(lambda line: line.product_id.type not in ('consu', 'product'))
lines = lines.with_prefetch(self._prefetch)
super(SaleOrderLine, lines)._compute_qty_delivered_updateable()
@api.onchange('product_id')
def _onchange_product_id_set_customer_lead(self):
+10 -1
View File
@@ -212,6 +212,9 @@ var loadJS = (function () {
* @param {Function} [options.success] callback in case of download success
* @param {Function} [options.error] callback in case of request error, provided with the error body
* @param {Function} [options.complete] called after both ``success`` and ``error`` callbacks have executed
* @returns {boolean} a false value means that a popup window was blocked. This
* mean that we probably need to inform the user that something needs to be
* changed to make it work.
*/
function get_file(options) {
// need to detect when the file is done downloading (not used
@@ -232,7 +235,12 @@ function get_file(options) {
var url = options.session.url(options.url, params);
if (options.complete) { options.complete(); }
return window.open(url);
var w = window.open(url);
if (!w || w.closed || typeof w.closed === 'undefined') {
// popup was blocked
return false;
}
return true;
}
var $form, $form_data = $('<div>');
@@ -309,6 +317,7 @@ function get_file(options) {
}
};
timer = setTimeout(waitLoop, CHECK_INTERVAL);
return true;
};
function post (controller_url, data) {
+1 -1
View File
@@ -278,7 +278,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
options.data.session_id = this.session_id;
}
options.session = this;
ajax.get_file(options);
return ajax.get_file(options);
},
/**
* (re)loads the content of a session: db name, username, user id, session
@@ -394,7 +394,7 @@ var LinkButton = AbstractField.extend({
var FieldDate = InputField.extend({
className: "o_field_date",
tagName: "span",
supportedFieldTypes: ['date'],
supportedFieldTypes: ['date', 'datetime'],
/**
* @override
@@ -453,12 +453,13 @@ var FieldDate = InputField.extend({
* @override
* @private
* @param {Moment|false} value
* @returns {boolean}
*/
_isSameValue: function (value) {
if (value === false) {
return this.value === false;
}
return value.isSame(this.value);
return value.isSame(this.value, 'day');
},
/**
* Instantiates a new DateWidget datepicker.
@@ -497,7 +498,16 @@ var FieldDateTime = FieldDate.extend({
var value = this.datewidget.getValue();
return value && value.add(-this.getSession().getTZOffset(value), 'minutes');
},
/**
* @override
* @private
*/
_isSameValue: function (value) {
if (value === false) {
return this.value === false;
}
return value.isSame(this.value);
},
/**
* Instantiates a new DateTimeWidget datepicker rather than DateWidget.
*
@@ -1171,7 +1181,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
supportedFieldTypes: ['binary'],
_render: function () {
var self = this;
var attrs = this.attrs;
var url = this.placeholder;
if (this.value) {
if (!utils.is_bin_size(this.value)) {
@@ -1186,11 +1195,7 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
});
}
}
var $img = $('<img>').attr('src', url);
$img.css({
width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width,
height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height,
});
var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url}));
this.$('> img').remove();
this.$el.prepend($img);
$img.on('error', function () {
@@ -2174,6 +2179,15 @@ var FieldDomain = AbstractField.extend({
// Public
//--------------------------------------------------------------------------
/**
* A domain field is always set since the false value is considered to be
* equal to "[]" (match all records).
*
* @override
*/
isSet: function () {
return true;
},
/**
* @override isValid from AbstractField.isValid
* Parsing the char value is not enough for this field. It is considered
@@ -21,7 +21,7 @@ var WKHTMLTOPDF_MESSAGES = {
};
var trigger_download = function (session, response, c, action, options) {
session.get_file({
return session.get_file({
url: '/report/download',
data: {data: JSON.stringify(response)},
complete: framework.unblockUI,
@@ -107,7 +107,10 @@ ActionManager.include({
report_urls['qweb-pdf'],
action.report_type, //The 'root' report is considered the maine one, so we use its type for all the others.
];
trigger_download(self.getSession(), response, c, current_action, options);
var success = trigger_download(self.getSession(), response, c, current_action, options);
if (!success) {
self.do_warn(_t('Warning'), _t('A popup window with your report was blocked. You may need to change your browser settings to allow popup windows for this page.'), true);
}
treated_actions.push(current_action);
current_action = current_action.next_report_to_generate;
@@ -875,7 +875,10 @@ var BasicModel = AbstractModel.extend({
// id never changes, and should not be written
delete record._changes.id;
}
var changes = self._generateChanges(record, {viewType: options.viewType});
var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'});
// id field should never be written/changed
delete changes.id;
if (method === 'create') {
var fieldNames = record.getFieldNames();
@@ -1551,6 +1554,9 @@ var BasicModel = AbstractModel.extend({
removedDef = this._applyX2ManyChange(record, fieldName, {
operation: 'DELETE',
ids: _.map(removedIds, function (resID) {
if (resID in list._cache) {
return list._cache[resID];
}
return _.findWhere(listData, {res_id: resID}).id;
}),
});
@@ -2455,7 +2461,7 @@ var BasicModel = AbstractModel.extend({
changes = _.extend({}, record.data, record._changes);
} else {
changes = _.extend({}, record._changes);
};
}
var withReadonly = options.withReadonly || false;
var commands = this._generateX2ManyCommands(record, {
changesOnly: 'changesOnly' in options ? options.changesOnly : true,
@@ -2492,6 +2498,7 @@ var BasicModel = AbstractModel.extend({
changes[fieldName] = false;
}
}
return changes;
},
/**
@@ -2608,6 +2615,7 @@ var BasicModel = AbstractModel.extend({
_.each(relRecordUpdated, function (relRecord) {
var changes = self._generateChanges(relRecord, options);
if (!_.isEmpty(changes)) {
delete changes.id;
var command = x2ManyCommands.update(relRecord.res_id, changes);
commands[fieldName].push(command);
}
@@ -2638,7 +2646,12 @@ var BasicModel = AbstractModel.extend({
// this is a new id
relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]});
changes = this._generateChanges(relRecord, options);
commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
if ('id' in changes) {
delete changes.id;
commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes));
} else {
commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
}
}
}
if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) {
@@ -3294,11 +3307,17 @@ var BasicModel = AbstractModel.extend({
* This method is quite important: it is supposed to perform the /onchange
* rpc and apply the result.
*
* The changes that triggered the onchange are assumed to have already been
* applied to the record.
*
* @param {Object} record
* @param {string[]} fields changed fields
* @param {string} [viewType] current viewType. If not set, we will assume
* main viewType from the record
* @returns {Deferred}
* @returns {Deferred} Note: this deferred cannot fail. It is either in the
* 'success' or 'pending' state. If the onchange rpc fails, it will be
* resolved with an empty dictionary. Note that the initial change was
* already applied.
*/
_performOnChange: function (record, fields, viewType) {
var self = this;
@@ -3318,7 +3337,9 @@ var BasicModel = AbstractModel.extend({
var context = this._getContext(record, options);
var currentData = this._generateOnChangeData(record, {changesOnly: false});
return self._rpc({
var def = $.Deferred();
self._rpc({
model: record.model,
method: 'onchange',
args: [idList, currentData, fields, onchangeSpec, context],
@@ -3343,9 +3364,12 @@ var BasicModel = AbstractModel.extend({
record._domains = _.extend(record._domains, result.domain);
}
return self._applyOnChange(result.value, record).then(function () {
return result;
def.resolve(result);
});
}).fail(function () {
def.resolve({});
});
return def;
},
/**
* Once a record is created and some data has been fetched, we need to do
@@ -807,7 +807,15 @@ var FormRenderer = BasicRenderer.extend({
// Attach the tooltips on the fields' label
_.each(this.allFieldWidgets[this.state.id], function (widget) {
var idForLabel = self.idsForLabels[widget.name];
// We usually don't support multiple widgets for the same field on the
// same view but it is the case with the new settings view on V11.0.
// Therefore, we need to retrieve the correct label since it could be
// displayed multiple times on the view, otherwise, for example the
// enterprise label will be displayed as many times as the field
// exists on settings.
var $widgets = self.$('.o_field_widget[name=' + widget.name + ']');
var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
$label = $label.eq($widgets.index(widget.$el));
if (config.debug || widget.attrs.help || widget.field.help) {
self._addFieldTooltip(widget, $label);
}
+2
View File
@@ -52,6 +52,8 @@
.modal-body {
overflow: auto;
// fix iOS issue https://github.com/scottjehl/Device-Bugs/issues/8
-webkit-transform: translate3d(0, 0, 0);
min-height: 0;
&.o_dialog_error {
+2 -2
View File
@@ -899,8 +899,8 @@
t-att-src='url'
t-att-border="widget.readonly ? 0 : 1"
t-att-name="widget.name"
t-att-width="widget.node.attrs.img_width || widget.node.attrs.width"
t-att-height="widget.node.attrs.img_height || widget.node.attrs.height"/>
t-att-width="widget.nodeOptions.size ? widget.nodeOptions.size[0] : widget.attrs.img_width || widget.attrs.width"
t-att-height="widget.nodeOptions.size ? widget.nodeOptions.size[1] : widget.attrs.img_height || widget.attrs.height"/>
</t>
<t t-name="FieldBinaryFile">
<a t-if="widget.mode === 'readonly'" href="javascript:void(0)" class="o_form_uri"/>
@@ -1462,6 +1462,42 @@ QUnit.module('basic_fields', {
}
});
QUnit.module('FieldImage');
QUnit.test('image fields are correctly rendered', function (assert) {
assert.expect(5);
this.data.partner.records[0].document = 'myimage';
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="document" widget="image" options="{\'size\': [90, 90]}"/> ' +
'</form>',
res_id: 1,
mockRPC: function (route) {
if (route === 'data:image/png;base64,myimage') {
assert.ok(true, "should called the correct route");
return $.when('wow');
}
return this._super.apply(this, arguments);
},
});
assert.ok(form.$('div[name="document"]').hasClass('o_field_image'),
"the widget should have the correct class");
assert.strictEqual(form.$('div[name="document"] > img').length, 1,
"the widget should contain an image");
assert.ok(form.$('div[name="document"] > img').hasClass('img-responsive'),
"the image should have the correct class");
assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90",
"the image should correctly set its attributes");
form.destroy();
});
QUnit.module('JournalDashboardGraph');
QUnit.test('graph dashboard widget is rendered correctly', function (assert) {
@@ -1844,6 +1880,39 @@ QUnit.module('basic_fields', {
form.destroy();
});
QUnit.test('do not trigger a field_changed for datetime field with date widget', function (assert) {
assert.expect(3);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners"><field name="datetime" widget="date"/></form>',
translateParameters: { // Avoid issues due to localization formats
date_format: '%m/%d/%Y',
time_format: '%H:%M:%S',
},
res_id: 1,
viewOptions: {
mode: 'edit',
},
mockRPC: function (route, args) {
assert.step(args.method);
return this._super.apply(this, arguments);
},
});
assert.strictEqual(form.$('.o_datepicker_input').val(), '02/08/2017',
'the date should be correct');
form.$('input[name="datetime"]').val('02/08/2017').trigger('input').trigger('change');
form.$buttons.find('.o_form_button_save').click();
assert.verifySteps(['read']); // should not have save as nothing changed
form.destroy();
});
QUnit.module('FieldDatetime');
QUnit.test('datetime field in form view', function (assert) {
@@ -3966,7 +4035,7 @@ QUnit.module('basic_fields', {
});
QUnit.test('domain field: handle false domain as []', function (assert) {
assert.expect(2);
assert.expect(3);
this.data.partner.records[0].foo = false;
this.data.partner.fields.bar.type = "char";
@@ -3992,11 +4061,13 @@ QUnit.module('basic_fields', {
return this._super.apply(this, arguments);
},
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_field_widget[name=foo]:not(.o_field_empty)').length, 1,
"there should be a domain field, not considered empty");
form.$buttons.find('.o_form_button_edit').click();
var $warning = form.$('.o_field_widget[name=foo] .text-warning');
assert.strictEqual($warning.length, 0, "should not display that the domain is invalid");
@@ -1064,14 +1064,14 @@ QUnit.module('relational_fields', {
});
QUnit.test('list in form: default_get with x2many create and onchange', function (assert) {
assert.expect(1);
assert.expect(2);
this.data.partner.onchanges.turtles = function (obj) {
assert.deepEqual(
obj.turtles,
[
[0, 2, {turtle_foo: 'blip', id: 2}],
[0, 3, {turtle_foo: 'kawa', id: 3}]
[1, 2, {turtle_foo: 'blip'}],
[1, 3, {turtle_foo: 'kawa'}]
],
"should have properly created the x2many command list");
};
@@ -1094,10 +1094,21 @@ QUnit.module('relational_fields', {
if (args.method === 'default_get') {
return $.when({turtles: [[6, 0, [2,3]]]});
}
if (args.method === 'create') {
// it would be even better if we did not send the current
// unchanged state with the command 1, but this seems more
// difficult.
assert.deepEqual(args.args[0].turtles, [
[1, 2, {turtle_foo: 'blip'}],
[1, 3, {turtle_foo: 'kawa'}]
], 'should send proper commands to create method');
}
return this._super.apply(this, arguments);
},
});
form.$buttons.find('.o_form_button_save').click();
form.destroy();
});
@@ -6313,6 +6324,66 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('display correct value after validation error', function (assert) {
assert.expect(4);
this.data.partner.onchanges.turtles = true;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<sheet>' +
'<field name="turtles">' +
'<tree editable="bottom">' +
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'onchange') {
// we simulate a validation error. In the 'real' web client,
// the server error will be used by the session to display
// an error dialog. From the point of view of the basic
// model, the deferred is just rejected.
return $.Deferred().reject();
}
if (args.method === 'write') {
assert.deepEqual(args.args[1].turtles[0], [1,2,{turtle_foo: 'foo'}],
'should send the "bad" value');
// we simulate a validation error
return $.Deferred().reject();
}
return this._super.apply(this, arguments);
},
viewOptions: {mode: 'edit'},
res_id: 1,
});
assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'blip',
"initial text should be correct");
form.$('.o_data_row .o_data_cell:nth(0)').click();
form.$('.o_field_widget[name=turtle_foo]').val('foo').trigger('input');
// we try to validate the line. This triggers an onchange which will be
// rejected. The line will be returned to readonly mode, but with the
// new invalid value.
form.$el.click();
assert.strictEqual(form.$('.o_data_row .o_data_cell:nth(0)').text(), 'foo',
"turtle_foo text should now be foo (invalid value)");
// we make sure here that when we save, the values are the current
// values displayed in the field.
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.mode, 'edit', "form view should still be in edit mode");
form.destroy();
});
QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) {
@@ -7157,6 +7228,42 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('default_get, onchange, onchange on m2m', function (assert) {
assert.expect(1);
this.data.partner.onchanges.int_field = function (obj) {
if (obj.int_field === 2) {
assert.deepEqual(obj.timmy, [
[6, false, [12]],
[1, 12, {display_name: 'gold'}]
]);
}
obj.timmy = [
[5],
[1, 12, {display_name: 'gold'}]
];
};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<sheet>' +
'<field name="timmy">' +
'<tree>' +
'<field name="display_name"/>' +
'</tree>' +
'</field>' +
'<field name="int_field"/>' +
'</sheet>' +
'</form>',
});
form.$('.o_field_widget[name=int_field]').val(2).trigger('input');
form.destroy();
});
QUnit.module('FieldStatus');
QUnit.test('static statusbar widget on many2one field', function (assert) {
@@ -8317,6 +8424,32 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('widget many2many_checkboxes: start non empty, then remove twice', function (assert) {
assert.expect(2);
this.data.partner.records[0].timmy = [12,14];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<group><field name="timmy" widget="many2many_checkboxes"/></group>' +
'</form>',
res_id: 1,
viewOptions: {mode: 'edit'},
});
form.$('div.o_field_widget div.o_checkbox input').eq(0).click();
form.$('div.o_field_widget div.o_checkbox input').eq(1).click();
form.$buttons.find('.o_form_button_save').click();
assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(0).prop('checked'),
"first checkbox should not be checked");
assert.notOk(form.$('div.o_field_widget div.o_checkbox input').eq(1).prop('checked'),
"second checkbox should not be checked");
form.destroy();
});
QUnit.module('FieldMany2ManyBinaryMultiFiles');
QUnit.test('widget many2many_binary', function (assert) {
@@ -110,6 +110,14 @@ var MockServer = Class.extend({
console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString));
}
return JSON.parse(resultString);
}).fail(function (result) {
var errorString = JSON.stringify(result || false);
if (logLevel === 1) {
console.log('Mock: (ERROR)' + route, JSON.parse(errorString));
} else if (logLevel === 2) {
console.log('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString));
}
return JSON.parse(errorString);
});
},
@@ -2649,6 +2649,44 @@ QUnit.module('Views', {
form.destroy();
});
QUnit.test('default_get, onchange which fails, should still work', function (assert) {
assert.expect(1);
this.data.partner.onchanges.foo = true;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<field name="foo"/>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'onchange') {
// we simulate a validation error. In the 'real' web client,
// the server error will be used by the session to display
// an error dialog. From the point of view of the basic
// model, the deferred is just rejected.
return $.Deferred().reject();
}
return this._super.apply(this, arguments);
},
});
// this test checks that a form view is still rendered when the server
// onchange fails (for example, because of a validation error, or, more
// likely, a bug in the onchange code). This is quite rare, but if the
// onchange fails, we still want to display the form view (with the error
// dialog from the session/crashmanager). Otherwise, we could be in the
// situation where a user clicks on a button, it should open a wizard,
// but something fails and the wizard is not even rendered. In that
// case, there is nothing that the user could do.
assert.strictEqual(form.$('.o_field_widget[name="foo"]').val(), 'My little Foo Value',
"should display proper default value");
form.destroy();
});
QUnit.test('attrs are properly transmitted to new records', function (assert) {
assert.expect(2);
@@ -206,6 +206,18 @@ define([
r.select();
}
// If only pasting a <p/> element in an unique <p/> element, only paste
// the <p/> element text
var $p = $arch.children('p');
var onlyAP = ($p.length === 1 && $arch.children().length === 1);
if (onlyAP) {
var $p1 = $(r.sc).closest('p');
var $p2 = $(r.ec).closest('p');
if ($p1.length && $p2.length && $p1[0] === $p2[0]) {
$arch.html($p.text());
}
}
/*
insert content
*/
@@ -650,6 +650,28 @@ var RTEWidget = Widget.extend({
self.historyRecordUndo($target, 'activate', true);
});
// To Fix Google Chrome Tripleclick Issue, which selects the ending
// whitespace characters (so Tripleclicking then typing text will remove
// the whole paragraph instead of its content).
// http://stackoverflow.com/questions/38467334/why-does-google-chrome-always-add-space-after-selected-text
if ($.browser.chrome === true && event.originalEvent.detail === 3) {
var currentSelection = range.create();
if (currentSelection.sc.parentNode === currentSelection.ec) {
_selectSC(currentSelection);
} else if (currentSelection.eo === 0) {
var $hasNext = $(currentSelection.sc).parent();
while (!$hasNext.next().length && !$hasNext.is('body')) {
$hasNext = $hasNext.parent();
}
if ($hasNext.next()[0] === currentSelection.ec) {
_selectSC(currentSelection);
}
}
}
function _selectSC(selection) {
range.create(selection.sc, selection.so, selection.sc, selection.sc.length).select();
}
// To Fix Google Chrome Tripleclick Issue, which selects the ending
// whitespace characters (so Tripleclicking then typing text will remove
// the whole paragraph instead of its content).
@@ -446,7 +446,7 @@ $.summernote.pluginEvents.visible = function (event, editor, layoutInfo) {
if (($node.is('[data-oe-type="html"]') || $node.is('[data-oe-field="arch"]')) &&
$node.hasClass("o_editable") &&
!$node[0].children.length &&
"h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span".toUpperCase().indexOf($node[0].nodeName) === -1) {
"h1 h2 h3 h4 h5 h6 p b bold i u code sup strong small pre th td span label".toUpperCase().indexOf($node[0].nodeName) === -1) {
var p = $('<p><br/></p>')[0];
$node.append( p );
range.createFromNode(p.firstChild).select();
@@ -761,7 +761,7 @@ eventHandler.attach = function (oLayoutInfo, options) {
* Open Link Dialog on double click on a link/button.
* Shows a tooltip on click to say to the user he can double click.
*/
create_dblclick_feature("a[href], .btn", function () {
create_dblclick_feature("a[href], a.btn, button.btn", function () {
eventHandler.modules.linkDialog.show(oLayoutInfo);
});
@@ -23,7 +23,9 @@ html, body {
/* ----- GENERIC LAYOUTING HELPERS ---- */
/* table */
#wrapwrap, .o_editable {
table.table {
// Only style editor-made tables (shop/portal/... tables are not supposed to
// use table-bordered...)
table.table.table-bordered {
table-layout: fixed;
td {
min-width: 20px;
@@ -128,7 +128,7 @@
</div>
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_website_version"/>
<field name="module_website_version" widget="upgrade_boolean"/>
</div>
<div class="o_setting_right_pane">
<label for="module_website_version"/>
+1 -1
View File
@@ -332,7 +332,7 @@ class WebsiteForum(http.Controller):
if post_type == 'question' and not post.get('post_name', ''):
return request.render('website.http_error', {'status_code': _('Bad Request'), 'status_message': _('Title should not be empty.')})
if post.get('content', '') == '<p><br></p>':
return werkzeug.utils.redirect("/forum/%s/question/%s?nocontent=1" % (slug(forum), post_parent and slug(post_parent)))
return request.render('website.http_error', {'status_code': _('Bad Request'), 'status_message': _('Question should not be empty.')})
post_tag_ids = forum._tag_to_write_vals(post.get('post_tags', ''))
@@ -2449,6 +2449,12 @@ msgstr ""
msgid "Title must not be empty"
msgstr ""
#. module: website_forum
#: code:addons/website_forum/controllers/main.py:325
#, python-format
msgid "Question should not be empty."
msgstr ""
#. module: website_forum
#: code:addons/website_forum/controllers/main.py:324
#: code:addons/website_forum/controllers/main.py:395
+1
View File
@@ -69,6 +69,7 @@
<group>
<group>
<field name="karma_ask"/>
<field name="karma_answer"/>
<field name="karma_upvote"/>
<field name="karma_downvote"/>
<field name="karma_edit_own"/>
+1
View File
@@ -4,4 +4,5 @@
from .import sale_order
from .import sale_quote
from .import product_template
from . import ir_model_fields
from .import res_config_settings
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
# !! Attention, this is a hack of a hack, do not try this at home !!
# This is done because website_quote defines the website_description
# field which is also defined by website_sale, but these two modules
# are independent of each other, the part of the ORM that generates
# xmlids does not support this case therefore it only creates a single
# pair of xmlids for whichever module is installed first, therefore
# when uninstalling any of the two modules the website_descripion field
# will be deleted from the db and the other module won't be able to
# use this field, resulting in a crash and data-loss.
# See opw-776464 for more details.
#
# This is hotfixed by overriding the unlink method so as to not delete
# the website_description field if website_quote is uninstalled, it's
# an ugly hack but it works and big changes can't be done to the ORM
# in stable versions, Odoo v12+ will properly fix this
class IrModelFields(models.Model):
_inherit = "ir.model.fields"
@api.multi
def unlink(self):
# Prevent the deletion of the field "website_description"
self = self.filtered(
lambda rec: not (
rec.model in ('product.product', 'product.template') and
rec.name == 'website_description'
)
)
return super(IrModelFields, self).unlink()
+2
View File
@@ -504,6 +504,8 @@ class WebsiteSale(http.Controller):
# vat validation
Partner = request.env['res.partner']
if data.get("vat") and hasattr(Partner, "check_vat"):
if data.get("country_id"):
data["vat"] = Partner.fix_eu_vat_number(data.get("country_id"), data.get("vat"))
partner_dummy = Partner.new({
'vat': data['vat'],
'country_id': (int(data['country_id'])
+1
View File
@@ -8,5 +8,6 @@ from . import product
from . import res_country
from . import res_partner
from . import sale_order
from . import ir_model_fields
from . import website
from . import res_config_settings
@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
# !! Attention, this is a hack of a hack, do not try this at home !!
# This is done because website_sale defines the website_description
# field which is also defined by website_quote, but these two modules
# are independent of each other, the part of the ORM that generates
# xmlids does not support this case therefore it only creates a single
# pair of xmlids for whichever module is installed first, therefore
# when uninstalling any of the two modules the website_descripion field
# will be deleted from the db and the other module won't be able to
# use this field, resulting in a crash and data-loss.
# See opw-776464 for more details.
#
# This is hotfixed by overriding the unlink method so as to not delete
# the website_description field if website_sale is uninstalled, it's
# an ugly hack but it works and big changes can't be done to the ORM
# in stable versions, Odoo v12+ will properly fix this
class IrModelFields(models.Model):
_inherit = "ir.model.fields"
@api.multi
def unlink(self):
# Prevent the deletion of the field "website_description"
self = self.filtered(
lambda rec: not (
rec.model in ('product.product', 'product.template') and
rec.name == 'website_description'
)
)
return super(IrModelFields, self).unlink()
+6
View File
@@ -5,6 +5,7 @@ from odoo.addons import decimal_precision as dp
from odoo.tools import pycompat
from odoo.tools.translate import html_translate
from odoo.tools import float_is_zero
class ProductStyle(models.Model):
@@ -130,6 +131,7 @@ class ProductTemplate(models.Model):
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price'))
website_price_difference = fields.Boolean('Website price difference', compute='_website_price')
def _website_price(self):
# First filter out the ones that have no variant:
@@ -139,6 +141,7 @@ class ProductTemplate(models.Model):
for template, product in pycompat.izip(self, self.mapped('product_variant_id')):
template.website_price = product.website_price
template.website_public_price = product.website_public_price
template.website_price_difference = product.website_price_difference
def _default_website_sequence(self):
self._cr.execute("SELECT MIN(website_sequence) FROM %s" % self._table)
@@ -179,6 +182,7 @@ class Product(models.Model):
website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price'))
website_public_price = fields.Float('Website public price', compute='_website_price', digits=dp.get_precision('Product Price'))
website_price_difference = fields.Boolean('Website price difference', compute='_website_price')
def _website_price(self):
qty = self._context.get('quantity', 1.0)
@@ -195,6 +199,8 @@ class Product(models.Model):
for p, p2 in pycompat.izip(self, self2):
taxes = partner.property_account_position_id.map_tax(p.taxes_id.filtered(lambda x: x.company_id == company_id))
p.website_price = taxes.compute_all(p2.price, pricelist.currency_id, quantity=qty, product=p2, partner=partner)[ret]
price_without_pricelist = taxes.compute_all(p.list_price, pricelist.currency_id)[ret]
p.website_price_difference = False if float_is_zero(price_without_pricelist - p.website_price, precision_rounding=pricelist.currency_id.rounding) else True
p.website_public_price = taxes.compute_all(p2.lst_price, quantity=qty, product=p2, partner=partner)[ret]
@api.multi
@@ -4,10 +4,10 @@ odoo.define('website_sale.payment', function (require) {
$(document).ready(function () {
// If option is enable
if ($("#checkbox_cgv").length) {
var enabling_button = function() {
$("#checkbox_cgv").change(function() {
$("button#o_payment_form_pay").prop("disabled", !this.checked);
};
$("#checkbox_cgv").click(enabling_button).each(enabling_button);
});
$('#checkbox_cgv').trigger('change');
}
});
+3 -3
View File
@@ -108,7 +108,7 @@
</h5>
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price" t-if="product.product_variant_ids">
<b>
<t t-if="(compute_currency(product.lst_price) - product.website_price ) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
<t t-if="product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" />
</t>
@@ -581,7 +581,7 @@
<div itemprop="offers" itemscope="itemscope" itemtype="http://schema.org/Offer" class="product_price mt16">
<h4 class="oe_price_h4 css_editable_mode_hidden">
<span class="text-danger oe_default_price" style="text-decoration: line-through; white-space: nowrap;"
t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if (compute_currency(product.lst_price) - product.website_price) &gt; 0.01 and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'"
t-esc="compute_currency(product.website_public_price)" t-options="{'widget': 'monetary', 'display_currency': website.get_current_pricelist().currency_id, 'from_currency': website.currency_id}" t-att-style="'text-decoration: line-through; white-space: nowrap; ' + '' if product.website_price_difference and website.get_current_pricelist().discount_policy == 'without_discount' else 'display: none;'"
/>
<b class="oe_price" style="white-space: nowrap;" t-esc="product.website_price" t-options="{'widget': 'monetary', 'display_currency': website.currency_id}"/>
<span itemprop="price" style="display:none;" t-esc="product.website_price"/>
@@ -965,7 +965,7 @@
<div class="text-muted hidden-xs" t-field="product.description_sale" />
</td>
<td class='td-price'>
<t t-if="(compute_currency(product.lst_price) - product.website_price) &gt; 0.01 and website.get_current_pricelist().discount_policy=='without_discount'">
<t t-if="product.website_price_difference and website.get_current_pricelist().discount_policy=='without_discount'">
<del class="text-danger mr8" style="white-space: nowrap;" t-field="product.lst_price" t-options="{'widget': 'monetary','from_currency': product.currency_id, 'display_currency': website.currency_id}" />
</t>
<span t-field="product.website_price" style="white-space: nowrap;" t-options="{'widget': 'monetary','display_currency': website.currency_id}" />
@@ -149,6 +149,8 @@ var SlideDialog = Widget.extend({
_.each(data.items, function (obj) {
page_content = page_content + obj.str + " ";
});
// page_content may contain null characters
page_content = page_content.replace(/\0/g, "");
self.index_content = self.index_content + page_number + ". " + page_content + '\n';
if (maxPages === page_number) {
if (loaded) {
+1
View File
@@ -25,3 +25,4 @@ Philippe Schmidt philippe.schmidt@acsone.eu https://github.com/phschmidt
Stéphane Bidoul stephane.bidoul@acsone.eu https://github.com/sbidoul
Thomas Binsfeld thomas.binsfeld@acsone.eu https://github.com/ThomasBinsfeld
Benjamin Willig benjamin.willig@acsone.eu https://github.com/benwillig
François Honoré francois.honore@acsone.eu https://github.com/acsonefho
+11 -6
View File
@@ -1,4 +1,4 @@
Canada, France, 2017-05-12
Canada, France, 2017-10-17
Savoir-faire Linux agrees to the terms of the Odoo Corporate Contributor License
Agreement v1.0.
@@ -8,17 +8,22 @@ declaration.
Signed,
Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard
João Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf
List of contributors:
Agathe Mollé agathe.molle@savoirfairelinux.com https://github.com/Ehtaga
Bruno Joliveau bruno.joliveau@savoirfairelinux.com https://github.com/bjoliveau
David Dufresne david.dufresne@savoirfairelinux.com https://github.com/dufresnedavid
Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard
Joao Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf
Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL
Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche
Istvan SZALAÏ istvan.szalai@savoirfairelinux.com https://github.com/ventilooo
João Alfredo Gama Batista joao.gama@savoirfairelinux.com https://github.com/joaoalf
Julien Jezequel-Breard julien.jezequel-breard@savoirfairelinux.com https://github.com/jjbreard
Quentin Lavallée-Bourdeau quentin.lavallee@savoirfairelinux.com https://github.com/qtiplb
Rim Ben Dhaou rim.bendhaou@savoirfairelinux.com https://github.com/rimbendhaou
Yasmine El Mrini yasmine.elmrini@savoirfairelinux.com https://github.com/yasmineelmrini
Jérome Boisvert-Chouinard jerome.boisvertchouinard@savoirfairelinux.com https://github.com/jbchouinard (up to 2017-10-17)
Julie Moussu julie.mousse@savoirfairelinux.com https://github.com/JulieSFL (up to 2017-10-17)
Pierre Lamarche pierre.lamarche@savoirfairelinux.com https://github.com/plamarche (up to 2017-10-17)
David Cormier david.cormier@savoirfairelinux.com https://github.com/cormier (up to 2017-05-12)
Davin Baragiotta davin.baragiotta@savoirfairelinux.com https://github.com/giotta (up to 2017-05-12)
El Hadji Dem elhadji.dem@savoirfairelinux.com https://github.com/ehdem (up to 2017-05-12)
+1 -1
View File
@@ -342,7 +342,7 @@ class IrHttp(models.AbstractModel):
# cache
etag = bool(request) and request.httprequest.headers.get('If-None-Match')
retag = '"%s"' % hashlib.md5(last_update.encode('utf-8')).hexdigest()
retag = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
status = status or (304 if etag == retag else 200)
headers.append(('ETag', retag))
headers.append(('Cache-Control', 'max-age=%s' % (STATIC_CACHE if unique else 0)))
+6
View File
@@ -324,6 +324,12 @@ class IrSequenceDateRange(models.Model):
for seq in self:
seq.write({'number_next': seq.number_next_actual or 0})
@api.model
def default_get(self, fields):
result = super(IrSequenceDateRange, self).default_get(fields)
result['number_next_actual'] = 1
return result
date_from = fields.Date(string='From', required=True)
date_to = fields.Date(string='To', required=True)
sequence_id = fields.Many2one("ir.sequence", string='Main Sequence', required=True, ondelete='cascade')
+19 -20
View File
@@ -1,5 +1,4 @@
@charset "utf-8";
/*
/**
* This CSS is for the html description of modules
* TODO clean
*/
@@ -78,11 +77,11 @@
/* ------ BASE GRID CONSTRUCTS ----- */
.oe_page {
margin: 0px auto 64px auto;
max-width: 992px;
max-width: 100%;
}
.oe_row {
width: 928px;
width: 100%;
margin-top: 16px;
margin-bottom: 16px;
margin-left: auto;
@@ -111,35 +110,35 @@
}
.oe_span12 {
width: 928px;
width: 100%;
}
.oe_span10 {
width: 773px;
width: 83.33333%;
}
.oe_span9 {
width: 696px;
width: 75%;
}
.oe_span8 {
width: 618px;
width: 66.66667%;
}
.oe_span6 {
width: 464px;
width: 50%;
}
.oe_span4 {
width: 309px;
width: 33.33333%;
}
.oe_span3 {
width: 232px;
width: 25%;
}
.oe_span2 {
width: 154px;
width: 16.66667%;
}
[class*='oe_span'].oe_fit {
@@ -162,28 +161,28 @@
width: auto;
}
.oe_row.oe_flex .oe_span12 {
max-width: 928px;
max-width: 100%;
}
.oe_row.oe_flex .oe_span10 {
max-width: 769px;
max-width: 83.33333%;
}
.oe_row.oe_flex .oe_span9 {
max-width: 692px;
max-width: 75%;
}
.oe_row.oe_flex .oe_span8 {
max-width: 614px;
max-width: 66.66667%;
}
.oe_row.oe_flex .oe_span6 {
max-width: 460px;
max-width: 50%;
}
.oe_row.oe_flex .oe_span4 {
max-width: 305px;
max-width: 33.33333%;
}
.oe_row.oe_flex .oe_span3 {
max-width: 228px;
max-width: 25%;
}
.oe_row.oe_flex .oe_span2 {
max-width: 150px;
max-width: 16.66667%;
}
.oe_mb0 {
@@ -86,11 +86,11 @@ $v8_anchor_visited_color: rgb(91, 40, 79)
.oe_page
margin: 0px auto 64px auto
max-width: 992px
max-width: 100%
.oe_row
width: 928px
width: 100%
margin-top: 16px
margin-bottom: 16px
margin-left: auto
@@ -107,14 +107,14 @@ $v8_anchor_visited_color: rgb(91, 40, 79)
line-height: 0
height: 0
$oe_span12_width: 928px
$oe_span10_width: 773px
$oe_span9_width: 696px
$oe_span8_width: 618px
$oe_span6_width: 464px
$oe_span4_width: 309px
$oe_span3_width: 232px
$oe_span2_width: 154px
$oe_span12_width: 100%
$oe_span10_width: 83.33333333%
$oe_span9_width: 75%
$oe_span8_width: 66.66666667%
$oe_span6_width: 50%
$oe_span4_width: 33.33333333%
$oe_span3_width: 25%
$oe_span2_width: 16.66666667%
[class*='oe_span']
float: left
@@ -155,19 +155,19 @@ $oe_span2_width: 154px
.oe_span12
max-width: $oe_span12_width
.oe_span10
max-width: ($oe_span10_width +-4px)
max-width: $oe_span10_width
.oe_span9
max-width: ($oe_span9_width +-4px)
.oe_span8
max-width: ($oe_span8_width +-4px)
max-width: $oe_span9_width
.oe_span8
max-width: $oe_span8_width
.oe_span6
max-width: ($oe_span6_width +-4px)
max-width: $oe_span6_width
.oe_span4
max-width: ($oe_span4_width +-4px)
max-width: $oe_span4_width
.oe_span3
max-width: ($oe_span3_width +-4px)
max-width: $oe_span3_width
.oe_span2
max-width: ($oe_span2_width +-4px)
max-width: $oe_span2_width
.oe_mb0
margin-bottom: 0px !important
@@ -736,6 +736,30 @@ class TestFields(common.TransactionCase):
self.assertEqual(len(discussion.important_messages), 2)
self.assertEqual(len(discussion.very_important_messages), 2)
def test_70_x2many_write(self):
discussion = self.env.ref('test_new_api.discussion_0')
Message = self.env['test_new_api.message']
# There must be 3 messages, 0 important
self.assertEqual(len(discussion.messages), 3)
self.assertEqual(len(discussion.important_messages), 0)
self.assertEqual(len(discussion.very_important_messages), 0)
discussion.important_messages = [(0, 0, {
'body': 'What is the answer?',
'important': True,
})]
# There must be 4 messages, 1 important
self.assertEqual(len(discussion.messages), 4)
self.assertEqual(len(discussion.important_messages), 1)
self.assertEqual(len(discussion.very_important_messages), 1)
discussion.very_important_messages |= Message.new({
'body': '42',
'important': True,
})
# There must be 5 messages, 2 important
self.assertEqual(len(discussion.messages), 5)
self.assertEqual(len(discussion.important_messages), 2)
self.assertEqual(len(discussion.very_important_messages), 2)
class TestX2many(common.TransactionCase):
def test_search_many2many(self):
@@ -27,6 +27,8 @@ class TestPyLint(TransactionCase):
'W0123', # eval used
'W0101', # unreachable code
'mixed-indentation',
# py3k checks
'print-statement',
'backtick',
+1 -1
View File
@@ -95,7 +95,7 @@ def ustr(value, hint_encoding='utf-8', errors='strict'):
def exception_to_unicode(e):
if hasattr(e, 'args'):
if getattr(e, 'args', ()):
return "\n".join((ustr(a) for a in e.args))
try:
return text_type(e)
+2 -1
View File
@@ -932,7 +932,8 @@ def trans_generate(lang, modules, cr):
def get_module_from_path(path):
for (mp, rec) in path_list:
mp = os.path.join(mp, '')
if rec and path.startswith(mp) and os.path.dirname(path) != mp:
dirname = os.path.join(os.path.dirname(path), '')
if rec and path.startswith(mp) and dirname != mp:
path = path[len(mp):]
return path.split(os.path.sep)[0]
return 'base' # files that are not in a module are considered as being in 'base' module