diff options
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() |
