[MERGE] forward port branch 11.0 up to 02ee3fd88e

This commit is contained in:
Christophe Simonis
2018-05-23 19:33:40 +02:00
40 changed files with 577 additions and 124 deletions
+5
View File
@@ -428,12 +428,17 @@ class account_payment(models.Model):
@api.multi
def button_invoices(self):
if self.partner_type == 'supplier':
views = [(self.env.ref('account.invoice_supplier_tree').id, 'tree'), (self.env.ref('account.invoice_supplier_form').id, 'form')]
else:
views = [(self.env.ref('account.invoice_tree').id, 'tree'), (self.env.ref('account.invoice_form').id, 'form')]
return {
'name': _('Paid Invoices'),
'view_type': 'form',
'view_mode': 'tree,form',
'res_model': 'account.invoice',
'view_id': False,
'views': views,
'type': 'ir.actions.act_window',
'domain': [('id', 'in', [x.id for x in self.invoice_ids])],
}
+20 -13
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import api, fields, models, _
from odoo.osv import expression
from odoo.exceptions import ValidationError
@@ -61,9 +62,9 @@ class AccountAnalyticAccount(models.Model):
@api.multi
def _compute_debit_credit_balance(self):
res_currency_obj = self.env['res.currency']
Curr = self.env['res.currency']
analytic_line_obj = self.env['account.analytic.line']
domain = [('account_id', 'in', self.mapped('id'))]
domain = [('account_id', 'in', self.ids)]
if self._context.get('from_date', False):
domain.append(('date', '>=', self._context['from_date']))
if self._context.get('to_date', False):
@@ -75,17 +76,23 @@ class AccountAnalyticAccount(models.Model):
domain.append(('company_id', 'in', self._context['company_ids']))
user_currency = self.env.user.company_id.currency_id
account_amounts = analytic_line_obj.search_read(domain, ['account_id', 'amount', 'currency_id'])
account_ids = set([line['account_id'][0] for line in account_amounts])
data_debit = {account_id: 0.0 for account_id in account_ids}
data_credit = {account_id: 0.0 for account_id in account_ids}
for account_amount in account_amounts:
currency_id = account_amount['currency_id'][0]
amount = res_currency_obj.browse(currency_id).compute(account_amount['amount'], user_currency)
if amount < 0.0:
data_debit[account_amount['account_id'][0]] += amount
else:
data_credit[account_amount['account_id'][0]] += amount
credit_groups = analytic_line_obj.read_group(
domain=domain + [('amount', '>=', 0.0)],
fields=['account_id', 'currency_id', 'amount'],
groupby=['account_id', 'currency_id'],
)
data_credit = defaultdict(float)
for l in credit_groups:
data_credit[l['account_id'][0]] += Curr.browse(l['currency_id'][0]).compute(l['amount'], user_currency)
debit_groups = analytic_line_obj.read_group(
domain=domain + [('amount', '<', 0.0)],
fields=['account_id', 'currency_id', 'amount'],
groupby=['account_id', 'currency_id'],
)
data_debit = defaultdict(float)
for l in debit_groups:
data_debit[l['account_id'][0]] += Curr.browse(l['currency_id'][0]).compute(l['amount'], user_currency)
for account in self:
account.debit = abs(data_debit.get(account.id, 0.0))
+7 -1
View File
@@ -655,7 +655,13 @@ class Meeting(models.Model):
}[data['rrule_type']]
deadline = fields.Datetime.from_string(data['stop'])
return deadline + relativedelta(**{delay: count * mult})
computed_final_date = False
while not computed_final_date and count > 0:
try: # may crash if year > 9999 (in case of recurring events)
computed_final_date = deadline + relativedelta(**{delay: count * mult})
except ValueError:
count -= data['interval']
return computed_final_date or deadline
return final_date
@api.multi
+4 -3
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# Copyright (C) David Arnold (devCO).
# Author David Arnold (devCO), dar@devco.co
# Copyright (C) David Arnold (XOE Solutions).
# Author David Arnold (XOE Solutions), dar@xoe.solutions
# Co-Authors Juan Pablo Aries (devCO), jpa@devco.co
# Hector Ivan Valencia Muñoz (TIX SAS)
# Nhomar Hernandez (Vauxoo)
@@ -13,7 +13,8 @@
'version': '0.8',
'category': 'Localization',
'description': 'Colombian Accounting and Tax Preconfiguration',
'author': 'David Arnold BA HSG (devCO)',
'author': 'David Arnold (XOE Solutions)',
'website': 'https://xoe.solutions',
'depends': [
'account',
],
+1
View File
@@ -3,6 +3,7 @@
# List of contributors:
# Jordi Esteve <jesteve@zikzakmedia.com>
# Ignacio Ibeas <ignacio@acysos.com>
# Dpto. Consultoría Grupo Opentia <consultoria@opentia.es>
# Pedro M. Baeza <pedro.baeza@tecnativa.com>
# Carlos Liébana <carlos.liebana@factorlibre.com>
@@ -113,6 +113,16 @@
<field name="chart_template_id" ref="account_chart_template_common"/>
</record>
<record id="fp_reagyp_a" model="account.fiscal.position.template">
<field name="name">REAGYP - Agricultura</field>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
</record>
<record id="fp_reagyp_gp" model="account.fiscal.position.template">
<field name="name">REAGYP - Ganadería y pesca</field>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
</record>
<!-- ************************************************************* -->
<!-- Fiscal Position Tax Templates -->
<!-- ************************************************************* -->
@@ -2889,6 +2899,28 @@
<field name="tax_dest_id" ref="account_tax_template_s_iva0_isp"/>
</record>
<!-- Régimen especial de Agricultura, Ganaderia y Pesca -->
<record id="fptt_reagyp_a_4b_1" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fp_reagyp_a"/>
<field name="tax_src_id" ref="l10n_es.account_tax_template_p_iva4_bc"/>
<field name="tax_dest_id" ref="l10n_es.account_tax_template_p_iva12_agr"/>
</record>
<record id="fptt_reagyp_a_4b_2" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fp_reagyp_a"/>
<field name="tax_src_id" ref="l10n_es.account_tax_template_p_iva4_bc"/>
<field name="tax_dest_id" ref="l10n_es.account_tax_template_p_irpf2"/>
</record>
<record id="fptt_reagyp_gp_4b_1" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fp_reagyp_gp"/>
<field name="tax_src_id" ref="l10n_es.account_tax_template_p_iva4_bc"/>
<field name="tax_dest_id" ref="account_tax_template_p_iva105_gan"/>
</record>
<record id="fptt_reagyp_gp_4b_2" model="account.fiscal.position.tax.template">
<field name="position_id" ref="fp_reagyp_gp"/>
<field name="tax_src_id" ref="l10n_es.account_tax_template_p_iva4_bc"/>
<field name="tax_dest_id" ref="l10n_es.account_tax_template_p_irpf2"/>
</record>
<!-- ************************************************************* -->
<!-- Fiscal Position Account Templates -->
<!-- ************************************************************* -->
+13
View File
@@ -1044,6 +1044,18 @@
<field name="tax_group_id" ref="tax_group_iva_12"/>
<field name="tag_ids" eval="[(6, False, [ref('mod_303_42')])]"/>
</record>
<record id="account_tax_template_p_iva105_gan" model="account.tax.template">
<field name="description">P_IVA105_GAN</field>
<field name="type_tax_use">purchase</field>
<field name="account_id" ref="l10n_es.account_common_472"/>
<field name="name">10,5% IVA Soportado régimen ganadero o pesca</field>
<field name="refund_account_id" ref="l10n_es.account_common_472"/>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
<field name="amount" eval="10.5"/>
<field name="amount_type">percent</field>
<field name="include_base_amount" eval="1"/>
<field name="tag_ids" eval="[(6, False, [ref('mod_303_42')])]"/>
</record>
<record id="account_tax_template_s_iva0_e" model="account.tax.template">
<field name="description">S_IVA0_E</field>
<field name="type_tax_use">sale</field>
@@ -1330,6 +1342,7 @@
<field name="refund_account_id" ref="l10n_es.account_common_4751"/>
<field name="chart_template_id" ref="l10n_es.account_chart_template_common"/>
<field name="amount" eval="-2"/>
<field name="sequence" eval="2"/>
<field name="amount_type">percent</field>
<field name="tax_group_id" ref="tax_group_retenciones_2"/>
<field name="tag_ids" eval="[(6, False, [ref('mod_111_08'), ref('mod_111_09')])]"/>
+10 -5
View File
@@ -124,7 +124,8 @@ class MailThread(models.AbstractModel):
followers = self.env['mail.followers'].sudo().search([
('res_model', '=', self._name),
('partner_id', operator, operand)])
return [('id', 'in', followers.mapped('res_id'))]
# using read() below is much faster than followers.mapped('res_id')
return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
@api.model
def _search_follower_channels(self, operator, operand):
@@ -137,7 +138,8 @@ class MailThread(models.AbstractModel):
followers = self.env['mail.followers'].sudo().search([
('res_model', '=', self._name),
('channel_id', operator, operand)])
return [('id', 'in', followers.mapped('res_id'))]
# using read() below is much faster than followers.mapped('res_id')
return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
@api.multi
@api.depends('message_follower_ids')
@@ -147,7 +149,8 @@ class MailThread(models.AbstractModel):
('res_id', 'in', self.ids),
('partner_id', '=', self.env.user.partner_id.id),
])
following_ids = followers.mapped('res_id')
# using read() below is much faster than followers.mapped('res_id')
following_ids = [res['res_id'] for res in followers.read(['res_id'])]
for record in self:
record.message_is_follower = record.id in following_ids
@@ -159,9 +162,11 @@ class MailThread(models.AbstractModel):
])
# Cases ('message_is_follower', '=', True) or ('message_is_follower', '!=', False)
if (operator == '=' and operand) or (operator == '!=' and not operand):
return [('id', 'in', followers.mapped('res_id'))]
# using read() below is much faster than followers.mapped('res_id')
return [('id', 'in', [res['res_id'] for res in followers.read(['res_id'])])]
else:
return [('id', 'not in', followers.mapped('res_id'))]
# using read() below is much faster than followers.mapped('res_id')
return [('id', 'not in', [res['res_id'] for res in followers.read(['res_id'])])]
@api.multi
def _get_message_unread(self):
+2
View File
@@ -32,6 +32,7 @@ function _readActivities(self, ids) {
model: 'mail.activity',
method: 'read',
args: [ids],
context: (self.record && self.record.getContext()) || self.getSession().user_context,
}).then(function (activities) {
// convert create_date and date_deadline to moments
_.each(activities, function (activity) {
@@ -108,6 +109,7 @@ var AbstractActivityField = AbstractField.extend({
method: 'action_feedback',
args: [[id]],
kwargs: {feedback: feedback},
context: this.record.getContext(),
});
},
_scheduleActivity: function (id, previous_activity_type_id, callback) {
+7 -1
View File
@@ -474,10 +474,16 @@ class MrpProduction(models.Model):
@api.multi
def _generate_workorders(self, exploded_boms):
workorders = self.env['mrp.workorder']
original_one = False
for bom, bom_data in exploded_boms:
# If the routing of the parent BoM and phantom BoM are the same, don't recreate work orders, but use one master routing
if bom.routing_id.id and (not bom_data['parent_line'] or bom_data['parent_line'].bom_id.routing_id.id != bom.routing_id.id):
workorders += self._workorders_create(bom, bom_data)
temp_workorders = self._workorders_create(bom, bom_data)
workorders += temp_workorders
if temp_workorders: # In order to avoid two "ending work orders"
if original_one:
temp_workorders[-1].next_work_order_id = original_one
original_one = temp_workorders[0]
return workorders
def _workorders_create(self, bom, bom_data):
+7 -1
View File
@@ -225,6 +225,7 @@ class MrpWorkorder(models.Model):
'done_wo': False,
'location_id': move.location_id.id,
'location_dest_id': move.location_dest_id.id,
'date': move.date,
})
qty_todo -= 1
elif float_compare(qty_todo, 0.0, precision_rounding=rounding) < 0:
@@ -416,7 +417,12 @@ class MrpWorkorder(models.Model):
@api.multi
def button_start(self):
# TDE CLEANME
self.ensure_one()
# As button_start is automatically called in the new view
if self.state in ('done', 'cancel'):
return True
# Need a loss in case of the real time exceeding the expected
timeline = self.env['mrp.workcenter.productivity']
if self.duration < self.duration_expected:
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type','=','productive')], limit=1)
@@ -449,6 +449,7 @@ PaymentScreenWidget.include({
order.selected_paymentline.paid = true;
order.selected_paymentline.mercury_swipe_pending = false;
order.selected_paymentline.mercury_amount = response.authorize;
order.selected_paymentline.set_amount(response.authorize);
order.selected_paymentline.mercury_card_number = decodedMagtek['number'];
order.selected_paymentline.mercury_card_brand = response.card_type;
order.selected_paymentline.mercury_card_owner_name = decodedMagtek['name'];
+1
View File
@@ -347,6 +347,7 @@ class PurchaseOrder(models.Model):
@api.multi
def print_quotation(self):
self.write({'state': "sent"})
return self.env.ref('purchase.report_purchase_quotation').report_action(self)
@api.multi
-5
View File
@@ -142,11 +142,6 @@
<field name="number_increment">1</field>
</record>
<record id="base.main_partner" model="res.partner">
<field name="property_stock_customer" model="stock.warehouse"
eval="obj().env.ref('stock.warehouse0').lot_stock_id.id"/>
</record>
<!-- create xml ids for demo data that are widely used in tests or in other codes, for more convenience -->
<function model="ir.model.data" name="_update">
<value>stock.location</value>
+8 -2
View File
@@ -28,12 +28,18 @@ class Company(models.Model):
location.sudo().write({'company_id': company.id})
company.write({'internal_transit_location_id': location.id})
warehouses = self.env['stock.warehouse'].search([('partner_id', '=', self.partner_id.id)])
warehouses.mapped('partner_id').with_context(force_company=self.id).write({
'property_stock_customer': location.id,
'property_stock_supplier': location.id,
})
@api.model
def create(self, vals):
company = super(Company, self).create(vals)
# multi-company rules prevents creating warehouse and sub-locations
company.create_transit_location()
# mutli-company rules prevents creating warehouse and sub-locations
self.env['stock.warehouse'].check_access_rights('create')
self.env['stock.warehouse'].sudo().create({'name': company.name, 'code': company.name[:5], 'company_id': company.id, 'partner_id': company.partner_id.id})
company.create_transit_location()
return company
+23 -20
View File
@@ -79,12 +79,6 @@ class StockQuant(models.Model):
if float_compare(quant.quantity, 1, precision_rounding=quant.product_uom_id.rounding) > 0 and quant.lot_id and quant.product_id.tracking == 'serial':
raise ValidationError(_('A serial number should only be linked to a single product.'))
@api.constrains('in_date', 'lot_id')
def check_in_date(self):
for quant in self:
if quant.in_date and not quant.lot_id:
raise ValidationError(_('An incoming date cannot be set to an untracked product.'))
@api.constrains('location_id')
def check_location_id(self):
for quant in self:
@@ -109,9 +103,9 @@ class StockQuant(models.Model):
@api.model
def _get_removal_strategy_order(self, removal_strategy):
if removal_strategy == 'fifo':
return 'in_date, id'
return 'in_date ASC NULLS FIRST, id'
elif removal_strategy == 'lifo':
return 'in_date desc, id desc'
return 'in_date DESC NULLS LAST, id desc'
raise UserError(_('Removal strategy %s not implemented.') % (removal_strategy,))
def _gather(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False):
@@ -134,7 +128,17 @@ class StockQuant(models.Model):
domain = expression.AND([[('owner_id', '=', owner_id and owner_id.id or False)], domain])
domain = expression.AND([[('location_id', '=', location_id.id)], domain])
return self.search(domain, order=removal_strategy_order)
# Copy code of _search for special NULLS FIRST/LAST order
self.sudo(self._uid).check_access_rights('read')
query = self._where_calc(domain)
self._apply_ir_rules(query, 'read')
from_clause, where_clause, where_clause_params = query.get_sql()
where_str = where_clause and (" WHERE %s" % where_clause) or ''
query_str = 'SELECT "%s".id FROM ' % self._table + from_clause + where_str + " ORDER BY "+ removal_strategy_order
self._cr.execute(query_str, where_clause_params)
res = self._cr.fetchall()
# No uniquify list necessary as auto_join is not applied anyways...
return self.browse([x[0] for x in res])
@api.model
def _get_available_quantity(self, product_id, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False):
@@ -196,17 +200,16 @@ class StockQuant(models.Model):
quants = self._gather(product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True)
rounding = product_id.uom_id.rounding
if lot_id:
incoming_dates = quants.mapped('in_date') # `mapped` already filtered out falsy items
incoming_dates = [fields.Datetime.from_string(incoming_date) for incoming_date in incoming_dates]
if in_date:
incoming_dates += [in_date]
# If multiple incoming dates are available for a given lot_id/package_id/owner_id, we
# consider only the oldest one as being relevant.
if incoming_dates:
in_date = fields.Datetime.to_string(min(incoming_dates))
else:
in_date = fields.Datetime.now()
incoming_dates = [d for d in quants.mapped('in_date') if d]
incoming_dates = [fields.Datetime.from_string(incoming_date) for incoming_date in incoming_dates]
if in_date:
incoming_dates += [in_date]
# If multiple incoming dates are available for a given lot_id/package_id/owner_id, we
# consider only the oldest one as being relevant.
if incoming_dates:
in_date = fields.Datetime.to_string(min(incoming_dates))
else:
in_date = fields.Datetime.now()
for quant in quants:
try:
+21 -14
View File
@@ -21,24 +21,31 @@ class MrpStockReport(models.TransientModel):
@api.model
def _get_move_lines(self, move_lines):
res = self.env['stock.move.line']
for move_line in move_lines:
lines_seen = move_lines
lines_todo = list(move_lines)
while lines_todo:
move_line = lines_todo.pop(0)
# if MTO
if move_line.move_id.move_orig_ids:
res |= move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered(
lambda m: m.lot_id.id == move_line.lot_id.id)
lines = move_line.move_id.move_orig_ids.mapped('move_line_ids').filtered(
lambda m: m.lot_id == move_line.lot_id
) - lines_seen
# if MTS
elif move_line:
lines = self.env['stock.move.line'].search([
('product_id', '=', move_line.product_id.id),
('lot_id', '=', move_line.lot_id.id),
('id', 'not in', lines_seen.ids),
('date', '<', move_line.date),
])
else:
if move_line:
res |= self.env['stock.move.line'].search([
('product_id', '=', move_line.product_id.id),
('lot_id', '=', move_line.lot_id.id),
('id', '!=', move_line.id),
('date', '<', move_line.date),
])
if res:
res |= self._get_move_lines(res)
return res
continue
lines_todo += list(lines)
lines_seen |= lines
return lines_seen - move_lines
lines_todo += list(lines)
lines_seen |= lines
return lines_seen - move_lines
@api.model
def get_lines(self, line_id=None, **kw):
+2 -1
View File
@@ -178,9 +178,10 @@ class Warehouse(models.Model):
ResCompany = self.env['res.company']
if company_id:
transit_loc = ResCompany.browse(company_id).internal_transit_location_id.id
self.env['res.partner'].browse(partner_id).with_context(force_company=company_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
else:
transit_loc = ResCompany._company_default_get('stock.warehouse').internal_transit_location_id.id
self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
self.env['res.partner'].browse(partner_id).write({'property_stock_customer': transit_loc, 'property_stock_supplier': transit_loc})
def create_sequences_and_picking_types(self):
IrSequenceSudo = self.env['ir.sequence'].sudo()
@@ -98,7 +98,7 @@
<span t-field="move.product_id.description_picking"/>
</td>
<td>
<span t-if="move.product_qty" t-esc="move.product_qty"/>
<span t-if="move.product_qty" t-field="move.product_qty"/>
<span t-if="not move.product_qty" t-esc="move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id, rounding_method='HALF-UP')"/>
<span t-field="move.product_id.uom_id" groups="product.group_uom"/>
</td>
+67
View File
@@ -185,6 +185,73 @@ class TestPickShip(TestStockCommon):
# the client picking should not be assigned anymore, as we returned partially what we took
self.assertEqual(picking_client.state, 'confirmed')
def test_mto_moves_return_return(self):
picking_pick, picking_client = self.create_pick_ship()
stock_location = self.env['stock.location'].browse(self.stock_location)
lot = self.env['stock.production.lot'].create({
'product_id': self.productA.id,
'name': '123456789'
})
self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 10.0, lot_id=lot)
picking_pick.action_assign()
picking_pick.move_lines[0].move_line_ids[0].qty_done = 10.0
picking_pick.button_validate()
self.assertEqual(picking_pick.state, 'done')
self.assertEqual(picking_client.state, 'assigned')
# return this picking
stock_return_picking = self.env['stock.return.picking']\
.with_context(active_ids=picking_pick.ids, active_id=picking_pick.ids[0])\
.create({})
stock_return_picking.product_return_moves.quantity = 10.0
stock_return_picking_action = stock_return_picking.create_returns()
return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id'])
return_pick.move_lines[0].move_line_ids[0].write({
'qty_done': 10.0,
'lot_id': lot.id,
})
return_pick.button_validate()
# return this return of this picking
stock_return_picking = self.env['stock.return.picking']\
.with_context(active_id=return_pick.id)\
.create({})
stock_return_picking.product_return_moves.quantity = 10.0
stock_return_picking_action = stock_return_picking.create_returns()
return_return_pick = self.env['stock.picking'].browse(stock_return_picking_action['res_id'])
return_return_pick.move_lines[0].move_line_ids[0].write({
'qty_done': 10.0,
'lot_id': lot.id,
})
return_return_pick.button_validate()
# test computation of traceability
vals = {
'line_id': 1,
'model_name': 'stock.move.line',
'level': 11,
'parent_quant': False,
}
lines = self.env['stock.traceability.report'].get_lines(
model_id=return_return_pick.move_line_ids[0].id,
stream='upstream',
**vals
)
self.assertEqual(
[l.get('res_id') for l in lines],
[return_return_pick.id, return_pick.id, picking_pick.id],
"Upstream computation from return of return worked"
)
lines = self.env['stock.traceability.report'].get_lines(
model_id=picking_pick.move_line_ids[0].id,
stream='downstream',
**vals
)
self.assertEqual(
[l.get('res_id') for l in lines],
[picking_pick.id, return_pick.id, return_return_pick.id],
"Downstream computation from original picking worked"
)
def test_mto_resupply_cancel_ship(self):
""" This test simulates a pick pack ship with a resupply route
set. Pick and pack are validated, ship is cancelled. This test
+46 -1
View File
@@ -629,7 +629,24 @@ class StockQuant(TransactionCase):
})
quantity, in_date = self.env['stock.quant']._update_available_quantity(product1, stock_location, 1.0)
self.assertEqual(quantity, 1)
self.assertEqual(in_date, None)
self.assertNotEqual(in_date, None)
def test_in_date_1b(self):
stock_location = self.env.ref('stock.stock_location_stock')
product1 = self.env['product.product'].create({
'name': 'Product A',
'type': 'product',
})
self.env['stock.quant'].create({
'product_id': product1.id,
'location_id': stock_location.id,
'quantity': 1.0,
})
quantity, in_date = self.env['stock.quant']._update_available_quantity(product1, stock_location, 2.0)
self.assertEqual(quantity, 3)
self.assertNotEqual(in_date, None)
def test_in_date_2(self):
""" Check that an incoming date is correctly set when updating the quantity of a tracked
@@ -707,6 +724,34 @@ class StockQuant(TransactionCase):
# Removal strategy is LIFO, so lot1 should be received as it was received later.
self.assertEqual(quants[0][0].lot_id.id, lot1.id)
def test_in_date_4b(self):
""" Check for LIFO and max with/without in_date that it handles the LIFO NULLS LAST well
"""
stock_location = self.env.ref('stock.stock_location_stock')
stock_location1 = self.env.ref('stock.stock_location_components')
stock_location2 = self.env.ref('stock.stock_location_14')
lifo_strategy = self.env['product.removal'].search([('method', '=', 'lifo')])
stock_location.removal_strategy_id = lifo_strategy
product1 = self.env['product.product'].create({
'name': 'Product A',
'type': 'product',
'tracking': 'serial',
})
self.env['stock.quant'].create({
'product_id': product1.id,
'location_id': stock_location1.id,
'quantity': 1.0,
})
in_date_location2 = datetime.now()
self.env['stock.quant']._update_available_quantity(product1, stock_location2, 1.0, in_date=in_date_location2)
quants = self.env['stock.quant']._update_reserved_quantity(product1, stock_location, 1)
# Removal strategy is LIFO, so the one with date is the most recent one and should be selected
self.assertEqual(quants[0][0].location_id.id, stock_location2.id)
def test_in_date_5(self):
""" Receive the same lot at different times, once they're in the same location, the quants
are merged and only the earliest incoming date is kept.
+6 -2
View File
@@ -1047,8 +1047,12 @@ class Binary(http.Controller):
elif status != 200 and download:
return request.not_found()
height = int(height or 0)
width = int(width or 0)
if headers and dict(headers).get('Content-Type', '') == 'image/svg+xml': # we shan't resize svg images
height = 0
width = 0
else:
height = int(height or 0)
width = int(width or 0)
if crop and (width or height):
content = crop_image(content, type='center', size=(width, height), ratio=(1, 1))
+14 -6
View File
@@ -260,12 +260,20 @@ function get_file(options) {
if (options.error) {
var body = this.contentDocument.body;
var nodes = body.children.length === 0 ? body.childNodes : body.children;
options.error({
message: nodes.length > 1 ? nodes[1].textContent : '',
data: {
title: nodes.length > 0 ? nodes[0].textContent : '',
},
});
var errorParams = {};
try { // Case of a serialized Odoo Exception: It is Json Parsable
var node = nodes[1] || nodes[0];
errorParams = JSON.parse(node.textContent);
} catch (e) { // Arbitrary uncaught python side exception
errorParams = {
message: nodes.length > 1 ? nodes[1].textContent : '',
data: {
title: nodes.length > 0 ? nodes[0].textContent : '',
}
}
}
options.error(errorParams);
}
} finally {
complete();
@@ -1542,8 +1542,10 @@ var BasicModel = AbstractModel.extend({
rec = self._makeDataPoint(params);
list._cache[rec.res_id] = rec.id;
}
rec._noAbandon = true;
// Do not abandon the record if it has been created
// from `default_get`. The list has a savepoint only
// after having fully executed `default_get`.
rec._noAbandon = !list._savePoint;
list._changes.push({operation: 'ADD', id: rec.id});
if (command[0] === 1) {
list._changes.push({operation: 'UPDATE', id: rec.id});
@@ -1962,6 +1964,9 @@ var BasicModel = AbstractModel.extend({
var records = [];
var ids = [];
list = this._applyX2ManyOperations(list);
if (_.isEmpty(list.data)) {
return $.when();
}
_.each(list.data, function (localId) {
var record = self.localData[localId];
var data = record._changes || record.data;
@@ -1975,13 +1980,16 @@ var BasicModel = AbstractModel.extend({
return this._rpc({
model: model,
method: 'name_get',
args: [ids],
args: [_.uniq(ids)],
context: list.context,
})
.then(function (name_gets) {
for (var i = 0; i < name_gets.length; i++) {
records[i].data.display_name = name_gets[i][1];
}
_.each(records, function (record) {
var nameGet = _.find(name_gets, function (nameGet) {
return nameGet[0] === record.data.id;
});
record.data.display_name = nameGet[1];
});
});
},
/**
@@ -2467,7 +2475,16 @@ var BasicModel = AbstractModel.extend({
var self = this;
var def;
if (list.static) {
def = this._readUngroupedList(list);
def = this._readUngroupedList(list).then(function () {
if (list.parentID && self.isNew(list.parentID)) {
// list from a default_get, so fetch display_name for many2one fields
var many2ones = self._getMany2OneFieldNames(list);
var defs = _.map(many2ones, function (name) {
return self._fetchNameGets(list, name);
});
return $.when.apply($, defs);
}
});
} else {
def = this._searchReadUngroupedList(list);
}
@@ -3035,6 +3052,23 @@ var BasicModel = AbstractModel.extend({
var viewType = options && options.viewType || element.viewType;
return Object.keys(fieldsInfo && fieldsInfo[viewType] || {});
},
/**
* Get many2one fields names in a datapoint. This is useful in order to
* fetch their names in the case of a default_get.
*
* @private
* @param {Object} datapoint a valid resource object
* @returns {string[]} list of field names that are many2one
*/
_getMany2OneFieldNames: function (datapoint) {
var many2ones = [];
_.each(datapoint.fields, function (field, name) {
if (field.type === 'many2one') {
many2ones.push(name);
}
});
return many2ones;
},
/**
* Evaluate the record evaluation context. This method is supposed to be
* called by _getEvalContext. It basically only generates a dictionary of
@@ -1237,8 +1237,167 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('list in form: discard newly added element with empty required field (onchange)', function (assert) {
assert.expect(8);
QUnit.test('list in form: name_get with unique ids (default_get)', function (assert) {
assert.expect(2);
this.data.partner.records[0].display_name = "MyTrululu";
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<sheet>' +
'<field name="p">' +
'<tree editable="bottom">' +
'<field name="trululu"/>' +
'</tree>' +
'</field>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'default_get') {
return $.when({
p: [
[0, 0, { trululu: 1 }],
[0, 0, { trululu: 1 }]
]
});
}
if (args.method === 'name_get') {
assert.deepEqual(args.args[0], _.uniq(args.args[0]),
"should not have duplicates in name_get rpc");
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(form.$('td.o_data_cell').text(), "MyTrululuMyTrululu",
"both records should have the correct display_name for trululu field");
form.destroy();
});
QUnit.test('list in form: show name of many2one fields in multi-page (default_get)', function (assert) {
assert.expect(4);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<sheet>' +
'<field name="p">' +
'<tree editable="bottom" limit="1">' +
'<field name="display_name"/>' +
'<field name="trululu"/>' +
'</tree>' +
'</field>' +
'</sheet>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'default_get') {
return $.when({
p: [
[0, 0, { display_name: 'record1', trululu: 1 }],
[0, 0, { display_name: 'record2', trululu: 2 }]
]
});
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(form.$('td.o_data_cell').first().text(),
"record1", "should show display_name of 1st record");
assert.strictEqual(form.$('td.o_data_cell').first().next().text(),
"first record", "should show display_name of trululu of 1st record");
form.$('button.o_pager_next').click();
assert.strictEqual(form.$('td.o_data_cell').first().text(),
"record2", "should show display_name of 2nd record");
assert.strictEqual(form.$('td.o_data_cell').first().next().text(),
"second record", "should show display_name of trululu of 2nd record");
form.destroy();
});
QUnit.test('list in form: discard newly added element with empty required field (onchange in default_get)', function (assert) {
// variant of the test "list in form: discard newly added element with
// empty required field (default_get)", in which the `default_get`
// performs an `onchange` at the same time. This `onchange` may create
// some records, which should not be abandoned on discard, similarly
// to records created directly by `default_get`
assert.expect(7);
var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
this.data.partner.onchanges = {
product_id: function (obj) {
if (obj.product_id === 37) {
obj.p = [[0, 0, { display_name: "entry", trululu: false }]];
}
},
};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<field name="product_id"/>' +
'<field name="p">' +
'<tree editable="bottom">' +
'<field name="display_name"/>' +
'<field name="trululu" required="1"/>' +
'</tree>' +
'</field>' +
'</form>',
mockRPC: function (route, args) {
if (args.method === 'default_get') {
return $.when({
product_id: 37,
});
}
return this._super.apply(this, arguments);
},
});
// check that there is a record in the editable list with empty string as required field
assert.strictEqual(form.$('.o_data_row').length, 1,
"should have a row in the editable list");
assert.strictEqual($('td.o_data_cell').first().text(), "entry",
"should have the correct displayed name");
var requiredField = $('td.o_data_cell.o_required_modifier');
assert.strictEqual(requiredField.length, 1,
"should have a required field on this record");
assert.strictEqual(requiredField.text(), "",
"should have empty string in the required field on this record");
// click on empty required field in editable list record
requiredField.click();
// click off so that the required field still stay empty
$('body').click();
// record should not be dropped
assert.strictEqual(form.$('.o_data_row').length, 1,
"should not have dropped record in the editable list");
assert.strictEqual($('td.o_data_cell').first().text(), "entry",
"should still have the correct displayed name");
assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
"should still have empty string in the required field");
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
form.destroy();
});
QUnit.test('list in form: discard newly added element with empty required field (onchange after default_get)', function (assert) {
// discarding a record from an `onchange` in a `default_get` should not
// abandon the record. However, any `onchange` after `default_get`
// should be abandoned on discard
assert.expect(6);
var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY;
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
@@ -1249,7 +1408,6 @@ QUnit.module('relational_fields', {
obj.p = [[0, 0, { display_name: "entry", trululu: false }]];
}
},
trululu: function () {},
};
var form = createView({
@@ -1291,13 +1449,9 @@ QUnit.module('relational_fields', {
// click off so that the required field still stay empty
$('body').click();
// record should not be dropped
assert.strictEqual(form.$('.o_data_row').length, 1,
"should not have droppped record in the editable list");
assert.strictEqual($('td.o_data_cell').first().text(), "entry",
"should still have the correct displayed name");
assert.strictEqual($('td.o_data_cell.o_required_modifier').text(), "",
"should still have empty string in the required field");
// record should be dropped
assert.strictEqual(form.$('.o_data_row').length, 0,
"should have dropped record in the editable list");
relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
form.destroy();
+1
View File
@@ -112,6 +112,7 @@ class WebsiteForm(http.Controller):
'record': {}, # Values to create record
'attachments': [], # Attached files
'custom': '', # Custom fields values
'meta': '', # Add metadata if enabled
}
authorized_fields = model.sudo()._get_form_writable_fields()
@@ -77,7 +77,7 @@ tour.register('shop_buy_product', {
content: "Pay Now",
//Either there are multiple payment methods, and one is checked, either there is only one, and therefore there are no radio inputs
extra_trigger: '#payment_method label:contains("Wire Transfer") input:checked,#payment_method:not(:has("input:radio:visible"))',
trigger: 'button[id="o_payment_form_pay"]:visible',
trigger: 'button[id="o_payment_form_pay"]:visible:not(:disabled)',
},
{
content: "finish",
@@ -4,7 +4,6 @@
from odoo import http, _
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.tools import float_repr
class WebsiteSaleDelivery(WebsiteSale):
@@ -46,9 +45,6 @@ class WebsiteSaleDelivery(WebsiteSale):
has_stockable_products = any(line.product_id.type in ['consu', 'product'] for line in order.order_line)
if has_stockable_products:
if order.carrier_id and not order.delivery_rating_success:
values['errors'].append(
(_("Ouch, you cannot choose this carrier!"),
_("%s does not ship to your address, please choose another one.\n(Error: %s)" % (order.carrier_id.name, order.delivery_message))))
order._remove_delivery_line()
delivery_carriers = order._get_delivery_methods()
@@ -67,8 +63,15 @@ class WebsiteSaleDelivery(WebsiteSale):
return {'status': order.delivery_rating_success,
'error_message': order.delivery_message,
'carrier_id': carrier_id,
'new_amount_delivery': float_repr(currency.round(order.delivery_price), currency.decimal_places),
'new_amount_untaxed': order.amount_untaxed,
'new_amount_tax': order.amount_tax,
'new_amount_total': order.amount_total,
'new_amount_delivery': self._format_amount(order.delivery_price, currency),
'new_amount_untaxed': self._format_amount(order.amount_untaxed, currency),
'new_amount_tax': self._format_amount(order.amount_tax, currency),
'new_amount_total': self._format_amount(order.amount_total, currency),
}
def _format_amount(self, amount, currency):
fmt = "%.{0}f".format(currency.decimal_places)
lang = request.env['res.lang']._lang_get(request.env.context.get('lang') or 'en_US')
return lang.format(fmt, currency.round(amount), grouping=True, monetary=True)\
.replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'\u2011')
@@ -65,12 +65,11 @@ class SaleOrder(models.Model):
carrier = delivery
break
self.write({'carrier_id': carrier.id})
self._remove_delivery_line()
if carrier:
self.get_delivery_price()
if self.delivery_rating_success:
self.set_delivery_line()
else:
self._remove_delivery_line()
return bool(carrier)
@@ -27,6 +27,10 @@ odoo.define('website_sale_delivery.checkout', function (require) {
else {
console.error(result.error_message);
$compute_badge.text(result.error_message);
$amount_delivery.text(result.new_amount_delivery);
$amount_untaxed.text(result.new_amount_untaxed);
$amount_tax.text(result.new_amount_tax);
$amount_total.text(result.new_amount_total);
}
};
@@ -40,6 +44,12 @@ odoo.define('website_sale_delivery.checkout', function (require) {
var $carriers = $("#delivery_carrier input[name='delivery_type']");
$carriers.click(_onCarrierClick);
// Workaround to:
// - update the amount/error on the label at first rendering
// - prevent clicking on 'Pay Now' if the shipper rating fails
if ($carriers.length > 0) {
$carriers.filter(':checked').click();
}
/* Handle stuff */
$(".oe_website_sale select[name='shipping_id']").on('change', function () {
@@ -3,7 +3,7 @@
<template id="cart_delivery" name="Delivery Costs" inherit_id="website_sale.total">
<xpath expr="//div[@id='order_total_untaxed']" position="before">
<div class="row" id="order_delivery" t-if="website_sale_order and website_sale_order.has_delivery">
<div class="row" id="order_delivery" t-if="website_sale_order and website_sale_order.carrier_id">
<span class="col-xs-6 text-right text-muted" title="Delivery will be updated after choosing a new delivery method"> Delivery:</span>
<span class="col-xs-6 text-right-not-xs text-left-xs text-muted">
<span t-field="website_sale_order.amount_delivery" style="white-space: nowrap;" t-options='{
+16
View File
@@ -0,0 +1,16 @@
Spain, 2018-05-12
Acysos S.L. agrees to the terms of the Odoo Corporate Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Ignacio Ibeas ignacio@acysos.com https://github.com/acysos
List of contributors:
Ignacio Ibeas ignacio@acysos.com https://github.com/acysos
Alex Ezquebo alexander@acysos.com https://github.com/acysos
+11
View File
@@ -0,0 +1,11 @@
Italy, 2018-05-17
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,
Samuele Carli <carlisamuele@csspace.net> https://github.com/WohthaN
@@ -397,6 +397,7 @@ state_it_sv,it,"Savona","SV"
state_it_si,it,"Siena","SI"
state_it_sr,it,"Siracusa","SR"
state_it_so,it,"Sondrio","SO"
state_it_su,it,"Sud Sardegna","SU"
state_it_ta,it,"Taranto","TA"
state_it_te,it,"Teramo","TE"
state_it_tr,it,"Terni","TR"
1 id country_id:id name code
397 state_it_si it Siena SI
398 state_it_sr it Siracusa SR
399 state_it_so it Sondrio SO
400 state_it_su it Sud Sardegna SU
401 state_it_ta it Taranto TA
402 state_it_te it Teramo TE
403 state_it_tr it Terni TR
@@ -1123,6 +1123,7 @@
<field file="base/static/img/country_flags/mx.png" name="image" type="base64" />
<field name="currency_id" ref="MXN" />
<field eval="52" name="phone_code" />
<field name="vat_label">RFC</field>
</record>
<record id="my" model="res.country">
<field name="name">Malaysia</field>
+1 -1
View File
@@ -236,7 +236,7 @@
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_global_click">
<div class="o_kanban_image">
<img t-att-src="kanban_image('res.users', 'image_medium', record.id.value)"/>
<img t-att-src="kanban_image('res.users', 'image_medium', record.id.raw_value)"/>
</div>
<div class="oe_kanban_details">
<ul>
+1 -1
View File
@@ -166,7 +166,7 @@ def redirect_with_hash(url, code=303):
# FIXME: decide whether urls should be bytes or text, apparently
# addons/website/controllers/main.py:91 calls this with a bytes url
# but addons/web/controllers/main.py:481 uses text... (blows up on login)
url = pycompat.to_text(url)
url = pycompat.to_text(url).strip()
if urls.url_parse(url, scheme='http').scheme not in ('http', 'https'):
url = u'http://' + url
url = url.replace("'", "%27").replace("<", "%3C")
+5 -5
View File
@@ -130,7 +130,7 @@ class Registry(Mapping):
self.loaded = False # whether all modules are loaded
self.ready = False # whether everything is set up
# Inter-process signaling (used only when odoo.multi_process is True):
# Inter-process signaling:
# The `base_registry_signaling` sequence indicates the whole registry
# must be reloaded.
# The `base_cache_signaling sequence` indicates all caches must be
@@ -358,7 +358,7 @@ class Registry(Mapping):
def setup_signaling(self):
""" Setup the inter-process signaling on this registry. """
if not odoo.multi_process:
if self.in_test_mode():
return
with self.cursor() as cr:
@@ -384,7 +384,7 @@ class Registry(Mapping):
""" Check whether the registry has changed, and performs all necessary
operations to update the registry. Return an up-to-date registry.
"""
if not odoo.multi_process:
if self.in_test_mode():
return self
with closing(self.cursor()) as cr:
@@ -410,7 +410,7 @@ class Registry(Mapping):
def signal_changes(self):
""" Notifies other processes if registry or cache has been invalidated. """
if odoo.multi_process and self.registry_invalidated:
if self.registry_invalidated and not self.in_test_mode():
_logger.info("Registry changed, signaling through the database")
with closing(self.cursor()) as cr:
cr.execute("select nextval('base_registry_signaling')")
@@ -418,7 +418,7 @@ class Registry(Mapping):
# no need to notify cache invalidation in case of registry invalidation,
# because reloading the registry implies starting with an empty cache
elif odoo.multi_process and self.cache_invalidated:
elif self.cache_invalidated and not self.in_test_mode():
_logger.info("At least one model cache has been invalidated, signaling through the database.")
with closing(self.cursor()) as cr:
cr.execute("select nextval('base_cache_signaling')")
+7 -9
View File
@@ -92,9 +92,6 @@ class RequestHandler(werkzeug.serving.WSGIRequestHandler):
me = threading.currentThread()
me.name = 'odoo.service.http.request.%s' % (me.ident,)
# _reexec() should set LISTEN_* to avoid connection refused during reload time. It
# should also work with systemd socket activation. This is currently untested
# and not yet used.
class ThreadedWSGIServerReloadable(LoggingBaseWSGIServerMixIn, werkzeug.serving.ThreadedWSGIServer):
""" werkzeug Threaded WSGI Server patched to allow reusing a listen socket
@@ -106,14 +103,15 @@ class ThreadedWSGIServerReloadable(LoggingBaseWSGIServerMixIn, werkzeug.serving.
handler=RequestHandler)
def server_bind(self):
envfd = os.environ.get('LISTEN_FDS')
if envfd and os.environ.get('LISTEN_PID') == str(os.getpid()):
SD_LISTEN_FDS_START = 3
if os.environ.get('LISTEN_FDS') == '1' and os.environ.get('LISTEN_PID') == str(os.getpid()):
self.reload_socket = True
self.socket = socket.fromfd(int(envfd), socket.AF_INET, socket.SOCK_STREAM)
# should we os.close(int(envfd)) ? it seem python duplicate the fd.
self.socket = socket.fromfd(SD_LISTEN_FDS_START, socket.AF_INET, socket.SOCK_STREAM)
_logger.info('HTTP service (werkzeug) running through socket activation')
else:
self.reload_socket = False
super(ThreadedWSGIServerReloadable, self).server_bind()
_logger.info('HTTP service (werkzeug) running on %s:%s', self.server_name, self.server_port)
def server_activate(self):
if not self.reload_socket:
@@ -260,7 +258,6 @@ class ThreadedServer(CommonServer):
t = threading.Thread(target=self.http_thread, name="odoo.service.httpd")
t.setDaemon(True)
t.start()
_logger.info('HTTP service (werkzeug) running on %s:%s', self.interface, self.port)
def start(self, stop=False):
_logger.debug("Setting signal handlers")
@@ -887,7 +884,8 @@ def _reexec(updated_modules=None):
args += ["-u", ','.join(updated_modules)]
if not args or args[0] != exe:
args.insert(0, exe)
os.execv(sys.executable, args)
# We should keep the LISTEN_* environment variabled in order to support socket activation on reexec
os.execve(sys.executable, args, os.environ)
def load_test_file_py(registry, test_file):
# Locate python module based on its filename and run the tests
+6 -3
View File
@@ -10,7 +10,7 @@ import socket
import threading
import time
from email.header import decode_header
from email.header import decode_header, Header
from email.utils import getaddresses, formataddr
from lxml import etree
@@ -524,11 +524,14 @@ def decode_smtp_header(smtp_header):
text. email.header decode_header method return a decoded string and its
charset for each decoded par of the header. This method unicodes the
decoded header and join them in a complete string. """
if isinstance(smtp_header, Header):
smtp_header = ustr(smtp_header)
if smtp_header:
text = decode_header(smtp_header.replace('\r', ''))
# The joining space will not be needed as of Python 3.3
# See https://hg.python.org/cpython/rev/8c03fe231877
return ' '.join([ustr(x[0], x[1]) for x in text])
# See https://github.com/python/cpython/commit/07ea53cb218812404cdbde820647ce6e4b2d0f8e
sep = ' ' if pycompat.PY2 else ''
return sep.join([ustr(x[0], x[1]) for x in text])
return u''
# was mail_thread.decode_header()