[MERGE] forward port branch saas-12.2 up to 2694174b41

This commit is contained in:
Christophe Simonis
2019-04-10 15:10:24 +02:00
70 changed files with 790 additions and 111 deletions
+1
View File
@@ -19,3 +19,4 @@ from . import account_cash_rounding
from . import reconciliation_widget
from . import account_incoterms
from . import digest
from . import res_users
+19 -9
View File
@@ -103,6 +103,11 @@ class AccountInvoice(models.Model):
if invoice_type == 'out_invoice' and self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'):
return self.env.user.company_id.invoice_terms
def _get_aml_for_amount_residual(self):
""" Get the aml to consider to compute the amount residual of invoices """
self.ensure_one()
return self.sudo().move_id.line_ids.filtered(lambda l: l.account_id == self.account_id)
@api.one
@api.depends(
'state', 'currency_id', 'invoice_line_ids.price_subtotal',
@@ -112,14 +117,13 @@ class AccountInvoice(models.Model):
residual = 0.0
residual_company_signed = 0.0
sign = self.type in ['in_refund', 'out_refund'] and -1 or 1
for line in self.sudo().move_id.line_ids:
if line.account_id == self.account_id:
residual_company_signed += line.amount_residual
if line.currency_id == self.currency_id:
residual += line.amount_residual_currency if line.currency_id else line.amount_residual
else:
from_currency = line.currency_id or line.company_id.currency_id
residual += from_currency._convert(line.amount_residual, self.currency_id, line.company_id, line.date or fields.Date.today())
for line in self._get_aml_for_amount_residual():
residual_company_signed += line.amount_residual
if line.currency_id == self.currency_id:
residual += line.amount_residual_currency if line.currency_id else line.amount_residual
else:
from_currency = line.currency_id or line.company_id.currency_id
residual += from_currency._convert(line.amount_residual, self.currency_id, line.company_id, line.date or fields.Date.today())
self.residual_company_signed = abs(residual_company_signed) * sign
self.residual_signed = abs(residual) * sign
self.residual = abs(residual)
@@ -1114,12 +1118,18 @@ class AccountInvoice(models.Model):
tax_grouped[key][field] += round_curr(val.get(field) or 0)
return tax_grouped
@api.multi
def _get_aml_for_register_payment(self):
""" Get the aml to consider to reconcile in register payment """
self.ensure_one()
return self.move_id.line_ids.filtered(lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable'))
@api.multi
def register_payment(self, payment_line, writeoff_acc_id=False, writeoff_journal_id=False):
""" Reconcile payable/receivable lines from the invoice with payment_line """
line_to_reconcile = self.env['account.move.line']
for inv in self:
line_to_reconcile += inv.move_id.line_ids.filtered(lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable'))
line_to_reconcile += inv._get_aml_for_register_payment()
return (line_to_reconcile + payment_line).reconcile(writeoff_acc_id, writeoff_journal_id)
@api.multi
+19 -3
View File
@@ -817,7 +817,9 @@ class AccountMoveLine(models.Model):
total_amount_currency = 0
maxdate = date.min
to_balance = {}
cash_basis_partial = self.env['account.partial.reconcile']
for aml in amls:
cash_basis_partial |= aml.move_id.tax_cash_basis_rec_id
total_debit += aml.debit
total_credit += aml.credit
maxdate = max(aml.date, maxdate)
@@ -832,15 +834,28 @@ class AccountMoveLine(models.Model):
to_balance[aml.currency_id] = [self.env['account.move.line'], 0]
to_balance[aml.currency_id][0] += aml
to_balance[aml.currency_id][1] += aml.amount_residual != 0 and aml.amount_residual or aml.amount_residual_currency
# Check if reconciliation is total
# To check if reconciliation is total we have 3 differents use case:
# 1) There are multiple currency different than company currency, in that case we check using debit-credit
# 2) We only have one currency which is different than company currency, in that case we check using amount_currency
# 3) We have only one currency and some entries that don't have a secundary currency, in that case we check debit-credit
# or amount_currency.
# 4) Cash basis full reconciliation
# - either none of the moves are cash basis reconciled, and we proceed
# - or some moves are cash basis reconciled and we make sure they are all fully reconciled
digits_rounding_precision = amls[0].company_id.currency_id.rounding
if (currency and float_is_zero(total_amount_currency, precision_rounding=currency.rounding)) or \
(multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0):
if (
(
not cash_basis_partial or (cash_basis_partial and all([p >= 1.0 for p in amls._get_matched_percentage().values()]))
) and
(
currency and float_is_zero(total_amount_currency, precision_rounding=currency.rounding) or
multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0
)
):
exchange_move_id = False
# Eventually create a journal entry to book the difference due to foreign currency's exchange rate that fluctuates
if to_balance and any([not float_is_zero(residual, precision_rounding=digits_rounding_precision) for aml, residual in to_balance.values()]):
@@ -934,7 +949,8 @@ class AccountMoveLine(models.Model):
for after_rec_dict in cash_basis_subjected:
new_rec = part_rec.create(after_rec_dict)
if cash_basis:
# if the pair belongs to move being reverted, do not create CABA entry
if cash_basis and not (new_rec.debit_move_id + new_rec.credit_move_id).mapped('move_id').mapped('reverse_entry_id'):
new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec)
self.recompute()
+18
View File
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, _
from odoo.exceptions import ValidationError
class Users(models.Model):
_inherit = "res.users"
@api.multi
@api.constrains('groups_id')
def _check_one_user_type(self):
super(Users, self)._check_one_user_type()
for user in self:
if (user.user_has_groups('account.group_show_line_subtotals_tax_included') and
user.user_has_groups('account.group_show_line_subtotals_tax_excluded')):
raise ValidationError(_('A user cannot have both Tax B2B and Tax B2C'))
+10 -12
View File
@@ -25,18 +25,6 @@
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="base.group_public" model="res.groups">
<field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/>
</record>
<record id="base.group_portal" model="res.groups">
<field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/>
</record>
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/>
</record>
<record id="group_account_user" model="res.groups">
<field name="name">Show Full Accounting Features</field>
<field name="category_id" ref="base.module_category_hidden"/>
@@ -80,6 +68,16 @@
<data noupdate="1">
<record id="base.group_public" model="res.groups">
<field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/>
</record>
<record id="base.group_portal" model="res.groups">
<field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/>
</record>
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/>
</record>
<record id="base.default_user" model="res.users">
<field name="groups_id" eval="[(4,ref('account.group_account_manager'))]"/>
</record>
+124 -1
View File
@@ -6,7 +6,8 @@ import time
import unittest
@tagged('post_install', '-at_install')
# TODO in master
# The name of this class should be TestReconciliationHelpers
class TestReconciliation(AccountingTestCase):
"""Tests for reconciliation (account.tax)
@@ -167,6 +168,10 @@ class TestReconciliation(AccountingTestCase):
supplier_move_lines = bank_stmt.move_line_ids
return customer_move_lines, supplier_move_lines
@tagged('post_install', '-at_install')
class TestReconciliationExec(TestReconciliation):
def test_statement_usd_invoice_eur_transaction_eur(self):
customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_euro_id, 30, self.bank_journal_usd, 42, 30, self.currency_euro_id)
self.assertRecordValues(customer_move_lines, [
@@ -1911,3 +1916,121 @@ class TestReconciliation(AccountingTestCase):
(move_lines - base_amount_tax_lines)
.filtered(lambda l: l.account_id == self.tax_final_account)
.debit, 17094.66)
def test_reconciliation_cash_basis_revert(self):
company = self.env.ref('base.main_company')
company.tax_cash_basis_journal_id = self.cash_basis_journal
tax_cash_basis10percent = self.tax_cash_basis.copy({'amount': 10})
self.tax_waiting_account.reconcile = True
tax_waiting_account10 = self.tax_waiting_account.copy({
'name': 'TAX WAIT 10',
'code': 'TWAIT1',
})
AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False)
# Purchase
purchase_move = self.env['account.move'].create({
'name': 'invoice',
'journal_id': self.purchase_journal.id,
})
purchase_payable_line0 = AccountMoveLine.create({
'account_id': self.account_rsa.id,
'credit': 175,
'move_id': purchase_move.id,
})
AccountMoveLine.create({
'name': 'expenseTaxed 10%',
'account_id': self.expense_account.id,
'debit': 50,
'move_id': purchase_move.id,
'tax_ids': [(4, tax_cash_basis10percent.id, False)],
})
tax_line0 = AccountMoveLine.create({
'name': 'TaxLine0',
'account_id': tax_waiting_account10.id,
'debit': 5,
'move_id': purchase_move.id,
'tax_line_id': tax_cash_basis10percent.id,
})
AccountMoveLine.create({
'name': 'expenseTaxed 20%',
'account_id': self.expense_account.id,
'debit': 100,
'move_id': purchase_move.id,
'tax_ids': [(4, self.tax_cash_basis.id, False)],
})
tax_line1 = AccountMoveLine.create({
'name': 'TaxLine1',
'account_id': self.tax_waiting_account.id,
'debit': 20,
'move_id': purchase_move.id,
'tax_line_id': self.tax_cash_basis.id,
})
purchase_move.post()
reverted = self.env['account.move'].browse(purchase_move.reverse_moves())
self.assertTrue(reverted.exists())
for inv_line in [purchase_payable_line0, tax_line0, tax_line1]:
self.assertTrue(inv_line.full_reconcile_id.exists())
reverted_expected = reverted.line_ids.filtered(lambda l: l.account_id == inv_line.account_id)
self.assertEqual(len(reverted_expected), 1)
self.assertEqual(reverted_expected.full_reconcile_id, inv_line.full_reconcile_id)
def test_reconciliation_cash_basis_foreign_currency_low_values(self):
journal = self.env['account.journal'].create({
'name': 'Bank', 'type': 'bank', 'code': 'THE',
'currency_id': self.currency_usd_id,
})
usd = self.env['res.currency'].browse(self.currency_usd_id)
usd.rate_ids.unlink()
self.env['res.currency.rate'].create({
'name': time.strftime('%Y-01-01'),
'rate': 1/17.0,
'currency_id': self.currency_usd_id,
'company_id': self.env.ref('base.main_company').id,
})
invoice = self.create_invoice(
type='out_invoice', invoice_amount=50,
currency_id=self.currency_usd_id)
invoice.journal_id.update_posted = True
invoice.action_cancel()
invoice.state = 'draft'
invoice.invoice_line_ids.write({
'invoice_line_tax_ids': [(6, 0, [self.tax_cash_basis.id])]})
invoice.compute_taxes()
invoice.action_invoice_open()
self.assertTrue(invoice.currency_id != self.env.user.company_id.currency_id)
# First Payment
payment0 = self.make_payment(invoice, journal, invoice.amount_total - 0.01)
self.assertEqual(invoice.residual, 0.01)
tax_waiting_line = invoice.move_id.line_ids.filtered(lambda l: l.account_id == self.tax_waiting_account)
self.assertFalse(tax_waiting_line.reconciled)
move_caba0 = tax_waiting_line.matched_debit_ids.debit_move_id.move_id
self.assertTrue(move_caba0.exists())
self.assertEqual(move_caba0.journal_id, self.env.user.company_id.tax_cash_basis_journal_id)
pay_receivable_line0 = payment0.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv)
self.assertTrue(pay_receivable_line0.reconciled)
self.assertEqual(pay_receivable_line0.matched_debit_ids, move_caba0.tax_cash_basis_rec_id)
# Second Payment
payment1 = self.make_payment(invoice, journal, 0.01)
self.assertEqual(invoice.residual, 0)
self.assertEqual(invoice.state, 'paid')
self.assertTrue(tax_waiting_line.reconciled)
move_caba1 = tax_waiting_line.matched_debit_ids.mapped('debit_move_id').mapped('move_id').filtered(lambda m: m != move_caba0)
self.assertEqual(len(move_caba1.exists()), 1)
self.assertEqual(move_caba1.journal_id, self.env.user.company_id.tax_cash_basis_journal_id)
pay_receivable_line1 = payment1.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv)
self.assertTrue(pay_receivable_line1.reconciled)
self.assertEqual(pay_receivable_line1.matched_debit_ids, move_caba1.tax_cash_basis_rec_id)
+2 -1
View File
@@ -36,6 +36,7 @@
<field name="invoice_id" attrs="{'invisible': [('invoice_id','=',False)]}"/>
</group>
<group string="Dates">
<field name="date"/>
<field name="date_maturity"/>
</group>
@@ -43,7 +44,7 @@
<field name="tax_line_id" readonly="1" attrs="{'invisible': [('tax_line_id','=',False)]}"/>
<field name="tax_ids" widget="many2many_tags" readonly="1" attrs="{'invisible': [('tax_ids','=',[])]}"/>
</group>
<group string="Matching" attrs="{'invisible':[('full_reconcile_id','=',False)]}">
<group string="Matching" attrs="{'invisible':[('full_reconcile_id','=',False), ('matched_debit_ids', '=', []),('matched_credit_ids', '=', [])]}">
<label for="full_reconcile_id"/>
<div>
<field name="full_reconcile_id"/>
+4
View File
@@ -51,12 +51,16 @@
<table class="table table-sm o_main_table" name="invoice_line_table">
<thead>
<tr>
<!-- TODO: remove in master -->
<t t-set="colspan" t-value="6"/>
<th class="text-left"><span>Description</span></th>
<th class="d-none text-left"><span>Source Document</span></th>
<th class="text-right"><span>Quantity</span></th>
<th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Unit Price</span></th>
<th t-if="display_discount" t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
<span>Disc.(%)</span>
<!-- TODO: remove in master -->
<t t-set="colspan" t-value="colspan+1"/>
</th>
<th t-attf-class="text-left {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Taxes</span></th>
<th class="text-right">
@@ -48,7 +48,7 @@
<div t-if="pms or acquirers" id="payment_method" class="text-left col-md-13">
<t t-call="payment.payment_tokens_list">
<t t-set="mode" t-value="'payment'"/>
<t t-set="partner_id" t-value="invoice.partner_id.id"/>
<t t-set="partner_id" t-value="request.env.user.partner_id.id"/>
<t t-set="success_url" t-value="invoice.get_portal_url()"/>
<t t-set="error_url" t-value="invoice.get_portal_url()"/>
<t t-set="access_token" t-value="access_token or ''"/>
@@ -31,13 +31,13 @@ class Partner(models.Model):
replacement_xml = """
<div>
<field name="country_enforce_cities" invisible="1"/>
<field name='city' placeholder="%(placeholder)s"
<field name='city' placeholder="%(placeholder)s" class="o_address_city"
attrs="{
'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)],
'readonly': [('type', '=', 'contact')%(parent_condition)s]
}"
/>
<field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s"
<field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s" class="o_address_city"
context="{'default_country_id': country_id,
'default_name': city,
'default_zipcode': zip,
+2 -2
View File
@@ -123,8 +123,8 @@
}
// Override height of graph. min-height doesn't do the trick
.o_graph_controller {
height: 500px;
.o_graph_svg_container {
height: 250px;
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ from odoo.addons.calendar.models.calendar import get_real_ids
class Partner(models.Model):
_inherit = 'res.partner'
calendar_last_notif_ack = fields.Datetime('Last notification marked as read from base Calendar')
calendar_last_notif_ack = fields.Datetime('Last notification marked as read from base Calendar', default=fields.Datetime.now)
@api.multi
def get_attendee_detail(self, meeting_id):
+4 -1
View File
@@ -7,4 +7,7 @@ from odoo import models, fields
class ProductTemplate(models.Model):
_inherit = 'product.template'
hs_code = fields.Char(string="HS Code", help="Standardized code for international shipping and goods declaration")
hs_code = fields.Char(
string="HS Code",
help="Standardized code for international shipping and goods declaration. At the moment, only used for the FedEx shipping provider.",
)
+1 -1
View File
@@ -133,7 +133,7 @@ class Digest(models.Model):
tip = self.env['digest.tip'].search([('user_ids', '!=', user.id), '|', ('group_id', 'in', user.groups_id.ids), ('group_id', '=', False)], limit=1)
if not tip:
return False
tip.user_ids = [4, user.id]
tip.user_ids += user
body = tools.html_sanitize(tip.tip_description)
tip_description = self.env['mail.template']._render_template(body, 'digest.tip', self.id)
return tip_description
+1 -1
View File
@@ -478,7 +478,7 @@ class HolidaysRequest(models.Model):
self.message_subscribe(partner_ids=employee.user_id.partner_id.ids)
@api.multi
@api.constrains('holiday_status_id')
@api.constrains('holiday_status_id', 'date_to', 'date_from')
def _check_leave_type_validity(self):
for leave in self:
if leave.holiday_status_id.validity_start and leave.holiday_status_id.validity_stop:
+4 -1
View File
@@ -42,7 +42,10 @@ class AccountAnalyticLine(models.Model):
@api.onchange('employee_id')
def _onchange_employee_id(self):
self.user_id = self.employee_id.user_id
if self.employee_id:
self.user_id = self.employee_id.user_id
else:
self.user_id = self._default_user()
@api.depends('employee_id')
def _compute_department_id(self):
+5 -1
View File
@@ -165,7 +165,7 @@ def is_multilang_url(local_url, langs=None):
local_url = '/'.join(spath)
try:
# Try to match an endpoint in werkzeug's routing table
url = local_url.split('?')
url = local_url.partition('#')[0].split('?')
path = url[0]
query_string = url[1] if len(url) > 1 else None
router = request.httprequest.app.get_db_router(request.db).bind('')
@@ -174,6 +174,10 @@ def is_multilang_url(local_url, langs=None):
return (func.routing.get('website', False) and
func.routing.get('multilang', func.routing['type'] == 'http'))
except werkzeug.exceptions.NotFound:
# Consider /static/ files as non-multilang
static_index = path.find('/static/', 1)
if static_index != -1 and static_index == path.find('/', 1):
return False
return True
except Exception as e:
return False
+1 -1
View File
@@ -256,7 +256,7 @@
<field name="type_tax_use">sale</field>
<field name="refund_account_id" ref="chart22030200"/>
<field name="account_id" ref="chart22030200"/>
<field name="tag_ids" eval="[ref('l10n_pl.tag_pl_08'), ref('l10n_pl.tag_pl_29')]"/>
<field name="tag_ids" eval="[(6,0,[ref('l10n_pl.tag_pl_08'), ref('l10n_pl.tag_pl_29')])]"/>
<field name="tax_group_id" ref="tax_group_vat_7"/>
</record>
+4 -3
View File
@@ -13,9 +13,9 @@ class IrAttachment(models.Model):
"""
super(IrAttachment, self)._post_add_create()
for record in self:
record.register_as_main_attachment()
record.register_as_main_attachment(force=False)
def register_as_main_attachment(self):
def register_as_main_attachment(self, force=True):
""" Registers this attachment as the main one of the model it is
attached to.
"""
@@ -24,4 +24,5 @@ class IrAttachment(models.Model):
# message_main_attachment_id field can be empty, that's why we compare to False;
# we are just checking that it exists on the model before writing it
if related_record and hasattr(related_record, 'message_main_attachment_id'):
related_record.message_main_attachment_id = self
if force or not related_record.message_main_attachment_id:
related_record.message_main_attachment_id = self
+7 -5
View File
@@ -5,6 +5,7 @@ import base64
import datetime
import logging
import psycopg2
import smtplib
import threading
import re
@@ -394,11 +395,12 @@ class MailMail(models.Model):
mail.id, mail.message_id)
# mail status will stay on ongoing since transaction will be rollback
raise
except psycopg2.Error:
# If an error with the database occurs, chances are that the cursor is unusable.
# This will lead to an `psycopg2.InternalError` being raised when trying to write
# `state`, shadowing the original exception and forbid a retry on concurrent
# update. Let's bubble it.
except (psycopg2.Error, smtplib.SMTPServerDisconnected):
# If an error with the database or SMTP session occurs, chances are that the cursor
# or SMTP session are unusable, causing further errors when trying to save the state.
_logger.exception(
'Exception while processing mail with ID %r and Msg-Id %r.',
mail.id, mail.message_id)
raise
except Exception as e:
failure_reason = tools.ustr(e)
+2 -2
View File
@@ -154,8 +154,8 @@ class Message(models.Model):
@api.multi
def _search_has_error(self, operator, operand):
if operator == '=' and operand:
return ['&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)]
return ['!', '&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False"
return [('notification_ids.email_status', 'in', ('bounce', 'exception'))]
return ['!', ('notification_ids.email_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False"
@api.depends('starred_partner_ids')
def _get_starred(self):
+1 -1
View File
@@ -237,7 +237,7 @@ class MailThread(models.AbstractModel):
@api.model
def _search_message_has_error(self, operator, operand):
return [('message_ids.has_error', operator, operand)]
return ['&', ('message_ids.has_error', operator, operand), ('message_ids.author_id', '=', self.env.user.partner_id.id)]
@api.multi
def _compute_message_attachment_count(self):
+4
View File
@@ -81,6 +81,7 @@ var Chatter = Widget.extend({
var nodeOptions = fieldsInfo[mailFields.mail_thread].options || {};
this.hasLogButton = options.display_log_button || nodeOptions.display_log_button;
this.postRefresh = nodeOptions.post_refresh || 'never';
this.reloadOnUploadAttachment = this.postRefresh === 'always';
}
},
/**
@@ -577,6 +578,9 @@ var Chatter = Widget.extend({
* @private
*/
_onReloadAttachmentBox: function () {
if (this.reloadOnUploadAttachment) {
this.trigger_up('reload');
}
this._reloadAttachmentBox();
},
/**
+17 -4
View File
@@ -21,11 +21,11 @@
position: relative;
float: right;
flex: 1 1 auto;
height: $o-statusbar-height;
.o_chatter_button_attachment {
height: $o-statusbar-height - 2px;
height: $o-statusbar-height;
float: right;
padding: 0px;
margin-right: 25px;
.fa-paperclip, .o_chatter_attachment_button_count {
@@ -33,15 +33,20 @@
}
}
.o_followers {
height: $o-statusbar-height + 1;
height: $o-statusbar-height;
border-bottom: 1px solid transparent;
flex: 0 1 auto;
}
}
> .btn {
height: $o-statusbar-height + 1;
height: $o-statusbar-height;
border: 1px solid transparent;
}
.btn {
@extend .text-nowrap;
padding-right: $btn-padding-x;
padding-left: $btn-padding-x;
}
}
@@ -60,3 +65,11 @@
}
}
}
@include media-breakpoint-down(sm) {
.o_chatter .o_chatter_topbar > .o_topbar_right_area {
.o_chatter_button_attachment {
margin-right: initial;
}
}
}
@@ -142,6 +142,9 @@
&:not(.o_chat_inline_composer) {
flex-flow: row wrap;
.o_composer_container {
max-width: 100%;
}
.o_composer_container, .o_composer_button_send {
flex: 1 0 100%;
}
@@ -66,14 +66,14 @@ class MailMailStats(models.Model):
stat.state = 'ignored'
elif stat.exception:
stat.state = 'exception'
elif stat.sent:
stat.state = 'sent'
elif stat.opened or stat.clicked:
stat.state = 'opened'
elif stat.replied:
stat.state = 'replied'
elif stat.bounced:
stat.state = 'bounced'
elif stat.sent:
stat.state = 'sent'
else:
stat.state = 'outgoing'
@@ -64,7 +64,8 @@ class MailComposeMessage(models.TransientModel):
if mail_values.get('email_to'):
mail_to = tools.email_normalize(mail_values['email_to'])
else:
mail_to = tools.email_normalize(partners_email.get(res_id))
partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1]
mail_to = tools.email_normalize(partners_email.get(partner_id))
if (opt_out_list and mail_to in opt_out_list) or (seen_list and mail_to in seen_list) \
or (not mail_to or not email_re.findall(mail_to)):
# prevent sending to blocked addresses that were included by mistake
@@ -720,9 +720,6 @@ var Chrome = PosBaseWidget.extend(AbstractAction.prototype, {
if(err.message === 'XmlHttpRequestError '){
title = 'Network Failure (XmlHttpRequestError)';
body = 'The Point of Sale could not be loaded due to a network problem.\n Please check your internet connection.';
}else if(err.message === 'TLSError'){
title = 'Https connection to IoT Box failed';
body = 'Make sure you are using IoT Box v18.12 or higher.\n\n Navigate to ' + err.url + ' to accept the certificate of your IoT Box.';
}else if(err.code === 200){
title = err.data.message;
body = err.data.debug;
+10 -5
View File
@@ -137,11 +137,16 @@ exports.PosModel = Backbone.Model.extend({
resolve();
},
function (statusText, url) {
if (statusText == 'error' && window.location.protocol == 'https:') {
var error = {message: 'TLSError', url: url};
self.chrome.loading_error(error);
} else {
resolve();
var show_loading_error = (self.gui.current_screen === null);
resolve();
if (show_loading_error && statusText == 'error' && window.location.protocol == 'https:') {
self.gui.show_popup('alert', {
title: _t('HTTPS connection to IoT Box failed'),
body: _.str.sprintf(
_t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'),
url
),
});
}
}
);
+1 -1
View File
@@ -20,6 +20,6 @@
'views/pos_order_report_view.xml',
],
'installable': True,
'auto-install': True,
'auto_install': True,
'qweb': ['static/src/xml/pos.xml'],
}
+5 -1
View File
@@ -15,6 +15,7 @@ from odoo.tools import float_compare
_logger = logging.getLogger(__name__)
class ProductCategory(models.Model):
_name = "product.category"
_description = "Product Category"
@@ -612,7 +613,10 @@ class ProductProduct(models.Model):
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
res = self.env['product.supplierinfo']
for seller in self._prepare_sellers(params):
sellers = self._prepare_sellers(params)
if self.env.context.get('force_company'):
sellers = sellers.filtered(lambda s: not s.company_id or s.company_id.id == self.env.context['force_company'])
for seller in sellers:
# Set quantity in UoM of seller
quantity_uom_seller = quantity
if quantity_uom_seller and uom_id and uom_id != seller.product_uom:
+6 -3
View File
@@ -523,6 +523,7 @@ class ProductTemplate(models.Model):
variants_to_create.append({
'product_tmpl_id': tmpl_id.id,
'attribute_value_ids': [(6, 0, list(value_ids))],
'active': tmpl_id.active,
})
if len(variants_to_create) > 1000:
raise UserError(_(
@@ -991,8 +992,10 @@ class ProductTemplate(models.Model):
if not self.active:
return _("The product template is archived so no combination is possible.")
ptal_stack = [self._get_valid_product_template_attribute_lines()]
combination_stack = [self.env['product.template.attribute.value']]
necessary_values = necessary_values or self.env['product.template.attribute.value']
necessary_attributes = necessary_values.mapped('attribute_id')
ptal_stack = [self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal.attribute_id not in necessary_attributes)]
combination_stack = [necessary_values]
# keep going while we have attribute lines to test
while len(ptal_stack):
@@ -1001,7 +1004,7 @@ class ProductTemplate(models.Model):
if not attribute_lines:
# full combination, if it's possible return it, otherwise skip it
if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])):
if self._is_combination_possible(combination, parent_combination):
yield(combination)
else:
# we have remaining attribute lines to consider
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import time
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@@ -423,6 +425,47 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2),
computer_ssd_256 + computer_ram_8 + computer_hdd_4)
# Make sure this is not extremely slow:
product_template = self.env['product.template'].create({
'name': 'many combinations',
})
for i in range(10):
# create the attributes
product_attribute = self.env['product.attribute'].create({
'name': "att %s" % i,
'create_variant': 'dynamic',
'sequence': i,
})
for j in range(10):
# create the attribute values
self.env['product.attribute.value'].create([{
'name': "val %s/%s" % (i, j),
'attribute_id': product_attribute.id,
'sequence': j,
}])
# set attribute and attribute values on the template
self.env['product.template.attribute.line'].create([{
'attribute_id': product_attribute.id,
'product_tmpl_id': product_template.id,
'value_ids': [(6, 0, product_attribute.value_ids.ids)]
}])
# Get a value in the middle for each attribute to make sure it would
# take time to reach it (if looping one by one like before the fix).
combination = self.env['product.template.attribute.value']
for ptal in product_template.attribute_line_ids:
combination += ptal.product_template_value_ids[5]
started_at = time.time()
self.assertEqual(product_template._get_closest_possible_combination(combination), combination)
elapsed = time.time() - started_at
# It should take around 10ms, but to avoid false positives we check an
# higher value. Before the fix it would take hours.
self.assertLess(elapsed, 0.5)
def test_clear_caches(self):
"""The goal of this test is to make sure the cache is invalidated when
it should be."""
+2 -1
View File
@@ -300,8 +300,9 @@
<div class="o_row col-5 pl-0">
<field name="lst_price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'readonly': [('product_variant_count', '&gt;', 1)]}"/>
</div>
<field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
<field name="standard_price" widget='monetary' options="{'currency_field': 'cost_currency_id'}"/>
<field name="currency_id" invisible='1'/>
<field name="cost_currency_id" invisible="1"/>
</group>
</group>
<group>
@@ -25,6 +25,10 @@ class AccountInvoice(models.Model):
return {}
self.purchase_id = self.vendor_bill_purchase_id.purchase_order_id
self.vendor_bill_id = self.vendor_bill_purchase_id.vendor_bill_id
vendor_ref = self.vendor_bill_purchase_id.purchase_order_id.partner_ref
if vendor_ref:
self.reference = ", ".join([self.reference, vendor_ref]) if (
self.reference and vendor_ref not in self.reference) else vendor_ref
self.vendor_bill_purchase_id = False
return {}
@@ -16,10 +16,11 @@ class ProductProduct(models.Model):
_inherit = 'product.product'
def _prepare_sellers(self, params):
sellers = super(ProductProduct, self)._prepare_sellers(params)
if params and params.get('order_id'):
return self.seller_ids.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id)
return sellers.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id)
else:
return self.seller_ids
return sellers
class ProductTemplate(models.Model):
+2 -2
View File
@@ -174,7 +174,7 @@ class StockRule(models.Model):
def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line):
partner = values['supplier'].name
procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id)
seller = product_id._select_seller(
seller = product_id.with_context(force_company=company_id.id)._select_seller(
partner_id=partner,
quantity=line.product_qty + procurement_uom_po_qty,
date=line.order_id.date_order and line.order_id.date_order.date(),
@@ -197,7 +197,7 @@ class StockRule(models.Model):
procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id)
# _select_seller is used if the supplier have different price depending
# the quantities ordered.
seller = product_id._select_seller(
seller = product_id.with_context(force_company=company_id.id)._select_seller(
partner_id=partner,
quantity=procurement_uom_po_qty,
date=po.date_order and po.date_order.date(),
+1
View File
@@ -61,6 +61,7 @@ class ResCompany(models.Model):
'product_uom_qty': 10,
'price_unit': 123,
'order_id': sample_sales_order.id,
'company_id': sample_sales_order.company_id.id,
})
return sample_sales_order
+16 -13
View File
@@ -1031,20 +1031,23 @@ class SaleOrderLine(models.Model):
res[field] = line._fields[field].convert_to_write(line[field], line)
return res
@api.model
def create(self, values):
if values.get('display_type', self.default_get(['display_type'])['display_type']):
values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0)
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
if values.get('display_type', self.default_get(['display_type'])['display_type']):
values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0)
values.update(self._prepare_add_missing_fields(values))
line = super(SaleOrderLine, self).create(values)
if line.product_id and line.order_id.state == 'sale':
msg = _("Extra line with %s ") % (line.product_id.display_name,)
line.order_id.message_post(body=msg)
# create an analytic account if at least an expense product
if line.product_id.expense_policy not in [False, 'no'] and not self.order_id.analytic_account_id:
self.order_id._create_analytic_account()
return line
values.update(self._prepare_add_missing_fields(values))
lines = super().create(vals_list)
for line in lines:
if line.product_id and line.order_id.state == 'sale':
msg = _("Extra line with %s ") % (line.product_id.display_name,)
line.order_id.message_post(body=msg)
# create an analytic account if at least an expense product
if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id:
line.order_id._create_analytic_account()
return lines
_sql_constraints = [
('accountable_required_fields',
@@ -70,11 +70,15 @@
<table class="table table-sm o_main_table">
<thead>
<tr>
<!-- TODO: remove in master -->
<t t-set="colspan" t-value="5"/>
<th class="text-left">Description</th>
<th class="text-right">Quantity</th>
<th class="text-right">Unit Price</th>
<th t-if="display_discount" class="text-right" groups="sale.group_discount_per_so_line">
<span>Disc.(%)</span>
<!-- TODO: remove in master -->
<t t-set="colspan" t-value="colspan+1"/>
</th>
<th class="text-right">Taxes</th>
<th class="text-right">
@@ -424,11 +424,15 @@
<table class="table table-sm" id="sales_order_table">
<thead class="bg-100">
<tr>
<!-- TODO: remove in master -->
<t t-set="colspan" t-value="6"/>
<th class="text-left">Products</th>
<th class="text-right">Quantity</th>
<th t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}">Unit Price</th>
<th t-if="display_discount" t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}">
<span>Discount</span>
<!-- TODO: remove in master -->
<t t-set="colspan" t-value="colspan+1"/>
</th>
<th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">Taxes</th>
<th class="text-right" >
+2 -1
View File
@@ -31,7 +31,8 @@ class SaleOrder(models.Model):
@api.onchange('partner_id')
def onchange_partner_id(self):
super(SaleOrder, self).onchange_partner_id()
self.note = self.sale_order_template_id.note or self.note
template = self.sale_order_template_id.with_context(lang=self.partner_id.lang)
self.note = template.note or self.note
def _compute_line_data_for_template_change(self, line):
return {
@@ -18,7 +18,7 @@ class SaleOrderTemplate(models.Model):
name = fields.Char('Quotation Template', required=True)
sale_order_template_line_ids = fields.One2many('sale.order.template.line', 'sale_order_template_id', 'Lines', copy=True)
note = fields.Text('Terms and conditions')
note = fields.Text('Terms and conditions', translate=True)
sale_order_template_option_ids = fields.One2many('sale.order.template.option', 'sale_order_template_id', 'Optional Products', copy=True)
number_of_days = fields.Integer('Quotation Duration',
help='Number of days for the validity date computation of the quotation')
@@ -15,6 +15,7 @@
<xpath expr="//section[@id='details']//table[@id='sales_order_table']/thead/tr" position="inside">
<th t-if="display_remove">
<!-- TODO: remove in master -->
<t t-set="colspan" t-value="colspan+1"/>
</th>
</xpath>
+6 -6
View File
@@ -3,7 +3,7 @@
from odoo import api, fields, models, _
from odoo.addons import decimal_precision as dp
from odoo.exceptions import UserError
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_utils, float_compare
@@ -151,13 +151,13 @@ class Inventory(models.Model):
if self.filter == 'none' and self.product_id and self.location_id and self.lot_id:
return
if self.filter not in ('product', 'product_owner') and self.product_id:
raise UserError(_('The selected product doesn\'t belong to that owner..'))
raise ValidationError(_('The selected product doesn\'t belong to that owner..'))
if self.filter != 'lot' and self.lot_id:
raise UserError(_('The selected lot number doesn\'t exist.'))
raise ValidationError(_('The selected lot number doesn\'t exist.'))
if self.filter not in ('owner', 'product_owner') and self.partner_id:
raise UserError(_('The selected owner doesn\'t have the proprietary of that product.'))
raise ValidationError(_('The selected owner doesn\'t have the proprietary of that product.'))
if self.filter != 'pack' and self.package_id:
raise UserError(_('The selected inventory options are not coherent, the package doesn\'t exist.'))
raise ValidationError(_('The selected inventory options are not coherent, the package doesn\'t exist.'))
def action_reset_product_qty(self):
self.mapped('line_ids').write({'product_qty': 0})
@@ -425,7 +425,7 @@ class InventoryLine(models.Model):
"""
for line in self:
if line.product_id.type != 'product':
raise UserError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type))
raise ValidationError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type))
def _get_move_values(self, qty, location_id, location_dest_id, out):
self.ensure_one()
+1 -1
View File
@@ -365,7 +365,7 @@ class QuantPackage(models.Model):
'res.partner', 'Owner', compute='_compute_package_info', search='_search_owner',
index=True, readonly=True)
@api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id')
@api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id', 'quant_ids.quantity', 'quant_ids.reserved_quantity')
def _compute_package_info(self):
for package in self:
values = {'location_id': False, 'company_id': self.env.user.company_id.id, 'owner_id': False}
@@ -6,6 +6,7 @@
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<br/>
<h2>Stock Inventory</h2>
<table class="table table-sm">
@@ -22,7 +22,7 @@ class StockImmediateTransfer(models.TransientModel):
picking.action_assign()
if picking.state != 'assigned':
raise UserError(_("Could not reserve all requested products. Please use the \'Mark as Todo\' button to handle the reservation manually."))
for move in picking.move_lines:
for move in picking.move_lines.filtered(lambda m: m.state not in ['done', 'cancel']):
for move_line in move.move_line_ids:
move_line.qty_done = move_line.product_uom_qty
if picking._check_backorder():
+1 -1
View File
@@ -303,7 +303,7 @@ class StockMove(models.Model):
if qty_to_take_on_candidates == 0:
move.write({
'value': -tmp_value if not quantity else move.value or -tmp_value, # outgoing move are valued negatively
'price_unit': -tmp_value / move.product_qty,
'price_unit': -tmp_value / (move.product_qty or quantity),
})
elif qty_to_take_on_candidates > 0:
last_fifo_price = new_standard_price or move.product_id.standard_price
+1 -1
View File
@@ -67,7 +67,7 @@
<div name="standard_price_div" class="row ml-0">
<field name="standard_price"
attrs="{'readonly':[('valuation','=','real_time'), ('cost_method', '!=', 'fifo')]}"
widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" class="float-left"/>
widget='monetary' options="{'currency_field': 'cost_currency_id', 'field_digits': True}" class="float-left"/>
<div name="update_cost_price">
<button string="Update Cost" type="action"
name="%(action_view_change_standard_price)d"
+4 -1
View File
@@ -98,7 +98,10 @@ if(!the_form.length) {
}
// checkboxes and radios
the_form.find("input[name^=" + key + "][type!='text']").each(function(){
the_form.find("input[name^='" + key + "_'][type='checkbox']").each(function(){
$(this).val(value);
});
the_form.find("input[name=" + key + "][type!='text']").each(function(){
$(this).val(value);
});
});
@@ -435,6 +435,18 @@ ListRenderer.include({
});
}
},
/**
* @override
*/
updateState: function (state, params) {
if (params.noRender) {
// the state changed, but we won't do a re-rendering right now, so
// remove computed modifiers data (as they are obsolete) to force
// them to be recomputed at next (sub-)rendering
this.allModifiersData = [];
}
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
@@ -955,6 +967,8 @@ ListRenderer.include({
* @param {boolean} [options.force=false] if true, force selecting the cell
* even if seems to be already the selected one (useful after a re-
* rendering, to reset the focus on the correct field)
* @param {integer} [options.inc=1] the increment to use when searching for
* the "next" possible cell (if the cell to select can't be selected)
* @return {Promise} fails if no cell could be selected
*/
_selectCell: function (rowIndex, fieldIndex, options) {
@@ -979,7 +993,7 @@ ListRenderer.include({
var oldFieldIndex = self.currentFieldIndex;
self.currentFieldIndex = fieldIndex;
fieldIndex = self._activateFieldWidget(record, fieldIndex, {
inc: 1,
inc: options.inc || 1,
wrap: wrap,
event: options && options.event,
});
@@ -1183,7 +1197,7 @@ ListRenderer.include({
switch (ev.data.direction) {
case 'previous':
if (this.currentFieldIndex > 0) {
this._selectCell(this.currentRow, this.currentFieldIndex - 1, {wrap: false})
this._selectCell(this.currentRow, this.currentFieldIndex - 1, {inc: -1, wrap: false})
.guardedCatch(this._moveToPreviousLine.bind(this));
} else {
this._moveToPreviousLine();
+125
View File
@@ -3212,6 +3212,90 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('pressing SHIFT-TAB in editable list with a readonly field [REQUIRE FOCUS]', async function (assert) {
assert.expect(4);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom">' +
'<field name="foo"/>' +
'<field name="int_field" readonly="1"/>' +
'<field name="qux"/>' +
'</tree>',
});
// start on 'qux', line 3
await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(2)'));
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=qux]')[0]);
// Press 'shift-Tab' -> should go to first cell (same line)
$(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
await testUtils.nextTick();
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]);
list.destroy();
});
QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in first column [REQUIRE FOCUS]', async function (assert) {
assert.expect(4);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom">' +
'<field name="int_field" readonly="1"/>' +
'<field name="foo"/>' +
'<field name="qux"/>' +
'</tree>',
});
// start on 'foo', line 3
await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(1)'));
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]);
// Press 'shift-Tab' -> should go to previous line (last cell)
$(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
await testUtils.nextTick();
assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=qux]')[0]);
list.destroy();
});
QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in last column [REQUIRE FOCUS]', async function (assert) {
assert.expect(4);
var list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="bottom">' +
'<field name="int_field"/>' +
'<field name="foo"/>' +
'<field name="qux" readonly="1"/>' +
'</tree>',
});
// start on 'int_field', line 3
await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:first'));
assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row');
assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=int_field]')[0]);
// Press 'shift-Tab' -> should go to previous line ('foo' field)
$(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true});
await testUtils.nextTick();
assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row');
assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=foo]')[0]);
list.destroy();
});
QUnit.test('skip invisible fields when navigating list view with TAB', async function (assert) {
assert.expect(2);
@@ -4460,6 +4544,47 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test('create record on list with modifiers depending on id', function (assert) {
assert.expect(8);
var list = createView({
View: ListView,
model: 'foo',
data: this.data,
arch: '<tree editable="top">' +
'<field name="id" invisible="1"/>' +
'<field name="foo" attrs="{\'readonly\': [[\'id\',\'!=\',False]]}"/>' +
'<field name="int_field" attrs="{\'invisible\': [[\'id\',\'!=\',False]]}"/>' +
'</tree>',
});
// add a new record
testUtils.dom.click(list.$buttons.find('.o_list_button_add'));
// modifiers should be evaluted to false
assert.containsOnce(list, '.o_selected_row');
assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier');
assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier');
// set a value and save
testUtils.fields.editInput(list.$('.o_selected_row input[name=foo]'), 'some value');
testUtils.dom.click(list.$buttons.find('.o_list_button_save'));
// modifiers should be evaluted to true
assert.hasClass(list.$('.o_data_row:first .o_data_cell:first'), 'o_readonly_modifier');
assert.hasClass(list.$('.o_data_row:first .o_data_cell:nth(1)'), 'o_invisible_modifier');
// edit again the just created record
testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first'));
// modifiers should be evaluted to true
assert.containsOnce(list, '.o_selected_row');
assert.hasClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier');
assert.hasClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier');
list.destroy();
});
QUnit.test('grouped list with async widget', async function (assert) {
assert.expect(4);
@@ -909,7 +909,7 @@ var VideoWidget = MediaWidget.extend({
options = options || {};
// Video url patterns(youtube, instagram, vimeo, dailymotion, youku, ...)
var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;
var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/;
var ytMatch = url.match(ytRegExp);
var insRegExp = /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/;
@@ -936,8 +936,8 @@ var VideoWidget = MediaWidget.extend({
var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0';
if (ytMatch && ytMatch[1].length === 11) {
$video.attr('src', '//www.youtube.com/embed/' + ytMatch[1] + autoplay);
if (ytMatch && ytMatch[2].length === 11) {
$video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay);
} else if (insMatch && insMatch[2].length) {
$video.attr('src', '//www.instagram.com/p/' + insMatch[2] + '/embed/');
videoType = 'ins';
+8 -1
View File
@@ -4,6 +4,7 @@ import re
from collections import OrderedDict
from odoo import models
from odoo.addons.http_routing.models.ir_http import url_for
from odoo.http import request
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.tools import html_escape as escape
@@ -50,10 +51,16 @@ class QWeb(models.AbstractModel):
if not website and options.get('website_id'):
website = self.env['website'].browse(options['website_id'])
if not website or not website.cdn_activated:
if not website:
return atts
name = self.URL_ATTRS.get(tagName)
if request and name and name in atts:
atts[name] = url_for(atts[name])
if not website.cdn_activated:
return atts
if name and name in atts:
atts = OrderedDict(atts)
atts[name] = website.get_cdn_url(atts[name])
+122
View File
@@ -2,7 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import re
import werkzeug
import odoo
from odoo import tools
from odoo.modules.module import get_module_resource
from odoo.tests.common import TransactionCase
@@ -65,3 +67,123 @@ class TestQweb(TransactionCase):
"filename": "Marc%20Demo",
"alt": "Marc Demo",
}).encode('utf8'))
class MockObject(object):
_log_call = []
def __init__(self, *args, **kwargs):
self.__dict__ = kwargs
def __call__(self, *args, **kwargs):
self._log_call.append((args, kwargs))
return self
def __getitem__(self, index):
return self
def werkzeugRaiseNotFound(*args, **kwargs):
raise werkzeug.exceptions.NotFound()
class MockRequest(object):
""" Class with context manager mocking odoo.http.request for tests """
def __init__(self, env, website=None, context=None, multilang=True, routing=True):
app = MockObject(routing={
'type': 'http',
'website': True,
'multilang': multilang,
})
app.get_db_router = app.bind = app.match = app
if not routing:
app.match = werkzeugRaiseNotFound
self.request = MockObject(
env=env, context=context or {}, db=None, debug=False,
website=website, httprequest=MockObject(
path='/hello/',
app=app
)
)
odoo.http._request_stack.push(self.request)
def __enter__(self):
return self.request
def __exit__(self, exc_type, exc_value, traceback):
odoo.http._request_stack.pop()
class TestQwebProcessAtt(TransactionCase):
def setUp(self):
super(TestQwebProcessAtt, self).setUp()
self.website = self.env['website'].browse(1)
self.website.language_ids = self.env.ref('base.lang_en') + self.env.ref('base.lang_fr')
self.website.default_lang_id = self.env.ref('base.lang_en')
self.website.cdn_activated = True
self.website.cdn_url = "http://test.cdn"
self.website.cdn_filters = "\n".join(["^(/[a-z]{2}_[A-Z]{2})?/a$", "^/b$"])
def _test_att(self, url, expect, tag='a', attribute='href'):
self.assertEqual(
self.env['ir.qweb']._post_processing_att(tag, {attribute: url}, {}),
expect
)
def test_process_att_no_request(self):
# no request so no URL rewriting
self._test_att('/', {'href': '/'})
self._test_att('/en_US/', {'href': '/en_US/'})
self._test_att('/fr_FR/', {'href': '/fr_FR/'})
# no URL rewritting for CDN
self._test_att('/a', {'href': '/a'})
def test_process_att_no_website(self):
with MockRequest(self.env) as request:
# no website so URL rewriting
self._test_att('/', {'href': '/'})
self._test_att('/en_US/', {'href': '/en_US/'})
self._test_att('/fr_FR/', {'href': '/fr_FR/'})
# no URL rewritting for CDN
self._test_att('/a', {'href': '/a'})
def test_process_att_monolang_route(self):
with MockRequest(self.env, website=self.website, multilang=False) as request:
# lang not changed in URL but CDN enabled
self._test_att('/a', {'href': 'http://test.cdn/a'})
self._test_att('/en_US/a', {'href': 'http://test.cdn/en_US/a'})
self._test_att('/b', {'href': 'http://test.cdn/b'})
self._test_att('/en_US/b', {'href': '/en_US/b'})
def test_process_att_no_request_lang(self):
with MockRequest(self.env, self.website) as request:
self._test_att('/', {'href': '/'})
self._test_att('/en_US/', {'href': '/'})
self._test_att('/fr_FR/', {'href': '/fr_FR/'})
def test_process_att_with_request_lang(self):
with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}) as request:
self._test_att('/', {'href': '/fr_FR/'})
self._test_att('/en_US/', {'href': '/'})
self._test_att('/fr_FR/', {'href': '/fr_FR/'})
def test_process_att_matching_cdn_and_lang(self):
with MockRequest(self.env, self.website) as request:
# lang prefix is added before CDN
self._test_att('/a', {'href': 'http://test.cdn/a'})
self._test_att('/en_US/a', {'href': 'http://test.cdn/a'})
self._test_att('/fr_FR/a', {'href': 'http://test.cdn/fr_FR/a'})
self._test_att('/b', {'href': 'http://test.cdn/b'})
self._test_att('/en_US/b', {'href': 'http://test.cdn/b'})
self._test_att('/fr_FR/b', {'href': '/fr_FR/b'})
def test_process_att_no_route(self):
with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}, routing=False) as request:
# default on multilang=True if route is not /{module}/static/
self._test_att('/web/static/hi', {'href': '/web/static/hi'})
self._test_att('/my-page', {'href': '/fr_FR/my-page'})
def test_process_att_url_crap(self):
with MockRequest(self.env, self.website) as request:
# #{fragment} is stripped from URL when testing route
self._test_att('/x#y?z', {'href': '/x#y?z'})
self.assertEqual(
request.httprequest.app._log_call[-1],
(('/x',), {'method': 'POST', 'query_args': None})
)
self._test_att('/x?y#z', {'href': '/x?y#z'})
self.assertEqual(
request.httprequest.app._log_call[-1],
(('/x',), {'method': 'POST', 'query_args': 'y'})
)
@@ -0,0 +1,15 @@
The Democratic Republic of Congo, April 3rd 2019
Business Solutions For Africa 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,
Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa
List of contributors:
Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa
+1
View File
@@ -13,3 +13,4 @@ David Arnold dar@xoe.solutions https://github.com/blaggacao
List of contributors:
David Arnold dar@devco.co https://github.com/blaggacao
Deiber Rincon dri@xoe.solutions https://github.com/deiber
+9
View File
@@ -0,0 +1,9 @@
Ukraine, 2019-04-08
I hereby agree to the terms of the Odoo Individual Contributor License Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this declaration.
Signed,
Andrii Semko semko.andrey.i@gmail.com https://github.com/andriisem
+11
View File
@@ -0,0 +1,11 @@
Switzerland, 2019-03-25
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,
Bernard Letourmy bernard.letourmy@gmail.com https://github.com/bletourmy
+11
View File
@@ -0,0 +1,11 @@
USA, 2019-04-04
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,
Matthew Watkins mwatkins.bulk@gmail.com https://github.com/matthewdwatkins
+11
View File
@@ -0,0 +1,11 @@
Sudan, 2019-03-15
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,
Mohammed Mahgoub mmahgoub@gmail.com https://github.com/mmahgoub
+5 -1
View File
@@ -277,7 +277,11 @@ Installing your theme
To install your theme, you just place your theme folder inside addons in your Odoo installation.
After that, navigate to the Settings page, look for your theme and click on the install button.
After that, navigate to the Odoo **Website** module, go to
:menuselection:`Configuration --> Settings`.
Under **Website** section click the **Choose a theme** button, then hover over
your theme and click **Use this theme**.
Structure of an Odoo page
=========================
@@ -483,6 +483,8 @@ class IrMailServer(models.Model):
# do not quit() a pre-established smtp_session
if not smtp_session:
smtp.quit()
except smtplib.SMTPServerDisconnected:
raise
except Exception as e:
params = (ustr(smtp_server), e.__class__.__name__, ustr(e))
msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % params
+11
View File
@@ -185,6 +185,11 @@ class IrModel(models.Model):
self._cr.execute('DROP VIEW "%s"' % table)
elif kind == 'r':
self._cr.execute('DROP TABLE "%s" CASCADE' % table)
# discard all translations for this model
self._cr.execute("""
DELETE FROM ir_translation
WHERE type IN ('model', 'model_terms') AND name LIKE %s
""", [model.model + ',%'])
else:
_logger.warning('The model %s could not be dropped because it did not exist in the registry.', model.model)
return True
@@ -574,6 +579,12 @@ class IrModelFields(models.Model):
tables_to_drop.add(rel_name)
if field.state == 'manual' and is_model:
model._pop_field(field.name)
if field.translate:
# discard all translations for this field
self._cr.execute("""
DELETE FROM ir_translation
WHERE type IN ('model', 'model_terms') AND name=%s
""", ['%s,%s' % (field.model, field.name)])
if tables_to_drop:
# drop the relation tables that are not used by other fields
+9
View File
@@ -19,6 +19,7 @@ from docutils.core import publish_string
from docutils.transforms import Transform, writer_aux
from docutils.writers.html4css1 import Writer
import lxml.html
import psycopg2
import odoo
from odoo import api, fields, models, modules, tools, _
@@ -542,6 +543,14 @@ class Module(models.Model):
@api.multi
def _button_immediate_function(self, function):
try:
# This is done because the installation/uninstallation/upgrade can modify a currently
# running cron job and prevent it from finishing, and since the ir_cron table is locked
# during execution, the lock won't be released until timeout.
self._cr.execute("SELECT * FROM ir_cron FOR UPDATE NOWAIT")
except psycopg2.OperationalError:
raise UserError(_("The server is busy right now, module operations are not possible at"
" this time, please try again later."))
function(self)
self._cr.commit()
+41
View File
@@ -669,6 +669,47 @@ class IrTranslation(models.Model):
})
self._modified_model(field.model_name)
@api.model
def _upsert_translations(self, vals_list):
""" Insert or update translations of type 'model' or 'model_terms'.
This method is used for creations of translations where the given
``vals_list`` is trusted to be the right values and potential
conflicts should be updated to the new given value.
"""
rows_by_type = defaultdict(list)
for vals in vals_list:
rows_by_type[vals['type']].append((
vals['name'], vals['lang'], vals['res_id'], vals['src'], vals['type'],
vals.get('module'), vals['value'], vals.get('state'), vals.get('comments'),
))
if rows_by_type['model']:
query = """
INSERT INTO ir_translation (name, lang, res_id, src, type,
module, value, state, comments)
VALUES {}
ON CONFLICT (type, lang, name, res_id) WHERE type='model'
DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
(EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
""".format(", ".join(["%s"] * len(rows_by_type['model'])))
self.env.cr.execute(query, rows_by_type['model'])
if rows_by_type['model_terms']:
query = """
INSERT INTO ir_translation (name, lang, res_id, src, type,
module, value, state, comments)
VALUES {}
ON CONFLICT (type, name, lang, res_id, md5(src))
DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
(EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
""".format(", ".join(["%s"] * len(rows_by_type['model_terms'])))
self.env.cr.execute(query, rows_by_type['model_terms'])
@api.model
def translate_fields(self, model, id, field=None):
""" Open a view for translating the field(s) of the record (model, id). """
+1 -1
View File
@@ -115,7 +115,7 @@ class Currency(models.Model):
fractional_value = int(parts[2] or 0)
lang_code = self.env.context.get('lang') or self.env.user.lang
lang = self.env['res.lang'].search([('code', '=', lang_code)])
lang = self.env['res.lang'].with_context(active_test=False).search([('code', '=', lang_code)])
amount_words = tools.ustr('{amt_value} {amt_word}').format(
amt_value=_num2words(integer_value, lang=lang.iso_code),
amt_word=self.currency_unit_label,
+5 -1
View File
@@ -945,7 +945,11 @@ class Field(MetaField('DummyField', (object,), {})):
"""
indexname = '%s_%s_index' % (model._table, self.name)
if self.index:
sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
try:
with model._cr.savepoint():
sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
except psycopg2.OperationalError:
_schema.error("Unable to add index for %s", self)
else:
sql.drop_index(model._cr, indexname, model._table)
+3 -2
View File
@@ -4311,11 +4311,12 @@ Fields:
if vals['lang'] == old.env.lang and field.translate is True:
# force a source if the new_val was not changed by copy override
if new_val == old[name]:
vals['source'] = old_wo_lang[name]
new_wo_lang[name] = old_wo_lang[name]
vals['src'] = old_wo_lang[name]
# the value should be the new value (given by copy())
vals['value'] = new_val
vals_list.append(vals)
Translation.create(vals_list)
Translation._upsert_translations(vals_list)
@api.multi
@api.returns('self', lambda value: value.id)