summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2018-09-07 20:20:45 +0200
committerChristophe Simonis <chs@odoo.com>2018-09-07 20:20:45 +0200
commit68d36512ef952d52547969b485951d86b1fb761c (patch)
tree99318570560bc144e8187cb2c314225ea8085ff0
parentcfe22cb59362736f68e57dc5975560ec96df0ba8 (diff)
parentd78f23df84840b151c20cc3bfccd2104d9ff0ace (diff)
[MERGE] forward port branch saas-11.4 up to d78f23df848
-rw-r--r--addons/account/models/account_move.py7
-rw-r--r--addons/account/report/account_invoice_report.py13
-rw-r--r--addons/account/static/src/js/reconciliation/reconciliation_model.js10
-rw-r--r--addons/calendar/models/calendar.py14
-rw-r--r--addons/delivery/models/delivery_carrier.py6
-rw-r--r--addons/delivery/models/delivery_grid.py2
-rw-r--r--addons/hr_expense/models/hr_expense.py7
-rw-r--r--addons/hr_gamification/wizard/gamification_badge_user_wizard.py2
-rw-r--r--addons/hr_holidays/models/hr_leave.py168
-rw-r--r--addons/hr_holidays/models/hr_leave_allocation.py121
-rw-r--r--addons/hr_holidays/report/hr_leave_report.py20
-rw-r--r--addons/hr_holidays/security/hr_holidays_security.xml66
-rw-r--r--addons/hr_holidays/tests/__init__.py1
-rw-r--r--addons/hr_holidays/tests/common.py30
-rw-r--r--addons/hr_holidays/tests/test_access_rights.py392
-rw-r--r--addons/hr_holidays/tests/test_holidays_flow.py48
-rw-r--r--addons/hw_posbox_homepage/controllers/main.py2
-rw-r--r--addons/l10n_th/data/l10n_th_chart_data.xml24
-rw-r--r--addons/mail/models/mail_channel.py6
-rw-r--r--addons/mail/models/mail_mail.py8
-rw-r--r--addons/mail/models/mail_message.py27
-rw-r--r--addons/mail/models/mail_notification.py16
-rw-r--r--addons/mail/models/mail_thread.py3
-rw-r--r--addons/mail/static/src/js/emojis.js3
-rw-r--r--addons/mail/static/src/js/thread_widget.js1
-rw-r--r--addons/mail/static/src/scss/thread.scss6
-rw-r--r--addons/mail/static/src/xml/thread.xml3
-rw-r--r--addons/mail/wizard/mail_resend_message.py6
-rw-r--r--addons/mass_mailing/models/mail_mail.py4
-rw-r--r--addons/mass_mailing_event/models/event.py8
-rw-r--r--addons/mrp/views/mrp_workorder_views.xml2
-rw-r--r--addons/payment/controllers/portal.py4
-rw-r--r--addons/payment/models/payment_acquirer.py31
-rw-r--r--addons/payment_authorize/models/authorize_request.py8
-rw-r--r--addons/point_of_sale/models/pos_order.py5
-rw-r--r--addons/point_of_sale/static/src/js/models.js9
-rw-r--r--addons/point_of_sale/static/src/js/screens.js10
-rw-r--r--addons/point_of_sale/views/pos_config_view.xml4
-rw-r--r--addons/portal/views/assets.xml2
-rw-r--r--addons/pos_sale/models/crm_team.py2
-rw-r--r--addons/product/models/product_template.py5
-rw-r--r--addons/project/controllers/portal.py11
-rw-r--r--addons/project/models/project.py4
-rw-r--r--addons/project/views/project_portal_templates.xml2
-rw-r--r--addons/project/views/project_views.xml1
-rw-r--r--addons/purchase/models/account_invoice.py1
-rw-r--r--addons/purchase_requisition/models/purchase_requisition.py12
-rw-r--r--addons/sale/models/analytic.py9
-rw-r--r--addons/sale/models/payment.py33
-rw-r--r--addons/sale/models/sale.py13
-rw-r--r--addons/sale_stock/report/stock_report_deliveryslip.xml4
-rw-r--r--addons/stock/models/product.py20
-rw-r--r--addons/stock/models/stock_move.py33
-rw-r--r--addons/stock/report/report_deliveryslip.xml10
-rw-r--r--addons/stock_account/views/stock_account_views.xml2
-rw-r--r--addons/transifex/models/ir_translation.py2
-rw-r--r--addons/web/controllers/main.py8
-rw-r--r--addons/web/static/lib/py.js/lib/py_extras.js10
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js7
-rw-r--r--addons/web/static/src/js/fields/relational_fields.js5
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js4
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js9
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js2
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_renderer.js3
-rw-r--r--addons/web/static/src/js/views/pivot/pivot_view.js7
-rw-r--r--addons/web/static/src/js/widgets/domain_selector.js40
-rw-r--r--addons/web/static/tests/core/py_utils_tests.js10
-rw-r--r--addons/web/static/tests/fields/basic_fields_tests.js64
-rw-r--r--addons/web/static/tests/fields/relational_fields_tests.js64
-rw-r--r--addons/web/static/tests/helpers/mock_server.js2
-rw-r--r--addons/web/static/tests/views/kanban_tests.js68
-rw-r--r--addons/web/static/tests/views/list_tests.js70
-rw-r--r--addons/web/static/tests/views/pivot_tests.js17
-rw-r--r--addons/web_editor/static/lib/summernote/src/js/EventHandler.js10
-rw-r--r--addons/web_editor/static/src/js/backend/fields.js3
-rw-r--r--addons/web_editor/static/src/js/editor/rte.js2
-rw-r--r--addons/web_editor/static/src/js/editor/summernote.js18
-rw-r--r--addons/web_editor/static/src/js/widgets/widgets.js2
-rw-r--r--addons/website/models/website.py2
-rw-r--r--addons/website_event/views/event_views.xml4
-rw-r--r--addons/website_sale/models/sale_order.py1
-rw-r--r--doc/cla/corporate/d2go.md15
-rw-r--r--doc/cla/individual/abdur-rahmaanJ.md11
-rw-r--r--doc/cla/individual/dek-odoo.md11
-rw-r--r--doc/howtos/backend.rst2
-rw-r--r--doc/reference/javascript_reference.rst2
-rw-r--r--doc/reference/orm.rst4
-rw-r--r--odoo/addons/base/data/ir_module_category_data.xml6
-rw-r--r--odoo/addons/base/data/res_country_data.xml7
-rw-r--r--odoo/addons/base/models/assetsbundle.py1
-rw-r--r--odoo/addons/base/models/ir_attachment.py5
-rw-r--r--odoo/addons/base/models/ir_qweb_fields.py6
-rw-r--r--odoo/addons/base/models/ir_ui_view.py6
-rw-r--r--odoo/addons/base/models/qweb.py3
-rw-r--r--odoo/addons/base/models/res_config.py2
-rw-r--r--odoo/addons/base/static/img/country_flags/xk.pngbin0 -> 2581 bytes
-rw-r--r--odoo/addons/base/wizard/base_update_translations.py9
-rw-r--r--odoo/addons/test_translation_import/__init__.py (renamed from odoo/tests/addons/test_translation_import/__init__.py)0
-rw-r--r--odoo/addons/test_translation_import/__manifest__.py (renamed from odoo/tests/addons/test_translation_import/__manifest__.py)0
-rw-r--r--odoo/addons/test_translation_import/i18n/fr.po (renamed from odoo/tests/addons/test_translation_import/i18n/fr.po)21
-rw-r--r--odoo/addons/test_translation_import/i18n/test_translation_import.pot (renamed from odoo/tests/addons/test_translation_import/i18n/test_translation_import.pot)21
-rw-r--r--odoo/addons/test_translation_import/models.py (renamed from odoo/tests/addons/test_translation_import/models.py)1
-rw-r--r--odoo/addons/test_translation_import/tests/__init__.py (renamed from odoo/tests/addons/test_translation_import/tests/__init__.py)0
-rw-r--r--odoo/addons/test_translation_import/tests/test_term_count.py (renamed from odoo/tests/addons/test_translation_import/tests/test_term_count.py)34
-rw-r--r--odoo/addons/test_translation_import/view.xml (renamed from odoo/tests/addons/test_translation_import/view.xml)0
-rw-r--r--odoo/models.py14
-rw-r--r--odoo/netsvc.py2
-rw-r--r--odoo/tools/image.py2
-rw-r--r--odoo/tools/misc.py4
-rw-r--r--odoo/tools/translate.py21
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&amp;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
new file mode 100644
index 00000000000..cbabf92cdcf
--- /dev/null
+++ b/odoo/addons/base/static/img/country_flags/xk.png
Binary files differ
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 \