summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--addons/account/models/account_move.py3
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_model.js1
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_renderer.js8
-rw-r--r--addons/account_asset/report/account_asset_report.py1
-rw-r--r--addons/auth_signup/models/res_partner.py13
-rw-r--r--addons/auth_signup/models/res_users.py12
-rw-r--r--addons/barcodes/static/src/js/barcode_events.js2
-rw-r--r--addons/crm/tests/test_lead2opportunity.py2
-rw-r--r--addons/event_sale/models/sale_order.py11
-rw-r--r--addons/hr_gamification/security/ir.model.access.csv2
-rw-r--r--addons/hr_holidays/report/hr_leave_report.py3
-rw-r--r--addons/mail/models/mail_channel.py2
-rw-r--r--addons/mail/models/mail_template.py6
-rw-r--r--addons/mail/views/mail_template_views.xml7
-rw-r--r--addons/pad_project/models/project.py2
-rw-r--r--addons/payment_stripe/static/src/js/stripe.js5
-rw-r--r--addons/point_of_sale/static/src/js/screens.js3
-rw-r--r--addons/point_of_sale/wizard/pos_payment.py7
-rw-r--r--addons/sale/models/product_template.py3
-rw-r--r--addons/sale/models/sale.py2
-rw-r--r--addons/sale/views/res_config_settings_views.xml2
-rw-r--r--addons/sale_stock/tests/test_sale_stock.py73
-rw-r--r--addons/sale_timesheet/models/product.py2
-rw-r--r--addons/stock/models/stock_picking.py7
-rw-r--r--addons/web/controllers/main.py5
-rw-r--r--addons/website/models/ir_attachment.py6
-rw-r--r--addons/website/models/website.py1
-rw-r--r--odoo/addons/base/models/assetsbundle.py7
-rw-r--r--odoo/addons/base/models/ir_actions.py13
-rw-r--r--odoo/addons/base/models/ir_actions_report.py2
-rw-r--r--odoo/addons/base/models/ir_attachment.py23
-rw-r--r--odoo/addons/base/models/ir_module.py1
-rw-r--r--odoo/addons/base/models/res_users.py25
-rw-r--r--odoo/http.py1
-rw-r--r--odoo/models.py13
-rw-r--r--odoo/tools/misc.py20
36 files changed, 238 insertions, 58 deletions
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index 3369007386e..b6f2fee7fe4 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -1123,8 +1123,9 @@ class AccountMoveLine(models.Model):
an analytic account. This method is intended to be extended in other modules.
"""
amount = (self.credit or 0.0) - (self.debit or 0.0)
+ default_name = self.name or (self.ref or '/' + ' -- ' + (self.partner_id and self.partner_id.name or '/'))
return {
- 'name': self.name,
+ 'name': default_name,
'date': self.date,
'account_id': self.analytic_account_id.id,
'tag_ids': [(6, 0, self._get_analytic_tag_ids())],
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js
index cf87b0a782c..27ad4066f76 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_model.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js
@@ -874,6 +874,7 @@ var StatementModel = BasicModel.extend({
}
});
total = Math.round(total*1000)/1000 || 0;
+ amount_currency = Math.round(amount_currency);
line.balance = {
amount: total,
amount_str: field_utils.format.monetary(Math.abs(total), {}, formatOptions),
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
index b9cb5f70fb7..0e5c7e2cad8 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
@@ -304,8 +304,12 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
self.fields = {
partner_id : new relational_fields.FieldMany2One(self,
'partner_id',
- self.model.get(recordID),
- {mode: 'edit'}
+ self.model.get(recordID), {
+ mode: 'edit',
+ attrs: {
+ placeholder: self._initialState.st_line.communication_partner_name || '',
+ }
+ }
)
};
self.fields.partner_id.appendTo(self.$('.accounting_view caption'));
diff --git a/addons/account_asset/report/account_asset_report.py b/addons/account_asset/report/account_asset_report.py
index 0b577215f56..eb3e08287c2 100644
--- a/addons/account_asset/report/account_asset_report.py
+++ b/addons/account_asset/report/account_asset_report.py
@@ -60,6 +60,7 @@ class AssetAssetReport(models.Model):
from account_asset_depreciation_line dl
left join account_asset_asset a on (dl.asset_id=a.id)
left join (select min(d.id) as id,ac.id as ac_id from account_asset_depreciation_line as d inner join account_asset_asset as ac ON (ac.id=d.asset_id) group by ac_id) as dlmin on dlmin.ac_id=a.id
+ where a.active is true
group by
dl.amount,dl.asset_id,dl.depreciation_date,dl.name,
a.date, dl.move_check, a.state, a.category_id, a.partner_id, a.company_id,
diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py
index 17171c645f6..c0da1d85f83 100644
--- a/addons/auth_signup/models/res_partner.py
+++ b/addons/auth_signup/models/res_partner.py
@@ -25,9 +25,9 @@ def now(**kwargs):
class ResPartner(models.Model):
_inherit = 'res.partner'
- signup_token = fields.Char(copy=False)
- signup_type = fields.Char(string='Signup Token Type', copy=False)
- signup_expiration = fields.Datetime(copy=False)
+ signup_token = fields.Char(copy=False, groups="base.group_erp_manager")
+ signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager")
+ signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager")
signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid')
signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL')
@@ -35,15 +35,17 @@ class ResPartner(models.Model):
@api.depends('signup_token', 'signup_expiration')
def _compute_signup_valid(self):
dt = now()
- for partner in self:
+ for partner in self.sudo():
partner.signup_valid = bool(partner.signup_token) and \
(not partner.signup_expiration or dt <= partner.signup_expiration)
@api.multi
def _compute_signup_url(self):
""" proxy for function field towards actual implementation """
- result = self._get_signup_url_for_action()
+ result = self.sudo()._get_signup_url_for_action()
for partner in self:
+ if any(u.has_group('base.group_user') for u in partner.user_ids if u != self.env.user):
+ self.env['res.users'].check_access_rights('write')
partner.signup_url = result.get(partner.id, False)
@api.multi
@@ -106,6 +108,7 @@ class ResPartner(models.Model):
allow_signup = self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope', 'b2b') == 'b2c'
for partner in self:
if allow_signup and not partner.user_ids:
+ partner = partner.sudo()
partner.signup_prepare()
res[partner.id]['auth_signup_token'] = partner.signup_token
elif partner.user_ids:
diff --git a/addons/auth_signup/models/res_users.py b/addons/auth_signup/models/res_users.py
index cc222498155..8792bad9489 100644
--- a/addons/auth_signup/models/res_users.py
+++ b/addons/auth_signup/models/res_users.py
@@ -137,10 +137,20 @@ class ResUsers(models.Model):
template = self.env.ref('auth_signup.reset_password_email')
assert template._name == 'mail.template'
+ template_values = {
+ 'email_to': '${object.email|safe}',
+ 'email_cc': False,
+ 'auto_delete': True,
+ 'partner_to': False,
+ 'scheduled_date': False,
+ }
+ template.write(template_values)
+
for user in self:
if not user.email:
raise UserError(_("Cannot send email: user %s has no email address.") % user.name)
- template.with_context(lang=user.lang).send_mail(user.id, force_send=True, raise_exception=True)
+ with self.env.cr.savepoint():
+ template.with_context(lang=user.lang).send_mail(user.id, force_send=True, raise_exception=True)
_logger.info("Password reset email sent for user <%s> to <%s>", user.login, user.email)
@api.model
diff --git a/addons/barcodes/static/src/js/barcode_events.js b/addons/barcodes/static/src/js/barcode_events.js
index 2d602a36b97..0f2849525de 100644
--- a/addons/barcodes/static/src/js/barcode_events.js
+++ b/addons/barcodes/static/src/js/barcode_events.js
@@ -143,6 +143,8 @@ var BarcodeEvents = core.Class.extend(mixins.PropertiesMixin, {
e.key === "ArrowUp" || e.key === "ArrowDown" ||
e.key === "Escape" || e.key === "Tab" ||
e.key === "Backspace" || e.key === "Delete" ||
+ e.key === "Home" || e.key === "End" ||
+ e.key === "PageUp" || e.key === "PageDown" ||
e.key === "Unidentified" || /F\d\d?/.test(e.key)) {
return true;
} else {
diff --git a/addons/crm/tests/test_lead2opportunity.py b/addons/crm/tests/test_lead2opportunity.py
index 2bc62921a59..4d7279ea9de 100644
--- a/addons/crm/tests/test_lead2opportunity.py
+++ b/addons/crm/tests/test_lead2opportunity.py
@@ -38,7 +38,7 @@ class TestLead2opportunity2win(TestCrmCases):
crm_case_3.message_post(subject='Test note', body='Détails envoyés par le client sur ​​le FAX pour la qualité')
# I convert mass lead into opportunity customer.
- mass = CrmLead2OpportunityPartnerMass.with_context({'active_model': 'crm.lead', 'active_ids': [crm_case_13.id, crm_case_2.id], 'active_id': crm_case_13.id}).create({
+ mass = CrmLead2OpportunityPartnerMass.sudo(self.crm_salemanager.id).with_context({'active_model': 'crm.lead', 'active_ids': [crm_case_13.id, crm_case_2.id], 'active_id': crm_case_13.id}).create({
'user_ids': [(6, 0, self.env.ref('base.user_root').ids)],
'team_id': self.env.ref("sales_team.team_sales_department").id
})
diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py
index 6902d8e4743..2ac4deaed5f 100644
--- a/addons/event_sale/models/sale_order.py
+++ b/addons/event_sale/models/sale_order.py
@@ -8,12 +8,13 @@ class SaleOrder(models.Model):
@api.multi
def action_confirm(self):
- self.ensure_one()
res = super(SaleOrder, self).action_confirm()
- # confirm registration if it was free (otherwise it will be confirmed once invoice fully paid)
- self.order_line._update_registrations(confirm=self.amount_total == 0, cancel_to_draft=False)
- if any(self.order_line.filtered(lambda line: line.event_id)):
- return self.env['ir.actions.act_window'].with_context(default_sale_order_id=self.id).for_xml_id('event_sale', 'action_sale_order_event_registration')
+ for order in self:
+ # confirm registration if it was free (otherwise it will be confirmed once invoice fully paid)
+ order.order_line._update_registrations(confirm=order.amount_total == 0, cancel_to_draft=False)
+ if any(order.order_line.filtered(lambda line: line.event_id)):
+ return self.env['ir.actions.act_window'].with_context(default_sale_order_id=order.id).for_xml_id(
+ 'event_sale', 'action_sale_order_event_registration')
return res
diff --git a/addons/hr_gamification/security/ir.model.access.csv b/addons/hr_gamification/security/ir.model.access.csv
index 9ace96f7139..78979d89de5 100644
--- a/addons/hr_gamification/security/ir.model.access.csv
+++ b/addons/hr_gamification/security/ir.model.access.csv
@@ -2,4 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
challenge_officer,"Challenge Officer",gamification.model_gamification_challenge,hr.group_hr_user,1,1,1,1
challenge_line_officer,"Challenge Line Officer",gamification.model_gamification_challenge_line,hr.group_hr_user,1,1,1,1
badge_officer,"Badge Officer",gamification.model_gamification_badge,hr.group_hr_user,1,1,1,1
-badge_user_officer,"Badge-user Officer",gamification.model_gamification_badge_user,hr.group_hr_user,1,1,1,1
+badge_user_officer,"Badge-user Officer",gamification.model_gamification_badge_user,hr.group_hr_user,1,1,1,1
diff --git a/addons/hr_holidays/report/hr_leave_report.py b/addons/hr_holidays/report/hr_leave_report.py
index 5df7a3e4636..3e918920c52 100644
--- a/addons/hr_holidays/report/hr_leave_report.py
+++ b/addons/hr_holidays/report/hr_leave_report.py
@@ -38,6 +38,7 @@ class LeaveReport(models.Model):
def init(self):
tools.drop_view_if_exists(self._cr, 'hr_leave_report')
+
self._cr.execute("""
CREATE or REPLACE view hr_leave_report as (
SELECT row_number() over(ORDER BY leaves.employee_id) as id,
@@ -61,7 +62,7 @@ class LeaveReport(models.Model):
FALSE as payslip_status,
'allocation' as type
from hr_leave_allocation as allocation
- union select
+ union all select
request.employee_id as employee_id,
request.name as name,
request.number_of_days as number_of_days,
diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py
index f93449fbe21..3584222035e 100644
--- a/addons/mail/models/mail_channel.py
+++ b/addons/mail/models/mail_channel.py
@@ -469,7 +469,7 @@ class Channel(models.Model):
'is_minimized': minimized
}
domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)]
- channel_partners = self.env['mail.channel.partner'].search(domain)
+ channel_partners = self.env['mail.channel.partner'].search(domain, limit=1)
channel_partners.write(values)
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_partners.channel_id.channel_info()[0])
diff --git a/addons/mail/models/mail_template.py b/addons/mail/models/mail_template.py
index 55c0d1beba5..36f7fa3bbb9 100644
--- a/addons/mail/models/mail_template.py
+++ b/addons/mail/models/mail_template.py
@@ -253,17 +253,17 @@ class MailTemplate(models.Model):
def unlink_action(self):
for template in self:
if template.ref_ir_act_window:
- template.ref_ir_act_window.sudo().unlink()
+ template.ref_ir_act_window.unlink()
return True
@api.multi
def create_action(self):
- ActWindowSudo = self.env['ir.actions.act_window'].sudo()
+ ActWindow = self.env['ir.actions.act_window']
view = self.env.ref('mail.email_compose_message_wizard_form')
for template in self:
button_name = _('Send Mail (%s)') % template.name
- action = ActWindowSudo.create({
+ action = ActWindow.create({
'name': button_name,
'type': 'ir.actions.act_window',
'res_model': 'mail.compose.message',
diff --git a/addons/mail/views/mail_template_views.xml b/addons/mail/views/mail_template_views.xml
index 80f1058a4f2..81def4a35ae 100644
--- a/addons/mail/views/mail_template_views.xml
+++ b/addons/mail/views/mail_template_views.xml
@@ -9,7 +9,9 @@
<sheet>
<div class="oe_button_box" name="button_box">
<field name="ref_ir_act_window" invisible="1"/>
- <button class="oe_stat_button" name="create_action" type="object"
+ <button class="oe_stat_button"
+ groups="base.group_system"
+ name="create_action" type="object"
attrs="{'invisible':[('ref_ir_act_window','!=',False)]}" icon="fa-plus"
help="Display an option on related documents to open a composition wizard with this template">
<div class="o_field_widget o_stat_info">
@@ -17,7 +19,8 @@
<span class="o_stat_text">Context Action</span>
</div>
</button>
- <button name="unlink_action" type="object"
+ <button name="unlink_action" type="object"
+ groups="base.group_system"
class="oe_stat_button" icon="fa-minus"
attrs="{'invisible':[('ref_ir_act_window','=',False)]}"
help="Remove the contextual action to use this template on related documents" widget="statinfo">
diff --git a/addons/pad_project/models/project.py b/addons/pad_project/models/project.py
index f994b3a5ea7..6d2273b15dd 100644
--- a/addons/pad_project/models/project.py
+++ b/addons/pad_project/models/project.py
@@ -9,7 +9,7 @@ class ProjectTask(models.Model):
_inherit = ["project.task", 'pad.common']
description_pad = fields.Char('Pad URL', pad_content_field='description', copy=False)
- use_pad = fields.Boolean(related="project_id.use_pads", string="Use collaborative pad")
+ use_pad = fields.Boolean(related="project_id.use_pads", string="Use collaborative pad", readonly=True)
@api.model
def create(self, vals):
diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js
index 529e0dc43d2..262068fd12d 100644
--- a/addons/payment_stripe/static/src/js/stripe.js
+++ b/addons/payment_stripe/static/src/js/stripe.js
@@ -105,7 +105,10 @@ odoo.define('payment_stripe.stripe', function(require) {
acquirer_id: acquirer_id,
access_token: access_token,
}).then(function(data) {
- try { provider_form[0].innerHTML = data; } catch (e) {};
+ var $pay_stripe = $('#pay_stripe').detach();
+ try { provider_form[0].innerHTML = data; } catch (e) {}
+ // Restore 'Pay Now' button HTML since data might have changed it.
+ $(provider_form[0]).find('#pay_stripe').replaceWith($pay_stripe);
}).done(function () {
getStripeHandler().open({
name: merchant,
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 172bd53a152..95a7555e5d2 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1372,6 +1372,9 @@ var ClientListScreenWidget = ScreenWidget.extend({
reload_partners: function(){
var self = this;
return this.pos.load_new_partners().then(function(){
+ // partners may have changed in the backend
+ self.partner_cache = new DomCache();
+
self.render_list(self.pos.db.get_partners_sorted(1000));
// update the currently assigned client if it has been changed in db.
diff --git a/addons/point_of_sale/wizard/pos_payment.py b/addons/point_of_sale/wizard/pos_payment.py
index e4b494badde..a2578ffef9c 100644
--- a/addons/point_of_sale/wizard/pos_payment.py
+++ b/addons/point_of_sale/wizard/pos_payment.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
+from odoo.tools import float_is_zero
class PosMakePayment(models.TransientModel):
@@ -30,7 +31,7 @@ class PosMakePayment(models.TransientModel):
session_id = fields.Many2one('pos.session', required=True, default=_default_session)
journal_id = fields.Many2one('account.journal', string='Payment Mode', required=True, default=_default_journal)
- amount = fields.Float(digits=(16, 2), required=True, default=_default_amount)
+ amount = fields.Float(digits=0, required=True, default=_default_amount)
payment_name = fields.Char(string='Payment Reference')
payment_date = fields.Date(string='Payment Date', required=True, default=lambda *a: fields.Datetime.now())
@@ -49,11 +50,13 @@ class PosMakePayment(models.TransientModel):
"""
self.ensure_one()
order = self.env['pos.order'].browse(self.env.context.get('active_id', False))
+ currency = order.pricelist_id.currency_id
amount = order.amount_total - order.amount_paid
data = self.read()[0]
# add_payment expect a journal key
data['journal'] = data['journal_id'][0]
- if amount != 0.0:
+ data['amount'] = currency.round(data['amount']) if currency else data['amount']
+ if not float_is_zero(amount, precision_rounding=currency.rounding or 0.01):
order.add_payment(data)
if order.test_paid():
order.action_pos_order_paid()
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
index 79dffdd719b..6a3ceb62cd9 100644
--- a/addons/sale/models/product_template.py
+++ b/addons/sale/models/product_template.py
@@ -57,5 +57,6 @@ class ProductTemplate(models.Model):
def _onchange_type(self):
""" Force values to stay consistent with integrity constraints """
if self.type == 'consu':
- self.invoice_policy = 'order'
+ if not self.invoice_policy:
+ self.invoice_policy = 'order'
self.service_type = 'manual'
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
index 7708f0e217f..bc9fcd8bd76 100644
--- a/addons/sale/models/sale.py
+++ b/addons/sale/models/sale.py
@@ -1247,13 +1247,13 @@ class SaleOrderLine(models.Model):
@api.onchange('product_id', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id')
def _onchange_discount(self):
- self.discount = 0.0
if not (self.product_id and self.product_uom and
self.order_id.partner_id and self.order_id.pricelist_id and
self.order_id.pricelist_id.discount_policy == 'without_discount' and
self.env.user.has_group('sale.group_discount_per_so_line')):
return
+ self.discount = 0.0
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id.id,
diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml
index a973243ed39..534064d84c2 100644
--- a/addons/sale/views/res_config_settings_views.xml
+++ b/addons/sale/views/res_config_settings_views.xml
@@ -347,7 +347,7 @@
</div>
<h2>Invoicing</h2>
<div class="row mt16 o_settings_container">
- <div class="col-xs-12 col-md-6 o_setting_box hidden" title="This default value is applied to any new product created. This can be changed in the product detail form.">
+ <div class="col-xs-12 col-md-6 o_setting_box" title="This default value is applied to any new product created. This can be changed in the product detail form.">
<div class="o_setting_right_pane">
<label for="default_invoice_policy"/>
<div class="text-muted">
diff --git a/addons/sale_stock/tests/test_sale_stock.py b/addons/sale_stock/tests/test_sale_stock.py
index 23cff0c7f8e..69ff5a37323 100644
--- a/addons/sale_stock/tests/test_sale_stock.py
+++ b/addons/sale_stock/tests/test_sale_stock.py
@@ -423,3 +423,76 @@ class TestSaleStock(TestSale):
# check the delivered quantity
self.assertEqual(so1.order_line.qty_delivered, 3.0)
+ def test_07_forced_qties(self):
+ """ Make multiple sale order lines of the same product which isn't available in stock. On
+ the picking, create new move lines (through the detailed operations view). See that the move
+ lines are correctly dispatched through the moves.
+ """
+ uom_unit = self.env.ref('product.product_uom_unit')
+ uom_dozen = self.env.ref('product.product_uom_dozen')
+ item1 = self.products['prod_order']
+
+ self.assertEqual(item1.uom_id.id, uom_unit.id)
+
+ # sell a dozen
+ so1 = self.env['sale.order'].create({
+ 'partner_id': self.partner.id,
+ 'order_line': [
+ (0, 0, {
+ 'name': item1.name,
+ 'product_id': item1.id,
+ 'product_uom_qty': 1,
+ 'product_uom': uom_dozen.id,
+ 'price_unit': item1.list_price,
+ }),
+ (0, 0, {
+ 'name': item1.name,
+ 'product_id': item1.id,
+ 'product_uom_qty': 1,
+ 'product_uom': uom_dozen.id,
+ 'price_unit': item1.list_price,
+ }),
+ (0, 0, {
+ 'name': item1.name,
+ 'product_id': item1.id,
+ 'product_uom_qty': 1,
+ 'product_uom': uom_dozen.id,
+ 'price_unit': item1.list_price,
+ }),
+ ],
+ })
+ so1.action_confirm()
+
+ self.assertEqual(len(so1.picking_ids.move_lines), 3)
+ so1.picking_ids.write({
+ 'move_line_ids': [
+ (0, 0, {
+ 'product_id': item1.id,
+ 'product_uom_qty': 0,
+ 'qty_done': 1,
+ 'product_uom_id': uom_dozen.id,
+ 'location_id': so1.picking_ids.location_id.id,
+ 'location_dest_id': so1.picking_ids.location_dest_id.id,
+ }),
+ (0, 0, {
+ 'product_id': item1.id,
+ 'product_uom_qty': 0,
+ 'qty_done': 1,
+ 'product_uom_id': uom_dozen.id,
+ 'location_id': so1.picking_ids.location_id.id,
+ 'location_dest_id': so1.picking_ids.location_dest_id.id,
+ }),
+ (0, 0, {
+ 'product_id': item1.id,
+ 'product_uom_qty': 0,
+ 'qty_done': 1,
+ 'product_uom_id': uom_dozen.id,
+ 'location_id': so1.picking_ids.location_id.id,
+ 'location_dest_id': so1.picking_ids.location_dest_id.id,
+ }),
+ ],
+ })
+ so1.picking_ids.button_validate()
+ self.assertEqual(so1.picking_ids.state, 'done')
+ self.assertEqual(so1.order_line.mapped('qty_delivered'), [1, 1, 1])
+
diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py
index ceb8273549b..202491b2e3f 100644
--- a/addons/sale_timesheet/models/product.py
+++ b/addons/sale_timesheet/models/product.py
@@ -83,3 +83,5 @@ class ProductTemplate(models.Model):
if self.type == 'service':
self.invoice_policy = 'order'
self.service_type = 'timesheet'
+ elif self.type == 'consu' and self.service_policy == 'ordered_timesheet':
+ self.invoice_policy = 'order'
diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py
index 660c68f27d4..03516c551bf 100644
--- a/addons/stock/models/stock_picking.py
+++ b/addons/stock/models/stock_picking.py
@@ -633,8 +633,9 @@ class Picking(models.Model):
# # Link existing moves or add moves when no one is related
for ops in pick.move_line_ids.filtered(lambda x: not x.move_id):
# Search move with this product
- moves = pick.move_lines.filtered(lambda x: x.product_id == ops.product_id)
- if moves: #could search move that needs it the most (that has some quantities left)
+ moves = pick.move_lines.filtered(lambda x: x.product_id == ops.product_id)
+ moves = sorted(moves, key=lambda m: m.quantity_done < m.product_qty, reverse=True)
+ if moves:
ops.move_id = moves[0].id
else:
new_move = self.env['stock.move'].create({
@@ -797,7 +798,7 @@ class Picking(models.Model):
quantity_done[quant.product_id.id] += quant.qty
for pack in self.mapped('move_line_ids').filtered(lambda x: x.product_id and not x.move_id):
quantity_done.setdefault(pack.product_id.id, 0)
- quantity_done[pack.product_id.id] += pack.qty_done
+ quantity_done[pack.product_id.id] += pack.product_uom_id._compute_quantity(pack.qty_done, pack.product_id.uom_id)
return any(quantity_done[x] < quantity_todo.get(x, 0) for x in quantity_done)
@api.multi
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index 41d5d229772..c4fba082be2 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -744,6 +744,8 @@ class Database(http.Controller):
@http.route('/web/database/restore', type='http', auth="none", methods=['POST'], csrf=False)
def restore(self, master_pwd, backup_file, name, copy=False):
try:
+ data_file = None
+ db.check_super(master_pwd)
with tempfile.NamedTemporaryFile(delete=False) as data_file:
backup_file.save(data_file)
db.restore_db(name, data_file.name, str2bool(copy))
@@ -752,7 +754,8 @@ class Database(http.Controller):
error = "Database restore error: %s" % (str(e) or repr(e))
return self._render_template(error=error)
finally:
- os.unlink(data_file.name)
+ if data_file:
+ os.unlink(data_file.name)
@http.route('/web/database/change_password', type='http', auth="none", methods=['POST'], csrf=False)
def change_password(self, master_pwd, master_pwd_new):
diff --git a/addons/website/models/ir_attachment.py b/addons/website/models/ir_attachment.py
index a71038adbf5..a1f9df5dad1 100644
--- a/addons/website/models/ir_attachment.py
+++ b/addons/website/models/ir_attachment.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import fields, models
+from odoo import api, fields, models
class Attachment(models.Model):
@@ -10,3 +10,7 @@ class Attachment(models.Model):
# related for backward compatibility with saas-6
website_url = fields.Char(string="Website URL", related='local_url', deprecated=True)
+
+ @api.model
+ def get_serving_groups(self):
+ return super(Attachment, self).get_serving_groups() + ['website.group_website_designer']
diff --git a/addons/website/models/website.py b/addons/website/models/website.py
index a733e1aad14..c32dd8fc239 100644
--- a/addons/website/models/website.py
+++ b/addons/website/models/website.py
@@ -791,7 +791,6 @@ class Page(models.Model):
@api.multi
def write(self, vals):
- self.ensure_one()
if 'url' in vals and not vals['url'].startswith('/'):
vals['url'] = '/' + vals['url']
result = super(Page, self).write(vals)
diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py
index f7cf8fd962e..8f555ad5eff 100644
--- a/odoo/addons/base/models/assetsbundle.py
+++ b/odoo/addons/base/models/assetsbundle.py
@@ -10,7 +10,7 @@ import uuid
from datetime import datetime
from subprocess import Popen, PIPE
from collections import OrderedDict
-from odoo import fields, tools
+from odoo import fields, tools, SUPERUSER_ID
from odoo.tools.pycompat import string_types, to_text
from odoo.http import request
from odoo.modules.module import get_resource_path
@@ -230,10 +230,11 @@ class AssetsBundle(object):
self.env.cr.execute("""
SELECT max(id)
FROM ir_attachment
- WHERE url like %s
+ WHERE create_uid = %s
+ AND url like %s
GROUP BY datas_fname
ORDER BY datas_fname
- """, [url_pattern])
+ """, [SUPERUSER_ID, url_pattern])
attachment_ids = [r[0] for r in self.env.cr.fetchall()]
return self.env['ir.attachment'].sudo().browse(attachment_ids)
diff --git a/odoo/addons/base/models/ir_actions.py b/odoo/addons/base/models/ir_actions.py
index 5559429709d..8ad6aaaa23a 100644
--- a/odoo/addons/base/models/ir_actions.py
+++ b/odoo/addons/base/models/ir_actions.py
@@ -5,13 +5,12 @@ import odoo
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import MissingError, UserError, ValidationError, AccessError
from odoo.tools.safe_eval import safe_eval, test_python_expr
-from odoo.tools import pycompat
+from odoo.tools import pycompat, wrap_module
from odoo.http import request
import base64
from collections import defaultdict
import datetime
-import dateutil
import logging
import time
@@ -19,6 +18,16 @@ from pytz import timezone
_logger = logging.getLogger(__name__)
+# build dateutil helper, starting with the relevant *lazy* imports
+import dateutil
+import dateutil.parser
+import dateutil.relativedelta
+import dateutil.rrule
+import dateutil.tz
+mods = {'parser', 'relativedelta', 'rrule', 'tz'}
+attribs = {atr for m in mods for atr in getattr(dateutil, m).__all__}
+dateutil = wrap_module(dateutil, mods | attribs)
+
class IrActions(models.Model):
_name = 'ir.actions.actions'
diff --git a/odoo/addons/base/models/ir_actions_report.py b/odoo/addons/base/models/ir_actions_report.py
index fa600211795..36326217f42 100644
--- a/odoo/addons/base/models/ir_actions_report.py
+++ b/odoo/addons/base/models/ir_actions_report.py
@@ -209,7 +209,7 @@ class IrActionsReport(models.Model):
if landscape is None and specific_paperformat_args and specific_paperformat_args.get('data-report-landscape'):
landscape = specific_paperformat_args.get('data-report-landscape')
- command_args = []
+ command_args = ['--disable-local-file-access']
if set_viewport_size:
command_args.extend(['--viewport-size', landscape and '1024x1280' or '1280x1024'])
diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py
index bae99e919a6..088a4ba3b34 100644
--- a/odoo/addons/base/models/ir_attachment.py
+++ b/odoo/addons/base/models/ir_attachment.py
@@ -11,7 +11,7 @@ from collections import defaultdict
import uuid
from odoo import api, fields, models, tools, SUPERUSER_ID, _
-from odoo.exceptions import AccessError
+from odoo.exceptions import AccessError, ValidationError
from odoo.tools import config, human_size, ustr, html_escape
from odoo.tools.mimetypes import guess_mimetype
@@ -263,6 +263,15 @@ class IrAttachment(models.Model):
index_content = b"\n".join(words).decode('ascii')
return index_content
+ @api.model
+ def get_serving_groups(self):
+ """ An ir.attachment record may be used as a fallback in the
+ http dispatch if its type field is set to "binary" and its url
+ field is set as the request's url. Only the groups returned by
+ this method are allowed to create and write on such records.
+ """
+ return ['base.group_system']
+
name = fields.Char('Attachment Name', required=True)
datas_fname = fields.Char('File Name')
description = fields.Text('Description')
@@ -299,6 +308,18 @@ class IrAttachment(models.Model):
self._table, ['res_model', 'res_id'])
return res
+ @api.one
+ @api.constrains('type', 'url')
+ def _check_serving_attachments(self):
+ # restrict writing on attachments that could be served by the
+ # ir.http's dispatch exception handling
+ if self.env.user._is_superuser():
+ return
+ if self.type == 'binary' and self.url:
+ has_group = self.env.user.has_group
+ if not any([has_group(g) for g in self.get_serving_groups()]):
+ raise ValidationError("Sorry, you are not allowed to write on this document")
+
@api.model
def check(self, mode, values=None):
"""Restricts the access to an ir.attachment, according to referred model
diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py
index 4a76bdd8eeb..d52a4882d89 100644
--- a/odoo/addons/base/models/ir_module.py
+++ b/odoo/addons/base/models/ir_module.py
@@ -188,6 +188,7 @@ class Module(models.Model):
'doctitle_xform': False,
'output_encoding': 'unicode',
'xml_declaration': False,
+ 'file_insertion_enabled': False,
}
output = publish_string(source=module.description or '', settings_overrides=overrides, writer=MyWriter())
module.description_html = tools.html_sanitize(output)
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index 2f27766a7de..3f5216aed95 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -280,6 +280,19 @@ class Users(models.Model):
def onchange_parent_id(self):
return self.mapped('partner_id').onchange_parent_id()
+ def _read_from_database(self, field_names, inherited_field_names=[]):
+ super(Users, self)._read_from_database(field_names, inherited_field_names)
+ canwrite = self.check_access_rights('write', raise_exception=False)
+ if not canwrite and set(USER_PRIVATE_FIELDS).intersection(field_names):
+ for record in self:
+ for f in USER_PRIVATE_FIELDS:
+ try:
+ record._cache[f]
+ record._cache[f] = '********'
+ except Exception:
+ # skip SpecialValue (e.g. for missing record or access right)
+ pass
+
@api.multi
@api.constrains('company_id', 'company_ids')
def _check_company(self):
@@ -303,17 +316,7 @@ class Users(models.Model):
# safe fields only, so we read as super-user to bypass access rights
self = self.sudo()
- result = super(Users, self).read(fields=fields, load=load)
-
- canwrite = self.env['ir.model.access'].check('res.users', 'write', False)
- if not canwrite:
- for vals in result:
- if vals['id'] != self._uid:
- for key in USER_PRIVATE_FIELDS:
- if key in vals:
- vals[key] = '********'
-
- return result
+ return super(Users, self).read(fields=fields, load=load)
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
diff --git a/odoo/http.py b/odoo/http.py
index 09999066941..03f41977bbc 100644
--- a/odoo/http.py
+++ b/odoo/http.py
@@ -1494,6 +1494,7 @@ def db_filter(dbs, httprequest=None):
if d == "www" and r:
d = r.partition('.')[0]
if odoo.tools.config['dbfilter']:
+ d, h = re.escape(d), re.escape(h)
r = odoo.tools.config['dbfilter'].replace('%h', h).replace('%d', d)
dbs = [i for i in dbs if re.match(r, i)]
elif odoo.tools.config['db_name']:
diff --git a/odoo/models.py b/odoo/models.py
index 2d46e7e5d60..397e260a2fd 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -3572,6 +3572,15 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
if table not in query.tables:
query.tables.append(table)
+ if self._transient:
+ # One single implicit access rule for transient models: owner only!
+ # This is ok because we assert that TransientModels always have
+ # log_access enabled, so that 'create_uid' is always there.
+ domain = [('create_uid', '=', self._uid)]
+ tquery = self._where_calc(domain, active_test=False)
+ apply_rule(tquery.where_clause, tquery.where_clause_params, tquery.tables)
+ return
+
# apply main rules on the object
Rule = self.env['ir.rule']
where_clause, where_params, tables = Rule.domain_get(self._name, mode)
@@ -3718,10 +3727,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
"""
self.sudo(access_rights_uid or self._uid).check_access_rights('read')
- # For transient models, restrict access to the current user, except for the super-user
- if self.is_transient() and self._log_access and self._uid != SUPERUSER_ID:
- args = expression.AND(([('create_uid', '=', self._uid)], args or []))
-
if expression.is_false(self, args):
# optimization: no need to query, as no record satisfies the domain
return 0 if count else []
diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py
index 8b1d7107c0d..639d9c7626e 100644
--- a/odoo/tools/misc.py
+++ b/odoo/tools/misc.py
@@ -1221,3 +1221,23 @@ pickle.load = _pickle_load
pickle.loads = lambda text, encoding='ASCII': _pickle_load(io.BytesIO(text), encoding=encoding)
pickle.dump = pickle_.dump
pickle.dumps = pickle_.dumps
+
+def wrap_module(module, attr_list):
+ """Helper for wrapping a package/module to expose selected attributes
+
+ :param Module module: the actual package/module to wrap, as returned by ``import <module>``
+ :param iterable attr_list: a global list of attributes to expose, usually the top-level
+ attributes and their own main attributes. No support for hiding attributes in case
+ of name collision at different levels.
+ """
+ attr_list = set(attr_list)
+ class WrappedModule(object):
+ def __getattr__(self, attrib):
+ if attrib in attr_list:
+ target = getattr(module, attrib)
+ if isinstance(target, types.ModuleType):
+ return wrap_module(target, attr_list)
+ return target
+ raise AttributeError(attrib)
+ # module and attr_list are in the closure
+ return WrappedModule()