[MERGE] forward port branch 11.0 up to dbb713c2f8

This commit is contained in:
Christophe Simonis
2018-02-21 20:19:26 +01:00
79 changed files with 1271 additions and 350 deletions
@@ -27,7 +27,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
res = []
total = []
cr = self.env.cr
user_company = self.env.user.company_id.id
company_ids = self.env.context.get('company_ids', (self.env.user.company_id.id,))
move_state = ['draft', 'posted']
if target_move == 'posted':
move_state = ['posted']
@@ -41,7 +41,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
if reconciled_after_date:
reconciliation_clause = '(l.reconciled IS FALSE OR l.id IN %s)'
arg_list += (tuple(reconciled_after_date),)
arg_list += (date_from, user_company)
arg_list += (date_from, tuple(company_ids))
query = '''
SELECT DISTINCT l.partner_id, UPPER(res_partner.name)
FROM account_move_line AS l left join res_partner on l.partner_id = res_partner.id, account_account, account_move am
@@ -51,7 +51,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND (account_account.internal_type IN %s)
AND ''' + reconciliation_clause + '''
AND (l.date <= %s)
AND l.company_id = %s
AND l.company_id IN %s
ORDER BY UPPER(res_partner.name)'''
cr.execute(query, arg_list)
@@ -76,8 +76,8 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND (COALESCE(l.date_maturity,l.date) > %s)\
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND (l.date <= %s)
AND l.company_id = %s'''
cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, user_company))
AND l.company_id IN %s'''
cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, tuple(company_ids)))
aml_ids = cr.fetchall()
aml_ids = aml_ids and [x[0] for x in aml_ids] or []
for line in self.env['account.move.line'].browse(aml_ids):
@@ -117,7 +117,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
else:
dates_query += ' <= %s)'
args_list += (periods[str(i)]['stop'],)
args_list += (date_from, user_company)
args_list += (date_from, tuple(company_ids))
query = '''SELECT l.id
FROM account_move_line AS l, account_account, account_move am
@@ -127,7 +127,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND ''' + dates_query + '''
AND (l.date <= %s)
AND l.company_id = %s'''
AND l.company_id IN %s'''
cr.execute(query, args_list)
partners_amount = {}
aml_ids = cr.fetchall()
@@ -62,9 +62,9 @@ class BaseGengoTranslations(models.TransientModel):
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'res.company',
'res_id': self.env.user.company_id.id,
'res_model': 'res.config.settings',
'target': 'current',
'context': {'module' : 'general_settings'},
}
@api.model
+2 -1
View File
@@ -76,7 +76,8 @@ bus.Bus = Widget.extend({
this.last_partners_presence_check = now;
}
var data = {channels: self.channels, last: self.last, options: options};
session.rpc('/longpolling/poll', data, {shadow : true}).then(function(result) {
// The backend has a maximum cycle time of 50 seconds so give +10 seconds
session.rpc('/longpolling/poll', data, {shadow : true, timeout: 60000}).then(function(result) {
self.on_notification(result);
if(!self.stop){
self.poll();
-10
View File
@@ -34,16 +34,6 @@
<field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field>
</record>
<record id="crm_rule_lead_assigned_activities" model="ir.rule">
<field name="name">Leads with assigned activities</field>
<field ref="model_crm_lead" name="model_id"/>
<field name="domain_force">[('activity_ids.user_id','=',user.id)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="crm_rule_all_lead" model="ir.rule">
<field name="name">All Leads</field>
<field ref="model_crm_lead" name="model_id"/>
+1 -1
View File
@@ -524,7 +524,7 @@ class HrExpenseSheet(models.Model):
@api.onchange('employee_id')
def _onchange_employee_id(self):
self.address_id = self.employee_id.address_home_id
self.address_id = self.employee_id.sudo().address_home_id
self.department_id = self.employee_id.department_id
@api.depends('expense_line_ids.total_amount_company')
+1 -1
View File
@@ -61,7 +61,7 @@
<field name="reference"/>
<field name="date"/>
<field name="account_id" domain="[('internal_type', '=', 'other')]" groups="account.group_account_user"/>
<field name="employee_id"/>
<field name="employee_id" groups="hr_expense.group_hr_expense_user"/>
<field name="sheet_id" invisible="1"/>
<field name="currency_id" groups="base.group_multi_currency"/>
<field name="analytic_account_id" groups="analytic.group_analytic_accounting"/>
+7 -2
View File
@@ -218,7 +218,12 @@ class Employee(models.Model):
def write(self, values):
res = super(Employee, self).write(values)
if 'parent_id' in values:
if 'parent_id' in values or 'department_id' in values:
holidays = self.env['hr.holidays'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
holidays.write({'manager_id': values['parent_id']})
hr_vals = {}
if values.get('parent_id') is not None:
hr_vals['manager_id'] = values['parent_id']
if values.get('department_id') is not None:
hr_vals['department_id'] = values['department_id']
holidays.write(hr_vals)
return res
+1
View File
@@ -217,6 +217,7 @@ class Holidays(models.Model):
@api.onchange('employee_id')
def _onchange_employee_id(self):
self.manager_id = self.employee_id and self.employee_id.parent_id
self.department_id = self.employee_id and self.employee_id.department_id
@api.multi
@api.depends('number_of_days_temp', 'type')
@@ -158,7 +158,6 @@ class HrContract(models.Model):
class HrPayslipRun(models.Model):
_inherit = 'hr.payslip.run'
_description = 'Payslip Run'
journal_id = fields.Many2one('account.journal', 'Salary Journal', states={'draft': [('readonly', False)]}, readonly=True,
required=True, default=lambda self: self.env['account.journal'].search([('type', '=', 'general')], limit=1))
@@ -545,7 +545,6 @@ result = -contract.car_atn
ref('hr_salary_rule_representation_fees'),
ref('hr_payroll_rules_suppl_net'),
ref('hr_payroll_rules_retained_net'),
ref('hr_salary_rule_commission_on_target'),
ref('hr_salary_rule_pp_reduction_employees'),
])]" name="rule_ids"/>
@@ -571,7 +570,6 @@ result = -contract.car_atn
ref('hr_salary_rule_representation_fees'),
ref('hr_payroll_rules_suppl_net'),
ref('hr_payroll_rules_retained_net'),
ref('hr_salary_rule_commission_on_target'),
ref('hr_salary_rule_pp_reduction_workers')
])]" name="rule_ids"/>
<field name="company_id" ref="base.main_company"/>
@@ -116,8 +116,8 @@ class HrContract(models.Model):
@api.depends('commission_on_target')
def _compute_warrants_cost(self):
for contract in self:
contract.warrants_cost = contract.commission_on_target * 1.326 / 1.05 * 12.0
contract.warrant_value_employee = contract.warrants_cost * 0.54
contract.warrants_cost = contract.commission_on_target * 1.05 * 12.0
contract.warrant_value_employee = contract.commission_on_target * (1.00 - 0.535) * 12.0
@api.depends('wage', 'fuel_card', 'representation_fees', 'transport_employer_cost',
'internet', 'mobile', 'mobile_plus')
@@ -230,7 +230,7 @@ class HrContract(models.Model):
- 12.0 * contract.internet \
- 12.0 * (contract.mobile + contract.mobile_plus) \
- 12.0 * contract.transport_employer_cost \
- (1.326 / 1.05 * 12.0) * contract.commission_on_target \
- contract.warrants_cost \
- 220.0 * contract.meal_voucher_paid_by_employer
gross = remaining_for_gross / (13.0 + 13.0 * 0.3507 + 0.92)
return gross
+1 -1
View File
@@ -74,6 +74,6 @@ class IrModelField(models.Model):
def _instanciate_attrs(self, field_data):
attrs = super(IrModelField, self)._instanciate_attrs(field_data)
if field_data.get('track_visibility'):
if attrs and field_data.get('track_visibility'):
attrs['track_visibility'] = field_data['track_visibility']
return attrs
+10 -2
View File
@@ -211,7 +211,7 @@ class Channel(models.Model):
# http://blogs.technet.com/b/exchange/archive/2006/10/06/3395024.aspx
headers['X-Auto-Response-Suppress'] = 'OOF'
if self.alias_domain and self.alias_name:
headers['List-Id'] = '%s.%s' % (self.alias_name, self.alias_domain)
headers['List-Id'] = '<%s.%s>' % (self.alias_name, self.alias_domain)
headers['List-Post'] = '<mailto:%s@%s>' % (self.alias_name, self.alias_domain)
# Avoid users thinking it was a personal message
# X-Forge-To: will replace To: after SMTP envelope is determined by ir.mail.server
@@ -505,7 +505,15 @@ class Channel(models.Model):
partners_to_add = partners - channel.channel_partner_ids
channel.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': partner_id}) for partner_id in partners_to_add.ids]})
for partner in partners_to_add:
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,)
if partner.id != self.env.user.partner_id.id:
notification = _('<div class="o_mail_notification">%(author)s invited %(new_partner)s to <a href="#" class="o_channel_redirect" data-oe-id="%(channel_id)s">#%(channel_name)s</a></div>') % {
'author': self.env.user.display_name,
'new_partner': partner.display_name,
'channel_id': channel.id,
'channel_name': channel.name,
}
else:
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (channel.id, channel.name,)
self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id)
# broadcast the channel header to the added partner
+1 -1
View File
@@ -11,7 +11,7 @@ from odoo.exceptions import UserError, AccessError
from odoo.osv import expression
_logger = logging.getLogger(__name__)
_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/]{3,}=*)([\'"])', re.I)
_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])', re.I)
class Message(models.Model):
+4
View File
@@ -210,6 +210,10 @@ var Thread = Widget.extend({
}
},
on_click_redirect: function (event) {
// ignore inherited branding
if ($(event.target).data('oe-field') !== undefined) {
return;
}
var id = $(event.target).data('oe-id');
if (id) {
event.preventDefault();
+2 -2
View File
@@ -60,7 +60,7 @@ function _parse_and_transform(nodes, transform_function) {
// Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
// Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match.
var url_regexp = /\b(?:https?:\/\/|(www\.))[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/gi;
var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&//=]*)/gi;
function linkify(text, attrs) {
attrs = attrs || {};
if (attrs.target === undefined) {
@@ -70,7 +70,7 @@ function linkify(text, attrs) {
return key + '="' + _.escape(value) + '"';
}).join(' ');
return text.replace(url_regexp, function (url) {
var href = (!/^(f|ht)tps?:\/\//i.test(url)) ? "http://" + url : url;
var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url;
return '<a ' + attrs + ' href="' + href + '">' + url + '</a>';
});
}
@@ -0,0 +1,34 @@
odoo.define('mail.mail_utils_tests', function (require) {
"use strict";
var utils = require('mail.utils');
QUnit.module('mail', {}, function () {
QUnit.module('Mail utils');
QUnit.test('add_link utility function', function (assert) {
assert.expect(7);
var testInputs = {
'http://admin:password@example.com:8/%2020': true,
'https://admin:password@example.com/test': true,
'www.example.com:8/test': true,
'https://127.0.0.5:8069': true,
'www.127.0.0.5': false,
'should.notmatch': false,
'fhttps://test.example.com/test': false,
};
_.each(testInputs, function (willLinkify, content) {
var output = utils.parse_and_transform(content, utils.add_link);
if (willLinkify) {
assert.strictEqual(output.indexOf('<a '), 0, "There should be a link");
} else {
assert.strictEqual(output.indexOf('<a '), -1, "There should be no link");
}
});
});
});
});
+1
View File
@@ -44,6 +44,7 @@
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/mail_utils_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/chat_window_test.js"></script>
</xpath>
+5
View File
@@ -81,6 +81,11 @@ class MrpBom(models.Model):
if self.product_tmpl_id:
self.product_uom_id = self.product_tmpl_id.uom_id.id
@api.onchange('routing_id')
def onchange_routing_id(self):
for line in self.bom_line_ids:
line.operation_id = False
@api.multi
def name_get(self):
return [(bom.id, '%s%s' % (bom.code and '%s: ' % bom.code or '', bom.product_tmpl_id.display_name)) for bom in self]
+2 -2
View File
@@ -50,7 +50,7 @@
</h1>
<group>
<group>
<field name="tracking" invisible="1"/>
<field name="tracking" invisible="1" attrs="{'readonly': 1}"/>
<field name="product_id" domain="[('type', 'in', ['product', 'consu'])]"/>
<label for="product_qty"/>
<div class="o_row">
@@ -116,7 +116,7 @@
<field name="product_uom_qty" string="Quantity"/>
<field name="product_uom" string="Unit of Measure" groups="product.group_uom"/>
<field name="price_unit"/>
<field name="tax_id" widget="many2many_tags"/>
<field name="tax_id" widget="many2many_tags" domain="[('type_tax_use','=','sale')]"/>
<field name="price_subtotal" widget="monetary"/>
</tree>
</field>
+80 -19
View File
@@ -31,49 +31,110 @@ class WebsitePayment(http.Controller):
currency_id = currency_id and int(currency_id) or user.company_id.currency_id.id
currency = env['res.currency'].browse(currency_id)
# Try default one then fallback on first
acquirer_id = acquirer_id and int(acquirer_id) or \
env['ir.default'].get('payment.transaction', 'acquirer_id', company_id=user.company_id.id) or \
env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)])[0].id
acquirer = env['payment.acquirer'].with_context(submit_class='btn btn-primary pull-right',
submit_txt=_('Pay Now')).browse(acquirer_id)
acquirers = None
if acquirer_id:
acquirers = env['payment.acquirer'].browse(int(acquirer_id))
if not acquirers:
acquirers = env['payment.acquirer'].search([('website_published', '=', True), ('company_id', '=', user.company_id.id)])
# auto-increment reference with a number suffix if the reference already exists
reference = request.env['payment.transaction'].get_next_reference(reference)
partner_id = user.partner_id.id if not user._is_public() else False
payment_form = acquirer.sudo().render(reference, float(amount), currency.id, values={'return_url': '/website_payment/confirm', 'partner_id': partner_id})
values = {
'reference': reference,
'acquirer': acquirer,
'currency': currency,
'amount': float(amount),
'payment_form': payment_form,
'return_url': '/website_payment/confirm',
'partner_id': partner_id,
'bootstrap_formatting': True,
'error_msg': kw.get('error_msg')
}
# TODO: remove return when payment is implemented with refactored payments, i.e. use of
# payment.payment_tokens_list in the template.
# return request.render('payment.pay', values)
return request.render('website.404')
@http.route(['/website_payment/transaction'], type='json', auth="public", website=True)
def transaction(self, reference, amount, currency_id, acquirer_id):
values['s2s_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 's2s']
values['form_acquirers'] = [acq for acq in acquirers if acq.payment_flow == 'form']
values['pms'] = request.env['payment.token'].search([('acquirer_id', 'in', [acq.id for acq in values['s2s_acquirers']])])
return request.render('payment.pay', values)
def _get_existing_transaction(self, reference, amount, partner_id, currency_id, acquirer_id, tx_id):
PaymentTransaction = request.env['payment.transaction']
tx = None
if tx_id:
tx = PaymentTransaction.sudo().browse(tx_id)
if not tx.exists() or tx.reference != reference or tx.acquirer_id.id != acquirer_id:
tx = None
if not tx:
tx = PaymentTransaction.sudo().search([('reference', '=', reference), ('acquirer_id', '=', acquirer_id)])
if tx and (tx.state != 'draft' or tx.partner_id.id != partner_id or tx.amount != amount or tx.currency_id.id != currency_id):
tx = None
return tx
@http.route(['/website_payment/transaction/<string:reference>/<string:amount>/<string:currency_id>',], type='json', auth='public')
def transaction(self, acquirer_id, reference, amount, currency_id, **kwargs):
partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
tx = self._get_existing_transaction(reference, float(amount), partner_id,
int(currency_id), int(acquirer_id), request.session.get('website_payment_tx_id'))
if not tx:
values = {
'acquirer_id': int(acquirer_id),
'reference': reference,
'amount': float(amount),
'currency_id': currency_id,
'partner_id': partner_id,
}
tx = request.env['payment.transaction'].sudo().create(values)
request.session['website_payment_tx_id'] = tx.id
render_values = {
'return_url': '/website_payment/confirm?tx_id=%d' % tx.id,
'partner_id': partner_id,
}
return acquirer.sudo().render(reference, float(amount), int(currency_id), values=render_values)
@http.route(['/website_payment/token/<string:reference>/<float:amount>/<int:currency_id>'], type='http', auth='public', website=True)
def payment_token(self, pm_id, reference, amount, currency_id, return_url=None, **kwargs):
token = request.env['payment.token'].browse(int(pm_id))
if not token:
return request.redirect('/website_payment/pay?error_msg=%s' % _('Cannot setup the payment.'))
partner_id = request.env.user.partner_id.id if not request.env.user._is_public() else False
values = {
'acquirer_id': int(acquirer_id),
'acquirer_id': token.acquirer_id.id,
'reference': reference,
'amount': float(amount),
'currency_id': int(currency_id),
'partner_id': partner_id,
'payment_token_id': pm_id
}
tx = request.env['payment.transaction'].sudo().create(values)
request.session['website_payment_tx_id'] = tx.id
return tx.id
try:
res = tx.s2s_do_transaction()
except Exception as e:
return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.'))
valid_state = 'authorized' if tx.acquirer_id.capture_manually else 'done'
if not res or tx.state != valid_state:
return request.redirect('/website_payment/pay?error_msg=%s' % _('Payment transaction failed.'))
return request.redirect(return_url if return_url else '/website_payment/confirm?tx_id=%d' % tx.id)
@http.route(['/website_payment/confirm'], type='http', auth='public', website=True)
def confirm(self, **kw):
tx_id = request.session.pop('website_payment_tx_id', False)
tx_id = int(kw.get('tx_id', 0)) or request.session.pop('website_payment_tx_id', 0)
if tx_id:
tx = request.env['payment.transaction'].browse(tx_id)
if tx.state == 'done':
@@ -1,24 +1,2 @@
odoo.define('payment.website_payment', function (require) {
"use strict";
require('web.dom_ready');
var ajax = require('web.ajax');
if (!$('.o_website_payment').length) {
return $.Deferred().reject("DOM doesn't contain '.o_website_payment'");
}
// When clicking on payment button: create the tx using json then continue to the acquirer
var $payment = $(".o_website_payment_form");
$payment.on("click", 'button[type="submit"],button[name="submit"]', function (ev) {
ev.preventDefault();
ev.stopPropagation();
$(ev.currentTarget).attr('disabled', true);
$(ev.currentTarget).prepend('<i class="fa fa-refresh fa-spin"></i> ');
var $form = $(ev.currentTarget).parents('form');
var data =$("div[class~='o_website_payment_new_payment']").data();
ajax.jsonRpc('/website_payment/transaction/', 'call', data).then(function (result) {
$form.submit();
});
});
});
// This file is kept to avoid breaking assets compilation on instance that didn't run "-u payment".
// TBE TODO: Remove me in master
@@ -46,32 +46,22 @@
<div class="wrap">
<div class="container o_website_payment">
<h1>Payment</h1>
<div class="row">
<div class="col-md-6">
<div class="oe_structure"></div>
<div>
<div class="form-horizontal row o_website_payment_new_payment" t-att-data-acquirer_id="acquirer.id" t-att-data-currency_id="currency.id" t-att-data-reference="reference" t-att-data-amount="amount">
<div class="form-group">
<label for="form_reference" class="col-sm-3 control-label">Reference</label>
<div class="col-sm-9">
<span name="form_reference" class="form-control" t-esc="reference"/>
</div>
</div>
<div class="form-group">
<label for="form_amount" class="col-sm-3 control-label">Amount</label>
<div class="col-sm-9">
<span name="form_amount" class="form-control" t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/>
</div>
</div>
<div class="col-sm-9 col-sm-offset-3 text-muted mt16" t-raw="acquirer.pre_msg"/>
<div class="col-sm-12 o_website_payment_form" t-raw="payment_form"/>
<div class="col-sm-6 col-sm-offset-6 text-muted mt16">
<span t-field="acquirer.image_small" t-att-title="acquirer.name" t-options='{"widget": "image", "style":"max-width: 60px; display: inline-block"}'/>
<span>Processed by <t t-esc="acquirer.name"/>.</span>
</div>
</div>
<div t-if="error_msg" class="alert alert-danger col-md-7">
<t t-esc="error_msg"/>
</div>
<div class="row mt32 mb32">
<div class="col-md-7">
<p><b>Reference:</b> <t t-esc="reference"/></p>
<p><b>Amount:</b> <t t-esc="amount" t-options="{'widget': 'monetary', 'display_currency': currency}"/></p>
<t t-call="payment.payment_tokens_list" t-if="reference and amount and currency">
<t t-set="mode" t-value="'payment'"/>
<t t-set="prepare_tx_url" t-value="'/website_payment/transaction/' + reference + '/' + str(amount) + '/' + str(currency.id)"/>
<t t-set="form_action" t-value="'/website_payment/token/' + reference + '/' + str(amount) + '/' + str(currency.id)"/>
</t>
<div t-if="not (s2s_acquirers or form_acquirers)" class="alert alert-danger">
<p>No payment acquirer found.</p>
<p>Please configure a payment acquirer.</p>
</div>
<div class="oe_structure"/>
</div>
</div>
</div>
@@ -108,11 +108,14 @@ odoo.define('payment_stripe.stripe', function(require) {
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,
var invoice_num = $("input[name='invoice_num']").val();
var url = _.str.sprintf("/website_payment/transaction/%s/%f/%s",
invoice_num, amount, currency_id);
var create_tx = ajax.jsonRpc(url, 'call', {
acquirer_id: acquirer_id
}).then(function (data) {
try { provider_form[0].innerHTML = data; } catch (e) {}
});
}
else if ($('.o_website_quote').length !== 0) {
+7 -3
View File
@@ -227,10 +227,14 @@ class PosOrder(models.Model):
return InvoiceLine.sudo().create(inv_line)
def _create_account_move_line(self, session=None, move=None):
def _flatten_tax_and_children(taxes):
def _flatten_tax_and_children(taxes, group_done=None):
children = self.env['account.tax']
for tax in taxes:
children |= _flatten_tax_and_children(tax.children_tax_ids)
if group_done is None:
group_done = set()
for tax in taxes.filtered(lambda t: t.amount_type == 'group'):
if tax.id not in group_done:
group_done.add(tax.id)
children |= _flatten_tax_and_children(tax.children_tax_ids, group_done)
return taxes + children
# Tricky, via the workflow, we only have one id in the ids variable
@@ -729,6 +729,7 @@ exports.PosModel = Backbone.Model.extend({
deferred.resolve();
};
img.crossOrigin = 'use-credentials';
img.src = url;
return deferred;
@@ -1170,8 +1170,9 @@ var ClientListScreenWidget = ScreenWidget.extend({
var order = this.pos.get_order();
if( this.has_client_changed() ){
var default_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.pos.config.default_fiscal_position_id[0]});
if ( this.new_client ) {
order.fiscal_position = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]});
if ( this.new_client && this.new_client.property_account_position_id ) {
var client_fiscal_position_id = _.findWhere(this.pos.fiscal_positions, {'id': this.new_client.property_account_position_id[0]});
order.fiscal_position = client_fiscal_position_id || default_fiscal_position_id;
order.set_pricelist(_.findWhere(this.pos.pricelists, {'id': this.new_client.property_product_pricelist[0]}) || this.pos.default_pricelist);
} else {
order.fiscal_position = default_fiscal_position_id;
@@ -1277,9 +1278,14 @@ var ClientListScreenWidget = ScreenWidget.extend({
.then(function(partner_id){
self.saved_client_details(partner_id);
},function(type,err){
var error_body = _t('Your Internet connection is probably down.');
if (err.data) {
var except = err.data;
error_body = except.arguments && except.arguments[0] || except.message || error_body;
}
self.gui.show_popup('error',{
'title': _t('Error: Could not Save Changes'),
'body': _t('Your Internet connection is probably down.'),
'body': error_body,
});
});
},
@@ -6,11 +6,11 @@
<TranType><t t-esc='transaction_type'/></TranType>
<TranCode><t t-esc='transaction_code'/></TranCode>
<InvoiceNo><t t-esc='invoice_no'/></InvoiceNo>
<Frequency>OneTime</Frequency>
<Frequency t-translation="off">OneTime</Frequency>
</template>
<template id='mercury_transaction'>
<TStream>
<TStream t-translation="off">
<Transaction>
<t t-call='pos_mercury.mercury_common'/>
<OperatorID><t t-esc='operator_id'/></OperatorID>
@@ -31,7 +31,7 @@
</template>
<template id='mercury_voidsale'>
<TStream>
<TStream t-translation="off">
<Transaction>
<t t-call='pos_mercury.mercury_common'/>
<RefNo><t t-esc='ref_no'/></RefNo>
@@ -52,7 +52,7 @@
</template>
<template id='mercury_return'>
<TStream>
<TStream t-translation="off">
<Transaction>
<t t-call='pos_mercury.mercury_common'/>
<OperatorID><t t-esc='operator_id'/></OperatorID>
+9 -7
View File
@@ -14,10 +14,12 @@ class CustomerPortal(CustomerPortal):
def _prepare_portal_layout_values(self):
values = super(CustomerPortal, self)._prepare_portal_layout_values()
values.update({
'project_count': request.env['project.project'].search_count([('privacy_visibility', '=', 'portal')]),
'task_count': request.env['project.task'].search_count([('project_id.privacy_visibility', '=', 'portal')])
})
Project = request.env['project.project']
Task = request.env['project.task']
# portal users can't view projects they don't follow
projects = Project.sudo().search([('privacy_visibility', '=', 'portal')])
values['project_count'] = Project.search_count([('id', 'in', projects.ids)])
values['task_count'] = Task.search_count([('project_id', 'in', projects.ids)])
return values
@http.route(['/my/projects', '/my/projects/page/<int:page>'], type='http', auth="user", website=True)
@@ -77,8 +79,6 @@ class CustomerPortal(CustomerPortal):
@http.route(['/my/tasks', '/my/tasks/page/<int:page>'], type='http', auth="user", website=True)
def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', **kw):
values = self._prepare_portal_layout_values()
domain = [('project_id.privacy_visibility', '=', 'portal')]
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'create_date desc'},
'name': {'label': _('Title'), 'order': 'name'},
@@ -96,7 +96,9 @@ class CustomerPortal(CustomerPortal):
'all': {'input': 'all', 'label': _('Search in All')},
}
# extends filterby criteria with project (criteria name is the project id)
projects = request.env['project.project'].search([('privacy_visibility', '=', 'portal')])
# Note: portal users can't view projects they don't follow
projects = request.env['project.project'].sudo().search([('privacy_visibility', '=', 'portal')])
domain = [('project_id', 'in', projects.ids)]
for proj in projects:
searchbar_filters.update({
str(proj.id): {'label': proj.name, 'domain': [('project_id', '=', proj.id)]}
+2 -4
View File
@@ -593,14 +593,12 @@ class Task(models.Model):
@api.onchange('project_id')
def _onchange_project(self):
default_partner_id = self.env.context.get('default_partner_id')
default_partner = self.env['res.partner'].browse(default_partner_id) if default_partner_id else self.env['res.partner']
if self.project_id:
self.partner_id = self.project_id.partner_id or default_partner
if self.project_id.partner_id:
self.partner_id = self.project_id.partner_id
if self.project_id not in self.stage_id.project_ids:
self.stage_id = self.stage_find(self.project_id.id, [('fold', '=', False)])
else:
self.partner_id = default_partner
self.stage_id = False
@api.onchange('user_id')
+3 -3
View File
@@ -192,13 +192,13 @@ class AccountInvoice(models.Model):
if child.type_tax_use != 'none':
tax_ids.append((4, child.id, None))
price_before = line.get('price', 0.0)
line.update({'price': company_currency.round(valuation_price_unit * line['quantity'])})
line.update({'price': inv.currency_id.round(valuation_price_unit * line['quantity'])})
diff_res.append({
'type': 'src',
'name': i_line.name[:64],
'price_unit': company_currency.round(price_unit - valuation_price_unit),
'price_unit': inv.currency_id.round(price_unit - valuation_price_unit),
'quantity': line['quantity'],
'price': company_currency.round(price_before - line.get('price', 0.0)),
'price': inv.currency_id.round(price_before - line.get('price', 0.0)),
'account_id': acc,
'product_id': line['product_id'],
'uom_id': line['uom_id'],
+9 -2
View File
@@ -695,7 +695,7 @@ class PurchaseOrderLine(models.Model):
qty = 0.0
price_unit = self._get_stock_move_price_unit()
for move in self.move_ids.filtered(lambda x: x.state != 'cancel' and not x.location_dest_id.usage == "supplier"):
qty += move.product_qty
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
template = {
'name': self.name or '',
'product_id': self.product_id.id,
@@ -719,7 +719,14 @@ class PurchaseOrderLine(models.Model):
}
diff_quantity = self.product_qty - qty
if float_compare(diff_quantity, 0.0, precision_rounding=self.product_uom.rounding) > 0:
template['product_uom_qty'] = diff_quantity
quant_uom = self.product_id.uom_id
get_param = self.env['ir.config_parameter'].sudo().get_param
if self.product_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1':
product_qty = self.product_uom._compute_quantity(diff_quantity, quant_uom, rounding_method='HALF-UP')
template['product_uom'] = quant_uom.id
template['product_uom_qty'] = product_qty
else:
template['product_uom_qty'] = diff_quantity
res.append(template)
return res
@@ -174,3 +174,53 @@ class TestCreatePicking(common.TestProductCommon):
customer_move._action_assign()
self.assertEqual(customer_move.state, 'assigned', 'Reservation should work with the new quantity provided by the PO.')
def test_03_uom(self):
""" Buy a dozen of products stocked in units. Check that the quantities on the purchase order
lines as well as the received quantities are handled in dozen while the moves themselves
are handled in units. Edit the ordered quantities, check that the quantites are correctly
updated on the moves. Edit the ir.config_parameter to propagate the uom of the purchase order
lines to the moves and edit a last time the ordered quantities. Receive, check the quantities.
"""
uom_unit = self.env.ref('product.product_uom_unit')
uom_dozen = self.env.ref('product.product_uom_dozen')
self.assertEqual(self.product_id_1.uom_po_id.id, uom_unit.id)
# buy a dozen
po = self.env['purchase.order'].create(self.po_vals)
po.order_line.product_qty = 1
po.order_line.product_uom = uom_dozen.id
po.button_confirm()
# the move should be 12 units
# note: move.product_qty = computed field, always in the uom of the quant
# move.product_uom_qty = stored field representing the initial demand in move.product_uom
move1 = po.picking_ids.move_lines[0]
self.assertEqual(move1.product_uom_qty, 12)
self.assertEqual(move1.product_uom.id, uom_unit.id)
self.assertEqual(move1.product_qty, 12)
# edit the so line, sell 2 dozen, the move should now be 24 units
po.order_line.product_qty = 2
self.assertEqual(move1.product_uom_qty, 24)
self.assertEqual(move1.product_uom.id, uom_unit.id)
self.assertEqual(move1.product_qty, 24)
# force the propagation of the uom, sell 3 dozen
self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1')
po.order_line.product_qty = 3
move2 = po.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id)
self.assertEqual(move2.product_uom_qty, 1)
self.assertEqual(move2.product_uom.id, uom_dozen.id)
self.assertEqual(move2.product_qty, 12)
# deliver everything
move1.quantity_done = 24
move2.quantity_done = 1
po.picking_ids.button_validate()
# check the delivered quantity
self.assertEqual(po.order_line.qty_received, 3.0)
@@ -198,3 +198,76 @@ class TestStockValuation(TransactionCase):
self.assertEquals(move1.price_unit, price_unit_usd_new_rate)
self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1)
def test_extra_move_fifo_1(self):
""" Check that the extra move when over processing a receipt is correctly merged back in
the original move.
"""
self.product1.product_tmpl_id.cost_method = 'fifo'
po1 = self.env['purchase.order'].create({
'partner_id': self.partner_id.id,
'order_line': [
(0, 0, {
'name': self.product1.name,
'product_id': self.product1.id,
'product_qty': 10.0,
'product_uom': self.product1.uom_po_id.id,
'price_unit': 100.0,
'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
}),
],
})
po1.button_confirm()
picking1 = po1.picking_ids[0]
move1 = picking1.move_lines[0]
move1.quantity_done = 15
res_dict = picking1.button_validate()
self.assertEqual(res_dict['res_model'], 'stock.overprocessed.transfer')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.action_confirm()
# there should be only one move
self.assertEqual(len(picking1.move_lines), 1)
self.assertEqual(move1.price_unit, 100)
self.assertEqual(move1.product_qty, 15)
self.assertEqual(self.product1.stock_value, 1500)
def test_backorder_fifo_1(self):
""" Check that the backordered move when under processing a receipt correctly keep the
price unit of the original move.
"""
self.product1.product_tmpl_id.cost_method = 'fifo'
po1 = self.env['purchase.order'].create({
'partner_id': self.partner_id.id,
'order_line': [
(0, 0, {
'name': self.product1.name,
'product_id': self.product1.id,
'product_qty': 10.0,
'product_uom': self.product1.uom_po_id.id,
'price_unit': 100.0,
'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
}),
],
})
po1.button_confirm()
picking1 = po1.picking_ids[0]
move1 = picking1.move_lines[0]
move1.quantity_done = 5
res_dict = picking1.button_validate()
self.assertEqual(res_dict['res_model'], 'stock.backorder.confirmation')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
self.assertEqual(len(picking1.move_lines), 1)
self.assertEqual(move1.price_unit, 100)
self.assertEqual(move1.product_qty, 5)
picking2 = po1.picking_ids.filtered(lambda p: p.backorder_id)
move2 = picking2.move_lines[0]
self.assertEqual(len(picking2.move_lines), 1)
self.assertEqual(move2.price_unit, 100)
self.assertEqual(move2.product_qty, 5)
+3 -1
View File
@@ -41,7 +41,9 @@ class AccountAnalyticLine(models.Model):
@api.multi
def _sale_postprocess(self, values, additional_so_lines=None):
if 'so_line' not in values: # allow to force a False value for so_line
self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no').with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
# only take the AAL from expense or vendor bill, meaning having a negative amount
self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no' and aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
self.filtered(lambda aal: aal.amount <= 0).with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()):
if not self._context.get('sale_analytic_norecompute'):
+2 -2
View File
@@ -166,8 +166,8 @@ class TestSaleMrpFlow(common.TransactionCase):
mnf_product_a = self.env['mrp.production'].search([('product_id', '=', product_a.id)])
self.assertTrue(mnf_product_a, 'Manufacturing order not created.')
self.assertEqual(mnf_product_a.product_qty, 10, 'Wrong product quantity in manufacturing order.')
self.assertEqual(mnf_product_a.product_uom_id, self.uom_dozen, 'Wrong unit of measure in manufacturing order.')
self.assertEqual(mnf_product_a.product_qty, 120, 'Wrong product quantity in manufacturing order.')
self.assertEqual(mnf_product_a.product_uom_id, self.uom_unit, 'Wrong unit of measure in manufacturing order.')
self.assertEqual(mnf_product_a.state, 'confirmed', 'Manufacturing order should be confirmed.')
# ------------------------------------------------------------------------------------------
+10 -2
View File
@@ -238,7 +238,7 @@ class SaleOrderLine(models.Model):
continue
qty = 0.0
for move in line.move_ids.filtered(lambda r: r.state != 'cancel'):
qty += move.product_qty
qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0:
continue
@@ -263,8 +263,16 @@ class SaleOrderLine(models.Model):
values = line._prepare_procurement_values(group_id=group_id)
product_qty = line.product_uom_qty - qty
procurement_uom = line.product_uom
quant_uom = line.product_id.uom_id
get_param = self.env['ir.config_parameter'].sudo().get_param
if procurement_uom.id != quant_uom.id and get_param('stock.propagate_uom') != '1':
product_qty = line.product_uom._compute_quantity(product_qty, quant_uom, rounding_method='HALF-UP')
procurement_uom = quant_uom
try:
self.env['procurement.group'].run(line.product_id, product_qty, line.product_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values)
self.env['procurement.group'].run(line.product_id, product_qty, procurement_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values)
except UserError as error:
errors.append(error.name)
if errors:
+120 -2
View File
@@ -244,8 +244,8 @@ class TestSaleStock(TestSale):
the new move lines.
"""
# sell two products
item1 = self.products['prod_order']
item2 = self.products['prod_del']
item1 = self.products['prod_order'] # consumable
item2 = self.products['prod_del'] # stockable
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
@@ -256,6 +256,66 @@ class TestSaleStock(TestSale):
})
self.so.action_confirm()
# deliver them
# One of the move is for a consumable product, thus is assigned. The second one is for a
# stockable product, thus is unavailable. Hitting `button_validate` will first ask to
# process all the reserved quantities and, if the user chose to process, a second wizard
# will ask to create a backorder for the unavailable product.
self.assertEquals(len(self.so.picking_ids), 1)
res_dict = self.so.picking_ids[0].button_validate()
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
self.assertEqual(wizard._name, 'stock.immediate.transfer')
res_dict = wizard.process()
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
self.assertEqual(wizard._name, 'stock.backorder.confirmation')
wizard.process()
# Now, the original picking is done and there is a new one (the backorder).
self.assertEquals(len(self.so.picking_ids), 2)
for picking in self.so.picking_ids:
move = picking.move_lines
if picking.backorder_id:
self.assertEqual(move.product_id.id, item2.id)
self.assertEqual(move.state, 'confirmed')
else:
self.assertEqual(picking.move_lines.product_id.id, item1.id)
self.assertEqual(move.state, 'done')
# update the two original sale order lines
self.so.write({
'order_line': [
(1, self.so.order_line[0].id, {'product_uom_qty': 2}),
(1, self.so.order_line[1].id, {'product_uom_qty': 2}),
]
})
# a single picking should be created for the new delivery
self.assertEquals(len(self.so.picking_ids), 2)
backorder = self.so.picking_ids.filtered(lambda p: p.backorder_id)
self.assertEqual(len(backorder.move_lines), 2)
for backorder_move in backorder.move_lines:
if backorder_move.product_id.id == item1.id:
self.assertEqual(backorder_move.product_qty, 1)
elif backorder_move.product_id.id == item2.id:
self.assertEqual(backorder_move.product_qty, 2)
def test_05_create_picking_update_saleorderline(self):
""" Same test than test_04 but only with enough products in stock so that the reservation
is successful.
"""
# sell two products
item1 = self.products['prod_order'] # consumable
item2 = self.products['prod_del'] # stockable
self.env['stock.quant']._update_available_quantity(item2, self.env.ref('stock.stock_location_stock'), 2)
self.so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [
(0, 0, {'name': item1.name, 'product_id': item1.id, 'product_uom_qty': 1, 'product_uom': item1.uom_id.id, 'price_unit': item1.list_price}),
(0, 0, {'name': item2.name, 'product_id': item2.id, 'product_uom_qty': 1, 'product_uom': item2.uom_id.id, 'price_unit': item2.list_price}),
],
})
self.so.action_confirm()
# deliver them
self.assertEquals(len(self.so.picking_ids), 1)
self.so.picking_ids[0].force_assign()
@@ -303,3 +363,61 @@ class TestSaleStock(TestSale):
self.assertEqual(len(so1.picking_ids), 2)
picking2 = so1.picking_ids.filtered(lambda p: p.state != 'cancel')
self.assertEqual(picking2.partner_id.id, partner2)
def test_06_uom(self):
""" Sell a dozen of products stocked in units. Check that the quantities on the sale order
lines as well as the delivered quantities are handled in dozen while the moves themselves
are handled in units. Edit the ordered quantities, check that the quantites are correctly
updated on the moves. Edit the ir.config_parameter to propagate the uom of the sale order
lines to the moves and edit a last time the ordered quantities. Deliver, check the
quantities.
"""
uom_unit = self.env.ref('product.product_uom_unit')
uom_dozen = self.env.ref('product.product_uom_dozen')
item1 = self.products['prod_order']
self.assertEqual(item1.uom_id.id, uom_unit.id)
# sell a dozen
so1 = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'name': item1.name,
'product_id': item1.id,
'product_uom_qty': 1,
'product_uom': uom_dozen.id,
'price_unit': item1.list_price,
})],
})
so1.action_confirm()
# the move should be 12 units
# note: move.product_qty = computed field, always in the uom of the quant
# move.product_uom_qty = stored field representing the initial demand in move.product_uom
move1 = so1.picking_ids.move_lines[0]
self.assertEqual(move1.product_uom_qty, 12)
self.assertEqual(move1.product_uom.id, uom_unit.id)
self.assertEqual(move1.product_qty, 12)
# edit the so line, sell 2 dozen, the move should now be 24 units
so1.order_line.product_uom_qty = 2
self.assertEqual(move1.product_uom_qty, 24)
self.assertEqual(move1.product_uom.id, uom_unit.id)
self.assertEqual(move1.product_qty, 24)
# force the propagation of the uom, sell 3 dozen
self.env['ir.config_parameter'].sudo().set_param('stock.propagate_uom', '1')
so1.order_line.product_uom_qty = 3
move2 = so1.picking_ids.move_lines.filtered(lambda m: m.product_uom.id == uom_dozen.id)
self.assertEqual(move2.product_uom_qty, 1)
self.assertEqual(move2.product_uom.id, uom_dozen.id)
self.assertEqual(move2.product_qty, 12)
# deliver everything
move1.quantity_done = 24
move2.quantity_done = 1
so1.picking_ids.button_validate()
# check the delivered quantity
self.assertEqual(so1.order_line.qty_delivered, 3.0)
+1 -1
View File
@@ -39,7 +39,7 @@ class ProductTemplate(models.Model):
def _inverse_service_policy(self):
for product in self:
policy = product.service_policy
if not policy:
if not policy and not product.invoice_policy =='delivery':
product.invoice_policy = 'order'
product.service_type = 'manual'
elif policy == 'ordered_timesheet':
+1 -1
View File
@@ -232,7 +232,7 @@ class CrmTeam(models.Model):
values[index][y_field] = data_item.get('y_value')
elif self.dashboard_graph_group == 'week':
weeks_in_start_year = int(date(start_date.year, 12, 31).isocalendar()[1])
weeks_in_start_year = int(date(start_date.year, 12, 28).isocalendar()[1]) # This date is always in the last week of ISO years
for week in range(0, (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1):
short_name = get_week_name(start_date + relativedelta(days=7 * week), locale)
values.append({x_field: short_name, y_field: 0})
+8 -2
View File
@@ -365,8 +365,14 @@ class Product(models.Model):
def write(self, values):
res = super(Product, self).write(values)
if 'active' in values and not values['active'] and self.mapped('orderpoint_ids').filtered(lambda r: r.active):
raise UserError(_('You still have some active reordering rules on this product. Please archive or delete them first.'))
if 'active' in values and not values['active']:
products = self.mapped('orderpoint_ids').filtered(lambda r: r.active).mapped('product_id')
if products:
msg = _('You still have some active reordering rules on this product. Please archive or delete them first.')
msg += '\n\n'
for product in products:
msg += '- %s \n' % product.display_name
raise UserError(msg)
return res
class ProductTemplate(models.Model):
+3 -1
View File
@@ -952,7 +952,8 @@ class StockMove(models.Model):
def _prepare_extra_move_vals(self, qty):
vals = {
'product_uom_qty': qty,
'picking_id': self.picking_id.id
'picking_id': self.picking_id.id,
'price_unit': self.price_unit,
}
return vals
@@ -1080,6 +1081,7 @@ class StockMove(models.Model):
'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if x.state not in ('done', 'cancel')],
'move_orig_ids': [(4, x.id) for x in self.move_orig_ids],
'origin_returned_move_id': self.origin_returned_move_id.id,
'price_unit': self.price_unit,
}
return vals
+8 -9
View File
@@ -8,7 +8,7 @@ import time
from itertools import groupby
from odoo import api, fields, models, _
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
from odoo.tools.float_utils import float_compare, float_round
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
from odoo.exceptions import UserError
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
from operator import itemgetter
@@ -443,7 +443,7 @@ class Picking(models.Model):
for picking in self:
if self._context.get('planned_picking') and picking.state == 'draft':
picking.show_validate = False
elif picking.state not in ('draft', 'confirmed', 'assigned') or not picking.is_locked:
elif picking.state not in ('draft', 'waiting', 'confirmed', 'assigned') or not picking.is_locked:
picking.show_validate = False
else:
picking.show_validate = True
@@ -594,8 +594,7 @@ class Picking(models.Model):
@return: True
"""
# TDE FIXME: remove decorator when migration the remaining
# TDE FIXME: draft -> automatically done, if waiting ?? CLEAR ME
todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'partially_available', 'assigned', 'confirmed'])
todo_moves = self.mapped('move_lines').filtered(lambda self: self.state in ['draft', 'waiting', 'partially_available', 'assigned', 'confirmed'])
# Check if there are ops not linked to moves yet
for pick in self:
# # Explode manually added packages
@@ -684,10 +683,10 @@ class Picking(models.Model):
# If no lots when needed, raise error
picking_type = self.picking_type_id
no_quantities_done = all(line.qty_done == 0.0 for line in self.move_line_ids)
no_initial_demand = all(move.product_uom_qty == 0.0 for move in self.move_lines)
if no_initial_demand and no_quantities_done:
raise UserError(_('You cannot validate a transfer if you have not processed any quantity.'))
no_quantities_done = all(float_is_zero(move_line.qty_done, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
no_reserved_quantities = all(float_is_zero(move_line.product_qty, precision_rounding=move_line.product_uom_id.rounding) for move_line in self.move_line_ids)
if no_reserved_quantities and no_quantities_done:
raise UserError(_('You cannot validate a transfer if you have not processed any quantity. You should rather cancel the transfer.'))
if picking_type.use_create_lots or picking_type.use_existing_lots:
lines_to_check = self.move_line_ids
@@ -832,7 +831,7 @@ class Picking(models.Model):
def _put_in_pack(self):
package = False
for pick in self:
for pick in self.filtered(lambda p: p.state not in ('done', 'cancel')):
operations = pick.move_line_ids.filtered(lambda o: o.qty_done > 0 and not o.result_package_id)
operation_ids = self.env['stock.move.line']
if operations:
@@ -42,17 +42,3 @@ class ProductionLot(models.Model):
# We only care for the quants in internal or transit locations.
quants = self.quant_ids.filtered(lambda q: q.location_id.usage in ['internal', 'transit'])
self.product_qty = sum(quants.mapped('quantity'))
@api.multi
def action_traceability(self):
move_ids = self.mapped('quant_ids').mapped('history_ids').ids
if not move_ids:
return False
return {
'domain': [('id', 'in', move_ids)],
'name': _('Traceability'),
'view_mode': 'tree,form',
'view_type': 'form',
'context': {'tree_view_ref': 'stock.view_move_tree'},
'res_model': 'stock.move',
'type': 'ir.actions.act_window'}
+118 -70
View File
@@ -2356,19 +2356,18 @@ class StockMove(TransactionCase):
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product3, self.stock_location, lot_id=lot2, package_id=package1), 1.0)
def test_immediate_validate_1(self):
""" Create a picking and simulates validate button effect.
Move line quantity should be set to their reservation quantity automatically
""" In a picking with a single available move, clicking on validate without filling any
quantities should open a wizard asking to process all the reservation (so, the whole move).
"""
owner = self.env['res.partner'].create({'name': 'Jean'})
partner = self.env['res.partner'].create({'name': 'Jean'})
picking = self.env['stock.picking'].create({
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'partner_id': owner.id,
'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_in').id,
})
# move from shelf1
self.env['stock.move'].create({
'name': 'test_edit_moveline_1',
'name': 'test_immediate_validate_1',
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
'picking_id': picking.id,
@@ -2379,29 +2378,28 @@ class StockMove(TransactionCase):
picking.action_confirm()
picking.action_assign()
res_dict = picking.button_validate()
self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 10.0)
def test_immediate_validate_2(self):
""" Create a picking and simulates validate button effect.
Move line quantity should be set to their reservation quantity automatically.
If the Initial demand quantity is greater than the available quantity. It should only
set the quantity done to the available(reserved) quantity and create a backorder with the
remaining quantity.
""" In a picking with a single partially available move, clicking on validate without
filling any quantities should open a wizard asking to process all the reservation (so, only
a part of the initial demand). Validating this wizard should open another one asking for
the creation of a backorder. If the backorder is created, it should contain the quantities
not processed.
"""
owner = self.env['res.partner'].create({'name': 'Jean'})
partner = self.env['res.partner'].create({'name': 'Jean'})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 5.0)
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'partner_id': owner.id,
'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
# move from shelf1
self.env['stock.move'].create({
'name': 'test_edit_moveline_1',
'name': 'test_immediate_validate_2',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'picking_id': picking.id,
@@ -2411,67 +2409,96 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
# Only 5 products are reserved on the move of 10, click on `button_validate`.
res_dict = picking.button_validate()
self.assertEqual(res_dict.get('res_model'), 'stock.immediate.transfer')
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
res_dict_for_back_order = wizard.process()
self.assertEqual(res_dict_for_back_order.get('res_model'), 'stock.backorder.confirmation')
backorder_wizard = self.env[(res_dict_for_back_order.get('res_model'))].browse(res_dict_for_back_order.get('res_id'))
# Chose to create a backorder.
backorder_wizard.process()
self.assertEqual(len(picking.move_lines.move_line_ids), 1)
# Only 5 products should be processed on the initial move.
self.assertEqual(picking.move_lines.state, 'done')
self.assertEqual(picking.move_lines.quantity_done, 5.0)
self.assertEqual(picking.move_lines.move_line_ids.qty_done, 5.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 0.0)
# The backoder should contain a move for the other 5 produts.
backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
self.assertEqual(len(backorder), 1.0)
self.assertEqual(backorder.move_lines.product_uom_qty, 5.0)
def test_immediate_validate_3(self):
""" Create a picking and simulates validate button effect.
In case of a force assign it should ignore the reserved quantity and set
the quantity done to the initial demand. It should also create the move line.
""" In a picking with two moves, one partially available and one unavailable, clicking
on validate without filling any quantities should open a wizard asking to process all the
reservation (so, only a part of one of the moves). Validating this wizard should open
another one asking for the creation of a backorder. If the backorder is created, it should
contain the quantities not processed.
"""
owner = self.env['res.partner'].create({'name': 'Jean'})
product5 = self.env['product.product'].create({
'name': 'Product 5',
'type': 'product',
'categ_id': self.env.ref('product.product_category_all').id,
})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1)
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'partner_id': owner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_dest_id': self.pack_location.id,
'picking_type_id': self.env.ref('stock.picking_type_internal').id,
})
# move from shelf1
self.env['stock.move'].create({
'name': 'test_edit_moveline_1',
product1_move = self.env['stock.move'].create({
'name': 'product1_move',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'location_dest_id': self.pack_location.id,
'picking_id': picking.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
'product_uom_qty': 100,
})
product5_move = self.env['stock.move'].create({
'name': 'product3_move',
'location_id': self.stock_location.id,
'location_dest_id': self.pack_location.id,
'picking_id': picking.id,
'product_id': product5.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 100,
})
picking.action_confirm()
picking.force_assign()
res_dict = picking.button_validate()
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
picking.action_assign()
self.assertEqual(picking.move_lines.quantity_done, 10.0)
# Check move_lines data
self.assertEqual(len(picking.move_lines.move_line_ids), 1)
self.assertEqual(picking.move_lines.move_line_ids.qty_done, 10.0)
self.assertEqual(picking.move_lines.move_line_ids.product_qty, 0.0)
# Check quants data
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location), 0.0)
self.assertEqual(self.env['stock.quant']._get_available_quantity(self.product1, self.stock_location, allow_negative=True), -10.0)
self.assertEqual(len(self.env['stock.quant']._gather(self.product1, self.stock_location)), 1.0)
# product1_move should be partially available (1/100), product5_move should be totally
# unavailable (0/100)
self.assertEqual(product1_move.state, 'partially_available')
self.assertEqual(product5_move.state, 'confirmed')
action = picking.button_validate()
self.assertEqual(action.get('res_model'), 'stock.immediate.transfer')
wizard = self.env[(action.get('res_model'))].browse(action.get('res_id'))
action = wizard.process()
self.assertTrue(isinstance(action, dict), 'Should open backorder wizard')
self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation')
wizard = self.env[(action.get('res_model'))].browse(action.get('res_id'))
wizard.process()
backorder = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
self.assertEqual(len(backorder), 1.0)
# The backorder should contain 99 product1 and 100 product5.
for backorder_move in backorder.move_lines:
if backorder_move.product_id.id == self.product1.id:
self.assertEqual(backorder_move.product_qty, 99)
elif backorder_move.product_id.id == product5.id:
self.assertEqual(backorder_move.product_qty, 100)
def test_immediate_validate_4(self):
""" Create a picking and simulates validate button effect.
If the product is tracked by lot. The user should set the lot
and the quantity done himself before validate. Otherwise it should
return a UserError.
""" In a picking with a single available tracked by lot move, clicking on validate without
filling any quantities should open an UserError.
"""
owner = self.env['res.partner'].create({'name': 'Jean'})
partner = self.env['res.partner'].create({'name': 'Jean'})
lot1 = self.env['stock.production.lot'].create({
'name': 'lot1',
'product_id': self.product3.id,
@@ -2480,12 +2507,12 @@ class StockMove(TransactionCase):
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'partner_id': owner.id,
'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
# move from shelf1
self.env['stock.move'].create({
'name': 'test_edit_moveline_1',
'name': 'test_immediate_validate_4',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'picking_id': picking.id,
@@ -2495,10 +2522,11 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
# No quantites/lot filled, it should raise.
with self.assertRaises(UserError):
picking.button_validate()
picking.move_lines.move_line_ids[0].qty_done = 5.0
# We do not need to catch wizard since everything is already defined and the action_done will be processed immediately
# All the information are present (lots and quantities), the wizard won't be opened.
picking.button_validate()
self.assertEqual(picking.move_lines.quantity_done, 5.0)
@@ -2535,9 +2563,9 @@ class StockMove(TransactionCase):
return picking
def test_immediate_validate_5(self):
""" Create a picking and simulates validate button effect.
Test that tracked products can be received without specifying a serial
number when the picking type is configured that way.
""" In a receipt with a single tracked by serial numbers move, clicking on validate without
filling any quantities nor lot should open an UserError except if the picking type is
configured to allow otherwise.
"""
picking_type_id = self.env.ref('stock.picking_type_in')
product_id = self.product2
@@ -2555,23 +2583,17 @@ class StockMove(TransactionCase):
self.assertEqual(picking.state, 'done')
def test_immediate_validate_6(self):
""" Create a picking and simulates validate button effect.
This tests three cases:
- if a user has processed no quantities and at least one product is tracked,
he should specify lots
- if a user has processed some quantities then it's ok because a backorder
will be created for the ones not filled in
- if a user overprocesses a move he will be asked to confirm if this is ok
""" In a receipt picking with two moves, one tracked and one untracked, clicking on
validate without filling any quantities should displays an UserError as long as no quantity
done and lot_name is set on the tracked move. Now if the user validates the picking, the
wizard telling the user all reserved quantities will be processed will NOT be opened. This
wizard is only opene if no quantities were filled. So validating the picking at this state
will open another wizard asking for the creation of a backorder. Now, if the user processed
on the second move more than the reservation, a wizard will ask him to confirm.
"""
picking_type = self.env.ref('stock.picking_type_in')
picking_type.use_create_lots = True
picking_type.use_existing_lots = False
lot1 = self.env['stock.production.lot'].create({
'name': 'lot1',
'product_id': self.product3.id,
})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1)
self.env['stock.quant']._update_available_quantity(self.product3, self.stock_location, 1, lot_id=lot1)
picking = self.env['stock.picking'].create({
'location_id': self.supplier_location.id,
'location_dest_id': self.stock_location.id,
@@ -2597,16 +2619,15 @@ class StockMove(TransactionCase):
})
picking.action_confirm()
picking.action_assign()
with self.assertRaises(UserError):
picking.button_validate()
product3_move.move_line_ids[0].qty_done = 1
# should still complain about lots because no lot_{name,id} was set
with self.assertRaises(UserError):
picking.button_validate()
product3_move.move_line_ids[0].lot_name = '271828'
action = picking.button_validate() # should open backorder wizard
self.assertTrue(isinstance(action, dict), 'Should open backorder wizard')
self.assertEqual(action.get('res_model'), 'stock.backorder.confirmation')
@@ -2615,6 +2636,33 @@ class StockMove(TransactionCase):
self.assertTrue(isinstance(action, dict), 'Should open overprocessing wizard')
self.assertEqual(action.get('res_model'), 'stock.overprocessed.transfer')
def test_immediate_validate_7(self):
""" In a picking with a single unavailable move, clicking on validate without filling any
quantities should display an UserError telling the user he cannot process a picking without
any processed quantity.
"""
partner = self.env['res.partner'].create({'name': 'Jean'})
picking = self.env['stock.picking'].create({
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'partner_id': partner.id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
})
self.env['stock.move'].create({
'name': 'test_immediate_validate_2',
'location_id': self.stock_location.id,
'location_dest_id': self.customer_location.id,
'picking_id': picking.id,
'product_id': self.product1.id,
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
})
picking.action_confirm()
picking.action_assign()
# No products are reserved on the move of 10, click on `button_validate`.
with self.assertRaises(UserError):
picking.button_validate()
def test_set_quantity_done_1(self):
move1 = self.env['stock.move'].create({
'name': 'test_set_quantity_done_1',
@@ -23,11 +23,8 @@ class StockImmediateTransfer(models.TransientModel):
if picking.state != 'assigned':
raise UserError(_("Could not reserve all requested products. Please use the \'Mark as Todo\' button to handle the reservation manually."))
for move in picking.move_lines:
if move.move_line_ids:
for move_line in move.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
else:
move.quantity_done = move.product_uom_qty
for move_line in move.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
if picking._check_backorder():
pick_to_backorder |= picking
continue
@@ -30,13 +30,13 @@ class AccountInvoice(models.Model):
company_currency = inv.company_id.currency_id
price_unit = i_line._get_anglo_saxon_price_unit()
if inv.currency_id != company_currency:
currency_id = inv.currency_id.id
currency = inv.currency_id
amount_currency = i_line._get_price(company_currency, price_unit)
else:
currency_id = False
currency = False
amount_currency = False
return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency_id, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids)
return self.env['product.product']._anglo_saxon_sale_move_lines(i_line.name, i_line.product_id, i_line.uom_id, i_line.quantity, price_unit, currency=currency, amount_currency=amount_currency, fiscal_position=inv.fiscal_position_id, account_analytic=i_line.account_analytic_id, analytic_tags=i_line.analytic_tag_ids)
class AccountInvoiceLine(models.Model):
+2 -2
View File
@@ -234,7 +234,7 @@ class ProductProduct(models.Model):
'product_id': product.id,
'uom_id': uom.id,
'account_analytic_id': account_analytic and account_analytic.id,
'analytic_tag_ids': analytic_tags and analytic_tag.ids and [(6, 0, analytic_tag.ids)] or False,
'analytic_tag_ids': analytic_tags and analytic_tags.ids and [(6, 0, analytic_tags.ids)] or False,
},
{
@@ -259,7 +259,7 @@ class ProductProduct(models.Model):
price = self.standard_price
if not uom or self.uom_id.id == uom.id:
return price
return self.uom_id._compute_price(self.uom_id.id, price, to_uom_id=uom.id)
return self.uom_id._compute_price(price, uom)
class ProductCategory(models.Model):
+1 -1
View File
@@ -1382,7 +1382,7 @@ class ExportFormat(object):
model, fields, ids, domain, import_compat = \
operator.itemgetter('model', 'fields', 'ids', 'domain', 'import_compat')(params)
Model = request.env[model].with_context(**params.get('context', {}))
Model = request.env[model].with_context(import_compat=import_compat, **params.get('context', {}))
records = Model.browse(ids) or Model.search(domain, offset=0, limit=False, order=False)
if not Model._is_an_ordinary_table():
@@ -59,7 +59,7 @@ return AbstractWebClient.extend({
update_logo: function(reload) {
var company = session.company_id;
var img = session.url('/web/binary/company_logo' + '?db=' + session.db + (company ? '&company=' + company : ''));
this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "#" + Date.now() : ''));
this.$('.o_sub_menu_logo img').attr('src', '').attr('src', img + (reload ? "&t=" + Date.now() : ''));
this.$('.oe_logo_edit').toggleClass('oe_logo_edit_admin', session.is_superuser);
},
logo_edit: function(ev) {
@@ -83,13 +83,8 @@ return AbstractWebClient.extend({
action_buttons: true,
headless: true,
};
self.action_manager.do_action(result).then(function () {
var form = self.action_manager.dialog_widget.views.form.controller;
form.on("on_button_cancel", self.action_manager, self.action_manager.dialog_stop);
form.on('record_saved', self, function() {
self.action_manager.dialog_stop();
self.update_logo();
});
self.action_manager.do_action(result, {
on_close: self.update_logo.bind(self, true),
});
});
});
+1 -1
View File
@@ -240,7 +240,7 @@ var Domain = collections.Tree.extend({
*/
stringToArray: function (domain, evalContext) {
if (!_.isString(domain)) return _.clone(domain);
return pyeval.eval("domain", domain || "[]", evalContext);
return pyeval.eval("domain", domain ? domain.replace(/%%/g, '%') : "[]", evalContext);
},
/**
* Makes implicit "&" operators explicit in the given JS prefix-array
+17
View File
@@ -392,6 +392,23 @@ var utils = {
array[i2] = elem1;
array[i1] = elem2;
},
/**
* @param {string} value
* @param {boolean} allow_mailto
* @returns boolean
*/
is_email: function (value, allow_mailto) {
// http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
var re;
if (allow_mailto) {
re = /^(mailto:)?(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
} else {
re = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
}
return re.test(value);
},
/**
* @param {any} str
* @param {any} elseValues
@@ -332,7 +332,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
}
self.model.discardChanges(recordID);
if (self.model.isNew(recordID)) {
self._abandonRecord(recordID);
if (self.model.canBeAbandoned(recordID)) {
self._abandonRecord(recordID);
}
return;
}
return self._confirmSave(recordID);
@@ -477,6 +477,30 @@ var BasicModel = AbstractModel.extend({
}
return _t("New");
},
/**
* Returns true if a record can be abandoned from a list datapoint.
*
* A record cannot be abandonned if it has been registered as "added"
* in the parent's savepoint, otherwise it can be abandonned.
*
* This is useful when discarding changes on this record, as it means that
* we must keep the record even if some fields are invalids (e.g. required
* field is empty).
*
* @param {string} id id for a local resource
* @returns {boolean}
*/
canBeAbandoned: function (id) {
var data = this.localData[id];
var parent = this.localData[data.parentID];
var abandonable = true;
if (parent) {
abandonable = !_.some(parent._savePoint, function (entry) {
return entry.operation === 'ADD' && entry.id === id;
});
}
return abandonable;
},
/**
* Returns true if a record is dirty. A record is considered dirty if it has
* some unsaved changes, marked by the _isDirty property on the record or
@@ -1428,25 +1452,23 @@ var BasicModel = AbstractModel.extend({
defs.push(self._applyOnChange(command[2], rec));
} else if (command[0] === 4) {
// LINK TO
rec = self.localData[list._cache[command[1]]];
if (rec) {
// modifications done on a record are discarded if
// the onchange uses a LINK TO
self.discardChanges(rec.id);
}
// the dataPoint id will be set when the record will be
// fetched (for now, this dataPoint may not exist yet)
list._changes.push({
operation: 'ADD',
id: rec ? rec.id : null,
resID: command[1],
});
linkRecord(list, command[1]);
} else if (command[0] === 5) {
// DELETE ALL
list._changes = [{operation: 'REMOVE_ALL'}];
} else if (command[0] === 6) {
list._changes = [{operation: 'REMOVE_ALL'}];
_.each(command[2], function (resID) {
linkRecord(list, resID);
});
}
});
defs.push(self._readUngroupedList(list));
var def = self._readUngroupedList(list).then(function () {
var x2ManysDef = self._fetchX2ManysBatched(list);
var referencesDef = self._fetchReferencesBatched(list);
return $.when(x2ManysDef, referencesDef);
});
defs.push(def);
} else {
var newValue = self._parseServerValue(field, val);
if (newValue !== oldValue) {
@@ -1455,6 +1477,25 @@ var BasicModel = AbstractModel.extend({
}
});
return $.when.apply($, defs);
// inner function that adds a record (based on its res_id) to a list
// dataPoint (used for onchanges that return commands 4 (LINK TO) or
// commands 6 (REPLACE WITH))
function linkRecord (list, resID) {
rec = self.localData[list._cache[resID]];
if (rec) {
// modifications done on a record are discarded if the onchange
// uses a LINK TO or a REPLACE WITH
self.discardChanges(rec.id);
}
// the dataPoint id will be set when the record will be fetched (for
// now, this dataPoint may not exist yet)
list._changes.push({
operation: 'ADD',
id: rec ? rec.id : null,
resID: resID,
});
}
},
/**
* When an operation is applied to a x2many field, the field widgets
@@ -1951,7 +1992,9 @@ var BasicModel = AbstractModel.extend({
_.each(list.data, function (dataPoint) {
var record = self.localData[dataPoint];
var value = record.data[fieldName];
if (value) {
// if the reference field has already been fetched, the value is a
// datapoint ID, and in this case there's nothing to do
if (value && !self.localData[value]) {
var model = value.split(',')[0];
var resID = value.split(',')[1];
if (!(model in toFetch)) {
@@ -73,7 +73,6 @@ var KanbanModel = BasicModel.extend({
domain: parent.domain.concat([[groupBy,"=",result[0]]]),
fields: parent.fields,
fieldsInfo: parent.fieldsInfo,
groupedBy: parent.groupedBy,
isOpen: true,
limit: parent.limit,
parentID: parent.id,
@@ -54,8 +54,13 @@ ListRenderer.include({
* @returns {Deferred}
*/
start: function () {
if (this._isEditable()) {
this.$el.css({height: '100%'});
// deliberately use the 'editable' attribute instead of '_isEditable'
// function, because the groupBy must not be taken into account to
// enable the '_onWindowClicked' handler (otherwise, an editable grouped
// list which is reloaded without groupBy wouldn't have this handler
// bound, and edited rows couldn't be left by clicking outside the list)
if (this.editable) {
this.$el.css({height: '100%'}); // seems useless: to remove in master
core.bus.on('click', this, this._onWindowClicked.bind(this));
}
return this._super();
@@ -1154,6 +1154,60 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('list in form: discard newly added element with empty required field', function (assert) {
// This test simulates discarding a record that has been created with
// one of its required field that is empty. When we discard the changes
// on this empty field, it should not assume that this record should be
// abandonned, since it has been added (even though it is a new record).
assert.expect(6);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<sheet>' +
'<field name="p">' +
'<tree editable="bottom">' +
'<field name="display_name"/>' +
'<field name="trululu" required="1"/>' +
'</tree>' +
'</field>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'default_get') {
return $.when({p: [[0, 0, {display_name: 'new record', trululu: false}]]});
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual($('tr.o_data_row').length, 1,
"should have created the new record in the o2m");
var requiredElement = $('td.o_data_cell.o_required_modifier');
assert.strictEqual(requiredElement.length, 1,
"should have a required field on this record");
assert.strictEqual(requiredElement.text(), "",
"should have empty string in the required field on this record");
requiredElement.click();
// discard by clicking on body
$('body').click();
assert.strictEqual($('tr.o_data_row').length, 1,
"should still have the record in the o2m");
// update selector of required field element
requiredElement = $('td.o_data_cell.o_required_modifier');
assert.strictEqual(requiredElement.length, 1,
"should still have the required field on this record");
assert.strictEqual(requiredElement.text(), "",
"should still have empty string in the required field on this record");
form.destroy();
});
QUnit.test('list in form: default_get with x2many create', function (assert) {
assert.expect(5);
@@ -2852,6 +2906,163 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('onchange returning a command 6 for an x2many', function (assert) {
assert.expect(2);
this.data.partner.onchanges = {
foo: function (obj) {
obj.turtles = [[6, false, [1, 2, 3]]];
},
};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form>' +
'<field name="foo"/>' +
'<field name="turtles">' +
'<tree>' +
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_data_row').length, 1,
"there should be one record in the relation");
// change the value of foo to trigger the onchange
form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
assert.strictEqual(form.$('.o_data_row').length, 3,
"there should be three records in the relation");
form.destroy();
});
QUnit.test('x2many fields inside x2manys are fetched after an onchange', function (assert) {
assert.expect(6);
this.data.turtle.records[0].partner_ids = [1];
this.data.partner.onchanges = {
foo: function (obj) {
obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
},
};
var checkRPC = false;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form>' +
'<sheet>' +
'<group>' +
'<field name="foo"/>' +
'<field name="turtles">' +
'<tree>' +
'<field name="turtle_foo"/>' +
'<field name="partner_ids" widget="many2many_tags"/>' +
'</tree>' +
'</field>' +
'</group>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (checkRPC && args.method === 'read' && args.model === 'partner') {
assert.deepEqual(args.args[1], ['display_name'],
"should only read the display_name for the m2m tags");
assert.deepEqual(args.args[0], [1],
"should only read the display_name of the unknown record");
}
return this._super.apply(this, arguments);
},
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_data_row').length, 1,
"there should be one record in the relation");
assert.strictEqual(form.$('.o_data_row .o_field_widget[name=partner_ids]').text().replace(/\s/g, ''),
'secondrecordaaa', "many2many_tags should be correctly displayed");
// change the value of foo to trigger the onchange
checkRPC = true; // enable flag to check read RPC for the m2m field
form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
assert.strictEqual(form.$('.o_data_row').length, 3,
"there should be three records in the relation");
assert.strictEqual(form.$('.o_data_row:first .o_field_widget[name=partner_ids]').text().trim(),
'first record', "many2many_tags should be correctly displayed");
form.destroy();
});
QUnit.test('reference fields inside x2manys are fetched after an onchange', function (assert) {
assert.expect(5);
this.data.turtle.records[1].turtle_ref = 'product,41';
this.data.partner.onchanges = {
foo: function (obj) {
obj.turtles = [[5], [4, 1], [4, 2], [4, 3]];
},
};
var checkRPC = false;
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form>' +
'<sheet>' +
'<group>' +
'<field name="foo"/>' +
'<field name="turtles">' +
'<tree>' +
'<field name="turtle_foo"/>' +
'<field name="turtle_ref" class="ref_field"/>' +
'</tree>' +
'</field>' +
'</group>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (checkRPC && args.method === 'name_get') {
assert.deepEqual(args.args[0], [37],
"should only fetch the name_get of the unknown record");
}
return this._super.apply(this, arguments);
},
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_data_row').length, 1,
"there should be one record in the relation");
assert.strictEqual(form.$('.ref_field').text().trim(), 'xpad',
"reference field should be correctly displayed");
// change the value of foo to trigger the onchange
checkRPC = true; // enable flag to check read RPC for reference field
form.$('.o_field_widget[name=foo]').val('some value').trigger('input');
assert.strictEqual(form.$('.o_data_row').length, 3,
"there should be three records in the relation");
assert.strictEqual(form.$('.ref_field').text().trim(), 'xpadxphone',
"reference fields should be correctly displayed");
form.destroy();
});
QUnit.test('embedded one2many with handle widget with minimum setValue calls', function (assert) {
var done = assert.async();
assert.expect(20);
@@ -1947,6 +1947,62 @@ QUnit.module('Views', {
kanban.destroy();
});
QUnit.test('archive new kanban column', function (assert) {
assert.expect(15);
this.data.partner.fields.active = {string: 'Active', type: 'char', default: true};
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban>' +
'<field name="product_id"/>' +
'<templates><t t-name="kanban-box">' +
'<div><field name="foo"/></div>' +
'</t></templates>' +
'</kanban>',
groupBy: ['product_id'],
mockRPC: function (route) {
assert.step(route);
return this._super.apply(this, arguments);
},
});
var rpcs = ['/web/dataset/call_kw/partner/read_group', '/web/dataset/search_read', '/web/dataset/search_read'];
assert.strictEqual(kanban.$('.o_kanban_group').length, 2,
"there should be 2 columns");
// create a column
kanban.$('.o_column_quick_create').click();
kanban.$('.o_column_quick_create input').val('new colum');
kanban.$('.o_column_quick_create button.o_kanban_add').click();
rpcs.push('/web/dataset/call_kw/product/name_create');
assert.verifySteps(rpcs);
// add a record inside
kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_add i').click();
var $quickCreate = kanban.$('.o_kanban_group:eq(2) .o_kanban_quick_create');
$quickCreate.find('input').val('new record');
$quickCreate.find('button.o_kanban_add').click();
assert.strictEqual(kanban.$('.o_kanban_group').length, 3,
"there should be 3 columns");
rpcs.push('/web/dataset/call_kw/partner/name_create');
rpcs.push('/web/dataset/call_kw/partner/read');
assert.verifySteps(rpcs);
var $newColumn = kanban.$('.o_kanban_group:eq(2)');
assert.strictEqual($newColumn.find('.o_kanban_record').length, 1,
"there should be 1 record in the new column");
$newColumn.find('.o_column_archive').click();
assert.strictEqual($newColumn.find('.o_kanban_record').length, 0,
"there should not be partners anymore");
rpcs.push('/web/dataset/call_kw/partner/write');
rpcs.push('/web/dataset/search_read');
assert.verifySteps(rpcs, "a search_read should be done after archiving the records");
kanban.destroy();
});
QUnit.test('load more records in column', function (assert) {
assert.expect(12);
+57 -1
View File
@@ -2751,10 +2751,13 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('grouped list are not editable', function (assert) {
QUnit.test('grouped list are not editable (ungrouped first)', function (assert) {
// Editable grouped list views are not supported, so the purpose of this
// test is to check that when a list view is grouped, its editable
// attribute is ignored
// In this test, the view isn't grouped at the beginning, so it is first
// editable, and then it is reloaded with a groupBy and is no longer
// editable
assert.expect(5);
var list = createView({
@@ -2790,6 +2793,59 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('grouped list are not editable (grouped first)', function (assert) {
// Editable grouped list views are not supported, so the purpose of this
// test is to check that when a list view is grouped, its editable
// attribute is ignored
// In this test, the view is grouped at the beginning, so it isn't
// editable, and then it is reloaded with no groupBy and becomes editable
assert.expect(6);
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top"><field name="foo"/><field name="bar"/></tree>',
intercepts: {
switch_view: function (event) {
var resID = event.data.res_id || false;
assert.step('switch view ' + event.data.view_type + ' ' + resID);
},
},
groupBy: ['bar'],
});
// the view being grouped, it is not editable, so clicking on a record
// should open the form view
list.$('.o_group_header:first').click(); // open first group
list.$('.o_data_cell:first').click();
// for the same reason, clicking on 'Create' should open the form view
list.$buttons.find('.o_list_button_add').click();
assert.verifySteps(['switch view form 1', 'switch view form false'],
"two switch view to form should have been requested");
// reload without groupBy
list.reload({groupBy: []});
// as the view is no longer grouped, it is editable, so clicking on a
// row should switch it in edition
list.$('.o_data_cell:first').click();
assert.verifySteps(['switch view form 1', 'switch view form false'],
"no more switch view should have been requested");
assert.strictEqual(list.$('.o_selected_row').length, 1,
"a row should be in edition");
// clicking on the body should leave the edition
$('body').click();
assert.strictEqual(list.$('.o_selected_row').length, 0,
"the row should no longer be in edition");
list.destroy();
});
QUnit.test('field values are escaped', function (assert) {
assert.expect(1);
var value = '<script>throw Error();</script>';
@@ -58,6 +58,10 @@ function checkXML(xml) {
* @returns {string} formatted xml
*/
function formatXML(xml) {
// do nothing if an inline script is present to avoid breaking it
if (/<script(?: [^>]*)?>[^<][\s\S]*<\/script>/i.test(xml)) {
return xml;
}
return window.vkbeautify.xml(xml, 4);
}
/**
@@ -4,6 +4,7 @@ odoo.define('web_editor.widget', function (require) {
var core = require('web.core');
var Dialog = require('web.Dialog');
var Widget = require('web.Widget');
var utils = require('web.utils');
var weContext = require("web_editor.context");
var QWeb = core.qweb;
@@ -1208,6 +1209,7 @@ var LinkDialog = Dialog.extend({
if (!$e.length) {
$e = this.$('input.url-source:first');
}
$e.closest('.form-group').removeClass('has-error');
var val = $e.val();
var label = this.$('#o_link_dialog_label_input').val() || val;
@@ -1228,8 +1230,7 @@ var LinkDialog = Dialog.extend({
var size = this.$("select.link-style").val() || '';
var classes = (this.data.className || "") + (style && style.length ? " btn " : "") + style + " " + size;
var isNewWindow = this.$('input.window-new').prop('checked');
if ($e.hasClass('email-address') && $e.val().indexOf("@") !== -1) {
if ($e.hasClass('email-address') && (_.str.startsWith(val, 'mailto:') || (val.indexOf("@") !== -1 && !_.str.startsWith(val, 'http') && !_.str.startsWith(val, 'www')))) {
self.get_data_buy_mail(def, $e, isNewWindow, label, classes, test);
} else {
self.get_data_buy_url(def, $e, isNewWindow, label, classes, test);
@@ -1238,7 +1239,13 @@ var LinkDialog = Dialog.extend({
},
get_data_buy_mail: function (def, $e, isNewWindow, label, classes, test) {
var val = $e.val();
def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes);
if (utils.is_email(val, true)) {
def.resolve(val.indexOf("mailto:") === 0 ? val : 'mailto:' + val, isNewWindow, label, classes);
} else {
$e.closest('.form-group').addClass('has-error');
$e.focus();
def.reject();
}
},
get_data_buy_url: function (def, $e, isNewWindow, label, classes, test) {
def.resolve($e.val(), isNewWindow, label, classes);
@@ -1322,5 +1329,9 @@ return {
getCssSelectors: getCssSelectors,
fontIcons: fontIcons,
computeFonts: computeFonts,
click_event: click_event,
fontIconsDialog: fontIconsDialog,
ImageDialog: ImageDialog,
VideoDialog: VideoDialog,
};
});
+3 -3
View File
@@ -520,7 +520,7 @@ class Website(models.Model):
if not force:
domain += [('website_indexed', '=', True)]
#is_visible
domain += [('website_published', '=', True), '|', ('date_publish', '!=', False), ('date_publish', '>', fields.Datetime.now())]
domain += [('website_published', '=', True), '|', ('date_publish', '=', False), ('date_publish', '<=', fields.Datetime.now())]
if query_string:
domain += [('url', 'like', query_string)]
@@ -875,12 +875,12 @@ class Menu(models.Model):
replace_id(mid, new_menu.id)
for menu in data['data']:
menu_id = self.browse(menu['id'])
if menu_id.page_id:
menu_id.page_id.write({'url': menu['url']})
# if the url match a website.page, set the m2o relation
page = self.env['website.page'].search([('url', '=', menu['url'])], limit=1)
if page:
menu['page_id'] = page.id
elif menu_id.page_id:
menu_id.page_id.write({'url': menu['url']})
menu_id.write(menu)
return True
+6 -3
View File
@@ -117,13 +117,16 @@
</xpath>
<xpath expr="//t[@t-set='head_website']" position="replace">
<t t-set="head_website">
<meta name="generator" content="Odoo"/>
<meta t-if="main_object and 'website_indexed' in main_object
and not main_object.website_indexed" name="robots" content="noindex"/>
<meta name="description" t-att-content="main_object and 'website_meta_description' in main_object
<t t-set="meta_description" t-value="main_object and 'website_meta_description' in main_object
and main_object.website_meta_description or website_meta_description"/>
<meta name="keywords" t-att-content="main_object and 'website_meta_keywords' in main_object
<t t-set="meta_keywords" t-value="main_object and 'website_meta_keywords' in main_object
and main_object.website_meta_keywords or website_meta_keywords"/>
<meta name="generator" content="Odoo"/>
<meta t-if="meta_description" name="description" t-att-content="meta_description"/>
<meta t-if="meta_keywords" name="keywords" t-att-content="meta_keywords"/>
<!-- OpenGraph tags for Facebook sharing -->
<meta property="og:title" t-att-content="additional_title" />
+2 -2
View File
@@ -23,8 +23,8 @@ class Event(models.Model):
website_menu = fields.Boolean(
'Dedicated Menu', compute='_compute_website_menu', inverse='_set_website_menu',
help="Creates menus Introduction, Location and Register on the page "
" of the event on the website.", store=True, copy=False)
menu_id = fields.Many2one('website.menu', 'Event Menu')
" of the event on the website.", store=True)
menu_id = fields.Many2one('website.menu', 'Event Menu', copy=False)
def _compute_is_participating(self):
# we don't allow public user to see participating label
+2 -2
View File
@@ -225,9 +225,9 @@ class WebsiteForum(http.Controller):
@http.route('/forum/get_url_title', type='json', auth="user", methods=['POST'], website=True)
def get_url_title(self, **kwargs):
try:
req = requests.get(kwargs.get('url'), stream=True)
req = requests.get(kwargs.get('url'))
req.raise_for_status()
arch = lxml.html.parse(req.raw)
arch = lxml.html.fromstring(req.content)
return arch.find(".//title").text
except IOError:
return False
@@ -70,9 +70,16 @@ class SaleOrder(models.Model):
require_payment = fields.Selection([
(0, 'Online Signature'),
(1, 'Online Payment')], default=_get_default_online_payment, string='Confirmation Mode',
compute='_compute_require_payment', store=True,
help="Choose how you want to confirm an order to launch the delivery process. You can either "
"request a digital signature or an upfront payment. With a digital signature, you can "
"request the payment when issuing the invoice.")
@api.one
@api.depends('template_id')
def _compute_require_payment(self):
# Do not overwrite if the field is already set
if self.template_id and (self.require_payment is None or self.require_payment is False):
self.require_payment = self.template_id.require_payment
@api.multi
def copy(self, default=None):
+4 -7
View File
@@ -600,14 +600,11 @@
<input type="hidden" t-if="len(product.product_variant_ids) == 1" class="product_id" name="product_id" t-att-value="product.product_variant_id.id"/>
<t t-if="len(product.product_variant_ids) &gt; 1">
<label label-default="label-default" class="radio" t-foreach="product.product_variant_ids" t-as="variant_id">
<input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else None" t-att-value="variant_id.id" t-att-data-lst_price="variant_id.lst_price" t-att-data-price="variant_id.price"/>
<input type="radio" name="product_id" class="js_product_change" t-att-checked="'checked' if variant_id_index == 0 else ''" t-att-value="variant_id.id" t-att-data-lst_price="compute_currency(variant_id.lst_price)" t-att-data-price="variant_id.price"/>
<span t-esc="variant_id.name_get()[0][1]"/>
<span class="badge" t-if="variant_id.price_extra">
<t t-esc="variant_id.price_extra > 0 and '+' or ''"/><span t-field="variant_id.price_extra" style="white-space: nowrap;" t-options='{
"widget": "monetary",
"from_currency": product.currency_id,
"display_currency": website.currency_id
}'/>
<t t-set="diff_price" t-value="variant_id.price - variant_id.product_tmpl_id.price"/>
<span class="badge" t-if="diff_price != 0">
<t t-esc="diff_price > 0 and '+' or '-'"/><span t-esc="abs(diff_price)" t-esc-options='{"widget": "monetary", "display_currency": "website.currency_id"}'/>
</span>
</label>
</t>
@@ -35,6 +35,16 @@ def test_parser():
types.Literal('null'),
])])
])
assert types.parse('Function<Array<Object[]>>') == types.Alt([
types.Type('Function', [types.Alt([
types.Type('Array', [types.Alt([
types.Type('Array', [
types.Type('Object', [])
])
])])
])])
])
def test_tokens():
toks = list(types.tokenize('A'))
@@ -64,6 +74,19 @@ def test_tokens():
(types.OP, '>')
]
toks = list(types.tokenize('Function<Array<Object[]>>'))
assert toks == [
(types.NAME, 'Function'),
(types.OP, '<'),
(types.NAME, 'Array'),
(types.OP, '<'),
(types.NAME, 'Object'),
(types.OP, '['),
(types.OP, ']'),
(types.OP, '>'),
(types.OP, '>')
]
def test_peekable():
p = types.Peekable(range(5))
@@ -29,6 +29,9 @@ def tokenize(typespec):
elif toktype == token.OP:
if string in '|<>[].,':
yield (OP, string)
elif string == '>>':
yield (OP, '>')
yield (OP, '>')
elif string == '*': # maybe?
yield (NAME, 'any')
elif string in '()':
+11
View File
@@ -0,0 +1,11 @@
France, 2018-02-16
I hereby agree to the terms of the Odoo Individual Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Olivier Sarrat olivier.sarrat@laposte.net https://github.com/osarrat
+11
View File
@@ -0,0 +1,11 @@
Nepal, 2018-02-09
I hereby agree to the terms of the Odoo Individual Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Subodh Dahal contact.subodhdahal@gmail.com https://github.com/SubodhDahal
+10
View File
@@ -0,0 +1,10 @@
Norway, 2018-02-08
I hereby agree to the terms of the Odoo Individual Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Marius Stedjan marius@stedjan.com https://github.com/willmakenoise
+2 -2
View File
@@ -54,7 +54,7 @@ Running the server
Maximum allowed virtual memory per worker. If the limit is exceeded,
the worker is killed and recycled at the end of the current request.
Defaults to 640MB.
Defaults to 2048MB.
.. option:: --limit-memory-hard <limit>
@@ -62,7 +62,7 @@ Running the server
immediately killed without waiting for the end of the current request
processing.
Defaults to 768MB.
Defaults to 2560MB.
.. option:: --limit-time-cpu <limit>
+14 -5
View File
@@ -214,6 +214,13 @@ Instead you must have a proxy redirecting requests whose URL starts with
``/longpolling/`` to the longpolling port. Other request should be proxied to
the :option:`normal HTTP port <odoo-bin --http-port>`
To achieve such a thing, you'll need to deploy a reverse proxy in front of Odoo,
like nginx or apache. When doing so, you'll need to forward some more http Headers
to Odoo, and activate the proxy_mode in Odoo configuration to have Odoo read those
headers.
Configuration sample
--------------------
@@ -316,16 +323,18 @@ in ``/etc/nginx/sites-enabled/odoo.conf`` set:
# log
access_log /var/log/nginx/odoo.access.log;
error_log /var/log/nginx/odoo.error.log;
# Redirect longpoll requests to odoo longpolling port
location /longpolling {
proxy_pass http://odoochat;
}
# Redirect requests to odoo backend server
location / {
proxy_redirect off;
proxy_pass http://odoo;
}
location /longpolling {
proxy_pass http://odoochat;
}
# common gzip
gzip_types text/css text/less text/plain text/xml application/xml application/json application/javascript;
gzip on;
+1 -1
View File
@@ -539,7 +539,7 @@ class Partner(models.Model):
self.ensure_one()
if self.company_name:
# Create parent company
values = dict(name=self.company_name, is_company=True)
values = dict(name=self.company_name, is_company=True, vat=self.vat)
values.update(self._update_fields_values(self._address_fields()))
new_company = self.create(values)
# Set new company as my parent
+2 -2
View File
@@ -459,7 +459,7 @@
<group col="4">
<field name="type"/>
<field name="name"/>
<field name="module"/>
<field name="module" can_create="false"/>
<field name="model"/>
<newline/>
<field name="date_update" />
@@ -495,7 +495,7 @@
<form string="ManyToMany Relations">
<group col="4">
<field name="name"/>
<field name="module"/>
<field name="module" can_create="false"/>
<field name="model"/>
<newline/>
<field name="date_update" />
+48 -1
View File
@@ -601,7 +601,54 @@ class test_m2m(CreatorCase):
['', u'export.many2many.other:13'],
])
# essentially same as o2m, so boring
def test_multiple_records_subfield(self):
r = self.make(self.commands)
xid = self.env['ir.model.data'].create({
'name': 'whopwhopwhop',
'module': '__t__',
'model': r._name,
'res_id': r.id,
}).complete_name
sids = [
self.env['ir.model.data'].create({
'name': sub.str,
'module': '__t__',
'model': sub._name,
'res_id': sub.id,
}).complete_name
for sub in r.value
]
r.invalidate_cache()
self.assertEqual(
r._export_rows([['value', 'id']]),
[['__t__.record000,__t__.record001,__t__.record010,__t__.record011,__t__.record100']]
)
self.assertEqual(
r.with_context(import_compat=True)._export_rows([['value', 'id']]),
[['__t__.record000,__t__.record001,__t__.record010,__t__.record011,__t__.record100']]
)
self.assertEqual(
r.with_context(import_compat=False)._export_rows([['id'], ['value', 'id'], ['value', 'value']]),
[
[xid, u'__t__.record000', u'4'],
[u'', u'__t__.record001', u'42'],
[u'', u'__t__.record010', u'36'],
[u'', u'__t__.record011', u'4'],
[u'', u'__t__.record100', u'13']
]
)
self.assertEqual(
r.with_context(import_compat=False)._export_rows([['id'], ['value', 'value'], ['value', 'id']]),
[
[xid, u'4', u'__t__.record000'],
[u'', u'42', u'__t__.record001'],
[u'', u'36', u'__t__.record010'],
[u'', u'4', u'__t__.record011'],
[u'', u'13', u'__t__.record100']
]
)
class test_function(CreatorCase):
+53 -37
View File
@@ -650,8 +650,22 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
:param fields: list of lists of fields to traverse
:return: list of lists of corresponding values
"""
import_compatible = self.env.context.get('import_compat', True)
lines = []
for record in self:
def splittor(rs):
""" Splits the self recordset in batches of 1000 (to avoid
entire-recordset-prefetch-effects) & removes the previous batch
from the cache after it's been iterated in full
"""
for idx in range(0, len(rs), 1000):
sub = rs[idx: idx+1000]
for rec in sub:
yield rec
rs.invalidate_cache(ids=sub.ids)
# memory stable but ends up prefetching 275 fields (???)
for idx, record in enumerate(splittor(self)):
# main line of record, initially empty
current = [''] * len(fields)
lines.append(current)
@@ -683,8 +697,9 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
else:
primary_done.append(name)
# This is a special case, its strange behavior is intended!
if field.type == 'many2many' and len(path) > 1 and path[1] == 'id':
# in import_compat mode, m2m should always be exported as
# a comma-separated list of xids in a single cell
if import_compatible and field.type == 'many2many' and len(path) > 1 and path[1] == 'id':
xml_ids = [r.__export_xml_id() for r in value]
current[i] = ','.join(xml_ids) or False
continue
@@ -2840,47 +2855,48 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
raise UserError(_('Unable to delete this document because it is used as a default property'))
# Delete the records' properties.
self.env['ir.property'].search([('res_id', 'in', refs)]).unlink()
with self.env.norecompute():
self.env['ir.property'].search([('res_id', 'in', refs)]).unlink()
self.check_access_rule('unlink')
self.check_access_rule('unlink')
cr = self._cr
Data = self.env['ir.model.data'].sudo().with_context({})
Defaults = self.env['ir.default'].sudo()
Attachment = self.env['ir.attachment']
cr = self._cr
Data = self.env['ir.model.data'].sudo().with_context({})
Defaults = self.env['ir.default'].sudo()
Attachment = self.env['ir.attachment']
for sub_ids in cr.split_for_in_conditions(self.ids):
query = "DELETE FROM %s WHERE id IN %%s" % self._table
cr.execute(query, (sub_ids,))
for sub_ids in cr.split_for_in_conditions(self.ids):
query = "DELETE FROM %s WHERE id IN %%s" % self._table
cr.execute(query, (sub_ids,))
# Removing the ir_model_data reference if the record being deleted
# is a record created by xml/csv file, as these are not connected
# with real database foreign keys, and would be dangling references.
#
# Note: the following steps are performed as superuser to avoid
# access rights restrictions, and with no context to avoid possible
# side-effects during admin calls.
data = Data.search([('model', '=', self._name), ('res_id', 'in', sub_ids)])
if data:
data.unlink()
# Removing the ir_model_data reference if the record being deleted
# is a record created by xml/csv file, as these are not connected
# with real database foreign keys, and would be dangling references.
#
# Note: the following steps are performed as superuser to avoid
# access rights restrictions, and with no context to avoid possible
# side-effects during admin calls.
data = Data.search([('model', '=', self._name), ('res_id', 'in', sub_ids)])
if data:
data.unlink()
# For the same reason, remove the defaults having some of the
# records as value
Defaults.discard_records(self.browse(sub_ids))
# For the same reason, remove the defaults having some of the
# records as value
Defaults.discard_records(self.browse(sub_ids))
# For the same reason, remove the relevant records in ir_attachment
# (the search is performed with sql as the search method of
# ir_attachment is overridden to hide attachments of deleted
# records)
query = 'SELECT id FROM ir_attachment WHERE res_model=%s AND res_id IN %s'
cr.execute(query, (self._name, sub_ids))
attachments = Attachment.browse([row[0] for row in cr.fetchall()])
if attachments:
attachments.unlink()
# For the same reason, remove the relevant records in ir_attachment
# (the search is performed with sql as the search method of
# ir_attachment is overridden to hide attachments of deleted
# records)
query = 'SELECT id FROM ir_attachment WHERE res_model=%s AND res_id IN %s'
cr.execute(query, (self._name, sub_ids))
attachments = Attachment.browse([row[0] for row in cr.fetchall()])
if attachments:
attachments.unlink()
# invalidate the *whole* cache, since the orm does not handle all
# changes made in the database, like cascading delete!
self.invalidate_cache()
# invalidate the *whole* cache, since the orm does not handle all
# changes made in the database, like cascading delete!
self.invalidate_cache()
# recompute new-style fields
if self.env.recompute and self._context.get('recompute', True):
+1 -1
View File
@@ -41,7 +41,7 @@ RPC_FAULT_CODE_ACCESS_ERROR = 4
def xmlrpc_handle_exception_int(e):
if isinstance(e, odoo.exceptions.UserError):
fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, odoo.tools.ustr(e.value))
fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, odoo.tools.ustr(e.name))
elif isinstance(e, odoo.exceptions.RedirectWarning):
fault = xmlrpclib.Fault(RPC_FAULT_CODE_WARNING, str(e))
elif isinstance(e, odoo.exceptions.MissingError):