[MERGE] forward port branch saas-11.3 up to 609491ad0e

This commit is contained in:
Christophe Simonis
2018-07-13 17:12:07 +02:00
41 changed files with 531 additions and 68 deletions
+5 -3
View File
@@ -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)
+1 -1
View File
@@ -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>
+4
View File
@@ -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:
+8
View File
@@ -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, ...
####################################################
+2 -1
View File
@@ -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]
+13 -10
View File
@@ -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
+1 -1
View File
@@ -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')
+2 -1
View File
@@ -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 () {
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+16 -7
View File
@@ -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:
+157
View File
@@ -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")
+2 -2
View File
@@ -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')
+2 -3
View File
@@ -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 = []
+1 -1
View File
@@ -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')
+2 -2
View File
@@ -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>
+2
View File
@@ -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):
+2 -2
View File
@@ -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);
-3
View File
@@ -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)
+2 -1
View File
@@ -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);
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+11
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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.
+3
View File
@@ -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()
+3 -1
View File
@@ -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')
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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