diff options
| author | Christophe Simonis <chs@odoo.com> | 2018-09-07 20:20:45 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2018-09-07 20:20:45 +0200 |
| commit | 68d36512ef952d52547969b485951d86b1fb761c (patch) | |
| tree | 99318570560bc144e8187cb2c314225ea8085ff0 | |
| parent | cfe22cb59362736f68e57dc5975560ec96df0ba8 (diff) | |
| parent | d78f23df84840b151c20cc3bfccd2104d9ff0ace (diff) | |
[MERGE] forward port branch saas-11.4 up to d78f23df848
110 files changed, 1498 insertions, 407 deletions
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 5b83ebfb0af..a336f1f096f 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1580,8 +1580,11 @@ class AccountPartialReconcile(models.Model): if rec.full_reconcile_id: full_to_unlink |= rec.full_reconcile_id #reverse the tax basis move created at the reconciliation time - move = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self._ids)]) - move.reverse_moves() + for move in self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self._ids)]): + if move.date > (move.company_id.period_lock_date or '0000-00-00'): + move.reverse_moves(date=move.date) + else: + move.reverse_moves() res = super(AccountPartialReconcile, self).unlink() if full_to_unlink: full_to_unlink.unlink() diff --git a/addons/account/report/account_invoice_report.py b/addons/account/report/account_invoice_report.py index 110ea082a75..b56e6170fdd 100644 --- a/addons/account/report/account_invoice_report.py +++ b/addons/account/report/account_invoice_report.py @@ -111,7 +111,7 @@ class AccountInvoiceReport(models.Model): 1 AS nbr, ai.id AS invoice_id, ai.type, ai.state, pt.categ_id, ai.date_due, ai.account_id, ail.account_id AS account_line_id, ai.partner_bank_id, - SUM ((invoice_type.sign * ail.quantity) / u.factor * u2.factor) AS product_qty, + SUM ((invoice_type.sign_qty * ail.quantity) / u.factor * u2.factor) AS product_qty, SUM(ail.price_subtotal_signed * invoice_type.sign) AS price_total, SUM(ABS(ail.price_subtotal_signed)) / CASE WHEN SUM(ail.quantity / u.factor * u2.factor) <> 0::numeric @@ -121,7 +121,7 @@ class AccountInvoiceReport(models.Model): ai.residual_company_signed / (SELECT count(*) FROM account_invoice_line l where invoice_id = ai.id) * count(*) * invoice_type.sign AS residual, ai.commercial_partner_id as commercial_partner_id, - partner.country_id + coalesce(partner.country_id, partner_ai.country_id) AS country_id """ return select_str @@ -130,6 +130,7 @@ class AccountInvoiceReport(models.Model): FROM account_invoice_line ail JOIN account_invoice ai ON ai.id = ail.invoice_id JOIN res_partner partner ON ai.commercial_partner_id = partner.id + JOIN res_partner partner_ai ON ai.partner_id = partner_ai.id LEFT JOIN product_product pr ON pr.id = ail.product_id left JOIN product_template pt ON pt.id = pr.product_tmpl_id LEFT JOIN uom_uom u ON u.id = ail.uom_id @@ -140,7 +141,11 @@ class AccountInvoiceReport(models.Model): WHEN ai.type::text = ANY (ARRAY['in_refund'::character varying::text, 'in_invoice'::character varying::text]) THEN -1 ELSE 1 - END) AS sign + END) AS sign,(CASE + WHEN ai.type::text = ANY (ARRAY['out_refund'::character varying::text, 'in_invoice'::character varying::text]) + THEN -1 + ELSE 1 + END) AS sign_qty FROM account_invoice ai ) AS invoice_type ON invoice_type.id = ai.id """ @@ -152,7 +157,7 @@ class AccountInvoiceReport(models.Model): ai.partner_id, ai.payment_term_id, u2.name, u2.id, ai.currency_id, ai.journal_id, ai.fiscal_position_id, ai.user_id, ai.company_id, ai.id, ai.type, invoice_type.sign, ai.state, pt.categ_id, ai.date_due, ai.account_id, ail.account_id, ai.partner_bank_id, ai.residual_company_signed, - ai.amount_total_company_signed, ai.commercial_partner_id, partner.country_id + ai.amount_total_company_signed, ai.commercial_partner_id, coalesce(partner.country_id, partner_ai.country_id) """ return group_by_str diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index 73b98a2f40b..765b7a9c6a5 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -889,8 +889,14 @@ var StatementModel = BasicModel.extend({ } } }); - total = Math.round(total*1000)/1000 || 0; - amount_currency = Math.round(amount_currency); + var company_currency = session.get_currency(line.st_line.currency_id); + var company_precision = company_currency && company_currency.digits[1] || 2; + total = utils.round_precision(total*1000, company_precision)/1000 || 0; + if(isOtherCurrencyId){ + var other_currency = session.get_currency(isOtherCurrencyId); + var other_precision = other_currency && other_currency.digits[1] || 2; + amount_currency = utils.round_precision(amount_currency, other_precision) + } line.balance = { amount: total, amount_str: field_utils.format.monetary(Math.abs(total), {}, formatOptions), diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index ad11e5b8155..68d2e40123e 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -792,6 +792,9 @@ class Meeting(models.Model): res_model = fields.Char('Document Model Name', related='res_model_id.model', readonly=True, store=True) activity_ids = fields.One2many('mail.activity', 'calendar_event_id', string='Activities') + #redifine message_ids to remove autojoin to avoid search to crash in get_recurrent_ids + message_ids = fields.One2many(auto_join=False) + # RECURRENCE FIELD rrule = fields.Char('Recurrent Rule', compute='_compute_rrule', inverse='_inverse_rrule', store=True) rrule_type = fields.Selection([ @@ -1115,10 +1118,11 @@ class Meeting(models.Model): order_fields.append('id') leaf_evaluations = None + recurrent_ids = [meeting.id for meeting in self if meeting.recurrency and meeting.rrule] #compose a query of the type SELECT id, condition1 as domain1, condition2 as domaine2 #This allows to load leaf interpretation of the where clause in one query #leaf_evaluations is then used when running custom interpretation of domain for recuring events - if self: + if self and recurrent_ids: select_fields = ["id"] where_params_list = [] for pos, arg in enumerate(domain): @@ -1129,7 +1133,7 @@ class Meeting(models.Model): where_params_list += where_params if len(select_fields) > 1: query = "SELECT %s FROM calendar_event WHERE id in %%s" % (", ".join(select_fields)) # could be improved by only taking event with recurency ? - where_params_list += [tuple(self.ids)] + where_params_list += [tuple(recurrent_ids)] self._cr.execute(query, where_params_list) leaf_evaluations = dict([(row['id'], row) for row in self._cr.dictfetchall()]) result_data = [] @@ -1792,8 +1796,6 @@ class Meeting(models.Model): def _fix_rrule(self, values): rule_str = values.get('rrule') if rule_str: - rule = rrule.rrulestr(rule_str) - if not rule._until and not rule._count: - rule._count = 100 - rule_str = str(rule).split('RRULE:')[-1] + if 'UNTIL' not in rule_str and 'COUNT' not in rule_str: + rule_str += ';COUNT=100' return rule_str diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py index aecd6fe5432..19e854e61af 100644 --- a/addons/delivery/models/delivery_carrier.py +++ b/addons/delivery/models/delivery_carrier.py @@ -210,6 +210,12 @@ class DeliveryCarrier(models.Model): carrier.product_id.list_price = carrier.fixed_price def fixed_rate_shipment(self, order): + carrier = self._match_address(order.partner_shipping_id) + if not carrier: + return {'success': False, + 'price': 0.0, + 'error_message': _('Error: this delivery method is not available for this address.'), + 'warning_message': False} price = self.fixed_price if self.company_id.currency_id.id != order.currency_id.id: price = self.env['res.currency']._compute(self.company_id.currency_id, order.currency_id, price) diff --git a/addons/delivery/models/delivery_grid.py b/addons/delivery/models/delivery_grid.py index 4fe4037aa5a..d74f3014898 100644 --- a/addons/delivery/models/delivery_grid.py +++ b/addons/delivery/models/delivery_grid.py @@ -47,7 +47,7 @@ class ProviderGrid(models.Model): if not carrier: return {'success': False, 'price': 0.0, - 'error_message': _('Error: no matching grid.'), + 'error_message': _('Error: this delivery method is not available for this address.'), 'warning_message': False} try: diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index b1d1d0297ef..1aab6729b8f 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -31,10 +31,10 @@ class HrExpense(models.Model): @api.model def _get_employee_id_domain(self): - res = [(1, '=', 0)] + res= [('id', '=', 0)] # Nothing accepted by domain, by default if self.user_has_groups('hr_expense.group_hr_expense_manager') or self.user_has_groups('account.group_account_user'): - res = [(1, '=', 1)] + res = [] # Then, domain accepts everything elif self.user_has_groups('hr_expense.group_hr_expense_user') and self.env.user.employee_ids: employee = self.env.user.employee_ids[0] res = ['|', '|', ('department_id.manager_id.id', '=', employee.id), @@ -42,12 +42,11 @@ class HrExpense(models.Model): elif self.env.user.employee_ids: employee = self.env.user.employee_ids[0] res = [('id', '=', employee.id)] - return res name = fields.Char('Description', readonly=True, required=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}) date = fields.Date(readonly=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, default=fields.Date.context_today, string="Date") - employee_id = fields.Many2one('hr.employee', string="Employee", required=True, readonly=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, default=_default_employee_id, domain=lambda self: self._get_employee_id_domain) + employee_id = fields.Many2one('hr.employee', string="Employee", required=True, readonly=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, default=_default_employee_id, domain=lambda self: self._get_employee_id_domain()) product_id = fields.Many2one('product.product', string='Product', readonly=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, domain=[('can_be_expensed', '=', True)], required=True) product_uom_id = fields.Many2one('uom.uom', string='Unit of Measure', required=True, readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=_default_product_uom_id) unit_amount = fields.Float("Unit Price", readonly=True, required=True, states={'draft': [('readonly', False)], 'reported': [('readonly', False)], 'refused': [('readonly', False)]}, digits=dp.get_precision('Product Price')) diff --git a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py index 6491d058f18..4e486a0e048 100644 --- a/addons/hr_gamification/wizard/gamification_badge_user_wizard.py +++ b/addons/hr_gamification/wizard/gamification_badge_user_wizard.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo.exceptions import UserError, AccessError class GamificationBadgeUserWizard(models.TransientModel): diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 5e77b7c2f35..078c0d7f460 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -10,7 +10,7 @@ from datetime import timedelta, datetime, time from pytz import timezone, UTC from odoo import api, fields, models -from odoo.exceptions import UserError, ValidationError +from odoo.exceptions import AccessError, UserError, ValidationError from odoo.tools import float_compare from odoo.tools.translate import _ @@ -18,27 +18,47 @@ from odoo.addons.resource.models.resource import float_to_time _logger = logging.getLogger(__name__) + class HolidaysRequest(models.Model): - """ - Here are the rights associated with the flow of leave requests - - State Groups Restriction - ============================================================================================================ - Draft Anybody Own Request only and state in [confirm, refuse] - Holiday Manager State in [confirm, refuse] - Confirm All State = draft - Validate1 Holiday Officer Not his own Request and state = confirm - Holiday Manager Not his own Request or has no manager and State = confirm - Validate Holiday Officer Not his own Request and state = confirm - Holiday Manager Not his own Request or has no manager and State in [validate1, confirm] - Refuse Holiday Officer State in [confirm, validate, validate1] - Holiday Manager State in [confirm, validate, validate1] - ============================================================================================================ + """ Leave Requests Access specifications + + - a regular employee / user + - can see all leaves; + - cannot see name field of leaves belonging to other user as it may contain + private information that we don't want to share to other people than + HR people; + - can modify only its own not validated leaves (except writing on state to + bypass approval); + - can discuss on its leave requests; + - can reset only its own leaves; + - cannot validate any leaves; + - an Officer + - can see all leaves; + - can validate "HR" single validation leaves from people if + - he is the employee manager; + - he is the department manager; + - he is member of the same department; + - target employee has no manager and no department manager; + - can validate "Manager" single validation leaves from people if + - he is the employee manager; + - he is the department manager; + - target employee has no manager and no department manager; + - can first validate "Both" double validation leaves from people like "HR" + single validation, moving the leaves to validate1 state; + - cannot validate its own leaves; + - can reset only its own leaves; + - can refuse all leaves; + - a Manager + - can do everything he wants + + On top of that multicompany rules apply based on company defined on the + leave request leave type. """ _name = "hr.leave" _description = "Leave" _order = "date_from desc" _inherit = ['mail.thread', 'mail.activity.mixin'] + _mail_post_access = 'read' def _default_domain_holiday_status(self): if self.user_has_groups('hr_holidays.group_hr_holidays_manager'): @@ -58,7 +78,6 @@ class HolidaysRequest(models.Model): def _default_employee(self): return self.env.context.get('default_employee_id') or self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) - name = fields.Char('Description') state = fields.Selection([ ('draft', 'To Submit'), @@ -297,25 +316,28 @@ class HolidaysRequest(models.Model): holiday.number_of_hours = holiday.number_of_days_temp * self.employee_id.resource_calendar_id.hours_per_day @api.multi + @api.depends('state', 'employee_id', 'department_id') def _compute_can_reset(self): - """ User can reset a leave request if it is its own leave request - or if he is an Hr Manager. - """ - user = self.env.user - group_hr_manager = self.env.ref('hr_holidays.group_hr_holidays_manager') for holiday in self: - if group_hr_manager in user.groups_id or holiday.employee_id and holiday.employee_id.user_id == user: + try: + holiday._check_approval_update('draft') + except (AccessError, UserError): + holiday.can_reset = False + else: holiday.can_reset = True - @api.depends('employee_id', 'department_id') + @api.depends('state', 'employee_id', 'department_id') def _compute_can_approve(self): - """ User can only approve a leave request if it is not his own - Exception : User is holiday manager and has no manager - """ for holiday in self: - # User is holiday manager and has no manager - manager = self.user_has_groups('hr_holidays.group_hr_holidays_manager') - holiday.can_approve = (holiday.employee_id.user_id.id != self.env.uid) or manager + try: + if holiday.state == 'confirm' and holiday.holiday_status_id.validation_type == 'both': + holiday._check_approval_update('validate1') + else: + holiday._check_approval_update('validate') + except (AccessError, UserError): + holiday.can_approve = False + else: + holiday.can_approve = True @api.constrains('date_from', 'date_to') def _check_date(self): @@ -382,8 +404,9 @@ class HolidaysRequest(models.Model): dto = leave.date_to if dfrom and dto and (dfrom.date() < vstart or dto.date() > vstop): - raise UserError(_('You can take %s only between %s and %s') % (leave.holiday_status_id.display_name, \ - leave.holiday_status_id.validity_start, leave.holiday_status_id.validity_stop)) + raise UserError( + _('You can take %s only between %s and %s') % ( + leave.holiday_status_id.display_name, leave.holiday_status_id.validity_start, leave.holiday_status_id.validity_stop)) @api.model def create(self, values): @@ -398,9 +421,29 @@ class HolidaysRequest(models.Model): holiday.activity_update() return holiday + def _read_from_database(self, field_names, inherited_field_names=[]): + if 'name' in field_names and 'employee_id' not in field_names: + field_names.append('employee_id') + super(HolidaysRequest, self)._read_from_database(field_names, inherited_field_names) + if 'name' in field_names: + if self.user_has_groups('hr_holidays.group_hr_holidays_user'): + return + current_employee = self.env['hr.employee'].sudo().search([('user_id', '=', self.env.uid)], limit=1) + for record in self: + emp_id = record._cache.get('employee_id', [False])[0] + if emp_id != current_employee.id: + try: + record._cache['name'] + record._cache['name'] = '*****' + except Exception: + # skip SpecialValue (e.g. for missing record or access right) + pass + @api.multi def write(self, values): employee_id = values.get('employee_id', False) + if values.get('state'): + self._check_approval_update(values['state']) result = super(HolidaysRequest, self).write(values) self.add_follower(employee_id) if 'employee_id' in values: @@ -494,8 +537,6 @@ class HolidaysRequest(models.Model): @api.multi def action_draft(self): for holiday in self: - if not holiday.can_reset: - raise UserError(_('Only an HR Manager or the concerned employee can reset the leave request to draft.')) if holiday.state not in ['confirm', 'refuse']: raise UserError(_('Leave request state must be "Refused" or "To Approve" in order to be reset to draft.')) holiday.write({ @@ -522,22 +563,10 @@ class HolidaysRequest(models.Model): def action_approve(self): # if validation_type == 'both': this method is the first approval approval # if validation_type != 'both': this method calls action_validate() below - if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'): - raise UserError(_('Only an HR Officer or Manager can approve leave requests.')) - - current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) if any(holiday.state != 'confirm' for holiday in self): raise UserError(_('Leave request must be confirmed ("To Approve") in order to approve it.')) - for holiday in self: - validation_type = holiday.holiday_status_id.validation_type - manager = holiday.employee_id.parent_id or holiday.employee_id.department_id.manager_id - if (validation_type in ['hr', 'both']) and (manager and manager != current_employee)\ - and not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): - raise UserError(_('You must be %s manager to approve this leave') % (holiday.employee_id.name)) - elif validation_type == 'manager' and not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): - raise UserError(_('You must be a Human Resource Manager to approve this leave.')) - + current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) self.filtered(lambda hol: hol.validation_type == 'both').write({'state': 'validate1', 'first_approver_id': current_employee.id}) self.filtered(lambda hol: not hol.validation_type == 'both').action_validate() self.activity_update() @@ -545,18 +574,10 @@ class HolidaysRequest(models.Model): @api.multi def action_validate(self): - if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'): - raise UserError(_('Only an HR Officer or Manager can approve leave requests.')) - - if any(not holiday.can_approve for holiday in self): - raise UserError(_('Only your manager can approve your leave requests')) - current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) for holiday in self: if holiday.state not in ['confirm', 'validate1']: raise UserError(_('Leave request must be confirmed in order to approve it.')) - if holiday.state == 'validate1' and not holiday.env.user.has_group('hr_holidays.group_hr_holidays_manager'): - raise UserError(_('Only an HR Manager can apply the second approval on leave requests.')) holiday.write({'state': 'validate'}) if holiday.validation_type == 'both': @@ -580,9 +601,6 @@ class HolidaysRequest(models.Model): @api.multi def action_refuse(self): - if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'): - raise UserError(_('Only an HR Officer or Manager can refuse leave requests.')) - current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) for holiday in self: if holiday.state not in ['confirm', 'validate', 'validate1']: @@ -601,6 +619,40 @@ class HolidaysRequest(models.Model): self.activity_update() return True + def _check_approval_update(self, state): + """ Check if target state is achievable. """ + current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) + is_officer = self.env.user.has_group('hr_holidays.group_hr_holidays_user') + is_manager = self.env.user.has_group('hr_holidays.group_hr_holidays_manager') + for holiday in self: + val_type = holiday.holiday_status_id.validation_type + if state == 'confirm': + continue + + if state == 'draft': + if holiday.employee_id != current_employee and not is_manager: + raise UserError(_('Only a Leave Manager can reset other people leaves.')) + continue + + if not is_officer: + raise UserError(_('Only a Leave Officer or Manager can approve or refuse leave requests.')) + + if is_officer: + # use ir.rule based first access check: department, members, ... (see security.xml) + holiday.check_access_rule('write') + + if holiday.employee_id == current_employee and not is_manager: + raise UserError(_('Only a Leave Manager can approve its own requests.')) + + if (state == 'validate1' and val_type == 'both') or (state == 'validate' and val_type == 'manager'): + manager = holiday.employee_id.parent_id or holiday.employee_id.department_id.manager_id + if (manager and manager != current_employee) and not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): + raise UserError(_('You must be either %s\'s manager or Leave manager to approve this leave') % (holiday.employee_id.name)) + + if state == 'validate' and val_type == 'both': + if not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): + raise UserError(_('Only an Leave Manager can apply the second approval on leave requests.')) + # ------------------------------------------------------------ # Activity methods # ------------------------------------------------------------ diff --git a/addons/hr_holidays/models/hr_leave_allocation.py b/addons/hr_holidays/models/hr_leave_allocation.py index 3a0fdde5ba1..e7ff67c498c 100644 --- a/addons/hr_holidays/models/hr_leave_allocation.py +++ b/addons/hr_holidays/models/hr_leave_allocation.py @@ -11,7 +11,7 @@ from datetime import datetime, time from dateutil.relativedelta import relativedelta from odoo import api, fields, models -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.tools.translate import _ from odoo.addons.resource.models.resource import HOURS_PER_DAY @@ -20,25 +20,11 @@ _logger = logging.getLogger(__name__) class HolidaysAllocation(models.Model): - """ - Here are the rights associated with the flow of allocation requests - - State Groups Restriction - ============================================================================================================ - Draft Anybody Own Allocation only and state in [confirm, refuse] - Holiday Manager State in [confirm, refuse] - Confirm All State = draft - Validate1 Holiday Officer Not his own Allocation and state = confirm - Holiday Manager Not his own Allocation or has no manager and State = confirm - Validate Holiday Officer Not his own Allocation and state = confirm - Holiday Manager Not his own Allocation or has no manager and State in [validate1, confirm] - Refuse Holiday Officer State in [confirm, validate, validate1] - Holiday Manager State in [confirm, validate, validate1] - ============================================================================================================ - """ + """ Allocation Requests Access specifications: similar to leave requests """ _name = "hr.leave.allocation" _description = "Leaves Allocation" _inherit = ['mail.thread', 'mail.activity.mixin'] + _mail_post_access = 'read' def _default_domain_holiday_status_id(self): if self.user_has_groups('hr_holidays.group_hr_holidays_manager'): @@ -98,10 +84,10 @@ class HolidaysAllocation(models.Model): help='This area is automatically filled by the user who validate the leave', oldname='manager_id') second_approver_id = fields.Many2one('hr.employee', string='Second Approval', readonly=True, copy=False, oldname='manager_id2', help='This area is automaticly filled by the user who validate the leave with second level (If Leave type need second validation)') - validation_type = fields.Selection('Validation Type', related='holiday_status_id.validation_type') + validation_type = fields.Selection('Validation Type', related='holiday_status_id.validation_type', readonly=True) can_reset = fields.Boolean('Can reset', compute='_compute_can_reset') can_approve = fields.Boolean('Can Approve', compute='_compute_can_approve') - type_request_unit = fields.Selection(related='holiday_status_id.request_unit') + type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True) accrual = fields.Boolean("Accrual", related='holiday_status_id.accrual', store=True, readonly=True) number_per_interval = fields.Float("Number of unit per interval", readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, default=1) interval_number = fields.Integer("Number of unit between two intervals", readonly=True, states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]}, default=1) @@ -200,25 +186,28 @@ class HolidaysAllocation(models.Model): holiday.number_of_days = number_of_days @api.multi + @api.depends('state', 'employee_id', 'department_id') def _compute_can_reset(self): - """ User can reset a leave request if it is its own leave request - or if he is an Hr Manager. - """ - user = self.env.user - group_hr_manager = self.env.ref('hr_holidays.group_hr_holidays_manager') - for holiday in self: - if group_hr_manager in user.groups_id or holiday.employee_id and holiday.employee_id.user_id == user: - holiday.can_reset = True + for allocation in self: + try: + allocation._check_approval_update('draft') + except (AccessError, UserError): + allocation.can_reset = False + else: + allocation.can_reset = True - @api.depends('employee_id', 'department_id') + @api.depends('state', 'employee_id', 'department_id') def _compute_can_approve(self): - """ User can only approve a leave request if it is not his own - Exception : User is holiday manager and has no manager - """ - for holiday in self: - # User is holiday manager and has no manager - manager = self.user_has_groups('hr_holidays.group_hr_holidays_manager') - holiday.can_approve = (holiday.employee_id.user_id.id != self.env.uid) or manager + for allocation in self: + try: + if allocation.state == 'confirm' and allocation.holiday_status_id.validation_type == 'both': + allocation._check_approval_update('validate1') + else: + allocation._check_approval_update('validate') + except (AccessError, UserError): + allocation.can_approve = False + else: + allocation.can_approve = True @api.onchange('holiday_type') def _onchange_type(self): @@ -312,6 +301,8 @@ class HolidaysAllocation(models.Model): @api.multi def write(self, values): employee_id = values.get('employee_id', False) + if values.get('state'): + self._check_approval_update(values['state']) result = super(HolidaysAllocation, self).write(values) self.add_follower(employee_id) return result @@ -347,8 +338,6 @@ class HolidaysAllocation(models.Model): @api.multi def action_draft(self): for holiday in self: - if not holiday.can_reset: - raise UserError(_('Only an HR Manager or the concerned employee can reset to draft.')) if holiday.state not in ['confirm', 'refuse']: raise UserError(_('Leave request state must be "Refused" or "To Approve" in order to reset to Draft.')) holiday.write({ @@ -375,21 +364,10 @@ class HolidaysAllocation(models.Model): def action_approve(self): # if validation_type == 'both': this method is the first approval approval # if validation_type != 'both': this method calls action_validate() below - if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'): - raise UserError(_('Only an HR Officer or Manager can approve leave requests.')) - - current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) if any(holiday.state != 'confirm' for holiday in self): raise UserError(_('Leave request must be confirmed ("To Approve") in order to approve it.')) - for holiday in self: - validation_type = holiday.holiday_status_id.validation_type - manager = holiday.employee_id.parent_id or holiday.employee_id.department_id.manager_id - if (validation_type in ['hr', 'both']) and (manager and manager != current_employee)\ - and not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): - raise UserError(_('You must be %s manager to approve this leave') % (holiday.employee_id.name)) - elif validation_type == 'manager' and not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): - raise UserError(_('You must be a Human Resource Manager to approve this Leave.')) + current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) self.filtered(lambda hol: hol.validation_type == 'both').write({'state': 'validate1', 'first_approver_id': current_employee.id}) self.filtered(lambda hol: not hol.validation_type == 'both').action_validate() @@ -398,18 +376,10 @@ class HolidaysAllocation(models.Model): @api.multi def action_validate(self): - if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'): - raise UserError(_('Only an HR Officer or Manager can approve leave requests.')) - - if any(not holiday.can_approve for holiday in self): - raise UserError(_('Only your manager can approve your allocations')) - current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) for holiday in self: if holiday.state not in ['confirm', 'validate1']: raise UserError(_('Leave request must be confirmed in order to approve it.')) - if holiday.state == 'validate1' and not holiday.env.user.has_group('hr_holidays.group_hr_holidays_manager'): - raise UserError(_('Only an HR Manager can apply the second approval on leave requests.')) holiday.write({'state': 'validate'}) if holiday.validation_type == 'both': @@ -431,9 +401,6 @@ class HolidaysAllocation(models.Model): @api.multi def action_refuse(self): - if not self.env.user.has_group('hr_holidays.group_hr_holidays_user'): - raise UserError(_('Only an HR Officer or Manager can refuse leave requests.')) - current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) for holiday in self: if holiday.state not in ['confirm', 'validate', 'validate1']: @@ -448,6 +415,40 @@ class HolidaysAllocation(models.Model): self.activity_update() return True + def _check_approval_update(self, state): + """ Check if target state is achievable. """ + current_employee = self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1) + is_officer = self.env.user.has_group('hr_holidays.group_hr_holidays_user') + is_manager = self.env.user.has_group('hr_holidays.group_hr_holidays_manager') + for holiday in self: + val_type = holiday.holiday_status_id.validation_type + if state == 'confirm': + continue + + if state == 'draft': + if holiday.employee_id != current_employee and not is_manager: + raise UserError(_('Only a Leave Manager can reset other people leaves.')) + continue + + if not is_officer: + raise UserError(_('Only a Leave Officer or Manager can approve or refuse leave requests.')) + + if is_officer: + # use ir.rule based first access check: department, members, ... (see security.xml) + holiday.check_access_rule('write') + + if holiday.employee_id == current_employee and not is_manager: + raise UserError(_('Only a Leave Manager can approve its own requests.')) + + if (state == 'validate1' and val_type == 'both') or (state == 'validate' and val_type == 'manager'): + manager = holiday.employee_id.parent_id or holiday.employee_id.department_id.manager_id + if (manager and manager != current_employee) and not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): + raise UserError(_('You must be either %s\'s manager or Leave manager to approve this leave') % (holiday.employee_id.name)) + + if state == 'validate' and val_type == 'both': + if not self.env.user.has_group('hr_holidays.group_hr_holidays_manager'): + raise UserError(_('Only an Leave Manager can apply the second approval on leave requests.')) + # ------------------------------------------------------------ # Activity methods # ------------------------------------------------------------ diff --git a/addons/hr_holidays/report/hr_leave_report.py b/addons/hr_holidays/report/hr_leave_report.py index 3e918920c52..50f4c1a69ca 100644 --- a/addons/hr_holidays/report/hr_leave_report.py +++ b/addons/hr_holidays/report/hr_leave_report.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, tools +from odoo import api, fields, models, tools class LeaveReport(models.Model): @@ -78,3 +78,21 @@ class LeaveReport(models.Model): from hr_leave as request) leaves ); """) + + def _read_from_database(self, field_names, inherited_field_names=[]): + if 'name' in field_names and 'employee_id' not in field_names: + field_names.append('employee_id') + super(LeaveReport, self)._read_from_database(field_names, inherited_field_names) + if 'name' in field_names: + if self.user_has_groups('hr_holidays.group_hr_holidays_user'): + return + current_employee = self.env['hr.employee'].sudo().search([('user_id', '=', self.env.uid)], limit=1) + for record in self: + emp_id = record._cache.get('employee_id', [False])[0] + if emp_id != current_employee.id: + try: + record._cache['name'] + record._cache['name'] = '*****' + except Exception: + # skip SpecialValue (e.g. for missing record or access right) + pass diff --git a/addons/hr_holidays/security/hr_holidays_security.xml b/addons/hr_holidays/security/hr_holidays_security.xml index f03fb388f72..546fe9440fd 100644 --- a/addons/hr_holidays/security/hr_holidays_security.xml +++ b/addons/hr_holidays/security/hr_holidays_security.xml @@ -18,64 +18,96 @@ </record> <record id="hr_leave_rule_employee" model="ir.rule"> - <field name="name">Employee Leaves</field> + <field name="name">Leaves: employee: read all</field> <field name="model_id" ref="model_hr_leave"/> - <field name="domain_force">[('employee_id.user_id','=',user.id)]</field> + <field name="domain_force">[(1, '=', 1)]</field> <field name="perm_create" eval="False"/> <field name="perm_write" eval="False"/> <field name="perm_unlink" eval="False"/> <field name="groups" eval="[(4,ref('base.group_user'))]"/> </record> - <record id="hr_leave_rule_employee_write" model="ir.rule"> - <field name="name">Employee Leaves Create, Write, Unlink</field> + <record id="hr_leave_rule_employee_update" model="ir.rule"> + <field name="name">Leaves: employee: create/write/unlink not validated own</field> <field name="model_id" ref="model_hr_leave"/> <field name="domain_force">[('employee_id.user_id','=',user.id), ('state', 'in', ['draft', 'confirm', 'cancel', 'refuse'])]</field> <field name="perm_read" eval="False"/> <field name="groups" eval="[(4,ref('base.group_user'))]"/> </record> - <record id="hr_leave_rule_officer" model="ir.rule"> - <field name="name">Leaves Officer</field> + <record id="hr_leave_rule_officer_read" model="ir.rule"> + <field name="name">Leaves: officer: read all</field> <field name="model_id" ref="model_hr_leave"/> - <field name="domain_force">['|','|','|', ('employee_id.user_id', '=', user.id), ('department_id', '=', False), ('department_id.manager_id', '=', False), ('department_id.manager_id.user_id', '=', user.id)]</field> - <field name="groups" eval="[(4,ref('hr_holidays.group_hr_holidays_user'))]"/> + <field name="domain_force">[(1, '=', 1)]</field> + <field name="perm_create" eval="False"/> + <field name="perm_write" eval="False"/> + <field name="perm_unlink" eval="False"/> + <field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_user'))]"/> + </record> + + <record id="hr_leave_rule_officer_update" model="ir.rule"> + <field name="name">Leaves: officer: create/write/unlink own + managed employee + without department + same or managed department</field> + <field name="model_id" ref="model_hr_leave"/> + <field name="domain_force">['|', +'|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id), +'|', '|', ('department_id', '=', False), ('department_id.member_ids.user_id', 'in', [user.id]), ('department_id.manager_id.user_id', '=', user.id)]</field> + <field name="perm_read" eval="False"/> + <field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_user'))]"/> </record> <record id="hr_leave_rule_manager" model="ir.rule"> - <field name="name">Manager Leaves</field> + <field name="name">Leaves: manager: no limit</field> <field name="model_id" ref="model_hr_leave"/> <field name="domain_force">[(1, '=', 1)]</field> <field name="groups" eval="[(4, ref('group_hr_holidays_manager'))]"/> </record> + <record id="hr_leave_rule_multicompany" model="ir.rule"> + <field name="name">Leaves: multi company global rule</field> + <field name="model_id" ref="model_hr_leave"/> + <field name="global" eval="True"/> + <field name="domain_force">['|', ('holiday_status_id.company_id', '=', False), ('holiday_status_id.company_id', 'in', [user.company_id.id])]</field> + </record> + <record id="hr_leave_allocation_rule_employee" model="ir.rule"> - <field name="name">Employee Allocations</field> + <field name="name">Allocations: employee: read own</field> <field name="model_id" ref="model_hr_leave_allocation"/> - <field name="domain_force">[('employee_id.user_id','=',user.id)]</field> + <field name="domain_force">[('employee_id.user_id', '=', user.id)]</field> <field name="perm_create" eval="False"/> <field name="perm_write" eval="False"/> <field name="perm_unlink" eval="False"/> <field name="groups" eval="[(4,ref('base.group_user'))]"/> </record> - <record id="hr_leave_allocation_rule_employee_write" model="ir.rule"> - <field name="name">Employee Allocations Create, Write, Unlink</field> + <record id="hr_leave_allocation_rule_employee_update" model="ir.rule"> + <field name="name">Allocations: employee: create/write/unlink not validated own</field> <field name="model_id" ref="model_hr_leave_allocation"/> <field name="domain_force">[('employee_id.user_id','=',user.id), ('state', 'in', ['draft', 'confirm', 'cancel', 'refuse'])]</field> <field name="perm_read" eval="False"/> <field name="groups" eval="[(4,ref('base.group_user'))]"/> </record> - <record id="hr_leave_allocation_rule_officer" model="ir.rule"> - <field name="name">Leaves Officer</field> + <record id="hr_leave_allocation_rule_officer_read" model="ir.rule"> + <field name="name">Allocations: officer: read all</field> + <field name="model_id" ref="model_hr_leave_allocation"/> + <field name="domain_force">[(1, '=', 1)]</field> + <field name="perm_create" eval="False"/> + <field name="perm_write" eval="False"/> + <field name="perm_unlink" eval="False"/> + <field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_user'))]"/> + </record> + + <record id="hr_leave_allocation_rule_officer_update" model="ir.rule"> + <field name="name">Allocations: officer: create/write/unlink own + managed employee + without department + same or managed department</field> <field name="model_id" ref="model_hr_leave_allocation"/> - <field name="domain_force">['|','|','|', ('employee_id.user_id', '=', user.id), ('department_id', '=', False), ('department_id.manager_id', '=', False), ('department_id.manager_id.user_id', '=', user.id)]</field> + <field name="domain_force">['|', +'|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id), +'|', '|', ('department_id', '=', False), ('department_id.member_ids.user_id', 'in', [user.id]), ('department_id.manager_id.user_id', '=', user.id)]</field> <field name="groups" eval="[(4,ref('hr_holidays.group_hr_holidays_user'))]"/> </record> <record id="hr_leave_allocation_rule_manager" model="ir.rule"> - <field name="name">Manager Allocations</field> + <field name="name">Allocations: manager: no limit</field> <field name="model_id" ref="model_hr_leave_allocation"/> <field name="domain_force">[(1, '=', 1)]</field> <field name="groups" eval="[(4, ref('group_hr_holidays_manager'))]"/> diff --git a/addons/hr_holidays/tests/__init__.py b/addons/hr_holidays/tests/__init__.py index 7bc7f93f42f..c8c2b4a438b 100644 --- a/addons/hr_holidays/tests/__init__.py +++ b/addons/hr_holidays/tests/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_access_rights from . import test_holidays_flow from . import test_hr_leave_type from . import test_accrual_allocations diff --git a/addons/hr_holidays/tests/common.py b/addons/hr_holidays/tests/common.py index 277d5c446c5..467eff2037c 100644 --- a/addons/hr_holidays/tests/common.py +++ b/addons/hr_holidays/tests/common.py @@ -15,24 +15,27 @@ class TestHrHolidaysBase(common.TransactionCase): group_employee_id = self.ref('base.group_user') # Test users to use through the various tests - self.user_hruser_id = Users.create({ + self.user_hruser = Users.create({ 'name': 'Armande HrUser', 'login': 'Armande', 'email': 'armande.hruser@example.com', 'groups_id': [(6, 0, [group_employee_id, self.ref('hr_holidays.group_hr_holidays_user')])] - }).id - self.user_hrmanager_id = Users.create({ + }) + self.user_hruser_id = self.user_hruser.id + self.user_hrmanager = Users.create({ 'name': 'Bastien HrManager', 'login': 'bastien', 'email': 'bastien.hrmanager@example.com', 'groups_id': [(6, 0, [group_employee_id, self.ref('hr_holidays.group_hr_holidays_manager')])] }).id - self.user_employee_id = Users.create({ + self.user_hrmanager_id = self.user_hrmanager + self.user_employee = Users.create({ 'name': 'David Employee', 'login': 'david', 'email': 'david.employee@example.com', 'groups_id': [(6, 0, [group_employee_id])] - }).id + }) + self.user_employee_id = self.user_employee.id self.user_hrmanager_2_id = Users.create({ 'name': 'Florence HrManager', 'login': 'florence', @@ -50,21 +53,26 @@ class TestHrHolidaysBase(common.TransactionCase): 'name': 'Research and devlopment', }) - self.employee_emp_id = self.env['hr.employee'].create({ + self.employee_emp = self.env['hr.employee'].create({ 'name': 'David Employee', 'user_id': self.user_employee_id, 'department_id': self.rd_dept.id, - }).id - self.employee_hruser_id = self.env['hr.employee'].create({ + }) + self.employee_emp_id = self.employee_emp.id + + self.employee_hruser = self.env['hr.employee'].create({ 'name': 'Armande HrUser', 'user_id': self.user_hruser_id, 'department_id': self.rd_dept.id, - }).id - self.employee_hrmanager_id = self.env['hr.employee'].create({ + }) + self.employee_hruser_id = self.employee_hruser.id + + self.employee_hrmanager = self.env['hr.employee'].create({ 'name': 'Bastien HrManager', 'user_id': self.user_hrmanager_id, 'department_id': self.hr_dept.id, - }).id + }) + self.employee_hrmanager_id = self.employee_hrmanager.id self.employee_hrmanager_2_id = self.env['hr.employee'].create({ 'name': 'Florence HrManager', diff --git a/addons/hr_holidays/tests/test_access_rights.py b/addons/hr_holidays/tests/test_access_rights.py new file mode 100644 index 00000000000..0631fa10006 --- /dev/null +++ b/addons/hr_holidays/tests/test_access_rights.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo import tests +from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase +from odoo.exceptions import AccessError, UserError +from odoo.tools import mute_logger + + +@tests.tagged('access_rights') +class TestLeavesRights(TestHrHolidaysBase): + + def setUp(self): + super(TestLeavesRights, self).setUp() + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Unlimited', + 'limit': True, + 'validation_type': 'hr', + }) + self.rd_dept.manager_id = False + self.hr_dept.manager_id = False + self.employee_emp.parent_id = False + self.employee_leave = self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_emp.department_id.id, + 'employee_id': self.employee_emp.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_messaging_by_user(self): + """ User may communicate on its own leaves, even if validated """ + self.employee_leave.sudo(self.user_employee_id).message_post( + body='I haz messaging', + subtype='mail.mt_comment', + message_type='comment' + ) + + self.employee_leave.sudo(self.user_hrmanager_id).action_approve() + + self.employee_leave.sudo(self.user_employee_id).message_post( + body='I still haz messaging', + subtype='mail.mt_comment', + message_type='comment' + ) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_read_by_user_other(self): + """ Users should be able to read other people requests except name field """ + other_leave = self.env['hr.leave'].sudo(self.user_hruser).create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_hruser.department_id.id, + 'employee_id': self.employee_hruser.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + res = other_leave.sudo(self.user_employee_id).read(['number_of_days_temp', 'state', 'name']) + self.assertEqual( + res[0]['name'], '*****', + 'Private information should have been stripped, received %s instead' % res[0]['name'] + ) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_read_by_user_other_browse(self): + """ Users should be able to browse other people requests except name field """ + other_leave = self.env['hr.leave'].sudo(self.user_hruser).create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_hruser.department_id.id, + 'employee_id': self.employee_hruser.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + self.assertEqual( + other_leave.sudo(self.user_employee_id).name, '*****', + 'Private information should have been stripped, received %s instead' % other_leave.sudo(self.user_employee_id).name + ) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_read_by_user_own(self): + """ Users should be able to read name field of own requests """ + res = self.employee_leave.read(['name', 'number_of_days_temp', 'state']) + self.assertEqual(res[0]['name'], 'Test') + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_read_by_user_own_browse(self): + """ Users should be able to browse name field of own requests """ + self.assertEqual(self.employee_leave.state, 'confirm') + self.assertEqual(self.employee_leave.name, 'Test') + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_update_hr_by_user(self): + """ User may update its leave """ + self.employee_leave.sudo(self.user_employee_id).write({'name': 'Crocodile Dundee is my man'}) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_update_hr_by_user_other(self): + """ User cannot update other people leaves """ + other_leave = self.env['hr.leave'].sudo(self.user_hruser).create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_hruser.department_id.id, + 'employee_id': self.employee_hruser.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + with self.assertRaises(AccessError): + other_leave.sudo(self.user_employee_id).write({'name': 'Crocodile Dundee is my man'}) + + # ---------------------------------------- + # Reset + # ---------------------------------------- + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_reset_by_manager(self): + """ Manager resets its own leaves """ + manager_leave = self.env['hr.leave'].sudo(self.user_hrmanager).create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_hrmanager.department_id.id, + 'employee_id': self.employee_hrmanager.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + manager_leave.sudo(self.user_hrmanager).action_draft() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_reset_by_manager_other(self): + """ Manager may not reset other leaves """ + self.employee_leave.sudo(self.user_hrmanager).action_draft() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_reset_by_officer(self): + """ Officer resets its own leaves """ + officer_leave = self.env['hr.leave'].sudo(self.user_hruser).create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_hruser.department_id.id, + 'employee_id': self.employee_hruser.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + officer_leave.sudo(self.user_hruser).action_draft() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_reset_by_officer_other(self): + """ Officer may not reset other leaves """ + with self.assertRaises(UserError): + self.employee_leave.sudo(self.user_hruser).action_draft() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_reset_by_user(self): + """ User resets its own leaves """ + self.employee_leave.sudo(self.user_employee_id).action_draft() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_reset_by_user_other(self): + """ User may not reset other leaves """ + other_leave = self.env['hr.leave'].sudo(self.user_hruser).create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_hruser.department_id.id, + 'employee_id': self.employee_hruser.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + with self.assertRaises(UserError): + other_leave.sudo(self.user_employee_id).action_draft() + + # ---------------------------------------- + # Validation: one validation, HR + # ---------------------------------------- + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_hr_by_manager(self): + """ Manager validates hr-only leaves """ + self.employee_leave.sudo(self.user_hrmanager_id).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_hr_by_officer_department(self): + """ Officer validates hr-only leaves for co-workers """ + self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_hr_by_officer_no_department(self): + """ Officer validates hr-only leaves for workers from no department and with no manager """ + self.employee_hruser.write({'department_id': False}) + # TDE FIXME: not sure for this one + # self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_hr_by_officer_other_department_with_manager(self): + """ Officer may not validate hr-only leaves for workers from another department that has a manager """ + self.employee_hruser.write({'department_id': self.hr_dept.id}) + with self.assertRaises(AccessError): + self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_hr_by_officer_other_department_wo_manager(self): + """ Officer may not validate hr-only leaves for workers from another department that has no manager """ + self.employee_hruser.write({'department_id': self.hr_dept.id}) + with self.assertRaises(AccessError): + self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_hr_by_officer_other_department_manager(self): + """ Officer validates hr-only leaves for workers from another department that he manages """ + self.employee_hruser.write({'department_id': self.hr_dept.id}) + self.employee_leave.sudo().department_id.write({'manager_id': self.employee_hruser.id}) + + self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_hr_by_user(self): + """ User may not validate any leaves """ + with self.assertRaises(UserError): + self.employee_leave.sudo(self.user_employee_id).action_approve() + + with self.assertRaises(UserError): + self.employee_leave.sudo(self.user_employee_id).write({'state': 'validate'}) + + # ---------------------------------------- + # Validation: one validation, manager + # ---------------------------------------- + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_manager_by_manager(self): + """ Manager validates manager-only leaves """ + self.leave_type.write({'validation_type': 'manager'}) + self.employee_leave.sudo(self.user_hrmanager_id).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_manager_by_officer_department_manager(self): + """ Officer validates manager-only leaves for co-workers from department he manages""" + self.leave_type.write({'validation_type': 'manager'}) + self.employee_hruser.write({'department_id': self.hr_dept.id}) + self.employee_leave.sudo().department_id.write({'manager_id': self.employee_hruser.id}) + self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_manager_by_officer_department_manager_other(self): + """ Officer may not validates manager-only leaves for co-workers from department he does not""" + self.leave_type.write({'validation_type': 'manager'}) + self.employee_hruser.write({'department_id': self.hr_dept.id}) + self.employee_leave.sudo().department_id.write({'manager_id': self.employee_hrmanager.id}) + with self.assertRaises(AccessError): + self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_manager_by_officer_manager(self): + """ Officer validates manager-only leaves for co-workers that he manages""" + self.leave_type.write({'validation_type': 'manager'}) + self.employee_emp.write({'parent_id': self.employee_hruser.id}) + self.employee_leave.sudo(self.user_hruser).action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_manager_by_officer_manager_other(self): + """ Officer may not validate manager-only leaves for co-workers he does not manage""" + self.leave_type.write({'validation_type': 'manager'}) + self.employee_emp.write({'parent_id': self.employee_hrmanager.id}) + with self.assertRaises(UserError): + self.employee_leave.sudo(self.user_hruser).action_approve() + + with self.assertRaises(UserError): + self.employee_leave.sudo(self.user_hruser).write({'state': 'validate'}) + + # ---------------------------------------- + # Validation: double + # ---------------------------------------- + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_both_by_manager(self): + """ Manager validates double validation leaves """ + self.leave_type.write({'validation_type': 'both'}) + self.employee_leave.sudo(self.user_hrmanager_id).action_approve() + self.employee_leave.sudo(self.user_hrmanager_id).action_validate() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_both_by_officer(self): + """ Officer may not validate double validation leaves """ + self.leave_type.write({'validation_type': 'both'}) + self.employee_leave.sudo(self.user_hruser).action_approve() + with self.assertRaises(UserError): + self.employee_leave.sudo(self.user_hruser).action_validate() + + with self.assertRaises(UserError): + self.employee_leave.sudo(self.user_hruser).write({'state': 'validate'}) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validation_both_by_officer_and_manager(self): + """ Officer + Manager complete double validation leaves """ + self.leave_type.write({'validation_type': 'both'}) + self.employee_leave.sudo(self.user_hruser).action_approve() + self.employee_leave.sudo(self.user_hrmanager_id).action_validate() + + +class TestMultiCompany(TestHrHolidaysBase): + + def setUp(self): + super(TestMultiCompany, self).setUp() + self.new_company = self.env['res.company'].create({ + 'name': 'Crocodile Dundee Company', + }) + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Unlimited - Company New', + 'limit': True, + 'company_id': self.new_company.id, + 'validation_type': 'hr', + }) + self.rd_dept.manager_id = False + self.hr_dept.manager_id = False + + self.employee_leave = self.env['hr.leave'].create({ + 'name': 'Test', + 'holiday_status_id': self.leave_type.id, + 'department_id': self.employee_emp.department_id.id, + 'employee_id': self.employee_emp.id, + 'date_from': datetime.now(), + 'date_to': datetime.now() + relativedelta(days=1), + 'number_of_days_temp': 1, + }) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_access_other_company_user(self): + employee_leave = self.employee_leave.sudo(self.user_employee) + + with self.assertRaises(AccessError): + name = employee_leave.name + + with self.assertRaises(AccessError): + employee_leave.action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_access_other_company_officer(self): + employee_leave_hruser = self.employee_leave.sudo(self.user_hruser) + + with self.assertRaises(AccessError): + name = employee_leave_hruser.name + + with self.assertRaises(AccessError): + employee_leave_hruser.action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_access_other_company_manager(self): + employee_leave_hrmanager = self.employee_leave.sudo(self.user_hrmanager) + + with self.assertRaises(AccessError): + name = employee_leave_hrmanager.name + + with self.assertRaises(AccessError): + employee_leave_hrmanager.action_approve() + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_access_no_company_user(self): + self.leave_type.write({'company_id': False}) + employee_leave = self.employee_leave.sudo(self.user_employee) + + name = employee_leave.name + with self.assertRaises(UserError): + employee_leave.action_approve() + self.assertEqual(employee_leave.state, 'confirm') + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_access_no_company_officer(self): + self.leave_type.write({'company_id': False}) + employee_leave_hruser = self.employee_leave.sudo(self.user_hruser) + + name = employee_leave_hruser.name + employee_leave_hruser.action_approve() + self.assertEqual(employee_leave_hruser.state, 'validate') + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_access_no_company_manager(self): + self.leave_type.write({'company_id': False}) + employee_leave_hrmanager = self.employee_leave.sudo(self.user_hrmanager) + + name = employee_leave_hrmanager.name + employee_leave_hrmanager.action_approve() + self.assertEqual(employee_leave_hrmanager.state, 'validate') diff --git a/addons/hr_holidays/tests/test_holidays_flow.py b/addons/hr_holidays/tests/test_holidays_flow.py index 44013d63710..0c3cff00ad9 100644 --- a/addons/hr_holidays/tests/test_holidays_flow.py +++ b/addons/hr_holidays/tests/test_holidays_flow.py @@ -72,15 +72,6 @@ class TestHolidaysFlow(TestHrHolidaysBase): # Employee creates a leave request for another employee -> should crash HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) - with self.assertRaises(ValidationError): - HolidaysEmployeeGroup.create({ - 'name': 'Hol10', - 'employee_id': self.employee_hruser_id, - 'holiday_status_id': self.holidays_status_1.id, - 'date_from': (datetime.today() - relativedelta(days=1)), - 'date_to': datetime.today(), - 'number_of_days_temp': 1, - }) Requests.search([('name', '=', 'Hol10')]).unlink() # Employee creates a leave request in a no-limit category hr manager only @@ -97,8 +88,6 @@ class TestHolidaysFlow(TestHrHolidaysBase): self.assertEqual(hol1_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state') # Employee validates its leave request -> should not work - with self.assertRaises(UserError): - hol1_employee_group.action_approve() self.assertEqual(hol1_manager_group.state, 'confirm', 'hr_holidays: employee should not be able to validate its own leave request') # HrUser validates the employee leave request -> should work @@ -119,14 +108,8 @@ class TestHolidaysFlow(TestHrHolidaysBase): self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state') # Employee validates its leave request -> should not work - with self.assertRaises(UserError): - hol12_employee_group.action_approve() self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: employee should not be able to validate its own leave request') - # HrUser validates the employee leave request -> should not work - with self.assertRaises(UserError): - hol12_user_group.action_approve() - # HrManager validate the employee leave request hol12_manager_group.action_approve() self.assertEqual(hol1_user_group.state, 'validate', 'hr_holidays: validates leave request should be in validate state') @@ -169,9 +152,7 @@ class TestHolidaysFlow(TestHrHolidaysBase): }) # HrUser validates the first step aloc1_user_group.action_approve() - # HrUser validates the second step -> should crash - with self.assertRaises(UserError): - aloc1_user_group.action_validate() + # HrManager validates the second step aloc1_user_group.sudo(self.user_hrmanager_id).action_validate() # Checks Employee has effectively some days left @@ -197,9 +178,6 @@ class TestHolidaysFlow(TestHrHolidaysBase): self.assertEqual(hol2.state, 'validate1', 'hr_holidays: first validation should lead to validate1 state') - # HrUser validates the second step -> should crash - with self.assertRaises(UserError): - hol2_user_group.action_validate() # HrManager validates the second step hol2_user_group.sudo(self.user_hrmanager_id).action_validate() self.assertEqual(hol2.state, 'validate', @@ -214,9 +192,6 @@ class TestHolidaysFlow(TestHrHolidaysBase): # Check left days: 2 days left again _check_holidays_status(hol_status_2_employee_group, 2.0, 0.0, 2.0, 2.0) - # Annoyed, HrUser tries to fix its error and tries to reset the leave request -> does not work, only HrManager - with self.assertRaises(UserError): - hol2_user_group.action_draft() self.assertEqual(hol2.state, 'refuse', 'hr_holidays: hr_user should not be able to reset a refused leave request') @@ -302,31 +277,10 @@ class TestHolidaysFlow(TestHrHolidaysBase): 'number_of_days_temp': 1, }) - # But not someone else's leave - with self.assertRaises(AccessError): - hol42.sudo(self.user_employee_id).action_draft() - - # An officer should not be able to reset someone else's leave - with self.assertRaises(UserError): - hol42.sudo(self.user_hruser_id).action_draft() - # A manager should be able to reset someone else's leave hol42.action_draft() hol42.unlink() - # Officer should not be able to approve it's own leave - hol50 = HolidaysEmployeeGroup.sudo(self.user_hruser_id).create({ - 'name': 'Hol50', - 'employee_id': self.employee_hruser_id, - 'holiday_status_id': self.holidays_status_1.id, - 'date_from': (datetime.today() + relativedelta(days=15)).strftime('%Y-%m-%d %H:%M'), - 'date_to': (datetime.today() + relativedelta(days=16)), - 'number_of_days_temp': 1, - }) - - with self.assertRaises(UserError): - hol50.action_approve() - # Manager should be able to approve it's own leave hol51 = HolidaysEmployeeGroup.sudo(self.user_hrmanager_2_id).create({ 'name': 'Hol51', diff --git a/addons/hw_posbox_homepage/controllers/main.py b/addons/hw_posbox_homepage/controllers/main.py index 6ba6c1ff62a..d5845003de2 100644 --- a/addons/hw_posbox_homepage/controllers/main.py +++ b/addons/hw_posbox_homepage/controllers/main.py @@ -58,7 +58,7 @@ index_template = """ </p> %s <p> - The PosBox software installed on this posbox is <b>version 17 BETA</b>, + The PosBox software installed on this posbox is <b>version 17</b>, the posbox version number is independent from Odoo. You can upgrade the software on the <a href='/hw_proxy/upgrade/'>upgrade page</a>. </p> diff --git a/addons/l10n_th/data/l10n_th_chart_data.xml b/addons/l10n_th/data/l10n_th_chart_data.xml index b919aabc83d..b9bfefc6c13 100644 --- a/addons/l10n_th/data/l10n_th_chart_data.xml +++ b/addons/l10n_th/data/l10n_th_chart_data.xml @@ -352,7 +352,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Company Withholding Tax 1% (Transportation)</field> <field name="amount_type">percent</field> - <field name="amount" eval="1"/> + <field name="amount" eval="-1"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -364,7 +364,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Company Withholding Tax 2% (Advertising)</field> <field name="amount_type">percent</field> - <field name="amount" eval="2"/> + <field name="amount" eval="-2"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -376,7 +376,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Company Withholding Tax 3% (Service)</field> <field name="amount_type">percent</field> - <field name="amount" eval="3"/> + <field name="amount" eval="-3"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -388,7 +388,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Company Withholding Tax 5% (Rental)</field> <field name="amount_type">percent</field> - <field name="amount" eval="5"/> + <field name="amount" eval="-5"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -400,7 +400,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Personal Withholding Tax 1% (Transportation)</field> <field name="amount_type">percent</field> - <field name="amount" eval="1"/> + <field name="amount" eval="-1"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -412,7 +412,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Personal Withholding Tax 2% (Advertising)</field> <field name="amount_type">percent</field> - <field name="amount" eval="2"/> + <field name="amount" eval="-2"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -424,7 +424,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Personal Withholding Tax 3% (Service)</field> <field name="amount_type">percent</field> - <field name="amount" eval="3"/> + <field name="amount" eval="-3"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -436,7 +436,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Personal Withholding Tax 5% (Rental)</field> <field name="amount_type">percent</field> - <field name="amount" eval="5"/> + <field name="amount" eval="-5"/> <field name="account_id" ref="a_wht"/> <field name="refund_account_id" ref="a_wht"/> <field name="type_tax_use">purchase</field> @@ -448,7 +448,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Withholding Income Tax 1% (Transportation)</field> <field name="amount_type">percent</field> - <field name="amount" eval="1"/> + <field name="amount" eval="-1"/> <field name="account_id" ref="a_wht_income"/> <field name="refund_account_id" ref="a_wht_income"/> <field name="type_tax_use">sale</field> @@ -459,7 +459,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Withholding Income Tax 2% (Advertising)</field> <field name="amount_type">percent</field> - <field name="amount" eval="2"/> + <field name="amount" eval="-2"/> <field name="account_id" ref="a_wht_income"/> <field name="refund_account_id" ref="a_wht_income"/> <field name="type_tax_use">sale</field> @@ -470,7 +470,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Withholding Income Tax 3% (Service)</field> <field name="amount_type">percent</field> - <field name="amount" eval="3"/> + <field name="amount" eval="-3"/> <field name="account_id" ref="a_wht_income"/> <field name="refund_account_id" ref="a_wht_income"/> <field name="type_tax_use">sale</field> @@ -481,7 +481,7 @@ <field name="chart_template_id" ref="chart"/> <field name="name">Withholding Income Tax 5% (Rental)</field> <field name="amount_type">percent</field> - <field name="amount" eval="5"/> + <field name="amount" eval="-5"/> <field name="account_id" ref="a_wht_income"/> <field name="refund_account_id" ref="a_wht_income"/> <field name="type_tax_use">sale</field> diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 3f2a655e1a9..3de00e9dca6 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -187,9 +187,9 @@ class Channel(models.Model): @api.onchange('moderator_ids') def _onchange_moderator_ids(self): - missing_partners = self.mapped('moderator_ids.partner_id') - self.channel_partner_ids - if missing_partners: - self.channel_partner_ids |= missing_partners + missing_partners = self.mapped('moderator_ids.partner_id') - self.mapped('channel_last_seen_partner_ids.partner_id') + for partner in missing_partners: + self.channel_last_seen_partner_ids += self.env['mail.channel.partner'].new({'partner_id': partner.id}) @api.onchange('email_send') def _onchange_email_send(self): diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 6e5a3697cd8..2937999bdb5 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -129,7 +129,7 @@ class MailMail(models.Model): return res @api.multi - def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type='NONE'): + def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None): """Perform any post-processing necessary after sending ``mail`` successfully, including deleting it completely along with its attachment if the ``auto_delete`` flag of the mail was set. @@ -147,7 +147,7 @@ class MailMail(models.Model): if notifications: #find all notification linked to a failure failed = self.env['mail.notification'] - if failure_type != 'NONE': + if failure_type: failed = notifications.filtered(lambda notif: notif.res_partner_id not in success_pids) failed.write({ 'email_status': 'exception', @@ -159,7 +159,7 @@ class MailMail(models.Model): (notifications - failed).write({ 'email_status': 'sent', }) - if failure_type in ('NONE', 'RECIPIENT'): # if we have another error, we want to keep the mail. + if not failure_type or failure_type == 'RECIPIENT': # if we have another error, we want to keep the mail. mail_to_delete_ids = [mail.id for mail in self if mail.auto_delete] self.browse(mail_to_delete_ids).sudo().unlink() return True @@ -261,7 +261,7 @@ class MailMail(models.Model): IrAttachment = self.env['ir.attachment'] for mail_id in self.ids: success_pids = [] - failure_type = 'NONE' + failure_type = None processing_pid = None mail = None try: diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 45bcb9498c5..15d744d4466 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -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'))] - 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" + 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" @api.depends('starred_partner_ids') def _get_starred(self): @@ -375,9 +375,20 @@ class Message(models.Model): else: partner_ids = [partner_tree[partner.id] for partner in message.partner_ids if partner.id in partner_tree] - + # we read customer_email_status before filtering inactive user because we don't want to miss a red enveloppe + customer_email_status = ( + (all(n.email_status == 'sent' for n in message.notification_ids) and 'sent') or + (any(n.email_status == 'exception' for n in message.notification_ids) and 'exception') or + (any(n.email_status == 'bounce' for n in message.notification_ids) and 'bounce') or + 'ready' + ) customer_email_data = [] - for notification in message.notification_ids.filtered(lambda notif: notif.email_status in ('bounce', 'exception', 'canceled') or (notif.res_partner_id.partner_share and notif.res_partner_id.active)): + def filter_notification(notif): + return ( + (notif.email_status in ('bounce', 'exception', 'canceled') or notif.res_partner_id.partner_share) and + notif.res_partner_id.active + ) + for notification in message.notification_ids.filtered(filter_notification): customer_email_data.append((partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.email_status)) attachment_ids = [] @@ -392,9 +403,7 @@ class Message(models.Model): message_dict.update({ 'author_id': author, 'partner_ids': partner_ids, - 'customer_email_status': (all(d[2] == 'sent' for d in customer_email_data) and 'sent') or - (any(d[2] == 'exception' for d in customer_email_data) and 'exception') or - (any(d[2] == 'bounce' for d in customer_email_data) and 'bounce') or 'ready', + 'customer_email_status': customer_email_status, 'customer_email_data': customer_email_data, 'attachment_ids': attachment_ids, 'tracking_value_ids': tracking_value_ids, @@ -688,9 +697,9 @@ class Message(models.Model): if operation == 'read': self._cr.execute(""" - SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, m.moderation_status, + SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), - channel_partner.channel_id as channel_id + channel_partner.channel_id as channel_id, m.moderation_status FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s diff --git a/addons/mail/models/mail_notification.py b/addons/mail/models/mail_notification.py index 66f18379565..b687633a86d 100644 --- a/addons/mail/models/mail_notification.py +++ b/addons/mail/models/mail_notification.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from odoo import api, fields, models +from odoo.tools.translate import _ class Notification(models.Model): @@ -29,12 +30,11 @@ class Notification(models.Model): # bot value. Working with set inclusion, we could have a notif matching message from mail 1 and partner from mail 2, we dont want that. # The solution would be to iterate over mail or to filter mail after search,... or add a mail_id field on notification to KISS failure_type = fields.Selection(selection=[ - ("NONE", "No error"), - ("SMTP", "Error while connecting to smtp server"), - ("RECIPIENT", "Invalid email adress"), - ("BOUNCE", "Email address not found"), - ("UNKNOWN", "Unknown error occured"), - ], default='NONE', string='Failure type') + ("SMTP", "Connection failed (outgoing mail server problem)"), + ("RECIPIENT", "Invalid email address"), + ("BOUNCE", "Email address rejected by destination"), + ("UNKNOWN", "Unknown error"), + ], string='Failure type') failure_reason = fields.Text('Failure reason', copy=False) @api.model_cr @@ -47,8 +47,8 @@ class Notification(models.Model): def format_failure_reason(self): self.ensure_one() if self.failure_type != 'UNKNOWN': - return dict(type(self).failure_type.selection).get(self.failure_type) + return dict(type(self).failure_type.selection).get(self.failure_type, _('No Error')) else: - return "Unknow error occured: %s" % (self.failure_reason) + return _("Unknown error") + ": %s" % (self.failure_reason or '') diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index da0c45051ce..d730d039050 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -27,6 +27,7 @@ from werkzeug import urls from odoo import _, api, exceptions, fields, models, tools from odoo.tools import pycompat, ustr +from odoo.tools.misc import clean_context from odoo.tools.safe_eval import safe_eval @@ -312,7 +313,7 @@ class MailThread(models.AbstractModel): # Perform the tracking if tracked_fields: - track_self.message_track(tracked_fields, initial_values) + track_self.with_context(clean_context(self._context)).message_track(tracked_fields, initial_values) return result diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js index dcd53a3c5d1..4a7bc76f05b 100644 --- a/addons/mail/static/src/js/emojis.js +++ b/addons/mail/static/src/js/emojis.js @@ -125,7 +125,8 @@ var data = { ":football": "🏈", ":8ball": "🎱", ":clapper": "🎬", - ":microphone": "🎤" + ":microphone": "🎤", + ":cheese": "🧀", }; // list of emojis in a dictionary, indexed by emoji unicode diff --git a/addons/mail/static/src/js/thread_widget.js b/addons/mail/static/src/js/thread_widget.js index 03b3706892d..d2e98d2c99a 100644 --- a/addons/mail/static/src/js/thread_widget.js +++ b/addons/mail/static/src/js/thread_widget.js @@ -41,6 +41,7 @@ var ThreadWidget = Widget.extend({ 'click .o_thread_message': '_onClickMessage', 'click': '_onClick', 'click .o_thread_message_email_exception': '_onClickEmailException', + 'click .o_thread_message_email_bounce': '_onClickEmailException', 'click .o_thread_message_moderation': '_onClickMessageModeration', 'change .moderation_checkbox': '_onChangeModerationCheckbox', }, diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index c1922747177..d27680fb404 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -193,15 +193,11 @@ &.o_thread_message_email_ready { color: grey; } - &.o_thread_message_email_exception { + &.o_thread_message_email_exception, &.o_thread_message_email_bounce { color: red; opacity: 1; cursor: pointer; } - &.o_thread_message_email_bounce { - color: red; - opacity: 1; - } } .o_attachments_list, .o_attachments_previews { diff --git a/addons/mail/static/src/xml/thread.xml b/addons/mail/static/src/xml/thread.xml index 78295a037c7..f06d1b378c9 100644 --- a/addons/mail/static/src/xml/thread.xml +++ b/addons/mail/static/src/xml/thread.xml @@ -316,7 +316,8 @@ <t t-if="message.originatesFromChannel() and (message.getOriginChannelID() !== thread.getID())"> (from <a t-att-data-oe-id="message.getOriginChannelID()" href="#">#<t t-esc="message.getOriginChannelName()"/></a>) </t> - <span t-if="options.displayEmailIcons and message.hasCustomerEmailData()" class="o_thread_tooltip_container"> + <span t-if="options.displayEmailIcons and (message.hasCustomerEmailData() or ['exception', 'bounce'].indexOf(message._customerEmailStatus) > -1" class="o_thread_tooltip_container"> + <t t-set="fatype" t-value="''"/> <t t-if="message.getCustomerEmailStatus() === 'sent' or message.getCustomerEmailStatus() === 'ready'"> <t t-set="fatype" t-value="'-o'"/> diff --git a/addons/mail/wizard/mail_resend_message.py b/addons/mail/wizard/mail_resend_message.py index 77b1a2a102d..4a76e40be97 100644 --- a/addons/mail/wizard/mail_resend_message.py +++ b/addons/mail/wizard/mail_resend_message.py @@ -29,7 +29,6 @@ class MailResendMessage(models.TransientModel): if message_id: mail_message_id = self.env['mail.message'].browse(message_id) notification_ids = mail_message_id.notification_ids.filtered(lambda notif: notif.email_status in ('exception', 'bounce')) - partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False) partner_ids = [(0, 0, { "partner_id": notif.res_partner_id.id, @@ -39,6 +38,11 @@ class MailResendMessage(models.TransientModel): "message": notif.format_failure_reason(), } ) for notif in notification_ids] + has_user = any([notif.res_partner_id.user_ids for notif in notification_ids]) + if has_user: + partner_readonly = not self.env['res.users'].check_access_rights('write', raise_exception=False) + else: + partner_readonly = not self.env['res.partner'].check_access_rights('write', raise_exception=False) rec['partner_readonly'] = partner_readonly rec['notification_ids'] = [(6, 0, notification_ids.ids)] rec['mail_message_id'] = mail_message_id.id diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index beb4952da1f..6d718a9ffbf 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -95,8 +95,8 @@ class MailMail(models.Model): return res @api.multi - def _postprocess_sent_message(self, failure_type='NONE', **kwargs): - mail_sent = failure_type == 'NONE' # we consider that a recipient error is a failure with mass mailling and show them as failed + def _postprocess_sent_message(self, failure_type=None, **kwargs): + mail_sent = not failure_type # we consider that a recipient error is a failure with mass mailling and show them as failed for mail in self: if mail.mailing_id: if mail_sent is True and mail.statistics_ids: diff --git a/addons/mass_mailing_event/models/event.py b/addons/mass_mailing_event/models/event.py index be37000e6f2..16b9c066862 100644 --- a/addons/mass_mailing_event/models/event.py +++ b/addons/mass_mailing_event/models/event.py @@ -8,6 +8,10 @@ class Event(models.Model): _inherit = "event.event" def action_mass_mailing_attendees(self): + if len(self) == 1: + domain = "[('event_id', '=', {})]".format(self.id) + else: + domain = "[('event_id', 'in', {})]".format(self.ids) mass_mailing_action = dict( name='Mass Mail Attendees', type='ir.actions.act_window', @@ -17,7 +21,7 @@ class Event(models.Model): target='current', context=dict( default_mailing_model_id=self.env.ref('event.model_event_registration').id, - default_mailing_domain="[('event_id', 'in', %s)]" % self.ids, # , ('state', 'in', ['draft', 'open', 'done']) + default_mailing_domain=domain, ), ) - return mass_mailing_action
\ No newline at end of file + return mass_mailing_action diff --git a/addons/mrp/views/mrp_workorder_views.xml b/addons/mrp/views/mrp_workorder_views.xml index a10d6ebd8bd..a01e3c700d3 100644 --- a/addons/mrp/views/mrp_workorder_views.xml +++ b/addons/mrp/views/mrp_workorder_views.xml @@ -149,7 +149,7 @@ <field name="active_move_line_ids" attrs="{'invisible': [('active_move_line_ids', '=', [])]}"> <tree editable="bottom" create="0" delete="0"> <field name="product_id"/> - <field name="product_uom_qty" readonly="1" string="To Consume"/> + <field name="product_uom_qty" readonly="1" invisible="1" string="To Consume"/> <field name="lot_id" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}"/> <field name="qty_done"/> <field name="product_uom_id" invisible="1"/> diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index ae9863c4ffc..904c440bcac 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -109,6 +109,8 @@ class WebsitePayment(http.Controller): if order_id: values['sale_order_ids'] = [(6, 0, [order_id])] + reference_values = order_id and {'sale_order_ids': [(4, order_id)]} or {} + values['reference'] = request.env['payment.transaction']._compute_reference(values=reference_values, prefix=reference) tx = request.env['payment.transaction'].sudo().with_context(lang=None).create(values) render_values = { @@ -116,7 +118,7 @@ class WebsitePayment(http.Controller): 'partner_id': partner_id, } - return acquirer.sudo().render(reference, float(amount), int(currency_id), values=render_values) + return acquirer.sudo().render(tx.reference, float(amount), int(currency_id), values=render_values) @http.route(['/website_payment/token/<string:reference>/<string:amount>/<string:currency_id>', '/website_payment/token/v2/<string:amount>/<string:currency_id>/<path:reference>'], type='http', auth='public', website=True) diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py index 013af29d764..0e776d1cd2e 100644 --- a/addons/payment/models/payment_acquirer.py +++ b/addons/payment/models/payment_acquirer.py @@ -1,4 +1,5 @@ # coding: utf-8 +from collections import defaultdict import hashlib import hmac import logging @@ -617,6 +618,13 @@ class PaymentTransaction(models.Model): @api.multi def _prepare_account_payment_vals(self): self.ensure_one() + + communication = [] + for invoice in self.invoice_ids: + inv_communication = invoice.type in ('in_invoice', 'in_refund') and invoice.reference or invoice.number + if invoice.origin: + communication.append('%s (%s)' % (inv_communication, invoice.origin)) + return { 'amount': self.amount, 'payment_type': 'inbound' if self.amount > 0 else 'outbound', @@ -629,6 +637,7 @@ class PaymentTransaction(models.Model): 'payment_method_id': self.env.ref('payment.account_payment_method_electronic_in').id, 'payment_token_id': self.payment_token_id and self.payment_token_id.id or None, 'payment_transaction_id': self.id, + 'communication': ' / '.join(communication), } @api.multi @@ -717,27 +726,32 @@ class PaymentTransaction(models.Model): if any(trans.state not in ('draft', 'authorized') for trans in self): raise ValidationError(_('Only draft/authorized transaction can be posted.')) + self.write({'state': 'done', 'date': datetime.now().strftime(DEFAULT_SERVER_DATETIME_FORMAT)}) + self._reconcile_after_transaction_done() + self._log_payment_transaction_received() + + @api.multi + def _reconcile_after_transaction_done(self): # Validate invoices automatically upon the transaction is posted. invoices = self.mapped('invoice_ids').filtered(lambda inv: inv.state == 'draft') invoices.action_invoice_open() # Create & Post the payments. - payments = self.env['account.payment'] + payments = defaultdict(lambda: self.env['account.payment']) for trans in self: if trans.payment_id: payments += trans.payment_id continue payment_vals = trans._prepare_account_payment_vals() - payment = payments.create(payment_vals) - payments += payment + payment = self.env['account.payment'].create(payment_vals) + payments[trans.acquirer_id.company_id.id] += payment # Track the payment to make a one2one. trans.payment_id = payment - payments.post() - self.write({'state': 'done', 'date': datetime.now().strftime(DEFAULT_SERVER_DATETIME_FORMAT)}) - self._log_payment_transaction_received() + for company in payments: + payments[company].with_context(force_company=company, company_id=company).post() @api.multi def _set_transaction_cancel(self): @@ -782,11 +796,10 @@ class PaymentTransaction(models.Model): # Fetch the last reference # E.g. If the last reference is SO42-5, this query will return '-5' self._cr.execute(''' - SELECT CAST(SUBSTRING(reference FROM '-\d+') AS INTEGER) AS suffix + SELECT CAST(SUBSTRING(reference FROM '-\d+$') AS INTEGER) AS suffix FROM payment_transaction WHERE reference LIKE %s ORDER BY suffix - ''', [prefix + '%']) + ''', [prefix + '-%']) query_res = self._cr.fetchone() - if query_res: # Increment the last reference by one suffix = '%s' % (-query_res[0] + 1) diff --git a/addons/payment_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index f38b6535b9c..e4bd3382caa 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -7,7 +7,7 @@ from uuid import uuid4 import pprint import logging -from odoo import _ +from odoo.addons.payment.models.payment_acquirer import _partner_split_name from odoo.exceptions import ValidationError, UserError from odoo import _ @@ -153,6 +153,12 @@ class AuthorizeAPI(): payment_profile = etree.SubElement(profile, "paymentProfiles") etree.SubElement(payment_profile, "customerType").text = 'business' if partner.is_company else 'individual' billTo = etree.SubElement(payment_profile, "billTo") + if partner.is_company: + etree.SubElement(billTo, "firstName").text = ' ' + etree.SubElement(billTo, "lastName").text = partner.name + else: + etree.SubElement(billTo, "firstName").text = _partner_split_name(partner.name)[0] + etree.SubElement(billTo, "lastName").text = _partner_split_name(partner.name)[1] etree.SubElement(billTo, "address").text = (partner.street or '' + (partner.street2 if partner.street2 else '')) or None missing_fields = [partner._fields[field].string for field in ['city', 'country_id'] if not partner[field]] diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 41b27d4e979..7a8b5f45d90 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -827,7 +827,10 @@ class PosOrder(models.Model): 'lot_id': lot_id, }) if not pack_lots and not float_is_zero(qty_done, precision_rounding=move.product_uom.rounding): - move.quantity_done = qty_done + if len(move._get_move_lines()) < 2: + move.quantity_done = qty_done + else: + move._set_quantity_done(qty_done) return has_wrong_lots def _prepare_bank_statement_line_payment_values(self, data): diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index b4a1e0ff6ae..6163e150c58 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -1238,6 +1238,15 @@ exports.Product = Backbone.Model.extend({ var self = this; var date = moment().startOf('day'); + // In case of nested pricelists, it is necessary that all pricelists are made available in + // the POS. Display a basic alert to the user in this case. + if (pricelist === undefined) { + alert(_t( + 'An error occurred when loading product prices. ' + + 'Make sure all pricelists are available in the POS.' + )); + } + var category_ids = []; var category = this.categ; while (category) { diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index 88fcb617ccb..60c10b980f8 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -394,7 +394,8 @@ var NumpadWidget = PosBaseWidget.extend({ this.$el.find('.mode-button').click(_.bind(this.clickChangeMode, this)); }, applyAccessRights: function() { - var has_price_control_rights = !this.pos.config.restrict_price_control || this.pos.get_cashier().role == 'manager'; + var cashier = this.pos.get('cashier') || this.pos.get_cashier(); + var has_price_control_rights = !this.pos.config.restrict_price_control || cashier.role == 'manager'; this.$el.find('.mode-button[data-mode="price"]') .toggleClass('disabled-mode', !has_price_control_rights) .prop('disabled', !has_price_control_rights); @@ -1558,7 +1559,12 @@ var ReceiptScreenWidget = ScreenWidget.extend({ }; }, print_web: function() { - window.print(); + if($.browser.safari){ + document.execCommand('print', false, null); + } + else{ + window.print(); + } this.pos.get_order()._printed = true; }, print_xml: function() { diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index 59fd56abb2d..b7de301c011 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -375,7 +375,7 @@ </div> </div> </div> - <div class="col-12 col-lg-6 o_setting_box" id="auto_printing" attrs="{'invisible': [('is_posbox', '=', False)]}"> + <div class="col-12 col-lg-6 o_setting_box" id="auto_printing"> <div class="o_setting_left_pane"> <field name="iface_print_auto"/> </div> @@ -384,7 +384,7 @@ <div class="text-muted"> Print receipts automatically once the payment is registered </div> - <div class="content-group mt16" attrs="{'invisible' : [('iface_print_auto', '=', False)]}"> + <div class="content-group mt16" attrs="{'invisible' : ['|', ('iface_print_auto', '=', False), ('is_posbox', '=', False)]}"> <div> <field name="iface_print_skip_screen" class="oe_inline"/><span class="oe_inline"><b>Skip Preview Screen</b></span> </div> diff --git a/addons/portal/views/assets.xml b/addons/portal/views/assets.xml index 15a3de3d775..2afa3c0b886 100644 --- a/addons/portal/views/assets.xml +++ b/addons/portal/views/assets.xml @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> - <template id="assets_frontend" inherit_id="web.assets_frontend" name="Portal Assets"> + <template id="assets_frontend" inherit_id="web.assets_frontend" name="Portal Assets" priority="15"> <xpath expr="//link[last()]" position="after"> <link rel="stylesheet" type="text/scss" href="/portal/static/src/scss/bootstrap.extend.scss"/> <link rel="stylesheet" type="text/scss" href="/portal/static/src/scss/portal.scss"/> diff --git a/addons/pos_sale/models/crm_team.py b/addons/pos_sale/models/crm_team.py index ffbfcd026c6..b8ed1c48710 100644 --- a/addons/pos_sale/models/crm_team.py +++ b/addons/pos_sale/models/crm_team.py @@ -77,7 +77,7 @@ class CrmTeam(models.Model): for data_point in order_data: result.append({'x_value': data_point.get('user_id')[0], 'y_value': data_point.get('price_total')}) - else: + elif self.dashboard_graph_group_pos in ['day', 'week', 'month']: # locale en_GB is used to be able to obtain the datetime from the string returned by read_group # /!\ do not use en_US as it's not ISO-standard and does not match datetime's library order_data = self.env['report.pos.order'].with_context(lang='en_GB').read_group( diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 7638db145b9..1edf90ed830 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -403,7 +403,10 @@ class ProductTemplate(models.Model): # We fetch the standard price as the superuser templates = self.with_context(force_company=company and company.id or self._context.get('force_company', self.env.user.company_id.id)).sudo() if not company: - company = self._context.get('force_company', self.env.user.company_id.id) + if self._context.get('force_company'): + company = self.env['res.company'].browse(self._context['force_company']) + else: + company = self.env.user.company_id date = self.env.context.get('date') or fields.Date.today() prices = dict.fromkeys(self.ids, 0.0) diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index ec8204eb2e0..1aa89e787f1 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -127,7 +127,16 @@ class CustomerPortal(CustomerPortal): } # extends filterby criteria with project (criteria name is the project id) # Note: portal users can't view projects they don't follow - projects = request.env['project.project'].sudo().search([('privacy_visibility', '=', 'portal')]) + partner = request.env.user.partner_id + domain_projects = [ + '&', + ('privacy_visibility', '=', 'portal'), + '|', + ('message_partner_ids', 'child_of', [partner.commercial_partner_id.id]), + ('task_ids.message_partner_ids', 'child_of', [partner.commercial_partner_id.id]) + ] + + projects = request.env['project.project'].sudo().search(domain_projects) domain = [('project_id', 'in', projects.ids)] for proj in projects: searchbar_filters.update({ diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 08977ac91ae..18f49389878 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -513,7 +513,7 @@ class Task(models.Model): def _compute_attachment_ids(self): for task in self: attachment_ids = self.env['ir.attachment'].search([('res_id', '=', task.id), ('res_model', '=', 'project.task')]).ids - message_attachment_ids = self.mapped('message_ids.attachment_ids').ids # from mail_thread + message_attachment_ids = task.mapped('message_ids.attachment_ids').ids # from mail_thread task.attachment_ids = list(set(attachment_ids) - set(message_attachment_ids)) @api.multi @@ -810,7 +810,7 @@ class Task(models.Model): @api.multi def _notify_get_reply_to(self, default=None, records=None, company=None, doc_names=None): """ Override to set alias of tasks to their project if any. """ - aliases = self.mapped('project_id')._notify_get_reply_to(default=default, records=None, company=company, doc_names=None) + aliases = self.sudo().mapped('project_id')._notify_get_reply_to(default=default, records=None, company=company, doc_names=None) res = {task.id: aliases.get(task.project_id.id) for task in self} leftover = self.filtered(lambda rec: not rec.project_id) if leftover: diff --git a/addons/project/views/project_portal_templates.xml b/addons/project/views/project_portal_templates.xml index e1c78408343..1db0eddc660 100644 --- a/addons/project/views/project_portal_templates.xml +++ b/addons/project/views/project_portal_templates.xml @@ -199,7 +199,7 @@ </strong> <div class='row'> <div class="col-lg-2 col-md-3 text-center oe_attachments" t-foreach='task.attachment_ids' t-as='attachment'> - <a t-attf-href="/web/content/#{attachment.id}?download=true" target="_blank"> + <a t-attf-href="/web/content/#{attachment.id}?download=true&access_token=#{attachment.generate_access_token()}" target="_blank"> <div class='oe_attachment_embedded o_image o_image_small' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype" t-attf-data-src="/web/image/#{attachment.id}/50x40"/> <div class='oe_attachment_name'><t t-esc='attachment.name' /></div> </a> diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index 2de90bdd174..879245ecea7 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -65,6 +65,7 @@ <field name="res_model">project.task</field> <field name="view_mode">kanban,tree,form,calendar,pivot,graph,activity</field> <field name="context">{ + 'group_by': 'stage_id', 'search_default_project_id': [active_id], 'default_project_id': active_id, }</field> diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index a042ce1975b..e9d601ee9f7 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -124,6 +124,7 @@ class AccountInvoice(models.Model): purchase_ids = self.invoice_line_ids.mapped('purchase_id') if purchase_ids: self.origin = ', '.join(purchase_ids.mapped('name')) + self.reference = ', '.join(purchase_ids.filtered('partner_ref').mapped('partner_ref')) or self.reference @api.onchange('partner_id', 'company_id') def _onchange_partner_id(self): diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py index 8c16176bff7..40bd2e74b25 100644 --- a/addons/purchase_requisition/models/purchase_requisition.py +++ b/addons/purchase_requisition/models/purchase_requisition.py @@ -435,6 +435,18 @@ class ProductTemplate(models.Model): ('tenders', 'Propose a call for tenders')], string='Procurement', default='rfq') +class StockMove(models.Model): + _inherit = "stock.move" + + requistion_line_ids = fields.One2many('purchase.requisition.line', 'move_dest_id') + +class ProcurementGroup(models.Model): + _inherit = 'procurement.group' + + @api.model + def _get_exceptions_domain(self): + return super(ProcurementGroup, self)._get_exceptions_domain() + [('requistion_line_ids', '=', False)] + class StockRule(models.Model): _inherit = 'stock.rule' diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py index 15b6f1b5bc5..9bac794a098 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -108,7 +108,14 @@ class AccountAnalyticLine(models.Model): continue if sale_order.state != 'sale': - raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name) + message_unconfirmed = _('The Sales Order %s linked to the Analytic Account %s must be validated before registering expenses.') + messages = { + 'draft': message_unconfirmed, + 'sent': message_unconfirmed, + 'done': _('The Sales Order %s linked to the Analytic Account %s is currently locked. You cannot register an expense on a locked Sales Order. Please create a new SO linked to this Analytic Account.'), + 'cancel': _('The Sales Order %s linked to the Analytic Account %s is cancelled. You cannot register an expense on a cancelled Sales Order.'), + } + raise UserError(messages[sale_order.state] % (sale_order.name, analytic_line.account_id.name)) so_line = None price = analytic_line._sale_get_invoice_price(sale_order) diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment.py index 15a16c9eae3..950aa41c1f9 100644 --- a/addons/sale/models/payment.py +++ b/addons/sale/models/payment.py @@ -83,26 +83,39 @@ class PaymentTransaction(models.Model): so.action_confirm() @api.multi - def _set_transaction_done(self): + def _reconcile_after_transaction_done(self): # Override of '_set_transaction_done' in the 'payment' module # to confirm the quotations automatically and to generate the invoices if needed. sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state == 'draft') sales_orders.force_quotation_send() sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state == 'sent') sales_orders.action_confirm() - automatic_invoice = self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice') - if automatic_invoice: + # invoice the sale orders if needed + self._invoice_sale_orders() + res = super(PaymentTransaction, self)._reconcile_after_transaction_done() + if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'): + default_template = self.env['ir.config_parameter'].sudo().get_param('sale.default_email_template') + if default_template: + ctx_company = {'company_id': self.acquirer_id.company_id.id, + 'force_company': self.acquirer_id.company_id.id, + 'mark_invoice_as_sent': True, + } + for trans in self.filtered(lambda t: t.sale_order_ids): + trans = trans.with_context(ctx_company) + for invoice in trans.invoice_ids: + invoice.message_post_with_template(int(default_template), notif_layout="mail.mail_notification_paynow") + return res + + @api.multi + def _invoice_sale_orders(self): + if self.env['ir.config_parameter'].sudo().get_param('website_sale.automatic_invoice'): + ctx_company = {'company_id': self.acquirer_id.company_id.id, + 'force_company': self.acquirer_id.company_id.id} for trans in self.filtered(lambda t: t.sale_order_ids): + trans = trans.with_context(ctx_company) trans.sale_order_ids._force_lines_to_invoice_policy_order() invoices = trans.sale_order_ids.action_invoice_create() trans.invoice_ids = [(6, 0, invoices)] - res = super(PaymentTransaction, self)._set_transaction_done() - if automatic_invoice: - default_template = self.env['ir.config_parameter'].sudo().get_param('sale.default_email_template') - if default_template: - for invoice in trans.invoice_ids: - invoice.with_context(mark_invoice_as_sent=True).message_post_with_template(int(default_template), notif_layout="mail.mail_notification_paynow") - return res @api.model def _compute_reference_prefix(self, values): diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index c24ffbecb37..14418dc7241 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -115,6 +115,11 @@ class SaleOrder(models.Model): for order in self: order.order_line._compute_tax_id() + @api.multi + def _get_payment_type(self): + self.ensure_one() + return 'form' + name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New')) origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.") client_order_ref = fields.Char(string='Customer Reference', copy=False) @@ -647,6 +652,11 @@ class SaleOrder(models.Model): @api.multi def action_confirm(self): + if self._get_forbidden_state_confirm() & set(self.mapped('state')): + raise UserError(_( + 'It is not allowed to confirm an order in the following states: %s' + ) % (', '.join(self._get_forbidden_state_confirm()))) + for order in self.filtered(lambda order: order.partner_id not in order.message_partner_ids): order.message_subscribe([order.partner_id.id]) self.write({ @@ -658,6 +668,9 @@ class SaleOrder(models.Model): self.action_done() return True + def _get_forbidden_state_confirm(self): + return {'done', 'cancel'} + @api.multi def _create_analytic_account(self, prefix=None): for order in self: diff --git a/addons/sale_stock/report/stock_report_deliveryslip.xml b/addons/sale_stock/report/stock_report_deliveryslip.xml index c127bdad671..01f7940c4a3 100644 --- a/addons/sale_stock/report/stock_report_deliveryslip.xml +++ b/addons/sale_stock/report/stock_report_deliveryslip.xml @@ -2,10 +2,10 @@ <odoo> <template id="report_delivery_document_inherit_sale_stock" inherit_id="stock.report_delivery_document"> <xpath expr="//h2" position="before"> - <div class="row justify-content-end" t-if="o.sale_id.client_order_ref"> + <div class="row justify-content-end" t-if="o.sudo().sale_id.client_order_ref"> <div class="col-4"> <strong>Customer Reference:</strong> - <p t-field="o.sale_id.client_order_ref"/> + <p t-field="o.sudo().sale_id.client_order_ref"/> </div> </div> </xpath> diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index cc5eeb3f3ef..cb625a758e5 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -134,17 +134,19 @@ class Product(models.Model): res = dict() for product in self.with_context(prefetch_fields=False): - res[product.id] = {} + product_id = product.id + rounding = product.uom_id.rounding + res[product_id] = {} if dates_in_the_past: - qty_available = quants_res.get(product.id, 0.0) - moves_in_res_past.get(product.id, 0.0) + moves_out_res_past.get(product.id, 0.0) + qty_available = quants_res.get(product_id, 0.0) - moves_in_res_past.get(product_id, 0.0) + moves_out_res_past.get(product_id, 0.0) else: - qty_available = quants_res.get(product.id, 0.0) - res[product.id]['qty_available'] = float_round(qty_available, precision_rounding=product.uom_id.rounding) - res[product.id]['incoming_qty'] = float_round(moves_in_res.get(product.id, 0.0), precision_rounding=product.uom_id.rounding) - res[product.id]['outgoing_qty'] = float_round(moves_out_res.get(product.id, 0.0), precision_rounding=product.uom_id.rounding) - res[product.id]['virtual_available'] = float_round( - qty_available + res[product.id]['incoming_qty'] - res[product.id]['outgoing_qty'], - precision_rounding=product.uom_id.rounding) + qty_available = quants_res.get(product_id, 0.0) + res[product_id]['qty_available'] = float_round(qty_available, precision_rounding=rounding) + res[product_id]['incoming_qty'] = float_round(moves_in_res.get(product_id, 0.0), precision_rounding=rounding) + res[product_id]['outgoing_qty'] = float_round(moves_out_res.get(product_id, 0.0), precision_rounding=rounding) + res[product_id]['virtual_available'] = float_round( + qty_available + res[product_id]['incoming_qty'] - res[product_id]['outgoing_qty'], + precision_rounding=rounding) return res diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index e6fc2209231..4fa38880b58 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1237,3 +1237,36 @@ class StockMove(models.Model): return result else: return [(self.picking_id, self.product_id.responsible_id, visited)] + + def _set_quantity_done(self, qty): + """ + Set the given quantity as quantity done on the move through the move lines. The method is + able to handle move lines with a different UoM than the move (but honestly, this would be + looking for trouble...). + @param qty: quantity in the UoM of move.product_uom + """ + for ml in self.move_line_ids: + # Convert move line qty into move uom + ml_qty = ml.product_uom_qty - ml.qty_done + if ml.product_uom_id != self.product_uom: + ml_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False) + + taken_qty = min(qty, ml_qty) + # Convert taken qty into move line uom + if ml.product_uom_id != self.product_uom: + taken_qty = self.product_uom._compute_quantity(ml_qty, ml.product_uom_id, round=False) + + # Assign qty_done and explicitly round to make sure there is no inconsistency between + # ml.qty_done and qty. + taken_qty = float_round(taken_qty, precision_rounding=ml.product_uom_id.rounding) + ml.qty_done += taken_qty + if ml.product_uom_id != self.product_uom: + taken_qty = ml.product_uom_id._compute_quantity(ml_qty, self.product_uom, round=False) + qty -= taken_qty + + if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) <= 0: + break + if float_compare(qty, 0.0, precision_rounding=self.product_uom.rounding) > 0: + vals = self._prepare_move_line_vals(quantity=0) + vals['qty_done'] = qty + ml = self.env['stock.move.line'].create(vals) diff --git a/addons/stock/report/report_deliveryslip.xml b/addons/stock/report/report_deliveryslip.xml index ab212f45598..ea7f91c6583 100644 --- a/addons/stock/report/report_deliveryslip.xml +++ b/addons/stock/report/report_deliveryslip.xml @@ -56,7 +56,15 @@ <tbody> <t t-set="lines" t-value="o.move_lines.filtered(lambda x: x.product_uom_qty)"/> <tr t-foreach="lines" t-as="move"> - <td><span t-field="move.product_id"/></td> + <td> + <span t-field="move.product_id"/> + <p t-if="o.picking_type_code == 'outgoing'"> + <span t-field="move.product_id.sudo().description_pickingout"/> + </p> + <p t-if="o.picking_type_code == 'incoming'"> + <span t-field="move.product_id.sudo().description_pickingin"/> + </p> + </td> <td> <span t-field="move.product_uom_qty"/> <span t-field="move.product_uom"/> diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml index df65bddb74c..3cf112f2f55 100644 --- a/addons/stock_account/views/stock_account_views.xml +++ b/addons/stock_account/views/stock_account_views.xml @@ -73,7 +73,7 @@ <field name="model">product.product</field> <field name="arch" type="xml"> <tree> - <field name="name"/> + <field name="display_name"/> <field name="qty_at_date"/> <field name="uom_id" groups="product.group_uom"/> <field name="currency_id" invisible="1"/> diff --git a/addons/transifex/models/ir_translation.py b/addons/transifex/models/ir_translation.py index 316fbba47b1..c19f66c39c1 100644 --- a/addons/transifex/models/ir_translation.py +++ b/addons/transifex/models/ir_translation.py @@ -86,6 +86,6 @@ class IrTranslation(models.Model): 'module': translation.module, 'src': werkzeug.url_quote_plus( "text:'" + - translation.source[:50].replace("'", "\'") + + translation.source[:50].replace("'", "\\'") + "'"), } diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 4577a21ddff..94d84399509 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -178,11 +178,11 @@ def module_installed_bypass_session(dbname): return {} def module_boot(db=None): - server_wide_modules = {'base', 'web'} | set(odoo.conf.server_wide_modules) - serverside = [] + server_wide_modules = odoo.conf.server_wide_modules or [] + serverside = ['base', 'web'] dbside = [] for i in server_wide_modules: - if i in http.addons_manifest: + if i in http.addons_manifest and i not in serverside: serverside.append(i) monodb = db or db_monodb() if monodb: @@ -1308,7 +1308,7 @@ class Export(http.Controller): 'relation_field': field.get('relation_field')} records.append(record) - if len(name.split('/')) < 3 and 'relation' in field: + if len(id.split('/')) < 3 and 'relation' in field: ref = field.pop('relation') record['value'] += '/id' record['params'] = {'model': ref, 'prefix': id, 'name': name, 'parent_field': field} diff --git a/addons/web/static/lib/py.js/lib/py_extras.js b/addons/web/static/lib/py.js/lib/py_extras.js index 60eb3be7f7f..402acb37871 100644 --- a/addons/web/static/lib/py.js/lib/py_extras.js +++ b/addons/web/static/lib/py.js/lib/py_extras.js @@ -866,13 +866,15 @@ var relativedelta = py.type('relativedelta', null, { this._fix(); }, _fix: function () { + var self = this; var months = asJS(this.ops.months); if (Math.abs(months) > 11) { var s = months > 0 ? 1 : -1; - var r = divmod(months * s, 12); - this.ops.months = py.float.fromJSON(r.mod*s); - this.ops.years = py.float.fromJSON( - asJS(this.ops.years) + r.div*s); + divmod(months * s, 12, function (years, months) { + self.ops.months = py.float.fromJSON(months*s); + self.ops.years = py.float.fromJSON( + asJS(self.ops.years) + years*s); + }); } this._has_time = 0; }, diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js index 243a28bb937..d3ca9a653fd 100644 --- a/addons/web/static/src/js/fields/basic_fields.js +++ b/addons/web/static/src/js/fields/basic_fields.js @@ -512,7 +512,7 @@ var FieldDate = InputField.extend({ this.datewidget = this._makeDatePicker(); this.datewidget.on('datetime_changed', this, function () { var value = this._getValue(); - if ((!value && this.value) || (value && !value.isSame(this.value))) { + if ((!value && this.value) || (value && !this._isSameValue(value))) { this._setValue(value); } }); @@ -2639,19 +2639,18 @@ var FieldDomain = AbstractField.extend({ // Convert char value to array value var value = this.value || "[]"; - var domain = Domain.prototype.stringToArray(value); // Create the domain selector or change the value of the current one... var def; if (!this.domainSelector) { - this.domainSelector = new DomainSelector(this, this._domainModel, domain, { + this.domainSelector = new DomainSelector(this, this._domainModel, value, { readonly: this.mode === "readonly" || this.inDialog, filters: this.fsFilters, debugMode: session.debug, }); def = this.domainSelector.prependTo(this.$el); } else { - def = this.domainSelector.setDomain(domain); + def = this.domainSelector.setDomain(value); } // ... then replace the other content (matched records, etc) return def.then(this._replaceContent.bind(this)); diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index ee511a390b0..437052d44e2 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -616,10 +616,11 @@ var FieldMany2One = AbstractField.extend({ * @param {OdooEvent} ev */ _onInputKeyup: function (ev) { - if (ev.which === $.ui.keyCode.ENTER) { - // If we pressed enter, we want to prevent _onInputFocusout from + if (ev.which === $.ui.keyCode.ENTER || ev.which === $.ui.keyCode.TAB) { + // If we pressed enter or tab, we want to prevent _onInputFocusout from // executing since it would open a M2O dialog to request // confirmation that the many2one is not properly set. + // It's a case that is already handled by the autocomplete lib. return; } this.isDirty = true; diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js index c095871b75d..fbb4b0b122c 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -2570,11 +2570,11 @@ var BasicModel = AbstractModel.extend({ } var def = $.Deferred(); - + var evalContext = this._getEvalContext(record); this._rpc({ model: domainModel, method: 'search_count', - args: [Domain.prototype.stringToArray(domainValue)], + args: [Domain.prototype.stringToArray(domainValue, evalContext)], context: context }) .then(_.identity, function (error, e) { diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index 6cd3fb836fd..9c0bcdf37fe 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -61,11 +61,15 @@ var KanbanModel = BasicModel.extend({ if (!groupByField || groupByField.type !== 'many2one') { return $.Deferred().reject(); // only supported when grouped on m2o } + // ignore action default which may be for another model + var context = _.pick(parent.context, function (value, key) { + return key.slice(0,8) !== 'default_'; + }); return this._rpc({ model: groupByField.relation, method: 'name_create', args: [name], - context: parent.context, // todo: combine with view context + context: context, // todo: combine with view context }) .then(function (result) { var newGroup = self._makeDataPoint({ @@ -272,6 +276,9 @@ var KanbanModel = BasicModel.extend({ options.groupBy = this.defaultGroupedBy; } var def = this._super(id, options); + if (options && options.loadMoreOffset) { + return def; + } return this._reloadProgressBarGroupFromRecord(id, def); }, /** diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js index 942b769e23d..b343f044bc2 100644 --- a/addons/web/static/src/js/views/list/list_renderer.js +++ b/addons/web/static/src/js/views/list/list_renderer.js @@ -663,6 +663,8 @@ var ListRenderer = BasicRenderer.extend({ this._computeAggregates(); $table.toggleClass('o_list_view_grouped', this.isGrouped); $table.toggleClass('o_list_view_ungrouped', !this.isGrouped); + this.hasHandle = this.state.orderedBy.length === 0 || + this.state.orderedBy[0].name === this.handleField; if (this.isGrouped) { $table .append(this._renderHeader(true)) diff --git a/addons/web/static/src/js/views/pivot/pivot_renderer.js b/addons/web/static/src/js/views/pivot/pivot_renderer.js index 370c0c009d5..68f2f4bf86d 100644 --- a/addons/web/static/src/js/views/pivot/pivot_renderer.js +++ b/addons/web/static/src/js/views/pivot/pivot_renderer.js @@ -24,6 +24,7 @@ var PivotRenderer = AbstractRenderer.extend({ init: function (parent, state, params) { this._super.apply(this, arguments); this.compare = state.compare; + this.fieldWidgets = params.widgets || {}; this.timeRangeDescription = params.timeRangeDescription; this.comparisonTimeRangeDescription = params.comparisonTimeRangeDescription; }, @@ -208,7 +209,7 @@ var PivotRenderer = AbstractRenderer.extend({ for (j = 0; j < length; j++) { value = rows[i].values[j]; name = this.state.measures[j % nbrMeasures]; - formatter = field_utils.format[measureTypes[j % nbrMeasures]]; + formatter = field_utils.format[this.fieldWidgets[name] || measureTypes[j % nbrMeasures]]; measure = this.state.fields[name]; if (this.compare) { if (value instanceof Object) { diff --git a/addons/web/static/src/js/views/pivot/pivot_view.js b/addons/web/static/src/js/views/pivot/pivot_view.js index 0bd973b4468..5e16d5ad493 100644 --- a/addons/web/static/src/js/views/pivot/pivot_view.js +++ b/addons/web/static/src/js/views/pivot/pivot_view.js @@ -43,6 +43,7 @@ var PivotView = AbstractView.extend({ var measures = {}; var groupableFields = {}; + var widgets = {}; this.fields.__count = {string: _t("Count"), type: "integer"}; _.each(this.fields, function (field, name) { @@ -64,6 +65,10 @@ var PivotView = AbstractView.extend({ name += ':' + field.attrs.interval; } + if (field.attrs.widget) { + widgets[name] = field.attrs.widget; + } + // add active measures to the measure list. This is very rarely // necessary, but it can be useful if one is working with a // functional field non stored, but in a model with an overrided @@ -96,6 +101,8 @@ var PivotView = AbstractView.extend({ this.loadParams.fields = this.fields; this.loadParams.default_order = params.default_order || this.arch.attrs.default_order; + this.rendererParams.widgets = widgets; + this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled"); this.controllerParams.enableLinking = !this.arch.attrs.disable_linking; this.controllerParams.measures = measures; diff --git a/addons/web/static/src/js/widgets/domain_selector.js b/addons/web/static/src/js/widgets/domain_selector.js index 948706cbd07..215c37cc128 100644 --- a/addons/web/static/src/js/widgets/domain_selector.js +++ b/addons/web/static/src/js/widgets/domain_selector.js @@ -179,18 +179,12 @@ var DomainTree = DomainNode.extend({ * operator from the domain. * @see DomainTree._addFlattenedChildren */ - init: function (parent, model, domain, options) { + init: function (parent, model, domain) { this._super.apply(this, arguments); - try { - domain = Domain.prototype.stringToArray(domain); - } catch (err) { - // TODO: domain could contain `parent` for example, which is - // currently not handled by the DomainSelector - this.invalidDomain = true; - this.children = []; - return; + var parsedDomain = this._parseDomain(domain); + if (parsedDomain) { + this._initialize(parsedDomain); } - this._initialize(domain); }, /** * @see DomainNode.start @@ -388,6 +382,23 @@ var DomainTree = DomainNode.extend({ }); }).bind(this)); }, + /** + * @param {string} domain + * @returns {Array[]} + */ + _parseDomain: function (domain) { + var parsedDomain = false; + try { + parsedDomain = Domain.prototype.stringToArray(domain); + this.invalidDomain = false; + } catch (err) { + // TODO: domain could contain `parent` for example, which is + // currently not handled by the DomainSelector + this.invalidDomain = true; + this.children = []; + } + return parsedDomain; + }, //-------------------------------------------------------------------------- // Handlers @@ -473,14 +484,17 @@ var DomainSelector = DomainTree.extend({ * If the internal domain value was already equal to the given one, this * does nothing. * - * @param {Array|string} domain + * @param {string} domain * @returns {Deferred} resolved when the rerendering is finished */ setDomain: function (domain) { - if (Domain.prototype.arrayToString(domain) === Domain.prototype.arrayToString(this.getDomain())) { + if (domain === Domain.prototype.arrayToString(this.getDomain())) { return $.when(); } - return this._redraw(domain); + var parsedDomain = this._parseDomain(domain); + if (parsedDomain) { + return this._redraw(parsedDomain); + } }, //-------------------------------------------------------------------------- diff --git a/addons/web/static/tests/core/py_utils_tests.js b/addons/web/static/tests/core/py_utils_tests.js index eee1124b22f..00b8a0e01c9 100644 --- a/addons/web/static/tests/core/py_utils_tests.js +++ b/addons/web/static/tests/core/py_utils_tests.js @@ -428,7 +428,7 @@ QUnit.module('core', function () { }); QUnit.test('relativedelta', function (assert) { - assert.expect(5); + assert.expect(7); assert.strictEqual( py.eval("(datetime.date(2012, 2, 15) + relativedelta(days=-1)).strftime('%Y-%m-%d 23:59:59')", @@ -450,6 +450,14 @@ QUnit.module('core', function () { py.eval("(datetime.date(2015,2,5)+relativedelta(days=-6,weekday=0)).strftime('%Y-%m-%d')", pyUtils.context()), '2015-02-02'); + assert.strictEqual( + py.eval("(datetime.date(2018, 2, 1) + relativedelta(years=7, months=42, days=42)).strftime('%Y-%m-%d')", + pyEval.context()), + '2028-09-12'); + assert.strictEqual( + py.eval("(datetime.date(2018, 2, 1) + relativedelta(years=-7, months=-42, days=-42)).strftime('%Y-%m-%d')", + pyEval.context()), + '2007-06-20'); }); diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index 830855d8c1d..548a9cfec23 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -4734,6 +4734,33 @@ QUnit.module('basic_fields', { QUnit.module('FieldDomain'); + QUnit.test('The domain editor should not crash the view when given a dynamic filter', function (assert) { + //dynamic filters (containing variables, such as uid, parent or today) + //are not handled by the domain editor, but it shouldn't crash the view + assert.expect(1); + + this.data.partner.records[0].foo = '[["int_field", "=", uid]]'; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<field name="foo" widget="domain" options="{\'model\': \'partner\'}"/>' + + '<field name="int_field" invisible="1"/>' + + '</form>', + res_id: 1, + session: { + user_context: {uid: 14}, + }, + }); + + assert.strictEqual(form.$('.o_read_mode').text(), "This domain is not supported.", + "The widget should not crash the view, but gracefully admit its failure."); + form.destroy(); + }); + QUnit.test('basic domain field usage is ok', function (assert) { assert.expect(6); @@ -4883,6 +4910,43 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('domain field can be reset with a new domain (from onchange)', function (assert) { + assert.expect(2); + + this.data.partner.records[0].foo = '[]'; + this.data.partner.onchanges = { + display_name: function (obj) { + obj.foo = '[["id", "=", 1]]'; + }, + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: + '<form>' + + '<field name="display_name"/>' + + '<field name="foo" widget="domain" options="{\'model\': \'partner\'}"/>' + + '</form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + assert.equal(form.$('.o_domain_show_selection_button').text().trim(), '5 record(s)', + "the domain being empty, there should be 5 records"); + + // update display_name to trigger the onchange and reset foo + form.$('.o_field_widget[name=display_name]').val('new value').trigger('input'); + + assert.equal(form.$('.o_domain_show_selection_button').text().trim(), '1 record(s)', + "the domain has changed, there should be only 1 record"); + + form.destroy(); + }); + QUnit.test('domain field: handle false domain as []', function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index e5a0ad43a70..faf5c07417d 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -2230,7 +2230,7 @@ QUnit.module('relational_fields', { }); }); - QUnit.test('pressing ENTER on a \'no_quick_create\' many2one should not trigger M2ODialog', function (assert) { + QUnit.test('pressing ENTER on a \'no_quick_create\' many2one should open a M2ODialog', function (assert) { var done = assert.async(); assert.expect(2); @@ -2281,6 +2281,68 @@ QUnit.module('relational_fields', { }); }); + QUnit.test('select a value by pressing TAB on a many2one with onchange', function (assert) { + var done = assert.async(); + assert.expect(3); + + this.data.partner.onchanges.trululu = function () {}; + + var M2O_DELAY = relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY; + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0; + var def = $.Deferred(); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="trululu"/>' + + '<field name="display_name"/>' + + '</form>', + mockRPC: function (route, args) { + var result = this._super.apply(this, arguments); + if (args.method === 'onchange') { + return $.when(def).then(_.constant(result)); + } + return result; + }, + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + var $input = form.$('.o_field_many2one input'); + $input.val("first").trigger('input'); + concurrency.delay(0).then(function () { + var evOptions = { + which: $.ui.keyCode.TAB, + keyCode: $.ui.keyCode.TAB, + }; + $input.trigger($.Event('keydown', evOptions)); + $input.trigger($.Event('keypress', evOptions)); + $input.trigger($.Event('keyup', evOptions)); + + // simulate a focusout (e.g. because the user clicks outside) + // before the onchange returns + form.$('.o_field_char').focus(); + + assert.strictEqual($('.modal').length, 0, + "there shouldn't be any modal in body"); + + // unlock the onchange + def.resolve(); + + assert.strictEqual($input.val(), 'first record', + "first record should have been selected"); + assert.strictEqual($('.modal').length, 0, + "there shouldn't be any modal in body"); + relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY; + form.destroy(); + done(); + }); + }); + QUnit.test('many2one in editable list + onchange, with enter [REQUIRE FOCUS]', function (assert) { assert.expect(6); var done = assert.async(); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index f05c89c1a7d..f5ddc0982d0 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -894,7 +894,7 @@ var MockServer = Class.extend({ var field = args.field ? args.field : 'sequence'; var records = this.data[args.model].records; for (var i in args.ids) { - var record = _.findWhere(this.data[args.model].records, {id: args.ids[i]}); + var record = _.findWhere(records, {id: args.ids[i]}); record[field] = Number(i) + offset; } }, diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index e0c2ccaa2bb..399095f9427 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -1490,6 +1490,43 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('m2o group record created does not use action defaults', function (assert) { + assert.expect(1); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: '<kanban class="o_kanban_test" on_create="quick_create">' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="foo"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['product_id'], + mockRPC: function (route, args) { + if (args.method === 'name_create') { + assert.notOk(_.has(args.kwargs.context, 'default_name'), + "default_* should be removed from context"); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + context: { + default_name: 'default name partner', + }, + }, + }); + kanban.renderButtons(); + + kanban.$('.o_column_quick_create input').val('new product'); + kanban.$('.o_column_quick_create button.o_kanban_add').click(); + + kanban.destroy(); + }); + + QUnit.test('quick create record fail in grouped', function (assert) { assert.expect(8); @@ -3665,6 +3702,37 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('column progressbars are working with load more', function (assert) { + assert.expect(1); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + domain: [['bar', '=', true]], + arch: + '<kanban limit="1">' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\'/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="id"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + groupBy: ['bar'], + }); + + // we have 1 record shown, load 2 more and check it worked + kanban.$('.o_kanban_group').find('.o_kanban_load_more').click(); + kanban.$('.o_kanban_group').find('.o_kanban_load_more').click(); + var shownIDs = _.map(kanban.$('.o_kanban_record'), function(record) { + return parseInt(record.innerText); + }); + assert.deepEqual(shownIDs, [1, 2, 3], "intended records are loaded"); + + kanban.destroy(); + }); + // XXX test deactivated in saas~11.2 as kanban archiving has been removed (and readded in saas~11.4) QUnit.skip('column progressbars on archiving records update counter', function (assert) { assert.expect(4); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 567e722c135..e27052506e4 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -3312,6 +3312,76 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('editable list, handle widget locks and unlocks on sort', function (assert) { + assert.expect(6); + + // we need another sortable field to lock/unlock the handle + this.data.foo.fields.amount.sortable = true; + // resequence makes sense on a sequence field, not on arbitrary fields + this.data.foo.records[0].int_field = 0; + this.data.foo.records[1].int_field = 1; + this.data.foo.records[2].int_field = 2; + this.data.foo.records[3].int_field = 3; + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top" default_order="int_field">' + + '<field name="int_field" widget="handle"/>' + + '<field name="amount" widget="float"/>' + + '</tree>', + }); + + assert.strictEqual(list.$('tbody').text(), '1200.00500.00300.000.00', + "default should be sorted by int_field"); + + // Drag and drop the fourth line in second position + testUtils.dragAndDrop( + list.$('.ui-sortable-handle').eq(3), + list.$('tbody tr').first(), + {position: 'bottom'} + ); + + // Handle should be unlocked at this point + assert.strictEqual(list.$('tbody').text(), '1200.000.00500.00300.00', + "drag and drop should have succeeded, as the handle is unlocked"); + + // Sorting by a field different for int_field should lock the handle + list.$('.o_column_sortable').eq(1).click(); + + assert.strictEqual(list.$('tbody').text(), '0.00300.00500.001200.00', + "should have been sorted by amount"); + + // Drag and drop the fourth line in second position (not) + testUtils.dragAndDrop( + list.$('.ui-sortable-handle').eq(3), + list.$('tbody tr').first(), + {position: 'bottom'} + ); + + assert.strictEqual(list.$('tbody').text(), '0.00300.00500.001200.00', + "drag and drop should have failed as the handle is locked"); + + // Sorting by int_field should unlock the handle + list.$('.o_column_sortable').eq(0).click(); + + assert.strictEqual(list.$('tbody').text(), '1200.000.00500.00300.00', + "records should be ordered as per the previous resequence"); + + // Drag and drop the fourth line in second position + testUtils.dragAndDrop( + list.$('.ui-sortable-handle').eq(3), + list.$('tbody tr').first(), + {position: 'bottom'} + ); + + assert.strictEqual(list.$('tbody').text(), '1200.00300.000.00500.00', + "drag and drop should have worked as the handle is unlocked"); + + list.destroy(); + }); + QUnit.test('editable list with handle widget with slow network', function (assert) { assert.expect(15); diff --git a/addons/web/static/tests/views/pivot_tests.js b/addons/web/static/tests/views/pivot_tests.js index fe318efd9e2..66e5a1c8cc5 100644 --- a/addons/web/static/tests/views/pivot_tests.js +++ b/addons/web/static/tests/views/pivot_tests.js @@ -113,6 +113,23 @@ QUnit.module('Views', { pivot.destroy(); }); + QUnit.test('pivot rendering with widget', function (assert) { + assert.expect(1); + + var pivot = createView({ + View: PivotView, + model: "partner", + data: this.data, + arch: '<pivot string="Partners">' + + '<field name="foo" type="measure" widget="float_time"/>' + + '</pivot>', + }); + + assert.strictEqual(pivot.$('td.o_pivot_cell_value:contains(32:00)').length, 1, + "should contain a pivot cell with the sum of all records"); + pivot.destroy(); + }); + QUnit.test('pivot view without "string" attribute', function (assert) { assert.expect(1); diff --git a/addons/web_editor/static/lib/summernote/src/js/EventHandler.js b/addons/web_editor/static/lib/summernote/src/js/EventHandler.js index efa57f8111c..578bcfd7a0c 100644 --- a/addons/web_editor/static/lib/summernote/src/js/EventHandler.js +++ b/addons/web_editor/static/lib/summernote/src/js/EventHandler.js @@ -207,11 +207,15 @@ define([ var layoutInfo = dom.makeLayoutInfo(target); /* ODOO: (start_modification */ var $editable = layoutInfo.editable(); - if (!event.isDefaultPrevented()) { - modules.editor.saveRange($editable); + if (event.setStyleInfoFromEditable) { + var styleInfo = modules.editor.styleFromNode($editable); + } else { + if (!event.isDefaultPrevented()) { + modules.editor.saveRange($editable); + } + var styleInfo = modules.editor.currentStyle(target); } /* ODOO: end_modification) */ - var styleInfo = modules.editor.currentStyle(target); self.updateStyleInfo(styleInfo, layoutInfo); }, 0); }; diff --git a/addons/web_editor/static/src/js/backend/fields.js b/addons/web_editor/static/src/js/backend/fields.js index 2bed78bf84f..7742f49bcde 100644 --- a/addons/web_editor/static/src/js/backend/fields.js +++ b/addons/web_editor/static/src/js/backend/fields.js @@ -225,7 +225,8 @@ var FieldTextHtmlSimple = basic_fields.DebouncedField.extend(TranslatableFieldMi this.$content = this.$('.note-editable:first'); this.$content.html(this._textToHtml(this.value)); // trigger a mouseup to refresh the editor toolbar - this.$content.trigger('mouseup'); + var mouseupEvent = $.Event('mouseup', {'setStyleInfoFromEditable': true}); + this.$content.trigger(mouseupEvent); if (this.nodeOptions['style-inline']) { transcoder.styleToClass(this.$content); transcoder.imgToFont(this.$content); diff --git a/addons/web_editor/static/src/js/editor/rte.js b/addons/web_editor/static/src/js/editor/rte.js index 06caaf65990..11001cc7a06 100644 --- a/addons/web_editor/static/src/js/editor/rte.js +++ b/addons/web_editor/static/src/js/editor/rte.js @@ -157,7 +157,7 @@ var History = function History($editable) { if (aUndo[pos]) { pos = Math.min(pos, aUndo.length); - aUndo.splice(Math.max(pos,1), aUndo.length); + aUndo.splice(pos, aUndo.length); } // => make a snap when the user change editable zone (because: don't make snap for each keydown) diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js index f420f951633..a2f0f6a308f 100644 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -1120,7 +1120,14 @@ $.summernote.pluginEvents.untab = function (event, editor, layoutInfo) { $.summernote.pluginEvents.up = function (event, editor, layoutInfo) { var r = range.create(); var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc); - if (!r.isOnCell() || (!dom.isCell(node) && dom.hasContentBefore(node) && (!dom.isBR(dom.hasContentBefore(node)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentBefore(dom.hasContentBefore(node))))) { + if (!r.isOnCell()) { + return; + } + // check if an ancestor between node and cell has content before + var ancestor = dom.ancestor(node, function (ancestorNode) { + return dom.hasContentBefore(ancestorNode) || dom.isCell(ancestorNode); + }); + if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentBefore(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentBefore(dom.hasContentBefore(ancestor)))) { return; } event.preventDefault(); @@ -1137,7 +1144,14 @@ $.summernote.pluginEvents.up = function (event, editor, layoutInfo) { $.summernote.pluginEvents.down = function (event, editor, layoutInfo) { var r = range.create(); var node = dom.firstChild(r.sc.childNodes[r.so] || r.sc); - if (!r.isOnCell() || (!dom.isCell(node) && dom.hasContentAfter(node) && (!dom.isBR(dom.hasContentAfter(node)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentAfter(dom.hasContentAfter(node))))) { + if (!r.isOnCell()) { + return; + } + // check if an ancestor between node and cell has content after + var ancestor = dom.ancestor(node, function (ancestorNode) { + return dom.hasContentAfter(ancestorNode) || dom.isCell(ancestorNode); + }); + if (!dom.isCell(ancestor) && (!dom.isBR(dom.hasContentAfter(ancestor)) || !dom.isText(node) || dom.isVisibleText(node) || dom.hasContentAfter(dom.hasContentAfter(ancestor)))) { return; } event.preventDefault(); diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js index 3ccba2da261..3e924cd0a2c 100644 --- a/addons/web_editor/static/src/js/widgets/widgets.js +++ b/addons/web_editor/static/src/js/widgets/widgets.js @@ -516,7 +516,7 @@ var ImageWidget = MediaWidget.extend({ self.$('.well > span').remove(); self.$('.well > div').show(); _.each(attachments, function (record) { - record.src = record.url || '/web/image/' + record.id; + record.src = record.url || _.str.sprintf('/web/image/%s/%s', record.id, encodeURI(record.name)); // Name is added for SEO purposes record.isDocument = !(/gif|jpe|jpg|png/.test(record.mimetype)); }); if (error || !attachments.length) { diff --git a/addons/website/models/website.py b/addons/website/models/website.py index e1a94984b46..2b24d545168 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -1002,7 +1002,7 @@ class Page(models.Model): pages_linked_to_iruiview = self.search( [('view_id', '=', page.view_id.id), ('id', '!=', page.id)] ) - if len(pages_linked_to_iruiview) == 0 and not page.view_id.inherit_children_ids: + if not pages_linked_to_iruiview and not page.view_id.inherit_children_ids: # If there is no other pages linked to that ir_ui_view, we can delete the ir_ui_view page.view_id.unlink() return super(Page, self).unlink() diff --git a/addons/website_event/views/event_views.xml b/addons/website_event/views/event_views.xml index 6b1f1dc8456..7e4df97c36a 100644 --- a/addons/website_event/views/event_views.xml +++ b/addons/website_event/views/event_views.xml @@ -50,12 +50,12 @@ <button name="action_open_badge_editor" type="object" states="confirm" - string="Edit Badges" + string="Preview Badges" class="oe_highlight"/> <button name="action_open_badge_editor" type="object" states="done" - string="Edit Badges"/> + string="Preview Badges"/> </xpath> </field> </record> diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py index 0c58cfdecf9..8fb7e3c1435 100644 --- a/addons/website_sale/models/sale_order.py +++ b/addons/website_sale/models/sale_order.py @@ -98,6 +98,7 @@ class SaleOrder(models.Model): 'order_id': order_id, 'product_uom': product.uom_id.id, 'price_unit': pu, + 'customer_lead': product.sale_delay, } @api.multi diff --git a/doc/cla/corporate/d2go.md b/doc/cla/corporate/d2go.md new file mode 100644 index 00000000000..f65bc6f5b8b --- /dev/null +++ b/doc/cla/corporate/d2go.md @@ -0,0 +1,15 @@ +Canada, August 27th, 2018 + +D2GO SOLUTIONS INC 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, + +Louis-Philippe Papillon lppapillon@attrix.ca https://github.com/stechnique + +List of contributors: + +Louis-Philippe Papillon lppapillon@attrix.ca https://github.com/stechnique diff --git a/doc/cla/individual/abdur-rahmaanJ.md b/doc/cla/individual/abdur-rahmaanJ.md new file mode 100644 index 00000000000..257221f1a91 --- /dev/null +++ b/doc/cla/individual/abdur-rahmaanJ.md @@ -0,0 +1,11 @@ +Mauritius, 2018-08-27 + +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, + +Abdur-Rahmaan Janhangeer cryptolabour@gmail.com https://github.com/Abdur-RahmaanJ diff --git a/doc/cla/individual/dek-odoo.md b/doc/cla/individual/dek-odoo.md new file mode 100644 index 00000000000..295973aee65 --- /dev/null +++ b/doc/cla/individual/dek-odoo.md @@ -0,0 +1,11 @@ +India, 2018-08-26 + +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, + +Devendra kavthekar dkatodoo@gmail.com https://github.com/dek-odoo diff --git a/doc/howtos/backend.rst b/doc/howtos/backend.rst index 933309ad190..01f2bcd7ce9 100644 --- a/doc/howtos/backend.rst +++ b/doc/howtos/backend.rst @@ -1760,7 +1760,7 @@ Examples can be easily adapted from XML-RPC to JSON-RPC. systems without *explicitly* going through XML-RPC or JSON-RPC, such as: * https://github.com/akretion/ooor - * https://github.com/syleam/openobject-library + * https://github.com/OCA/odoorpc * https://github.com/nicolas-van/openerp-client-lib * http://pythonhosted.org/OdooRPC * https://github.com/abhishek-jaiswal/php-openerp-lib diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst index c1f2ae9cb0d..8089fd1205e 100644 --- a/doc/reference/javascript_reference.rst +++ b/doc/reference/javascript_reference.rst @@ -446,7 +446,7 @@ of them in the new class. }, }; - var Hamster = Hamster.extend(DanceMixin, { + var Hamster = Animal.extend(DanceMixin, { sleep: function () { console.log('sleeping'); }, diff --git a/doc/reference/orm.rst b/doc/reference/orm.rst index 14dfdb4c058..73b96552c21 100644 --- a/doc/reference/orm.rst +++ b/doc/reference/orm.rst @@ -505,8 +505,8 @@ necessary to clear caches when using ``CREATE``, ``UPDATE`` or ``DELETE`` in SQL, but not ``SELECT`` (which simply reads the database). Clearing caches can be performed using the -:meth:`~odoo.api.Environment.invalidate_all` method of the -:class:`~odoo.api.Environment` object. +:meth:`~odoo.models.BaseModel.invalidate_cache` method of the +:class:`~odoo.models.BaseModel` object. .. _reference/orm/oldapi: diff --git a/odoo/addons/base/data/ir_module_category_data.xml b/odoo/addons/base/data/ir_module_category_data.xml index 53fe9a4ba27..7d8b3803208 100644 --- a/odoo/addons/base/data/ir_module_category_data.xml +++ b/odoo/addons/base/data/ir_module_category_data.xml @@ -66,6 +66,12 @@ <field name="sequence">9</field> </record> + <record model="ir.module.category" id="module_category_human_resources"> + <field name="name">Employees</field> + <field name="description">Helps you manage your employees.</field> + <field name="sequence">9</field> + </record> + <record model="ir.module.category" id="module_category_hr_holidays"> <field name="name">Leaves</field> <field name="description">Helps you manage your leaves.</field> diff --git a/odoo/addons/base/data/res_country_data.xml b/odoo/addons/base/data/res_country_data.xml index 1648d24430c..54497ad8f97 100644 --- a/odoo/addons/base/data/res_country_data.xml +++ b/odoo/addons/base/data/res_country_data.xml @@ -1787,6 +1787,13 @@ <field name="currency_id" ref="ZWD" /> <field eval="263" name="phone_code" /> </record> + <record id="xk" model="res.country"> + <field name="name">Kosovo</field> + <field name="code">xk</field> + <field file="base/static/img/country_flags/xk.png" name="image" type="base64" /> + <field name="currency_id" ref="EUR" /> + <field eval="383" name="phone_code" /> + </record> <record id="europe" model="res.country.group"> <field name="name">Europe</field> diff --git a/odoo/addons/base/models/assetsbundle.py b/odoo/addons/base/models/assetsbundle.py index 121ba2fd968..719aa71e07a 100644 --- a/odoo/addons/base/models/assetsbundle.py +++ b/odoo/addons/base/models/assetsbundle.py @@ -449,6 +449,7 @@ class AssetsBundle(object): if not self.css_errors and old_attachments: old_attachments.unlink() + old_attachments = None fragments = self.rx_css_split.split(compiled) at_rules = fragments.pop(0) diff --git a/odoo/addons/base/models/ir_attachment.py b/odoo/addons/base/models/ir_attachment.py index d1019d800f5..94760a13b35 100644 --- a/odoo/addons/base/models/ir_attachment.py +++ b/odoo/addons/base/models/ir_attachment.py @@ -364,6 +364,11 @@ class IrAttachment(models.Model): if res_model not in self.env: require_employee = True continue + elif res_model == 'res.users' and len(res_ids) == 1 and self._uid == list(res_ids)[0]: + # by default a user cannot write on itself, despite the list of writeable fields + # e.g. in the case of a user inserting an image into his image signature + # we need to bypass this check which would needlessly throw us away + continue records = self.env[res_model].browse(res_ids).exists() if len(records) < len(res_ids): require_employee = True diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py index 32c7738665f..60764cdd24d 100644 --- a/odoo/addons/base/models/ir_qweb_fields.py +++ b/odoo/addons/base/models/ir_qweb_fields.py @@ -401,7 +401,11 @@ class MonetaryConverter(models.AbstractModel): if options.get('from_currency'): date = options.get('date') or fields.Date.today() - company = options.get('company_id') or self.env.user.company_id + company_id = options.get('company_id') + if company_id: + company = self.env['res.company'].browse(company_id) + else: + company = self.env.user.company_id value = options['from_currency']._convert(value, display_currency, company, date) lang = self.user_lang() diff --git a/odoo/addons/base/models/ir_ui_view.py b/odoo/addons/base/models/ir_ui_view.py index 312d76f4d2d..00bf2cec474 100644 --- a/odoo/addons/base/models/ir_ui_view.py +++ b/odoo/addons/base/models/ir_ui_view.py @@ -949,6 +949,12 @@ actual arch. 'datetime', 'relativedelta', 'current_date', + 'abs', + 'len', + 'bool', + 'float', + 'str', + 'unicode', } def get_attrs_field_names(self, arch, model, editable): diff --git a/odoo/addons/base/models/qweb.py b/odoo/addons/base/models/qweb.py index 3a3bd531a4b..51693c55cb1 100644 --- a/odoo/addons/base/models/qweb.py +++ b/odoo/addons/base/models/qweb.py @@ -12,6 +12,7 @@ from textwrap import dedent import itertools from lxml import etree, html +from psycopg2.extensions import TransactionRollbackError import werkzeug from werkzeug.utils import escape as _escape @@ -341,7 +342,7 @@ class QWeb(object): new.update(values) try: return compiled(self, append, new, options, log) - except QWebException as e: + except (QWebException, TransactionRollbackError) as e: raise e except Exception as e: path = log['last_path_node'] diff --git a/odoo/addons/base/models/res_config.py b/odoo/addons/base/models/res_config.py index ab593780f61..ece9100e2e9 100644 --- a/odoo/addons/base/models/res_config.py +++ b/odoo/addons/base/models/res_config.py @@ -575,7 +575,7 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin) IrDefault.set(model, field, value) # group fields: modify group / implied groups - current_settings = self.new(self.default_get(list(self.fields_get()))) + current_settings = self.default_get(list(self.fields_get())) with self.env.norecompute(): for name, groups, implied_group in classified['group']: if self[name] == current_settings[name]: diff --git a/odoo/addons/base/static/img/country_flags/xk.png b/odoo/addons/base/static/img/country_flags/xk.png Binary files differnew file mode 100644 index 00000000000..cbabf92cdcf --- /dev/null +++ b/odoo/addons/base/static/img/country_flags/xk.png diff --git a/odoo/addons/base/wizard/base_update_translations.py b/odoo/addons/base/wizard/base_update_translations.py index f9c9c0c8555..145176e2db5 100644 --- a/odoo/addons/base/wizard/base_update_translations.py +++ b/odoo/addons/base/wizard/base_update_translations.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import contextlib -import io +import tempfile from odoo import api, fields, models, tools, _ from odoo.exceptions import UserError @@ -30,7 +29,7 @@ class BaseUpdateTranslations(models.TransientModel): def act_update(self): this = self[0] lang_name = self._get_lang_name(this.lang) - with contextlib.closing(io.BytesIO()) as buf: - tools.trans_export(this.lang, ['all'], buf, 'csv', self._cr) - tools.trans_load_data(self._cr, buf, 'csv', this.lang, lang_name=lang_name) + with tempfile.NamedTemporaryFile() as buf: + tools.trans_export(this.lang, ['all'], buf, 'po', self._cr) + tools.trans_load_data(self._cr, buf, 'po', this.lang, lang_name=lang_name) return {'type': 'ir.actions.act_window_close'} diff --git a/odoo/tests/addons/test_translation_import/__init__.py b/odoo/addons/test_translation_import/__init__.py index a0fdc10fe11..a0fdc10fe11 100644 --- a/odoo/tests/addons/test_translation_import/__init__.py +++ b/odoo/addons/test_translation_import/__init__.py diff --git a/odoo/tests/addons/test_translation_import/__manifest__.py b/odoo/addons/test_translation_import/__manifest__.py index 73a0c450032..73a0c450032 100644 --- a/odoo/tests/addons/test_translation_import/__manifest__.py +++ b/odoo/addons/test_translation_import/__manifest__.py diff --git a/odoo/tests/addons/test_translation_import/i18n/fr.po b/odoo/addons/test_translation_import/i18n/fr.po index f179cc1eefd..478682cd0a8 100644 --- a/odoo/tests/addons/test_translation_import/i18n/fr.po +++ b/odoo/addons/test_translation_import/i18n/fr.po @@ -14,11 +14,11 @@ msgstr "" "Plural-Forms: \n" # Note: there is normally an additional line: -# #: code:addons/test_translation_import/models.py:17 +# #: code:addons/test_translation_import/models.py:15 # This line is present in the POT and removed here to test the translation # import behavior. #. module: test_translation_import -#: field:test.translation.import,name:0 +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__name #, python-format msgid "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB" msgstr "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB in french" @@ -50,3 +50,20 @@ msgstr "Test translation import in french" msgid "Test translation" msgstr "Test translation in french" +#. module: test_translation_import +#: code:addons/base/models/arthur.py:42 +#: code:addons/base/models/arthur.py:43 +msgid "Test translation with two code lines" +msgstr "Test traduction avec deux lignes de code" + +#. module: test_translation_import +#: code:addons/base/models/ford.py:43 +msgid "Test translation with a code type but different line number in pot" +msgstr "Test traduction avec un type code mais différent numéro de ligne dans le pot" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__other_name +#: code:addons/base/models/marvin.py:42 +#: code:addons/base/models/trillian.py:42 +msgid "Test translation with two code type and model" +msgstr "Test traduction avec deux type code et un model" diff --git a/odoo/tests/addons/test_translation_import/i18n/test_translation_import.pot b/odoo/addons/test_translation_import/i18n/test_translation_import.pot index 034fa5254dc..7d197cd620c 100644 --- a/odoo/tests/addons/test_translation_import/i18n/test_translation_import.pot +++ b/odoo/addons/test_translation_import/i18n/test_translation_import.pot @@ -23,8 +23,8 @@ msgstr "" "Plural-Forms: \n" #. module: test_translation_import -#: code:addons/test_translation_import/models.py:17 -#: field:test.translation.import,name:0 +#: code:addons/test_translation_import/models.py:15 +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__name #, python-format msgid "1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB" msgstr "" @@ -56,3 +56,20 @@ msgstr "" msgid "Test translation" msgstr "" +#. module: test_translation_import +#: code:addons/base/models/arthur.py:42 +#: code:addons/base/models/arthur.py:43 +msgid "Test translation with two code lines" +msgstr "" + +#. module: test_translation_import +#: code:addons/base/models/ford.py:42 +msgid "Test translation with a code type but different line number in pot" +msgstr "" + +#. module: test_translation_import +#: model:ir.model.fields,field_description:test_translation_import.field_test_translation_import__other_name +#: code:addons/base/models/marvin.py:42 +#: code:addons/base/models/trillian.py:42 +msgid "Test translation with two code type and model" +msgstr "" diff --git a/odoo/tests/addons/test_translation_import/models.py b/odoo/addons/test_translation_import/models.py index bf9e5e39fcb..5cfa59d990c 100644 --- a/odoo/tests/addons/test_translation_import/models.py +++ b/odoo/addons/test_translation_import/models.py @@ -8,6 +8,7 @@ class m(models.TransientModel): name = fields.Char('1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB', size=32, help='Efgh') + other_name = fields.Char('Test translation with two code type and model') _('Ijkl') diff --git a/odoo/tests/addons/test_translation_import/tests/__init__.py b/odoo/addons/test_translation_import/tests/__init__.py index e51db6a2527..e51db6a2527 100644 --- a/odoo/tests/addons/test_translation_import/tests/__init__.py +++ b/odoo/addons/test_translation_import/tests/__init__.py diff --git a/odoo/tests/addons/test_translation_import/tests/test_term_count.py b/odoo/addons/test_translation_import/tests/test_term_count.py index 8011338cee7..c89bf44ed25 100644 --- a/odoo/tests/addons/test_translation_import/tests/test_term_count.py +++ b/odoo/addons/test_translation_import/tests/test_term_count.py @@ -1,15 +1,11 @@ # -*- coding: utf-8 -*- from contextlib import closing +import io import odoo from odoo.tests import common -try: - from StringIO import StringIO -except ImportError: - from io import StringIO - class TestTermCount(common.TransactionCase): @@ -17,7 +13,7 @@ class TestTermCount(common.TransactionCase): """ Just make sure we have as many translation entries as we wanted. """ - odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', verbose=False) + odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', module_name='test_translation_import', verbose=False) ids = self.env['ir.translation'].search( [('src', '=', '1XBUO5PUYH2RYZSA1FTLRYS8SPCNU1UYXMEYMM25ASV7JC2KTJZQESZYRV9L8CGB')]) self.assertEqual(len(ids), 2) @@ -29,21 +25,39 @@ class TestTermCount(common.TransactionCase): menu = self.env.ref('test_translation_import.menu_test_translation_import') menu.name = "New Name" # install french and change translation content - odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', verbose=False) + odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', module_name='test_translation_import', verbose=False) menu.with_context(lang='fr_FR').name = "Nouveau nom" # reload with overwrite - odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', verbose=False, context={'overwrite': True}) + odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', module_name='test_translation_import', verbose=False, context={'overwrite': True}) # trans_load invalidates ormcache but not record cache - menu.refresh() + menu.clear_caches() self.assertEqual(menu.name, "New Name") self.assertEqual(menu.with_context(lang='fr_FR').name, "Nouveau nom") + def test_no_duplicate(self): + """ + Just make sure we do not create duplicated translation with 'code' type + """ + odoo.tools.trans_load(self.cr, 'test_translation_import/i18n/fr.po', 'fr_FR', module_name='test_translation_import', verbose=False) + ids = self.env['ir.translation'].search( + [('src', '=', 'Test translation with two code lines')]) + self.assertEqual(len(ids), 1) + + ids = self.env['ir.translation'].search( + [('src', '=', 'Test translation with a code type but different line number in pot')]) + self.assertEqual(len(ids), 1) + + ids = self.env['ir.translation'].search( + [('src', '=', 'Test translation with two code type and model')]) + self.assertEqual(len(ids), 2) + self.assertEqual(len(ids.filtered(lambda t: t.type == 'code')), 1) + def test_export_empty_string(self): """When the string and the translation is equal the translation is empty""" # Export the translations def update_translations(): - with closing(StringIO()) as bufferobj: + with closing(io.BytesIO()) as bufferobj: odoo.tools.trans_export('fr_FR', ['test_translation_import'], bufferobj, 'po', self.cr) bufferobj.name = 'test_translation_import/i18n/fr.po' odoo.tools.trans_load_data(self.cr, bufferobj, 'po', 'fr_FR', verbose=False, context={'overwrite': True}) diff --git a/odoo/tests/addons/test_translation_import/view.xml b/odoo/addons/test_translation_import/view.xml index c4e31d6cebd..c4e31d6cebd 100644 --- a/odoo/tests/addons/test_translation_import/view.xml +++ b/odoo/addons/test_translation_import/view.xml diff --git a/odoo/models.py b/odoo/models.py index 8f3737c1fcb..ab6f04fe060 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -55,7 +55,7 @@ from .tools import frozendict, lazy_classproperty, lazy_property, ormcache, \ Collector, LastOrderedSet, OrderedSet, pycompat, groupby from .tools.config import config from .tools.func import frame_codeinfo -from .tools.misc import CountingStream, DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT +from .tools.misc import CountingStream, clean_context, DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT from .tools.safe_eval import safe_eval from .tools.translate import _ from .tools import date_utils @@ -3376,11 +3376,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): # set the value of non-column fields if other_fields: # discard default values from context - other = self.with_context({ - key: val - for key, val in self._context.items() - if not key.startswith('default_') - }) + other = self.with_context(clean_context(self._context)) for field in sorted(other_fields, key=attrgetter('_sequence')): field.write(other, vals[field.name]) @@ -3609,11 +3605,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): if other_fields: # discard default values from context for other fields - others = records.with_context({ - key: val - for key, val in self._context.items() - if not key.startswith('default_') - }) + others = records.with_context(clean_context(self._context)) for field in sorted(other_fields, key=attrgetter('_sequence')): field.create([ (other, data['stored'][field.name]) diff --git a/odoo/netsvc.py b/odoo/netsvc.py index c9cb9726ac0..286ee740584 100644 --- a/odoo/netsvc.py +++ b/odoo/netsvc.py @@ -157,7 +157,7 @@ def init_logger(): if dirname and not os.path.isdir(dirname): os.makedirs(dirname) if tools.config['logrotate'] is not False: - if tools.config['workers'] > 1: + if tools.config['workers'] and tools.config['workers'] > 1: # TODO: fallback to regular file logging in master for safe(r) defaults? # # Doing so here would be a good idea but also might break diff --git a/odoo/tools/image.py b/odoo/tools/image.py index 8dbeb9f06b1..887ba823b96 100644 --- a/odoo/tools/image.py +++ b/odoo/tools/image.py @@ -220,6 +220,8 @@ def crop_image(data, type='top', ratio=False, size=None, image_format=None): raise ValueError('ERROR: invalid value for crop_type') if size: thumbnail = Image.open(io.BytesIO(output_stream.getvalue())) + output_stream.truncate(0) + output_stream.seek(0) thumbnail.thumbnail(size, Image.ANTIALIAS) thumbnail.save(output_stream, image_format) return base64.b64encode(output_stream.getvalue()) diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py index 8a33558c790..762c07e535c 100644 --- a/odoo/tools/misc.py +++ b/odoo/tools/misc.py @@ -960,6 +960,10 @@ def freehash(arg): else: return id(arg) +def clean_context(context): + """ This function take a dictionary and remove each entry with its key starting with 'default_' """ + return {k: v for k, v in context.items() if not k.startswith('default_')} + class frozendict(dict): """ An implementation of an immutable dictionary. """ def __delitem__(self, key): diff --git a/odoo/tools/translate.py b/odoo/tools/translate.py index a225231e062..5d1404af5c0 100644 --- a/odoo/tools/translate.py +++ b/odoo/tools/translate.py @@ -587,8 +587,11 @@ class PoFile(object): # end of this next() call), and keep the others to generate # additional entries (returned the next next() calls). trans_type, name, res_id = targets.pop(0) + code = trans_type == 'code' for t, n, r in targets: - if t == trans_type == 'code': continue + if t == 'code' and code: continue + if t == 'code': + code = True self.extra_lines.append((t, n, r, source, trad, comments)) if name is None: @@ -1083,7 +1086,7 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, for type, name, res_id, src, _ignored, comments in pot_reader: if type is not None: target = pot_targets[src] - target.targets.add((type, name, res_id)) + target.targets.add((type, name, type != 'code' and res_id or 0)) target.comments = comments # read the rest of the file @@ -1101,18 +1104,14 @@ def trans_load_data(cr, fileobj, fileformat, lang, lang_name=None, verbose=True, # discard the target from the POT targets. src = dic['src'] - target = pot_targets.get(src) - if not target or (dic['type'], dic['name'], dic['res_id']) not in target.targets: - _logger.info("Translation '%s' (%s, %s, %s) not found in reference pot, skipping", - src[:60], dic['type'], dic['name'], dic['res_id']) - return - - target.value = dic['value'] - target.targets.discard((dic['type'], dic['name'], dic['res_id'])) + if src in pot_targets: + target = pot_targets[src] + target.value = dic['value'] + target.targets.discard((dic['type'], dic['name'], dic['type'] != 'code' and dic['res_id'] or 0)) # This would skip terms that fail to specify a res_id res_id = dic['res_id'] - if not res_id: + if not res_id and dic['type'] != 'code': return if isinstance(res_id, pycompat.integer_types) or \ |
