[MERGE] forward port branch saas-11.3 up to 609491ad0e
This commit is contained in:
@@ -391,6 +391,9 @@ class AccountMove(models.Model):
|
||||
date = date or fields.Date.today()
|
||||
reversed_moves = self.env['account.move']
|
||||
for ac_move in self:
|
||||
#unreconcile all lines reversed
|
||||
aml = ac_move.line_ids.filtered(lambda x: x.account_id.reconcile or x.account_id.internal_type == 'liquidity')
|
||||
aml.remove_move_reconcile()
|
||||
reversed_move = ac_move._reverse_move(date=date,
|
||||
journal_id=journal_id,
|
||||
auto=auto)
|
||||
@@ -464,7 +467,7 @@ class AccountMoveLine(models.Model):
|
||||
for unreconciled lines, and something in-between for partially reconciled lines.
|
||||
"""
|
||||
for line in self:
|
||||
if not line.account_id.reconcile:
|
||||
if not line.account_id.reconcile and line.account_id.internal_type != 'liquidity':
|
||||
line.reconciled = False
|
||||
line.amount_residual = 0
|
||||
line.amount_residual_currency = 0
|
||||
@@ -607,7 +610,7 @@ class AccountMoveLine(models.Model):
|
||||
help="This field is used for payable and receivable journal entries. You can put the limit date for the payment of this line.")
|
||||
date = fields.Date(related='move_id.date', string='Date', index=True, store=True, copy=False) # related is required
|
||||
analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines")
|
||||
tax_ids = fields.Many2many('account.tax', string='Taxes')
|
||||
tax_ids = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)])
|
||||
tax_line_id = fields.Many2one('account.tax', string='Originator tax', ondelete='restrict')
|
||||
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', index=True)
|
||||
analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags')
|
||||
@@ -1553,7 +1556,6 @@ class AccountPartialReconcile(models.Model):
|
||||
""" When removing a partial reconciliation, also unlink its full reconciliation if it exists """
|
||||
full_to_unlink = self.env['account.full.reconcile']
|
||||
for rec in self:
|
||||
#without the deleted partial reconciliations, the full reconciliation won't be full anymore
|
||||
if rec.full_reconcile_id:
|
||||
full_to_unlink |= rec.full_reconcile_id
|
||||
#reverse the tax basis move created at the reconciliation time
|
||||
|
||||
@@ -817,6 +817,74 @@ class TestReconciliation(AccountingTestCase):
|
||||
self.assertEqual(positive_line[0]['amount'], 50.0, 'The amount of the amls should be 50')
|
||||
self.assertEqual(negative_line[0]['amount'], -50.0, 'The amount of the amls should be -50')
|
||||
|
||||
def test_revert_payment_and_reconcile_exchange(self):
|
||||
|
||||
# A reversal of a reconciled payment which created a currency exchange entry, should create reversal moves
|
||||
# which move lines should be reconciled two by two with the original move's lines
|
||||
|
||||
def _determine_debit_credit_line(move):
|
||||
line_ids_reconciliable = move.line_ids.filtered(lambda l: l.account_id.reconcile or l.account_id.internal_type == 'liquidity')
|
||||
return line_ids_reconciliable.filtered(lambda l: l.debit), line_ids_reconciliable.filtered(lambda l: l.credit)
|
||||
|
||||
def _move_revert_test_pair(move, revert):
|
||||
self.assertTrue(move.line_ids)
|
||||
self.assertTrue(revert.line_ids)
|
||||
|
||||
move_lines = _determine_debit_credit_line(move)
|
||||
revert_lines = _determine_debit_credit_line(revert)
|
||||
|
||||
# in the case of the exchange entry, only one pair of lines will be found
|
||||
if move_lines[0] and revert_lines[1]:
|
||||
self.assertTrue(move_lines[0].full_reconcile_id.exists())
|
||||
self.assertEqual(move_lines[0].full_reconcile_id.id, revert_lines[1].full_reconcile_id.id)
|
||||
|
||||
if move_lines[1] and revert_lines[0]:
|
||||
self.assertTrue(move_lines[1].full_reconcile_id.exists())
|
||||
self.assertEqual(move_lines[1].full_reconcile_id.id, revert_lines[0].full_reconcile_id.id)
|
||||
|
||||
self.env['res.currency.rate'].create({
|
||||
'name': time.strftime('%Y') + '-07-01',
|
||||
'rate': 1.0,
|
||||
'currency_id': self.currency_usd_id,
|
||||
'company_id': self.env.ref('base.main_company').id
|
||||
})
|
||||
self.env['res.currency.rate'].create({
|
||||
'name': time.strftime('%Y') + '-08-01',
|
||||
'rate': 0.5,
|
||||
'currency_id': self.currency_usd_id,
|
||||
'company_id': self.env.ref('base.main_company').id
|
||||
})
|
||||
inv = self.create_invoice(invoice_amount=111, currency_id=self.currency_usd_id)
|
||||
payment = self.env['account.payment'].create({
|
||||
'payment_type': 'inbound',
|
||||
'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
|
||||
'partner_type': 'customer',
|
||||
'partner_id': self.partner_agrolait_id,
|
||||
'amount': 111,
|
||||
'currency_id': self.currency_usd_id,
|
||||
'journal_id': self.bank_journal_usd.id,
|
||||
'payment_date': time.strftime('%Y') + '-08-01',
|
||||
})
|
||||
payment.post()
|
||||
|
||||
credit_aml = payment.move_line_ids.filtered('credit')
|
||||
inv.assign_outstanding_credit(credit_aml.id)
|
||||
self.assertTrue(inv.state == 'paid', 'The invoice should be paid')
|
||||
|
||||
exchange_reconcile = payment.move_line_ids.mapped('full_reconcile_id')
|
||||
exchange_move = exchange_reconcile.exchange_move_id
|
||||
payment_move = payment.move_line_ids[0].move_id
|
||||
|
||||
reverted_payment_move = self.env['account.move'].browse(payment_move.reverse_moves(time.strftime('%Y') + '-08-01'))
|
||||
|
||||
# After reversal of payment, the invoice should be open
|
||||
self.assertTrue(inv.state == 'open', 'The invoice should be open again')
|
||||
self.assertFalse(exchange_reconcile.exists())
|
||||
|
||||
reverted_exchange_move = self.env['account.move'].search([('journal_id', '=', exchange_move.journal_id.id), ('ref', 'ilike', exchange_move.name)], limit=1)
|
||||
_move_revert_test_pair(payment_move, reverted_payment_move)
|
||||
_move_revert_test_pair(exchange_move, reverted_exchange_move)
|
||||
|
||||
def test_partial_reconcile_currencies_02(self):
|
||||
####
|
||||
# Day 1: Invoice Cust/001 to customer (expressed in USD)
|
||||
|
||||
@@ -1381,7 +1381,7 @@
|
||||
<filter string="Partner" name="partner" domain="[]" context="{'group_by':'partner_id'}"/>
|
||||
<filter string="Journal" name="journal" domain="[]" context="{'group_by':'journal_id'}"/>
|
||||
<filter string="Account" name="account" context="{'group_by':'account_id'}"/>
|
||||
<filter string="Date" name="date_filter" domain="[]" context="{'group_by':'date'}"/>
|
||||
<filter string="Date" name="groupby_date" domain="[]" context="{'group_by':'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
|
||||
@@ -131,6 +131,8 @@ class OAuthController(http.Controller):
|
||||
def signin(self, **kw):
|
||||
state = json.loads(kw['state'])
|
||||
dbname = state['d']
|
||||
if not http.db_filter([dbname]):
|
||||
return BadRequest()
|
||||
provider = state['p']
|
||||
context = state.get('c', {})
|
||||
registry = registry_get(dbname)
|
||||
@@ -180,6 +182,8 @@ class OAuthController(http.Controller):
|
||||
dbname = db_monodb()
|
||||
if not dbname:
|
||||
return BadRequest()
|
||||
if not http.db_filter([dbname]):
|
||||
return BadRequest()
|
||||
|
||||
registry = registry_get(dbname)
|
||||
with registry.cursor() as cr:
|
||||
|
||||
@@ -948,6 +948,14 @@ class Meeting(models.Model):
|
||||
self.start = self.start_datetime
|
||||
self.stop = fields.Datetime.to_string(start + timedelta(hours=self.duration))
|
||||
|
||||
@api.onchange('start_date')
|
||||
def _onchange_start_date(self):
|
||||
self.start = self.start_date
|
||||
|
||||
@api.onchange('stop_date')
|
||||
def _onchange_stop_date(self):
|
||||
self.stop = self.stop_date
|
||||
|
||||
####################################################
|
||||
# Calendar Business, Reccurency, ...
|
||||
####################################################
|
||||
|
||||
@@ -553,7 +553,8 @@ class Lead(models.Model):
|
||||
for field in fields:
|
||||
value = getattr(self, field.name, False)
|
||||
if field.ttype == 'selection':
|
||||
value = dict(field.get_values(self.env)).get(value, value)
|
||||
selections = self.fields_get()[field.name]['selection']
|
||||
value = next((v[1] for v in selections if v[0] == value), value)
|
||||
elif field.ttype == 'many2one':
|
||||
if value:
|
||||
value = value.sudo().name_get()[0][1]
|
||||
|
||||
@@ -103,16 +103,19 @@ class MailMail(models.Model):
|
||||
messages to send (by default all 'outgoing'
|
||||
messages are sent).
|
||||
"""
|
||||
if not self.ids:
|
||||
filters = ['&',
|
||||
('state', '=', 'outgoing'),
|
||||
'|',
|
||||
('scheduled_date', '<', datetime.datetime.now()),
|
||||
('scheduled_date', '=', False)]
|
||||
if 'filters' in self._context:
|
||||
filters.extend(self._context['filters'])
|
||||
# TODO: make limit configurable
|
||||
ids = self.search(filters, limit=10000).ids
|
||||
filters = ['&',
|
||||
('state', '=', 'outgoing'),
|
||||
'|',
|
||||
('scheduled_date', '<', datetime.datetime.now()),
|
||||
('scheduled_date', '=', False)]
|
||||
if 'filters' in self._context:
|
||||
filters.extend(self._context['filters'])
|
||||
# TODO: make limit configurable
|
||||
filtered_ids = self.search(filters, limit=10000).ids
|
||||
if not ids:
|
||||
ids = filtered_ids
|
||||
else:
|
||||
ids = list(set(filtered_ids) & set(ids))
|
||||
res = None
|
||||
try:
|
||||
# auto-commit except in testing mode
|
||||
|
||||
@@ -1886,7 +1886,7 @@ class MailThread(models.AbstractModel):
|
||||
if not attachment:
|
||||
attachment = fname_mapping.get(node.get('data-filename'), '')
|
||||
if attachment:
|
||||
node.set('src', '/web/image/%s' % attachment.id)
|
||||
node.set('src', '/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token))
|
||||
postprocessed = True
|
||||
if postprocessed:
|
||||
body = lxml.html.tostring(root, pretty_print=False, encoding='UTF-8')
|
||||
|
||||
@@ -655,7 +655,7 @@ var BasicComposer = Widget.extend({
|
||||
on_attachment_delete: function(event){
|
||||
event.stopPropagation();
|
||||
var self = this;
|
||||
var attachment_id = $(event.target).data("id");
|
||||
var attachment_id = $(event.currentTarget).data("id");
|
||||
if (attachment_id) {
|
||||
var attachments = [];
|
||||
_.each(this.get('attachment_ids'), function(attachment){
|
||||
@@ -666,6 +666,7 @@ var BasicComposer = Widget.extend({
|
||||
}
|
||||
});
|
||||
this.set('attachment_ids', attachments);
|
||||
this.$('input.o_input_file').val('');
|
||||
}
|
||||
},
|
||||
do_check_attachment_upload: function () {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
invisible="not context.get('mail_invite_follower_channel_only')"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="send_mail" invisible="context.get('mail_invite_follower_channel_only')"/>
|
||||
<field name="message" attrs="{'invisible': [('send_mail','!=',True)]}" options="{'style-inline': true}" class="test_message"/>
|
||||
<field name="message" attrs="{'invisible': [('send_mail','!=',True)]}" options="{'style-inline': true, 'no-attachment': true}" class="test_message"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Add Followers"
|
||||
|
||||
@@ -210,7 +210,7 @@ class MailComposer(models.TransientModel):
|
||||
new_attachment_ids.append(attachment.copy({'res_model': 'mail.compose.message', 'res_id': wizard.id}).id)
|
||||
else:
|
||||
new_attachment_ids.append(attachment.id)
|
||||
wizard.write({'attachment_ids': [(6, 0, new_attachment_ids)]})
|
||||
wizard.write({'attachment_ids': [(6, 0, new_attachment_ids)]})
|
||||
|
||||
# Mass Mailing
|
||||
mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post')
|
||||
|
||||
@@ -246,6 +246,8 @@ exports.PosModel = Backbone.Model.extend({
|
||||
|
||||
self.db.set_uuid(self.config.uuid);
|
||||
self.set_cashier(self.get_cashier());
|
||||
// We need to do it here, since only then the local storage has the correct uuid
|
||||
self.db.save('pos_session_id', self.pos_session.id);
|
||||
|
||||
var orders = self.db.get_orders();
|
||||
for (var i = 0; i < orders.length; i++) {
|
||||
@@ -628,6 +630,10 @@ exports.PosModel = Backbone.Model.extend({
|
||||
|
||||
// returns the user who is currently the cashier for this point of sale
|
||||
get_cashier: function(){
|
||||
// reset the cashier to the current user if session is new
|
||||
if (this.db.load('pos_session_id') !== this.pos_session.id) {
|
||||
this.set_cashier(this.user);
|
||||
}
|
||||
return this.db.get_cashier() || this.get('cashier') || this.user;
|
||||
},
|
||||
// changes the current cashier
|
||||
|
||||
@@ -1096,9 +1096,9 @@ class SaleOrderLine(models.Model):
|
||||
# TO DO: move me in master/saas-16 on sale.order
|
||||
if self.order_id.pricelist_id.discount_policy == 'with_discount':
|
||||
return product.with_context(pricelist=self.order_id.pricelist_id.id).price
|
||||
final_price, rule_id = self.order_id.pricelist_id.get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id)
|
||||
context_partner = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order)
|
||||
base_price, currency = self.with_context(context_partner)._get_real_price_currency(self.product_id, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id)
|
||||
product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id)
|
||||
final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id)
|
||||
base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id)
|
||||
if currency != self.order_id.pricelist_id.currency_id:
|
||||
base_price = currency._convert(
|
||||
base_price, self.order_id.pricelist_id.currency_id,
|
||||
@@ -1257,11 +1257,20 @@ class SaleOrderLine(models.Model):
|
||||
self.env.user.has_group('sale.group_discount_per_so_line')):
|
||||
return
|
||||
|
||||
context_partner = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order)
|
||||
pricelist_context = dict(context_partner, uom=self.product_uom.id)
|
||||
product = self.product_id.with_context(
|
||||
lang=self.order_id.partner_id.lang,
|
||||
partner=self.order_id.partner_id.id,
|
||||
quantity=self.product_uom_qty,
|
||||
date=self.order_id.date_order,
|
||||
pricelist=self.order_id.pricelist_id.id,
|
||||
uom=self.product_uom.id,
|
||||
fiscal_position=self.env.context.get('fiscal_position')
|
||||
)
|
||||
|
||||
price, rule_id = self.order_id.pricelist_id.with_context(pricelist_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id)
|
||||
new_list_price, currency = self.with_context(context_partner)._get_real_price_currency(self.product_id, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id)
|
||||
product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id)
|
||||
|
||||
price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id)
|
||||
new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id)
|
||||
|
||||
if new_list_price != 0:
|
||||
if self.order_id.pricelist_id.currency_id != currency:
|
||||
|
||||
@@ -112,3 +112,160 @@ class TestOnchangeProductId(TransactionCase):
|
||||
|
||||
self.assertEqual(so.order_line[0].price_unit, 50, "Second date pricelist rule not applied")
|
||||
self.assertEquals(so.order_line[0].price_subtotal, so.order_line[0].price_unit * so.order_line[0].product_uom_qty, 'Total of SO line should be a multiplication of unit price and ordered quantity')
|
||||
|
||||
def test_pricelist_uom_discount(self):
|
||||
""" Test prices and discounts are correctly applied based on date and uom"""
|
||||
computer_case = self.env.ref('product.product_product_16')
|
||||
computer_case.list_price = 100
|
||||
partner = self.res_partner_model.create(dict(name="George"))
|
||||
categ_unit_id = self.ref('uom.product_uom_categ_unit')
|
||||
goup_discount_id = self.ref('sale.group_discount_per_so_line')
|
||||
self.env.user.write({'groups_id': [(4, goup_discount_id, 0)]})
|
||||
new_uom = self.env['uom.uom'].create({
|
||||
'name': '10 units',
|
||||
'factor_inv': 10,
|
||||
'uom_type': 'bigger',
|
||||
'rounding': 1.0,
|
||||
'category_id': categ_unit_id
|
||||
})
|
||||
christmas_pricelist = self.env['product.pricelist'].create({
|
||||
'name': 'Christmas pricelist',
|
||||
'discount_policy': 'without_discount',
|
||||
'item_ids': [(0, 0, {
|
||||
'date_start': "2017-12-01",
|
||||
'date_end': "2017-12-30",
|
||||
'compute_price': 'percentage',
|
||||
'base': 'list_price',
|
||||
'percent_price': 10,
|
||||
'applied_on': '3_global',
|
||||
'name': 'Christmas discount'
|
||||
})]
|
||||
})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'date_order': '2017-12-20',
|
||||
'pricelist_id': christmas_pricelist.id,
|
||||
})
|
||||
|
||||
order_line = self.env['sale.order.line'].new({
|
||||
'order_id': so.id,
|
||||
'product_id': computer_case.id,
|
||||
})
|
||||
|
||||
# force compute uom and prices
|
||||
order_line.product_id_change()
|
||||
order_line.product_uom_change()
|
||||
order_line._onchange_discount()
|
||||
self.assertEqual(order_line.price_subtotal, 90, "Christmas discount pricelist rule not applied")
|
||||
self.assertEqual(order_line.discount, 10, "Christmas discount not equalt to 10%")
|
||||
order_line.product_uom = new_uom
|
||||
order_line.product_uom_change()
|
||||
order_line._onchange_discount()
|
||||
self.assertEqual(order_line.price_subtotal, 900, "Christmas discount pricelist rule not applied")
|
||||
self.assertEqual(order_line.discount, 10, "Christmas discount not equalt to 10%")
|
||||
|
||||
def test_pricelist_based_on_other(self):
|
||||
""" Test price and discount are correctly applied with a pricelist based on an other one"""
|
||||
computer_case = self.env.ref('product.product_product_16')
|
||||
computer_case.list_price = 100
|
||||
partner = self.res_partner_model.create(dict(name="George"))
|
||||
goup_discount_id = self.ref('sale.group_discount_per_so_line')
|
||||
self.env.user.write({'groups_id': [(4, goup_discount_id, 0)]})
|
||||
|
||||
first_pricelist = self.env['product.pricelist'].create({
|
||||
'name': 'First pricelist',
|
||||
'discount_policy': 'without_discount',
|
||||
'item_ids': [(0, 0, {
|
||||
'compute_price': 'percentage',
|
||||
'base': 'list_price',
|
||||
'percent_price': 10,
|
||||
'applied_on': '3_global',
|
||||
'name': 'First discount'
|
||||
})]
|
||||
})
|
||||
|
||||
second_pricelist = self.env['product.pricelist'].create({
|
||||
'name': 'Second pricelist',
|
||||
'discount_policy': 'without_discount',
|
||||
'item_ids': [(0, 0, {
|
||||
'compute_price': 'formula',
|
||||
'base': 'pricelist',
|
||||
'base_pricelist_id': first_pricelist.id,
|
||||
'price_discount': 10,
|
||||
'applied_on': '3_global',
|
||||
'name': 'Second discount'
|
||||
})]
|
||||
})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'date_order': '2018-07-11',
|
||||
'pricelist_id': second_pricelist.id,
|
||||
})
|
||||
|
||||
order_line = self.env['sale.order.line'].new({
|
||||
'order_id': so.id,
|
||||
'product_id': computer_case.id,
|
||||
})
|
||||
|
||||
# force compute uom and prices
|
||||
order_line.product_id_change()
|
||||
order_line._onchange_discount()
|
||||
self.assertEqual(order_line.price_subtotal, 81, "Second pricelist rule not applied")
|
||||
self.assertEqual(order_line.discount, 19, "Second discount not applied")
|
||||
|
||||
def test_pricelist_with_other_currency(self):
|
||||
""" Test prices are correctly applied with a pricelist with an other currency"""
|
||||
computer_case = self.env.ref('product.product_product_16')
|
||||
computer_case.list_price = 100
|
||||
partner = self.res_partner_model.create(dict(name="George"))
|
||||
categ_unit_id = self.ref('uom.product_uom_categ_unit')
|
||||
other_currency = self.env['res.currency'].create({'name': 'other currency',
|
||||
'symbol': 'other'})
|
||||
self.env['res.currency.rate'].create({'name': '2018-07-11',
|
||||
'rate': 2.0,
|
||||
'currency_id': other_currency.id,
|
||||
'company_id': self.env.user.company_id.id})
|
||||
self.env['res.currency.rate'].search(
|
||||
[('currency_id', '=', self.env.user.company_id.currency_id.id)]
|
||||
).unlink()
|
||||
new_uom = self.env['uom.uom'].create({
|
||||
'name': '10 units',
|
||||
'factor_inv': 10,
|
||||
'uom_type': 'bigger',
|
||||
'rounding': 1.0,
|
||||
'category_id': categ_unit_id
|
||||
})
|
||||
|
||||
# This pricelist doesn't show the discount
|
||||
first_pricelist = self.env['product.pricelist'].create({
|
||||
'name': 'First pricelist',
|
||||
'currency_id': other_currency.id,
|
||||
'discount_policy': 'with_discount',
|
||||
'item_ids': [(0, 0, {
|
||||
'compute_price': 'percentage',
|
||||
'base': 'list_price',
|
||||
'percent_price': 10,
|
||||
'applied_on': '3_global',
|
||||
'name': 'First discount'
|
||||
})]
|
||||
})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'date_order': '2018-07-12',
|
||||
'pricelist_id': first_pricelist.id,
|
||||
})
|
||||
|
||||
order_line = self.env['sale.order.line'].new({
|
||||
'order_id': so.id,
|
||||
'product_id': computer_case.id,
|
||||
})
|
||||
|
||||
# force compute uom and prices
|
||||
order_line.product_id_change()
|
||||
self.assertEqual(order_line.price_unit, 180, "First pricelist rule not applied")
|
||||
order_line.product_uom = new_uom
|
||||
order_line.product_uom_change()
|
||||
self.assertEqual(order_line.price_unit, 1800, "First pricelist rule not applied")
|
||||
|
||||
@@ -52,7 +52,7 @@ class TestSaleStock(TestSale):
|
||||
self.assertEqual(len(self.so.picking_ids), 2, 'Sale Stock: number of pickings should be 2')
|
||||
pick_2 = self.so.picking_ids[0]
|
||||
pick_2.move_lines.write({'quantity_done': 1})
|
||||
self.assertTrue(pick_2.button_validate(), 'Sale Stock: second picking should be final without need for a backorder')
|
||||
self.assertIsNone(pick_2.button_validate(), 'Sale Stock: second picking should be final without need for a backorder')
|
||||
self.assertEqual(self.so.invoice_status, 'to invoice', 'Sale Stock: so invoice_status should be "to invoice" after complete delivery')
|
||||
del_qties = [sol.qty_delivered for sol in self.so.order_line]
|
||||
del_qties_truth = [2.0 if sol.product_id.type in ['product', 'consu'] else 0.0 for sol in self.so.order_line]
|
||||
@@ -103,7 +103,7 @@ class TestSaleStock(TestSale):
|
||||
# deliver, check the delivered quantities
|
||||
pick = self.so.picking_ids
|
||||
pick.move_lines.write({'quantity_done': 2})
|
||||
self.assertTrue(pick.button_validate(), 'Sale Stock: complete delivery should not need a backorder')
|
||||
self.assertIsNone(pick.button_validate(), 'Sale Stock: complete delivery should not need a backorder')
|
||||
del_qties = [sol.qty_delivered for sol in self.so.order_line]
|
||||
del_qties_truth = [2.0 if sol.product_id.type in ['product', 'consu'] else 0.0 for sol in self.so.order_line]
|
||||
self.assertEqual(del_qties, del_qties_truth, 'Sale Stock: delivered quantities are wrong after partial delivery')
|
||||
|
||||
@@ -203,9 +203,8 @@ class SaleTimesheetController(http.Controller):
|
||||
|
||||
# remaining computation of SO row, as Sold - Done (timesheet total)
|
||||
for sale_order_id, done_sold_vals in rows_sale_order_done_sold.items():
|
||||
item = done_sold_vals.get(sale_order_id)
|
||||
if item:
|
||||
rows_sale_order[sale_order_id] = item['sold'] - item['done']
|
||||
if sale_order_id in rows_sale_order:
|
||||
rows_sale_order[sale_order_id][-1] = done_sold_vals['sold'] - done_sold_vals['done']
|
||||
|
||||
# group rows SO, SOL and their related employee rows.
|
||||
timesheet_forecast_table_rows = []
|
||||
|
||||
@@ -736,7 +736,7 @@ class Picking(models.Model):
|
||||
if self._check_backorder():
|
||||
return self.action_generate_backorder_wizard()
|
||||
self.action_done()
|
||||
return True
|
||||
return
|
||||
|
||||
def action_generate_backorder_wizard(self):
|
||||
view = self.env.ref('stock.view_backorder_confirmation')
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<div>
|
||||
<span><strong>Customer Address:</strong></span>
|
||||
</div>
|
||||
<div t-if="o.move_lines and o.move_lines[0].partner_id" name="partner_header">
|
||||
<div t-if="o.move_lines and o.move_lines[0].partner_id and not o.partner_id" name="partner_header">
|
||||
<div t-field="o.move_lines[0].partner_id"
|
||||
t-options='{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": True}'/>
|
||||
</div>
|
||||
<div t-if="not (o.move_lines and o.move_lines[0].partner_id) and o.partner_id" name="partner_header">
|
||||
<div t-if="o.partner_id" name="partner_header">
|
||||
<div t-field="o.partner_id"
|
||||
t-options='{"widget": "contact", "fields": ["address", "name", "phone"], "no_marker": True}'/>
|
||||
</div>
|
||||
|
||||
@@ -1496,6 +1496,8 @@ class ExcelExport(ExportFormat, http.Controller):
|
||||
|
||||
if isinstance(cell_value, pycompat.string_types):
|
||||
cell_value = re.sub("\r", " ", pycompat.to_text(cell_value))
|
||||
# Excel supports a maximum of 32767 characters in each cell:
|
||||
cell_value = cell_value[:32767]
|
||||
elif isinstance(cell_value, datetime.datetime):
|
||||
cell_style = datetime_style
|
||||
elif isinstance(cell_value, datetime.date):
|
||||
|
||||
@@ -26,7 +26,7 @@ class Http(models.AbstractModel):
|
||||
"session_id": request.session.sid,
|
||||
"uid": request.session.uid,
|
||||
"is_system": user._is_system(),
|
||||
"is_superuser": user._is_superuser(),
|
||||
"is_superuser": user._is_superuser() if request.session.uid else False,
|
||||
"user_context": request.session.get_context() if request.session.uid else {},
|
||||
"db": request.session.db,
|
||||
"server_version": version_info.get('server_version'),
|
||||
@@ -37,7 +37,7 @@ class Http(models.AbstractModel):
|
||||
"company_id": user.company_id.id if request.session.uid else None,
|
||||
"partner_id": user.partner_id.id if request.session.uid and user.partner_id else None,
|
||||
"user_companies": {'current_company': (user.company_id.id, user.company_id.name), 'allowed_companies': [(comp.id, comp.name) for comp in user.company_ids]} if display_switch_company_menu else False,
|
||||
"currencies": self.get_currencies(),
|
||||
"currencies": self.get_currencies() if request.session.uid else {},
|
||||
"web.base.url": self.env['ir.config_parameter'].sudo().get_param('web.base.url', default=''),
|
||||
"show_effect": True
|
||||
}
|
||||
|
||||
@@ -148,15 +148,25 @@ var ControlPanel = Widget.extend({
|
||||
}
|
||||
|
||||
// Detach control_panel old content and attach new elements
|
||||
var toDetach = this.nodes;
|
||||
if (status.searchview && this.searchview === status.searchview) {
|
||||
// If the searchview is the same as before, don't detach it s.t.
|
||||
// we don't loose any floating content, nor the focus
|
||||
toDetach = _.omit(toDetach, '$searchview');
|
||||
new_cp_content = _.omit(new_cp_content, '$searchview');
|
||||
}
|
||||
if (options.clear) {
|
||||
this._detach_content(this.nodes);
|
||||
this._detach_content(toDetach);
|
||||
// Show the searchview buttons area, which might have been hidden by
|
||||
// the searchview, as client actions may insert elements into it
|
||||
this.nodes.$searchview_buttons.show();
|
||||
} else {
|
||||
this._detach_content(_.pick(this.nodes, _.keys(new_cp_content)));
|
||||
this._detach_content(_.pick(toDetach, _.keys(new_cp_content)));
|
||||
}
|
||||
this._attach_content(new_cp_content);
|
||||
if (options.clear || status.searchview) {
|
||||
this.searchview = status.searchview;
|
||||
}
|
||||
|
||||
// Update the searchview and switch buttons
|
||||
if (status.searchview || options.clear) {
|
||||
|
||||
@@ -866,8 +866,16 @@ ListRenderer.include({
|
||||
*/
|
||||
_onRemoveIconClick: function (event) {
|
||||
event.stopPropagation();
|
||||
var id = $(event.target).closest('tr').data('id');
|
||||
this.trigger_up('list_record_remove', {id: id});
|
||||
var $row = $(event.target).closest('tr');
|
||||
var id = $row.data('id');
|
||||
if ($row.hasClass('o_selected_row')) {
|
||||
this.trigger_up('list_record_remove', {id: id});
|
||||
} else {
|
||||
var self = this;
|
||||
this.unselectRow().then(function () {
|
||||
self.trigger_up('list_record_remove', {id: id});
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* If the list view editable, just let the event bubble. We don't want to
|
||||
|
||||
@@ -3432,6 +3432,62 @@ QUnit.module('ActionManager', {
|
||||
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('Search View Action');
|
||||
|
||||
QUnit.test('search view should keep focus during do_search', function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
/* One should be able to type something in the search view, press on enter to
|
||||
* make the facet and trigger the search, then do this process
|
||||
* over and over again seamlessly.
|
||||
* Verifying the input's value is a lot trickier than verifying the search_read
|
||||
* because of how native events are handled in tests
|
||||
*/
|
||||
|
||||
var searchDeferred = $.Deferred();
|
||||
|
||||
var actionManager = createActionManager({
|
||||
actions: this.actions,
|
||||
archs: this.archs,
|
||||
data: this.data,
|
||||
mockRPC: function (route, args) {
|
||||
if (route === '/web/dataset/search_read') {
|
||||
assert.step('search_read ' + args.domain);
|
||||
if ( _.isEqual(args.domain, [['foo', 'ilike', 'm']])) {
|
||||
return searchDeferred.then(this._super.bind(this, route, args));
|
||||
}
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
}
|
||||
});
|
||||
|
||||
actionManager.doAction(3);
|
||||
|
||||
var $searchInput = $('.o_searchview input');
|
||||
$searchInput.trigger($.Event('keypress', {key: 'm', which: 109, keyCode: 109}));
|
||||
$searchInput.trigger($.Event('keydown', {key: 'Enter', which: 13, keyCode: 13}));
|
||||
|
||||
assert.verifySteps(["search_read ",
|
||||
"search_read foo,ilike,m"]);
|
||||
|
||||
// Triggering the do_search above will kill the current searchview Input
|
||||
$searchInput = $('.o_searchview input');
|
||||
$searchInput.trigger($.Event('keypress', {key: 'o', which: 111, keyCode: 111}));
|
||||
|
||||
// We have something in the input of the search view. Making the search_read
|
||||
// return at this point will trigger the redraw of the view.
|
||||
// However we want to hold on to what we just typed
|
||||
searchDeferred.resolve();
|
||||
|
||||
$searchInput.trigger($.Event('keydown', {key: 'Enter', which: 13, keyCode: 13}));
|
||||
|
||||
assert.verifySteps(["search_read ",
|
||||
"search_read foo,ilike,m",
|
||||
"search_read |,foo,ilike,m,foo,ilike,o"]);
|
||||
|
||||
actionManager.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -3257,6 +3257,42 @@ QUnit.module('Views', {
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('delete a line in a one2many while editing another line triggers a warning', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.data.partner.records[0].p = [1, 2];
|
||||
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<field name="p">' +
|
||||
'<tree editable="bottom">' +
|
||||
'<field name="display_name" required="True"/>' +
|
||||
'</tree>' +
|
||||
'</field>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
});
|
||||
|
||||
form.$buttons.find('.o_form_button_edit').click();
|
||||
form.$('.o_data_cell').first().click(); // edit first row
|
||||
form.$('input').val('').trigger('input');
|
||||
form.$('.fa-trash-o').eq(1).click(); // delete second row
|
||||
|
||||
assert.strictEqual($('.modal').find('.modal-title').first().text(), "Warning",
|
||||
"Clicking out of a dirty line while editing should trigger a warning modal.");
|
||||
|
||||
$('.modal').find('.btn-primary').click(); // discard changes
|
||||
|
||||
assert.strictEqual(form.$('.o_data_cell').first().text(), "first record",
|
||||
"Value should have been reset to what it was before editing began.");
|
||||
assert.strictEqual(form.$('.o_data_row').length, 1,
|
||||
"The other line should have been deleted.");
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('properly apply onchange on many2many fields', function (assert) {
|
||||
assert.expect(14);
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
<link rel="stylesheet" type="text/scss" href="/portal/static/src/scss/portal.scss"/>
|
||||
<link rel="stylesheet" type="text/scss" href="/website/static/src/scss/website.scss"/>
|
||||
|
||||
<!-- TODO: Put in the right report ..? -->
|
||||
<link href="https://fonts.googleapis.com/css?family=Work+Sans:thin,light,regular,medium,bold,semi-bold" rel="stylesheet"/>
|
||||
|
||||
<link href="/web/static/lib/fontawesome/css/font-awesome.css" rel="stylesheet" type="text/css"/>
|
||||
|
||||
<link rel="stylesheet" type="text/scss" href="/web/static/src/scss/report.scss"/>
|
||||
|
||||
@@ -111,9 +111,9 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
|
||||
attachedDocumentDomain.unshift('&');
|
||||
attachedDocumentDomain.push(['create_uid', '=', session.uid]);
|
||||
}
|
||||
if (this.recordData.model) {
|
||||
if (this.recordData.res_model || this.recordData.model) {
|
||||
var relatedDomain = ['&',
|
||||
['res_model', '=', this.recordData.model],
|
||||
['res_model', '=', this.recordData.res_model || this.recordData.model],
|
||||
['res_id', '=', this.recordData.res_id|0]];
|
||||
if (!this.recordData.res_id) {
|
||||
relatedDomain.unshift('&');
|
||||
@@ -142,7 +142,7 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
|
||||
['color', ['color']],
|
||||
['para', ['ul', 'ol', 'paragraph']],
|
||||
['table', ['table']],
|
||||
['insert', ['link', 'picture']],
|
||||
['insert', this.nodeOptions['no-attachment'] ? ['link'] : ['link', 'picture']],
|
||||
['history', ['undo', 'redo']]
|
||||
],
|
||||
prettifyHtml: false,
|
||||
@@ -150,6 +150,7 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
|
||||
inlinemedia: ['p'],
|
||||
lang: "odoo",
|
||||
onChange: this._doDebouncedAction.bind(this),
|
||||
disableDragAndDrop: !!this.nodeOptions['no-attachment'],
|
||||
};
|
||||
|
||||
var fieldNameAttachment =_.chain(this.recordData)
|
||||
@@ -164,8 +165,9 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
|
||||
this.fieldNameAttachment = fieldNameAttachment;
|
||||
this.attachments = [];
|
||||
summernoteConfig.onUpload = this._onUpload.bind(this);
|
||||
summernoteConfig.getMediaDomain = this._getAttachmentsDomain.bind(this);
|
||||
}
|
||||
summernoteConfig.getMediaDomain = this._getAttachmentsDomain.bind(this);
|
||||
|
||||
|
||||
if (config.debug) {
|
||||
summernoteConfig.toolbar.splice(7, 0, ['view', ['codeview']]);
|
||||
@@ -178,9 +180,9 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
|
||||
*/
|
||||
_getValue: function () {
|
||||
if (this.nodeOptions['style-inline']) {
|
||||
transcoder.linkImgToAttachmentThumbnail(this.$content);
|
||||
transcoder.classToStyle(this.$content);
|
||||
transcoder.attachmentThumbnailToLinkImg(this.$content);
|
||||
transcoder.fontToImg(this.$content);
|
||||
transcoder.classToStyle(this.$content);
|
||||
}
|
||||
return this.$content.html();
|
||||
},
|
||||
@@ -226,6 +228,8 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi
|
||||
this.$content.trigger('mouseup');
|
||||
if (this.nodeOptions['style-inline']) {
|
||||
transcoder.styleToClass(this.$content);
|
||||
transcoder.imgToFont(this.$content);
|
||||
transcoder.linkImgToAttachmentThumbnail(this.$content);
|
||||
}
|
||||
// reset the history (otherwise clicking on undo before editing the
|
||||
// value will empty the editor)
|
||||
|
||||
@@ -41,6 +41,7 @@ snippet_editor.Class.include({
|
||||
start: function () {
|
||||
_.defer(function () {
|
||||
var $editable = $('#editable_area');
|
||||
transcoder.linkImgToAttachmentThumbnail($editable);
|
||||
transcoder.imgToFont($editable);
|
||||
transcoder.styleToClass($editable);
|
||||
|
||||
@@ -53,7 +54,7 @@ snippet_editor.Class.include({
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
var $editable = $('#editable_area');
|
||||
transcoder.linkImgToAttachmentThumbnail($editable);
|
||||
transcoder.attachmentThumbnailToLinkImg($editable);
|
||||
transcoder.fontToImg($editable);
|
||||
transcoder.classToStyle($editable);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
///
|
||||
|
||||
html, body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,7 @@ var PagePropertiesDialog = weWidgets.Dialog.extend({
|
||||
horizontal: 'auto',
|
||||
vertical: 'top',
|
||||
},
|
||||
widgetParent: 'body',
|
||||
};
|
||||
if (this.page.date_publish) {
|
||||
datepickersOptions.defaultDate = time.str_to_datetime(this.page.date_publish);
|
||||
|
||||
@@ -237,7 +237,7 @@ class BlogPost(models.Model):
|
||||
return super(BlogPost, self).get_access_action(access_uid)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': self.url,
|
||||
'url': self.website_url,
|
||||
'target': 'self',
|
||||
'target_type': 'public',
|
||||
'res_id': self.id,
|
||||
|
||||
@@ -8,9 +8,6 @@ var Widget = require('web.Widget');
|
||||
var weContext = require('web_editor.context');
|
||||
require('web_editor.editor');
|
||||
var translate = require('web_editor.translate');
|
||||
var websiteNavbarData = require('website.navbar');
|
||||
|
||||
var WebsiteNavbar = websiteNavbarData.WebsiteNavbar;
|
||||
|
||||
var qweb = core.qweb;
|
||||
var _t = core._t;
|
||||
@@ -83,7 +80,7 @@ translate.Class.include({
|
||||
'lang': weContext.get().lang,
|
||||
}).then(function () {
|
||||
ajax.jsonRpc('/website/post_gengo_jobs', 'call', {});
|
||||
self.save_and_reload();
|
||||
self._save();
|
||||
}).fail(function () {
|
||||
Dialog.alert(null, _t("Could not Post translation"));
|
||||
});
|
||||
@@ -113,12 +110,12 @@ translate.Class.include({
|
||||
});
|
||||
|
||||
var GengoTranslatorPostDialog = Widget.extend({
|
||||
events: _.extend({}, WebsiteNavbar.prototype.events, {
|
||||
events: {
|
||||
'hidden.bs.modal': 'destroy',
|
||||
'click button[data-action=service_level]': function () {
|
||||
this.trigger('service_level');
|
||||
},
|
||||
}),
|
||||
},
|
||||
template: 'website.GengoTranslatorPostDialog',
|
||||
init: function (new_words){
|
||||
this.new_words = new_words;
|
||||
@@ -130,9 +127,9 @@ var GengoTranslatorPostDialog = Widget.extend({
|
||||
});
|
||||
|
||||
var GengoTranslatorStatisticDialog = Widget.extend({
|
||||
events: _.extend({}, WebsiteNavbar.prototype.events, {
|
||||
events: {
|
||||
'hidden.bs.modal': 'destroy',
|
||||
}),
|
||||
},
|
||||
template: 'website.GengoTranslatorStatisticDialog',
|
||||
init: function (res) {
|
||||
var self = this;
|
||||
@@ -154,10 +151,10 @@ var GengoTranslatorStatisticDialog = Widget.extend({
|
||||
});
|
||||
|
||||
var GengoApiConfigDialog = Widget.extend({
|
||||
events: _.extend({}, WebsiteNavbar.prototype.events, {
|
||||
events: {
|
||||
'hidden.bs.modal': 'destroy',
|
||||
'click button[data-action=set_config]': 'set_config'
|
||||
}),
|
||||
},
|
||||
template: 'website.GengoApiConfigDialog',
|
||||
init:function (company_id){
|
||||
this.company_id = company_id;
|
||||
|
||||
@@ -36,13 +36,14 @@ class WebsiteSaleDelivery(WebsiteSale):
|
||||
|
||||
def _get_shop_payment_values(self, order, **kwargs):
|
||||
values = super(WebsiteSaleDelivery, self)._get_shop_payment_values(order, **kwargs)
|
||||
if not order._get_delivery_methods():
|
||||
has_stockable_products = any(line.product_id.type in ['consu', 'product'] for line in order.order_line)
|
||||
|
||||
if not order._get_delivery_methods() and has_stockable_products:
|
||||
values['errors'].append(
|
||||
(_('Sorry, we are unable to ship your order'),
|
||||
_('No shipping method is available for your current order and shipping address. '
|
||||
'Please contact us for more information.')))
|
||||
|
||||
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:
|
||||
order._remove_delivery_line()
|
||||
@@ -50,6 +51,7 @@ class WebsiteSaleDelivery(WebsiteSale):
|
||||
delivery_carriers = order._get_delivery_methods()
|
||||
values['deliveries'] = delivery_carriers.sudo()
|
||||
|
||||
values['delivery_has_stockable'] = has_stockable_products
|
||||
values['delivery_action_id'] = request.env.ref('delivery.action_delivery_carrier_form').id
|
||||
return values
|
||||
|
||||
|
||||
@@ -52,6 +52,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
<!-- we shouldn't be able to pay if there is no way to deliver -->
|
||||
<xpath expr="//div[@id='payment_method']" position="attributes">
|
||||
<attribute name="t-att-style">'display: none!important' if not deliveries and delivery_has_stockable else ''</attribute>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="portal_order_page_shipping_tracking" name="Shipping tracking on orders followup" inherit_id="sale_stock.portal_order_page_shipping">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
México, 9-2-2015
|
||||
México, 2015-02-09
|
||||
|
||||
Vauxoo agrees to the terms of the Odoo Corporate Contributor License Agreement v1.0.
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
Austria, 10/07/2018
|
||||
|
||||
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,
|
||||
|
||||
Giulio Marcon gmarcon@gmail.com https://github.com/gmarcon
|
||||
@@ -106,6 +106,7 @@ Blocks
|
||||
|
||||
# bad, trailing spaces, blocks out of context
|
||||
_("You have ") + len(invoices) + _(" invoices waiting")
|
||||
_t("You have ") + invoices.length + _t(" invoices waiting");
|
||||
|
||||
# bad, multiple small translations
|
||||
_("Reference of the document that generated ") + \
|
||||
@@ -115,6 +116,7 @@ Blocks
|
||||
|
||||
# good, allow to change position of the number in the translation
|
||||
_("You have %s invoices wainting") % len(invoices)
|
||||
_.str.sprintf(_t("You have %s invoices wainting"), invoices.length);
|
||||
|
||||
# good, full sentence is understandable
|
||||
_("Reference of the document that generated " + \
|
||||
|
||||
+66
-1
@@ -484,6 +484,14 @@ security-related topics:
|
||||
restricting access via a VPN, allowing only trusted IPs in the firewall, and/or
|
||||
running a brute-force detection system such as `fail2ban` or equivalent.
|
||||
|
||||
- Consider installing appropriate rate-limiting on your proxy or firewall, to prevent
|
||||
brute-force attacks and denial of service attacks. See also :ref:`login_brute_force`
|
||||
for specific measures.
|
||||
|
||||
Many network providers provide automatic mitigation for Distributed Denial of
|
||||
Service attacks (DDOS), but this is often an optional service, so you should consult
|
||||
with them.
|
||||
|
||||
- Whenever possible, host your public-facing demo/test/staging instances on different
|
||||
machines than the production ones. And apply the same security precautions as for
|
||||
production.
|
||||
@@ -495,6 +503,50 @@ security-related topics:
|
||||
archiving server that is not accessible from the server itself.
|
||||
|
||||
|
||||
.. _login_brute_force:
|
||||
|
||||
Blocking Brute Force Attacks
|
||||
----------------------------
|
||||
For internet-facing deployments, brute force attacks on user passwords are very common, and this
|
||||
threat should not be neglected for Odoo servers. Odoo emits a log entry whenever a login attempt
|
||||
is performed, and reports the result: success or failure, along with the target login and source IP.
|
||||
|
||||
The log entries will have the following form.
|
||||
|
||||
Failed login::
|
||||
|
||||
2018-07-05 14:56:31,506 24849 INFO db_name odoo.addons.base.res.res_users: Login failed for db:db_name login:admin from 127.0.0.1
|
||||
|
||||
Successful login::
|
||||
|
||||
2018-07-05 14:56:31,506 24849 INFO db_name odoo.addons.base.res.res_users: Login successful for db:db_name login:admin from 127.0.0.1
|
||||
|
||||
|
||||
These logs can be easily analyzed by an intrusion prevention system such as `fail2ban`.
|
||||
|
||||
For example, the following fail2ban filter definition should match a
|
||||
failed login::
|
||||
|
||||
[Definition]
|
||||
failregex = ^ \d+ INFO \S+ \S+ Login failed for db:\S+ login:\S+ from <HOST>
|
||||
ignoreregex =
|
||||
|
||||
This could be used with a jail definition to block the attacking IP on HTTP(S).
|
||||
|
||||
Here is what it could look like for blocking the IP for 15 minutes when
|
||||
10 failed login attempts are detected from the same IP within 1 minute::
|
||||
|
||||
[odoo-login]
|
||||
enabled = true
|
||||
port = http,https
|
||||
bantime = 900 ; 15 min ban
|
||||
maxretry = 10 ; if 10 attempts
|
||||
findtime = 60 ; within 1 min /!\ Should be adjusted with the TZ offset
|
||||
logpath = /var/log/odoo.log ; set the actual odoo log path here
|
||||
|
||||
|
||||
|
||||
|
||||
.. _db_manager_security:
|
||||
|
||||
Database Manager Security
|
||||
@@ -507,7 +559,20 @@ dump or restore databases).
|
||||
|
||||
If the management screens must not be accessible at all, you should set ``list_db``
|
||||
configuration option to ``False``, to block access to all the database selection and
|
||||
management screens. But be sure to setup an appropriate ``db_name`` parameter
|
||||
management screens.
|
||||
|
||||
.. warning::
|
||||
|
||||
It is strongly recommended to disable the Database Manager for any internet-facing
|
||||
system! It is meant as a development/demo tool, to make it easy to quickly create
|
||||
and manage databases. It is not designed for use in production, and may even expose
|
||||
dangerous features to attackers. It is also not designed to handle large databases,
|
||||
and may trigger memory limits.
|
||||
|
||||
On production systems, database management operations should always be performed by
|
||||
the system administrator, including provisioning of new databases and automated backups.
|
||||
|
||||
Be sure to setup an appropriate ``db_name`` parameter
|
||||
(and optionally, ``db_filter`` too) so that the system can determine the target database
|
||||
for each request, otherwise users will be blocked as they won't be allowed to choose the
|
||||
database themselves.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import logging
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.exceptions import AccessDenied
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,6 +37,8 @@ class AutoVacuum(models.AbstractModel):
|
||||
|
||||
@api.model
|
||||
def power_on(self, *args, **kwargs):
|
||||
if not self.env.user._is_admin():
|
||||
raise AccessDenied()
|
||||
self.env['ir.attachment']._file_gc()
|
||||
self._gc_transient_models()
|
||||
self._gc_user_logs()
|
||||
|
||||
@@ -329,7 +329,9 @@ class Partner(models.Model):
|
||||
@api.multi
|
||||
def copy(self, default=None):
|
||||
self.ensure_one()
|
||||
default = dict(default or {}, name=_('%s (copy)') % self.name)
|
||||
chosen_name = default.get('name') if default else ''
|
||||
new_name = chosen_name or _('%s (copy)') % self.name
|
||||
default = dict(default or {}, name=new_name)
|
||||
return super(Partner, self).copy(default)
|
||||
|
||||
@api.onchange('parent_id')
|
||||
|
||||
@@ -131,7 +131,9 @@ class Groups(models.Model):
|
||||
@api.multi
|
||||
def copy(self, default=None):
|
||||
self.ensure_one()
|
||||
default = dict(default or {}, name=_('%s (copy)') % self.name)
|
||||
chosen_name = default.get('name') if default else ''
|
||||
default_name = chosen_name or _('%s (copy)') % self.name
|
||||
default = dict(default or {}, name=default_name)
|
||||
return super(Groups, self).copy(default)
|
||||
|
||||
@api.multi
|
||||
|
||||
@@ -18,7 +18,8 @@ def compute_session_token(session, env):
|
||||
|
||||
def check_session(session, env):
|
||||
self = env['res.users'].browse(session.uid)
|
||||
if odoo.tools.misc.consteq(self._compute_session_token(session.sid), session.session_token):
|
||||
expected = self._compute_session_token(session.sid)
|
||||
if expected and odoo.tools.misc.consteq(expected, session.session_token):
|
||||
return True
|
||||
self._invalidate_session_cache()
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user