[MERGE] forward port branch 12.0 up to ea1fc124ef

This commit is contained in:
Christophe Simonis
2019-04-09 11:11:48 +02:00
48 changed files with 398 additions and 77 deletions
+1
View File
@@ -19,3 +19,4 @@ from . import reconciliation_widget
from . import account_incoterms
from . import digest
from . import account_invoice_import_wizard
from . import res_users
+19 -9
View File
@@ -98,6 +98,11 @@ class AccountInvoice(models.Model):
journal = self._default_journal()
return journal.currency_id or journal.company_id.currency_id or self.env.user.company_id.currency_id
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',
@@ -107,14 +112,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)
@@ -1028,12 +1032,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
+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'))
+2 -1
View File
@@ -1257,6 +1257,7 @@
<field name="invoice_id" attrs="{'invisible': [('invoice_id','=',False)]}"/>
</group>
<group string="Dates">
<field name="date"/>
<field name="date_maturity"/>
</group>
@@ -1264,7 +1265,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"/>
@@ -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,
+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
@@ -466,7 +466,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>
+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):
@@ -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
@@ -721,9 +721,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({
done.resolve();
},
function(statusText, url){
if (statusText == 'error' && window.location.protocol == 'https:') {
var error = {message: 'TLSError', url: url};
self.chrome.loading_error(error);
} else {
done.resolve();
var show_loading_error = (self.gui.current_screen === null);
done.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
),
});
}
});
return done;
+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"
@@ -560,7 +561,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:
+2 -1
View File
@@ -297,8 +297,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):
+3 -3
View File
@@ -85,7 +85,7 @@ class StockRule(models.Model):
def _get_purchase_order_date(self, product_id, product_qty, product_uom, values, partner, schedule_date):
"""Return the datetime value to use as Order Date (``date_order``) for the
Purchase Order created to satisfy the given procurement. """
seller = product_id._select_seller(
seller = product_id.with_context(force_company=values['company_id'].id)._select_seller(
partner_id=partner,
quantity=product_qty,
date=schedule_date and schedule_date.date(),
@@ -95,7 +95,7 @@ class StockRule(models.Model):
def _update_purchase_order_line(self, product_id, product_qty, product_uom, values, line, partner):
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=values['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(),
@@ -115,7 +115,7 @@ class StockRule(models.Model):
@api.multi
def _prepare_purchase_order_line(self, product_id, product_qty, product_uom, values, po, partner):
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=values['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
@@ -62,6 +62,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
@@ -1029,20 +1029,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',
+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}
@@ -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
@@ -304,7 +304,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
@@ -95,7 +95,7 @@ if(!the_form.length) {
}
// checkboxes and radios
the_form.find("input[name^=" + key + "][type!='text']").each(function(){
the_form.find("input[name=" + key + "][type!='text']").each(function(){
$(this).val(value);
});
});
@@ -963,7 +963,10 @@ eventHandler.attach = function (oLayoutInfo, options) {
show_tooltip = true;
setTimeout(function () {
if (!show_tooltip) return;
// Do not show tooltip on double-click and if there is already one
if (!show_tooltip || $target.attr('title') !== undefined) {
return;
}
$target.tooltip({title: _t('Double-click to edit'), trigger: 'manuel', container: 'body'}).tooltip('show');
setTimeout(function () {
$target.tooltip('dispose');
@@ -940,7 +940,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]*)/;
@@ -967,8 +967,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
@@ -63,3 +65,123 @@ class TestQweb(TransactionCase):
"css": attachments[1].url,
"user_id": demo.id,
}).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, _
@@ -543,6 +544,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). """
+5 -1
View File
@@ -929,7 +929,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
@@ -4254,11 +4254,12 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
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)