diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index dc4c37f54db80..9e94cc389e894 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -167,8 +167,8 @@ def _get_lines_to_sum(line_ids, tax, tag_ids, analytic_account_id): return line_ids.filtered(lambda x: tax in x.tax_ids) def _get_tax_account(tax, amount): - if tax.tax_exigibility == 'on_payment' and tax.cash_basis_account: - return tax.cash_basis_account + if tax.tax_exigibility == 'on_payment' and tax.cash_basis_account_id: + return tax.cash_basis_account_id if tax.type_tax_use == 'purchase': return tax.refund_account_id if amount < 0 else tax.account_id return tax.refund_account_id if amount >= 0 else tax.account_id diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index 948cebf5c2398..b89ec6bcb13aa 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -46,7 +46,9 @@ def _get_partner_move_lines(self, account_type, date_from, target_move, period_l total = [] partner_clause = '' cr = self.env.cr - company_ids = self.env.context.get('company_ids', (self.env.user.company_id.id,)) + user_company = self.env.user.company_id + user_currency = user_company.currency_id + company_ids = self._context.get('company_ids') or [user_company.id] move_state = ['draft', 'posted'] if target_move == 'posted': move_state = ['posted'] @@ -128,15 +130,15 @@ def _get_partner_move_lines(self, account_type, date_from, target_move, period_l partner_id = line.partner_id.id or False if partner_id not in partners_amount: partners_amount[partner_id] = 0.0 - line_amount = line.balance - if line.balance == 0: + line_amount = line.company_id.currency_id._convert(line.balance, user_currency, line.company_id, date_from) + if user_currency.is_zero(line_amount): continue for partial_line in line.matched_debit_ids: if partial_line.max_date <= date_from: - line_amount += partial_line.amount + line_amount += partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from) for partial_line in line.matched_credit_ids: if partial_line.max_date <= date_from: - line_amount -= partial_line.amount + line_amount -= partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from) if not self.env.user.company_id.currency_id.is_zero(line_amount): partners_amount[partner_id] += line_amount @@ -166,15 +168,15 @@ def _get_partner_move_lines(self, account_type, date_from, target_move, period_l partner_id = line.partner_id.id or False if partner_id not in undue_amounts: undue_amounts[partner_id] = 0.0 - line_amount = line.balance - if line.balance == 0: + line_amount = line.company_id.currency_id._convert(line.balance, user_currency, line.company_id, date_from) + if user_currency.is_zero(line_amount): continue for partial_line in line.matched_debit_ids: if partial_line.max_date <= date_from: - line_amount += partial_line.amount + line_amount += partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from) for partial_line in line.matched_credit_ids: if partial_line.max_date <= date_from: - line_amount -= partial_line.amount + line_amount -= partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, partial_line.currency_id, date_from) if not self.env.user.company_id.currency_id.is_zero(line_amount): undue_amounts[partner_id] += line_amount lines[partner_id].append({ diff --git a/addons/account/security/ir.model.access.csv b/addons/account/security/ir.model.access.csv index 52ac52ad108e5..ef09176438b5c 100644 --- a/addons/account/security/ir.model.access.csv +++ b/addons/account/security/ir.model.access.csv @@ -39,8 +39,6 @@ access_account_journal_invoice,account.journal invoice,model_account_journal,acc access_account_invoice_group_invoice,account.invoice group invoice,model_account_invoice,account.group_account_invoice,1,1,1,1 access_res_currency_account_manager,res.currency account manager,base.model_res_currency,group_account_manager,1,1,1,1 access_res_currency_rate_account_manager,res.currency.rate account manager,base.model_res_currency_rate,group_account_manager,1,1,1,1 -access_account_invoice_user,account.invoice user,model_account_invoice,base.group_user,1,0,0,0 -access_account_invoice_line_user,account.invoice.line user,model_account_invoice_line,base.group_user,1,0,0,0 access_account_invoice_portal,account.invoice.portal,account.model_account_invoice,base.group_portal,1,0,0,0 access_account_invoice_line_portal,account.invoice.line.portal,account.model_account_invoice_line,base.group_portal,1,0,0,0 access_account_payment_term_partner_manager,account.payment.term partner manager,model_account_payment_term,base.group_user,1,0,0,0 diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index b9504bd6ee62d..3c9174546cf01 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -1262,8 +1262,9 @@ var ManualModel = StatementModel.extend({ }); var domainReconcile = []; - if (context && context.company_ids) { - domainReconcile.push(['company_id', 'in', context.company_ids]); + var company_ids = context && context.company_ids || [session.company_id] + if (company_ids) { + domainReconcile.push(['company_id', 'in', company_ids]); } var def_reconcileModel = this._rpc({ model: 'account.reconcile.model', diff --git a/addons/account/wizard/account_report_common.py b/addons/account/wizard/account_report_common.py index 6b47f2a7e8eac..f5e5814c814dd 100644 --- a/addons/account/wizard/account_report_common.py +++ b/addons/account/wizard/account_report_common.py @@ -45,4 +45,4 @@ def check_report(self): data['form'] = self.read(['date_from', 'date_to', 'journal_ids', 'target_move', 'company_id'])[0] used_context = self._build_contexts(data) data['form']['used_context'] = dict(used_context, lang=self.env.context.get('lang') or 'en_US') - return self._print_report(data) + return self.with_context(discard_logo_check=True)._print_report(data) diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index d6161e17b91ff..87a47ffa455a4 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -57,17 +57,17 @@ def _get_signup_url_for_action(self, action=None, view_type=None, menu_id=None, for partner in self: # when required, make sure the partner has a valid signup token if self.env.context.get('signup_valid') and not partner.user_ids: - partner.signup_prepare() + partner.sudo().signup_prepare() route = 'login' # the parameters to encode for the query query = dict(db=self.env.cr.dbname) - signup_type = self.env.context.get('signup_force_type_in_url', partner.signup_type or '') + signup_type = self.env.context.get('signup_force_type_in_url', partner.sudo().signup_type or '') if signup_type: route = 'reset_password' if signup_type == 'reset' else signup_type - if partner.signup_token and signup_type: - query['token'] = partner.signup_token + if partner.sudo().signup_token and signup_type: + query['token'] = partner.sudo().signup_token elif partner.user_ids: query['login'] = partner.user_ids[0].login else: diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index 07c42694ef10d..7591299f3e284 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -25,18 +25,58 @@ def _fields_view_get_address(self, arch): # render the partner address accordingly to address_view_id doc = etree.fromstring(arch) if doc.xpath("//field[@name='city_id']"): - return arch - label = _('City') - for city_node in doc.xpath("//field[@name='city']"): - replacement_xml = """ + return arch + + replacement_xml = """
- - + +
- """ % (label, label, label) - city_id_node = etree.fromstring(replacement_xml) - city_node.getparent().replace(city_node, city_id_node) + """ + + replacement_data = { + 'placeholder': _('City'), + } + + def _arch_location(node): + in_subview = False + view_type = False + parent = node.getparent() + while parent is not None and (not view_type or not in_subview): + if parent.tag == 'field': + in_subview = True + elif parent.tag in ['list', 'tree', 'kanban', 'form']: + view_type = parent.tag + parent = parent.getparent() + return { + 'view_type': view_type, + 'in_subview': in_subview, + } + + for city_node in doc.xpath("//field[@name='city']"): + location = _arch_location(city_node) + replacement_data['parent_condition'] = '' + if location['view_type'] == 'form' or not location['in_subview']: + replacement_data['parent_condition'] = ", ('parent_id', '!=', False)" + + replacement_formatted = replacement_xml % replacement_data + for replace_node in etree.fromstring(replacement_formatted).getchildren(): + city_node.addprevious(replace_node) + parent = city_node.getparent() + parent.remove(city_node) arch = etree.tostring(doc, encoding='unicode') return arch diff --git a/addons/base_import_module/controllers/main.py b/addons/base_import_module/controllers/main.py index ca76cd53b8eaf..d382d360961c2 100644 --- a/addons/base_import_module/controllers/main.py +++ b/addons/base_import_module/controllers/main.py @@ -24,22 +24,14 @@ def check_user(self, uid=None): if not is_admin: raise AccessError(_("Only administrators can upload a module")) - @route('/base_import_module/login', type='http', auth='none', methods=['POST'], csrf=False) + @route( + '/base_import_module/login_upload', + type='http', auth='none', methods=['POST'], csrf=False, save_session=False) @webservice - def login(self, login, password, db=None): + def login_upload(self, login, password, db=None, force='', mod_file=None, **kw): if db and db != request.db: raise Exception(_("Could not select database '%s'") % db) uid = request.session.authenticate(request.db, login, password) - if not uid: - return Response(response="Wrong login/password", status=401) self.check_user(uid) - return Response(headers={ - 'X-CSRF-TOKEN': request.csrf_token(), - }) - - @route('/base_import_module/upload', type='http', auth='user', methods=['POST']) - @webservice - def upload(self, mod_file=None, force='', **kw): - self.check_user() force = True if force == '1' else False return request.env['ir.module.module'].import_zipfile(mod_file, force=force)[0] diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 4267c7efae29d..c0e4c45409079 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -1789,7 +1789,15 @@ def _sync_activities(self, values): if values.get('description'): activity_values['note'] = values['description'] if values.get('start'): - activity_values['date_deadline'] = fields.Datetime.from_string(values['start']).date() + # self.start is a datetime UTC *only when the event is not allday* + # activty.date_deadline is a date (No TZ, but should represent the day in which the user's TZ is) + # See 72254129dbaeae58d0a2055cba4e4a82cde495b7 for the same issue, but elsewhere + deadline = fields.Datetime.from_string(values['start']) + user_tz = self.env.context.get('tz') + if user_tz and not self.allday: + deadline = pytz.UTC.localize(deadline) + deadline = deadline.astimezone(pytz.timezone(user_tz)) + activity_values['date_deadline'] = deadline.date() if values.get('user_id'): activity_values['user_id'] = values['user_id'] if activity_values.keys(): diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py index c3ff67d54d54f..1d409d48dd729 100644 --- a/addons/calendar/tests/test_calendar.py +++ b/addons/calendar/tests/test_calendar.py @@ -314,3 +314,73 @@ def test_recurring_around_dst(self): else: self.assertEqual(d.hour, 15) self.assertEqual(d.minute, 30) + + def test_event_activity_timezone(self): + activty_type = self.env['mail.activity.type'].create({ + 'name': 'Meeting', + 'category': 'meeting' + }) + + activity_id = self.env['mail.activity'].create({ + 'summary': 'Meeting with partner', + 'activity_type_id': activty_type.id, + 'res_model_id': self.env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id, + 'res_id': self.env['res.partner'].search([('name', 'ilike', 'Deco Addict')], limit=1).id, + }) + + calendar_event = self.env['calendar.event'].create({ + 'name': 'Meeting with partner', + 'activity_ids': [(6, False, activity_id.ids)], + 'start': '2018-11-12 21:00:00', + 'stop': '2018-11-13 00:00:00', + }) + + # Check output in UTC + self.assertEqual(str(activity_id.date_deadline), '2018-11-12') + + # Check output in the user's tz + # write on the event to trigger sync of activities + calendar_event.with_context({'tz': 'Australia/Brisbane'}).write({ + 'start': '2018-11-12 21:00:00', + }) + + self.assertEqual(str(activity_id.date_deadline), '2018-11-13') + + def test_event_allday_activity_timezone(self): + # Covers use case of commit eef4c3b48bcb4feac028bf640b545006dd0c9b91 + # Also, read the comment in the code at calendar.event._inverse_dates + activty_type = self.env['mail.activity.type'].create({ + 'name': 'Meeting', + 'category': 'meeting' + }) + + activity_id = self.env['mail.activity'].create({ + 'summary': 'Meeting with partner', + 'activity_type_id': activty_type.id, + 'res_model_id': self.env['ir.model'].search([('model', '=', 'res.partner')], limit=1).id, + 'res_id': self.env['res.partner'].search([('name', 'ilike', 'Deco Addict')], limit=1).id, + }) + + calendar_event = self.env['calendar.event'].create({ + 'name': 'All Day', + 'start': "2018-10-16 00:00:00", + 'start_date': "2018-10-16", + 'start_datetime': False, + 'stop': "2018-10-18 00:00:00", + 'stop_date': "2018-10-18", + 'stop_datetime': False, + 'allday': True, + 'activity_ids': [(6, False, activity_id.ids)], + }) + + # Check output in UTC + self.assertEqual(str(activity_id.date_deadline), '2018-10-16') + + # Check output in the user's tz + # write on the event to trigger sync of activities + calendar_event.with_context({'tz': 'Pacific/Honolulu'}).write({ + 'start': '2018-10-16 00:00:00', + 'start_date': '2018-10-16', + }) + + self.assertEqual(str(activity_id.date_deadline), '2018-10-16') diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py index 77f34f856d030..9f07ad5c6e561 100644 --- a/addons/delivery/models/delivery_carrier.py +++ b/addons/delivery/models/delivery_carrier.py @@ -217,7 +217,7 @@ def fixed_rate_shipment(self, order): '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: + if self.company_id and 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) return {'success': True, 'price': price, diff --git a/addons/digest/data/digest_template_data.xml b/addons/digest/data/digest_template_data.xml index c168dd065b786..f405892e692b7 100644 --- a/addons/digest/data/digest_template_data.xml +++ b/addons/digest/data/digest_template_data.xml @@ -123,7 +123,7 @@ @@ -145,7 +145,7 @@ % endif

- Send by + Sent by Odoo - Unsubscribe

diff --git a/addons/event/data/email_template_data.xml b/addons/event/data/email_template_data.xml index c1ca30a134759..023cb9d44bc91 100644 --- a/addons/event/data/email_template_data.xml +++ b/addons/event/data/email_template_data.xml @@ -205,6 +205,7 @@

-
Run your bussiness from anywhere with Odoo Mobile.
+
Run your business from anywhere with Odoo Mobile.
+% if object.company_id
@@ -216,6 +217,7 @@
+% endif ${object.partner_id.lang} @@ -399,6 +401,7 @@ +% if object.company_id
@@ -410,6 +413,7 @@
+% endif ${object.partner_id.lang} diff --git a/addons/event/i18n/event.pot b/addons/event/i18n/event.pot index 9af3e6e90ed59..36d5324019aea 100644 --- a/addons/event/i18n/event.pot +++ b/addons/event/i18n/event.pot @@ -316,11 +316,13 @@ msgid "\n" @@ -502,11 +504,13 @@ msgid "
\n" " \n" " \n" "
\n" +" % if object.company_id\n" " Sent by ${object.company_id.name}\n" " % if 'website_url' in object.event_id and object.event_id.website_url:\n" "
\n" " Discover all our events.\n" " % endif\n" +" % endif\n" "
\n" "
\n" diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 886472e8aba7e..f4086bb20066f 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -658,7 +658,7 @@ def action_validate(self): elif holiday.holiday_type == 'company': employees = self.env['hr.employee'].search([('company_id', '=', self.mode_company_id.id)]) else: - holiday.department_id.member_ids + employees = holiday.department_id.member_ids for employee in employees: values = holiday._prepare_holiday_values(employee) leaves += self.with_context( diff --git a/addons/hr_holidays/tests/__init__.py b/addons/hr_holidays/tests/__init__.py index 68ef444542894..9510f75b34bf1 100644 --- a/addons/hr_holidays/tests/__init__.py +++ b/addons/hr_holidays/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_hr_leave_type from . import test_accrual_allocations from . import test_change_department +from . import test_leave_requests diff --git a/addons/hr_holidays/tests/test_access_rights.py b/addons/hr_holidays/tests/test_access_rights.py index cf4e8f76ad8d8..ca651a69010ef 100644 --- a/addons/hr_holidays/tests/test_access_rights.py +++ b/addons/hr_holidays/tests/test_access_rights.py @@ -6,7 +6,7 @@ from odoo import tests from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase -from odoo.exceptions import AccessError, UserError +from odoo.exceptions import AccessError, ValidationError, UserError from odoo.tools import mute_logger @@ -117,6 +117,24 @@ def test_leave_update_hr_by_user_other(self): with self.assertRaises(AccessError): other_leave.sudo(self.user_employee_id).write({'name': 'Crocodile Dundee is my man'}) + # ---------------------------------------- + # Creation + # ---------------------------------------- + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_creation_for_other_user(self): + """ Employee cannot creates a leave request for another employee """ + HolidaysEmployeeGroup = self.env['hr.leave'].sudo(self.user_employee_id) + with self.assertRaises(AccessError): + HolidaysEmployeeGroup.create({ + 'name': 'Hol10', + 'employee_id': self.employee_hruser_id, + 'holiday_status_id': self.leave_type.id, + 'date_from': (datetime.today() - relativedelta(days=1)), + 'date_to': datetime.today(), + 'number_of_days': 1, + }) + # ---------------------------------------- # Reset # ---------------------------------------- @@ -137,7 +155,7 @@ def test_leave_reset_by_manager(self): @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 """ + """ Manager may reset other leaves """ self.employee_leave.sudo(self.user_hrmanager).action_draft() @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') @@ -187,19 +205,23 @@ def test_leave_reset_by_user_other(self): @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.assertEqual(self.employee_leave.state, 'confirm') self.employee_leave.sudo(self.user_hrmanager_id).action_approve() + self.assertEqual(self.employee_leave.state, 'validate') @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.assertEqual(self.employee_leave.state, 'confirm') self.employee_leave.sudo(self.user_hruser).action_approve() + self.assertEqual(self.employee_leave.state, 'validate') @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() + 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_with_manager(self): @@ -207,6 +229,7 @@ def test_leave_validation_hr_by_officer_other_department_with_manager(self): self.employee_hruser.write({'department_id': self.hr_dept.id}) with self.assertRaises(AccessError): self.employee_leave.sudo(self.user_hruser).action_approve() + self.assertEqual(self.employee_leave.state, 'confirm') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_leave_validation_hr_by_officer_other_department_wo_manager(self): @@ -214,6 +237,7 @@ def test_leave_validation_hr_by_officer_other_department_wo_manager(self): self.employee_hruser.write({'department_id': self.hr_dept.id}) with self.assertRaises(AccessError): self.employee_leave.sudo(self.user_hruser).action_approve() + self.assertEqual(self.employee_leave.state, 'confirm') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_leave_validation_hr_by_officer_other_department_manager(self): @@ -221,7 +245,9 @@ def test_leave_validation_hr_by_officer_other_department_manager(self): self.employee_hruser.write({'department_id': self.hr_dept.id}) self.employee_leave.sudo().department_id.write({'manager_id': self.employee_hruser.id}) + self.assertEqual(self.employee_leave.state, 'confirm') self.employee_leave.sudo(self.user_hruser).action_approve() + self.assertEqual(self.employee_leave.state, 'validate') @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') def test_leave_validation_hr_by_user(self): @@ -232,6 +258,36 @@ def test_leave_validation_hr_by_user(self): with self.assertRaises(UserError): self.employee_leave.sudo(self.user_employee_id).write({'state': 'validate'}) + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validate_by_manager(self): + """ Manager (who has no manager) validate its own leaves """ + manager_leave = self.env['hr.leave'].sudo(self.user_hrmanager_id).create({ + 'name': 'Hol manager', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.employee_hrmanager_id, + 'date_from': (datetime.today() + relativedelta(days=15)), + 'date_to': (datetime.today() + relativedelta(days=16)), + 'number_of_days': 1, + }) + self.assertEqual(manager_leave.state, 'confirm') + manager_leave.action_approve() + self.assertEqual(manager_leave.state, 'validate') + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_leave_validate_by_manager_2(self): + """ Manager (who has also a manager) validate its own leaves """ + manager_leave2 = self.env['hr.leave'].sudo(self.user_hrmanager_2_id).create({ + 'name': 'Hol manager2', + 'holiday_status_id': self.leave_type.id, + 'employee_id': self.employee_hrmanager_2_id, + 'date_from': (datetime.today() + relativedelta(days=15)), + 'date_to': (datetime.today() + relativedelta(days=16)), + 'number_of_days': 1, + }) + self.assertEqual(manager_leave2.state, 'confirm') + manager_leave2.action_approve() + self.assertEqual(manager_leave2.state, 'validate') + # ---------------------------------------- # Validation: one validation, manager # ---------------------------------------- diff --git a/addons/hr_holidays/tests/test_holidays_flow.py b/addons/hr_holidays/tests/test_holidays_flow.py index 51bcafe049cb4..08e1be1b20dca 100644 --- a/addons/hr_holidays/tests/test_holidays_flow.py +++ b/addons/hr_holidays/tests/test_holidays_flow.py @@ -13,29 +13,11 @@ class TestHolidaysFlow(TestHrHolidaysBase): @mute_logger('odoo.addons.base.models.ir_model', 'odoo.models') - def test_00_leave_request_flow(self): - """ Testing leave request flow """ + def test_00_leave_request_flow_unlimited(self): + """ Testing leave request flow: unlimited type of leave request """ Requests = self.env['hr.leave'] - Allocations = self.env['hr.leave.allocation'] HolidaysStatus = self.env['hr.leave.type'] - def _check_holidays_status(holiday_status, ml, lt, rl, vrl): - self.assertEqual(holiday_status.max_leaves, ml, - 'hr_holidays: wrong type days computation') - self.assertEqual(holiday_status.leaves_taken, lt, - 'hr_holidays: wrong type days computation') - self.assertEqual(holiday_status.remaining_leaves, rl, - 'hr_holidays: wrong type days computation') - self.assertEqual(holiday_status.virtual_remaining_leaves, vrl, - 'hr_holidays: wrong type days computation') - - # HrUser creates some holiday statuses -> crash because only HrManagers should do this - with self.assertRaises(AccessError): - HolidaysStatus.sudo(self.user_hruser_id).create({ - 'name': 'UserCheats', - 'allocation_type': 'no', - }) - # HrManager creates some holiday statuses HolidayStatusManagerGroup = HolidaysStatus.sudo(self.user_hrmanager_id) HolidayStatusManagerGroup.create({ @@ -43,42 +25,24 @@ def _check_holidays_status(holiday_status, ml, lt, rl, vrl): 'allocation_type': 'no', 'categ_id': self.env['calendar.event.type'].sudo(self.user_hrmanager_id).create({'name': 'NotLimitedMeetingType'}).id }) - self.holidays_status_1 = HolidayStatusManagerGroup.create({ + self.holidays_status_hr = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedHR', 'allocation_type': 'no', 'validation_type': 'hr', }) - self.holidays_status_2 = HolidayStatusManagerGroup.create({ - 'name': 'Limited', - 'allocation_type': 'fixed', - 'validation_type': 'both', - }) - self.holidays_status_3 = HolidayStatusManagerGroup.create({ + self.holidays_status_manager = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedManager', 'allocation_type': 'no', 'validation_type': 'manager', }) - self.holiday_status_4 = HolidayStatusManagerGroup.create({ - 'name': 'TimeNotLimited', - 'allocation_type': 'no', - 'validation_type': 'manager', - 'validity_start': fields.Datetime.from_string('2017-01-01 00:00:00'), - 'validity_stop': fields.Datetime.from_string('2017-06-01 00:00:00'), - }) - - # -------------------------------------------------- - # Case1: unlimited type of leave request - # -------------------------------------------------- - # Employee creates a leave request for another employee -> should crash HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) - Requests.search([('name', '=', 'Hol10')]).unlink() # Employee creates a leave request in a no-limit category hr manager only hol1_employee_group = HolidaysEmployeeGroup.create({ 'name': 'Hol11', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_1.id, + 'holiday_status_id': self.holidays_status_hr.id, 'date_from': (datetime.today() - relativedelta(days=1)), 'date_to': datetime.today(), 'number_of_days': 1, @@ -87,9 +51,6 @@ def _check_holidays_status(holiday_status, ml, lt, rl, vrl): hol1_manager_group = hol1_employee_group.sudo(self.user_hrmanager_id) 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 - 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 hol1_user_group.action_approve() self.assertEqual(hol1_manager_group.state, 'validate', 'hr_holidays: validated leave request should be in validate state') @@ -98,7 +59,7 @@ def _check_holidays_status(holiday_status, ml, lt, rl, vrl): hol12_employee_group = HolidaysEmployeeGroup.create({ 'name': 'Hol12', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_3.id, + 'holiday_status_id': self.holidays_status_manager.id, 'date_from': (datetime.today() + relativedelta(days=12)), 'date_to': (datetime.today() + relativedelta(days=13)), 'number_of_days': 1, @@ -107,47 +68,48 @@ def _check_holidays_status(holiday_status, ml, lt, rl, vrl): hol12_manager_group = hol12_employee_group.sudo(self.user_hrmanager_id) 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 - self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: employee should not be able to validate its own leave request') - # 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') - # -------------------------------------------------- - # Case2: limited type of leave request - # -------------------------------------------------- - # Employee creates a new leave request at the same time -> crash, avoid interlapping - with self.assertRaises(ValidationError): - HolidaysEmployeeGroup.create({ - 'name': 'Hol21', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_1.id, - 'date_from': (datetime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'), - 'date_to': datetime.today(), - 'number_of_days': 1, - }) + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.models') + def test_01_leave_request_flow_limited(self): + """ Testing leave request flow: limited type of leave request """ + Requests = self.env['hr.leave'] + Allocations = self.env['hr.leave.allocation'] + HolidaysStatus = self.env['hr.leave.type'] + + def _check_holidays_status(holiday_status, ml, lt, rl, vrl): + self.assertEqual(holiday_status.max_leaves, ml, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.leaves_taken, lt, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.remaining_leaves, rl, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.virtual_remaining_leaves, vrl, + 'hr_holidays: wrong type days computation') - # Employee creates a leave request in a limited category -> crash, not enough days left - with self.assertRaises(ValidationError): - HolidaysEmployeeGroup.create({ - 'name': 'Hol22', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_2.id, - 'date_from': (datetime.today() + relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'), - 'date_to': (datetime.today() + relativedelta(days=2)), - 'number_of_days': 1, - }) + # HrManager creates some holiday statuses + HolidayStatusManagerGroup = HolidaysStatus.sudo(self.user_hrmanager_id) + HolidayStatusManagerGroup.create({ + 'name': 'WithMeetingType', + 'allocation_type': 'no', + 'categ_id': self.env['calendar.event.type'].sudo(self.user_hrmanager_id).create({'name': 'NotLimitedMeetingType'}).id + }) - # Clean transaction - Requests.search([('name', 'in', ['Hol21', 'Hol22'])]).unlink() + self.holidays_status_limited = HolidayStatusManagerGroup.create({ + 'name': 'Limited', + 'allocation_type': 'fixed', + 'validation_type': 'both', + }) + HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) # HrUser allocates some leaves to the employee aloc1_user_group = Allocations.sudo(self.user_hruser_id).create({ 'name': 'Days for limited category', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_2.id, + 'holiday_status_id': self.holidays_status_limited.id, 'number_of_days': 2, }) # HrUser validates the first step @@ -156,14 +118,14 @@ def _check_holidays_status(holiday_status, ml, lt, rl, vrl): # HrManager validates the second step aloc1_user_group.sudo(self.user_hrmanager_id).action_validate() # Checks Employee has effectively some days left - hol_status_2_employee_group = self.holidays_status_2.sudo(self.user_employee_id) + hol_status_2_employee_group = self.holidays_status_limited.sudo(self.user_employee_id) _check_holidays_status(hol_status_2_employee_group, 2.0, 0.0, 2.0, 2.0) # Employee creates a leave request in the limited category, now that he has some days left hol2 = HolidaysEmployeeGroup.create({ 'name': 'Hol22', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_2.id, + 'holiday_status_id': self.holidays_status_limited.id, 'date_from': (datetime.today() + relativedelta(days=2)).strftime('%Y-%m-%d %H:%M'), 'date_to': (datetime.today() + relativedelta(days=3)), 'number_of_days': 1, @@ -201,15 +163,6 @@ def _check_holidays_status(holiday_status, ml, lt, rl, vrl): self.assertEqual(hol2.state, 'draft', 'hr_holidays: resetting should lead to draft state') - # HrManager changes the date and put too much days -> crash when confirming - hol2_manager_group.write({ - 'date_from': (datetime.today() + relativedelta(days=4)).strftime('%Y-%m-%d %H:%M'), - 'date_to': (datetime.today() + relativedelta(days=7)), - 'number_of_days': 4, - }) - with self.assertRaises(ValidationError): - hol2_manager_group.action_confirm() - employee_id = self.ref('hr.employee_admin') # cl can be of maximum 20 days for employee_admin hol3_status = self.env.ref('hr_holidays.holiday_status_cl').with_context(employee_id=employee_id) @@ -236,75 +189,6 @@ def _check_holidays_status(holiday_status, ml, lt, rl, vrl): # Check left days for casual leave: 19 days left _check_holidays_status(hol3_status, 20.0, 1.0, 19.0, 19.0) - Requests.create({ - 'name': 'Sick Leave', - 'holiday_status_id': self.holiday_status_4.id, - 'date_from': fields.Datetime.from_string('2017-03-03 06:00:00'), - 'date_to': fields.Datetime.from_string('2017-03-11 19:00:00'), - 'employee_id': employee_id, - 'number_of_days': 1, - }).unlink() - - with self.assertRaises(ValidationError): - Requests.create({ - 'name': 'Sick Leave', - 'holiday_status_id': self.holiday_status_4.id, - 'date_from': fields.Datetime.from_string('2017-07-03 06:00:00'), - 'date_to': fields.Datetime.from_string('2017-07-11 19:00:00'), - 'employee_id': employee_id, - 'number_of_days': 1, - }) - - hol41 = HolidaysEmployeeGroup.create({ - 'name': 'Hol41', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_1.id, - 'date_from': (datetime.today() + relativedelta(days=9)).strftime('%Y-%m-%d %H:%M'), - 'date_to': (datetime.today() + relativedelta(days=10)), - 'number_of_days': 1, - }) - - # A simple user should be able to reset it's own leave - hol41.action_draft() - hol41.unlink() - - hol42 = Requests.sudo(self.user_hrmanager_id).create({ - 'name': 'Hol41', - 'employee_id': self.employee_hrmanager_id, - 'holiday_status_id': self.holidays_status_1.id, - 'date_from': (datetime.today() + relativedelta(days=9)).strftime('%Y-%m-%d %H:%M'), - 'date_to': (datetime.today() + relativedelta(days=10)), - 'number_of_days': 1, - }) - - # A manager should be able to reset someone else's leave - hol42.action_draft() - hol42.unlink() - - # Manager should be able to approve it's own leave - hol51 = HolidaysEmployeeGroup.sudo(self.user_hrmanager_2_id).create({ - 'name': 'Hol51', - 'employee_id': self.employee_hrmanager_2_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': 1, - }) - - hol51.action_approve() - - # Unless there is not manager above - hol52 = HolidaysEmployeeGroup.sudo(self.user_hrmanager_id).create({ - 'name': 'Hol52', - 'employee_id': self.employee_hrmanager_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': 1, - }) - - hol52.action_approve() - def test_10_leave_summary_reports(self): # Print the HR Holidays(Summary Department) Report through the wizard ctx = { diff --git a/addons/hr_holidays/tests/test_hr_leave_type.py b/addons/hr_holidays/tests/test_hr_leave_type.py index 495d1aa2e4bf7..dfe1b77713d30 100644 --- a/addons/hr_holidays/tests/test_hr_leave_type.py +++ b/addons/hr_holidays/tests/test_hr_leave_type.py @@ -4,6 +4,8 @@ from datetime import datetime from dateutil.relativedelta import relativedelta +from odoo.exceptions import AccessError + from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase @@ -30,3 +32,11 @@ def test_time_type(self): self.env['resource.calendar.leaves'].search([('holiday_id', '=', leave_1.id)]).time_type, 'leave' ) + + def test_type_creation_right(self): + # HrUser creates some holiday statuses -> crash because only HrManagers should do this + with self.assertRaises(AccessError): + self.env['hr.leave.type'].sudo(self.user_hruser_id).create({ + 'name': 'UserCheats', + 'allocation_type': 'no', + }) diff --git a/addons/hr_holidays/tests/test_leave_requests.py b/addons/hr_holidays/tests/test_leave_requests.py new file mode 100644 index 0000000000000..8dad86eae3999 --- /dev/null +++ b/addons/hr_holidays/tests/test_leave_requests.py @@ -0,0 +1,152 @@ +# -*- 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 fields +from odoo.exceptions import ValidationError +from odoo.tools import mute_logger + +from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase + +class TestLeaveRequests(TestHrHolidaysBase): + + def _check_holidays_status(self, holiday_status, ml, lt, rl, vrl): + self.assertEqual(holiday_status.max_leaves, ml, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.leaves_taken, lt, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.remaining_leaves, rl, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.virtual_remaining_leaves, vrl, + 'hr_holidays: wrong type days computation') + + def setUp(self): + super(TestLeaveRequests, self).setUp() + + # Make sure we have the rights to create, validate and delete the leaves, leave types and allocations + LeaveType = self.env['hr.leave.type'].sudo(self.user_hrmanager_id).with_context(tracking_disable=True) + + self.holidays_type_1 = LeaveType.create({ + 'name': 'NotLimitedHR', + 'allocation_type': 'no', + 'validation_type': 'hr', + }) + self.holidays_type_2 = LeaveType.create({ + 'name': 'Limited', + 'allocation_type': 'fixed', + 'validation_type': 'hr', + }) + self.holidays_type_3 = LeaveType.create({ + 'name': 'TimeNotLimited', + 'allocation_type': 'no', + 'validation_type': 'manager', + 'validity_start': fields.Datetime.from_string('2017-01-01 00:00:00'), + 'validity_stop': fields.Datetime.from_string('2017-06-01 00:00:00'), + }) + + self.set_employee_create_date(self.employee_emp_id, '2010-02-03 00:00:00') + self.set_employee_create_date(self.employee_hruser_id, '2010-02-03 00:00:00') + + def set_employee_create_date(self, id, newdate): + """ This method is a hack in order to be able to define/redefine the create_date + of the employees. + This is done in SQL because ORM does not allow to write onto the create_date field. + """ + self.env.cr.execute(""" + UPDATE + hr_employee + SET create_date = '%s' + WHERE id = %s + """ % (newdate, id)) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_overlapping_requests(self): + """ Employee cannot create a new leave request at the same time, avoid interlapping """ + self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Hol11', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_1.id, + 'date_from': (datetime.today() - relativedelta(days=1)), + 'date_to': datetime.today(), + 'number_of_days': 1, + }) + + with self.assertRaises(ValidationError): + self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Hol21', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_1.id, + 'date_from': (datetime.today() - relativedelta(days=1)), + 'date_to': datetime.today(), + 'number_of_days': 1, + }) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_limited_type_no_days(self): + """ Employee creates a leave request in a limited category but has not enough days left """ + + with self.assertRaises(ValidationError): + self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Hol22', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_2.id, + 'date_from': (datetime.today() + relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'), + 'date_to': (datetime.today() + relativedelta(days=2)), + 'number_of_days': 1, + }) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_limited_type_days_left(self): + """ Employee creates a leave request in a limited category and has enough days left """ + aloc1_user_group = self.env['hr.leave.allocation'].sudo(self.user_hruser_id).create({ + 'name': 'Days for limited category', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_2.id, + 'number_of_days': 2, + }) + aloc1_user_group.action_approve() + + holiday_status = self.holidays_type_2.sudo(self.user_employee_id) + self._check_holidays_status(holiday_status, 2.0, 0.0, 2.0, 2.0) + + hol = self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Hol11', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_2.id, + 'date_from': (datetime.today() - relativedelta(days=2)), + 'date_to': datetime.today(), + 'number_of_days': 2, + }) + + holiday_status.invalidate_cache() + self._check_holidays_status(holiday_status, 2.0, 0.0, 2.0, 0.0) + + hol.sudo(self.user_hrmanager_id).action_approve() + + self._check_holidays_status(holiday_status, 2.0, 2.0, 0.0, 0.0) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_accrual_validity_time_valid(self): + """ Employee ask leave during a valid validity time """ + self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Valid time period', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_3.id, + 'date_from': fields.Datetime.from_string('2017-03-03 06:00:00'), + 'date_to': fields.Datetime.from_string('2017-03-11 19:00:00'), + 'number_of_days': 1, + }) + + @mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail') + def test_accrual_validity_time_not_valid(self): + """ Employee ask leav during a not valid validity time """ + with self.assertRaises(ValidationError): + self.env['hr.leave'].sudo(self.user_employee_id).create({ + 'name': 'Sick Leave', + 'employee_id': self.employee_emp_id, + 'holiday_status_id': self.holidays_type_3.id, + 'date_from': fields.Datetime.from_string('2017-07-03 06:00:00'), + 'date_to': fields.Datetime.from_string('2017-07-11 19:00:00'), + 'number_of_days': 1, + }) diff --git a/addons/l10n_be_invoice_bba/__manifest__.py b/addons/l10n_be_invoice_bba/__manifest__.py index 274fd996fc6d6..53c121f9de631 100644 --- a/addons/l10n_be_invoice_bba/__manifest__.py +++ b/addons/l10n_be_invoice_bba/__manifest__.py @@ -11,17 +11,18 @@ 'description': """ Add Structured Communication to customer invoices. ---------------------------------------------------------------------------------------------------------------------- +-------------------------------------------------- -Using BBA structured communication simplifies the reconciliation between invoices and payments. +Using BBA structured communication simplifies the reconciliation between invoices and payments. You can select the structured communication as payment communication in Invoicing/Accounting settings. Three algorithms are suggested: + 1) Random : +++RRR/RRRR/RRRDD+++ **R..R =** Random Digits, **DD =** Check Digits 2) Date : +++DOY/YEAR/SSSDD+++ **DOY =** Day of the Year, **SSS =** Sequence Number, **DD =** Check Digits 3) Customer Reference +++RRR/RRRR/SSSDDD+++ - **R..R =** Customer Reference without non-numeric characters, **SSS =** Sequence Number, **DD =** Check Digits + **R..R =** Customer Reference without non-numeric characters, **SSS =** Sequence Number, **DD =** Check Digits """, 'depends': ['account', 'l10n_be'], 'data' : [ diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index 2c7b833452d0f..9553cc5b69edc 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -88,11 +88,14 @@

- Sent by + Sent + + by + using Odoo.

diff --git a/addons/mail/i18n/mail.pot b/addons/mail/i18n/mail.pot index beb83dc09ebc5..80b1399e13995 100644 --- a/addons/mail/i18n/mail.pot +++ b/addons/mail/i18n/mail.pot @@ -4533,7 +4533,8 @@ msgstr "" #. module: mail #. openerp-web -#: code:addons/mail/static/src/xml/thread.xml:397 +#: code:addons/mail/static/src/xml/thread.xml:390 +#: model_terms:ir.ui.view,arch_db:mail.message_notification_email #: model_terms:ir.ui.view,arch_db:mail.view_mail_search #: selection:mail.mail,state:0 #: selection:mail.notification,email_status:0 @@ -5566,6 +5567,7 @@ msgstr "" #. module: mail #. openerp-web #: code:addons/mail/static/src/xml/activity.xml:43 +#: model_terms:ir.ui.view,arch_db:mail.message_notification_email #: model_terms:ir.ui.view,arch_db:mail.view_mail_form #, python-format msgid "by" diff --git a/addons/mail/models/mail_activity.py b/addons/mail/models/mail_activity.py index 4602fe1ff8edf..d7829ae0a1a49 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -422,8 +422,10 @@ def activity_format(self): @api.model def get_activity_data(self, res_model, domain): - res = self.env[res_model].search(domain) - activity_domain = [('res_id', 'in', res.ids), ('res_model', '=', res_model)] + activity_domain = [('res_model', '=', res_model)] + if domain: + res = self.env[res_model].search(domain) + activity_domain.append(('res_id', 'in', res.ids)) grouped_activities = self.env['mail.activity'].read_group( activity_domain, ['res_id', 'activity_type_id', 'res_name:max(res_name)', 'ids:array_agg(id)', 'date_deadline:min(date_deadline)'], @@ -443,7 +445,6 @@ def get_activity_data(self, res_model, domain): state = self._compute_state_from_date(group['date_deadline'], self.user_id.sudo().tz) activity_data[res_id][activity_type_id] = { 'count': group['__count'], - 'domain': group['__domain'], 'ids': group['ids'], 'state': state, 'o_closest_deadline': group['date_deadline'], diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 497f32fe6288c..17d11c89401b4 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -340,7 +340,7 @@ def message_receive_bounce(self, email, partner, mail_id=None): @api.multi def _notify_email_recipients(self, message, recipient_ids): # Excluded Blacklisted - whitelist = self.env['res.partner'].sudo().search([('id', 'in', recipient_ids), ('is_blacklisted', '=', False)]) + whitelist = self.env['res.partner'].sudo().search([('id', 'in', recipient_ids)]).filtered(lambda p: not p.is_blacklisted) # real mailing list: multiple recipients (hidden by X-Forge-To) if self.alias_domain and self.alias_name: return { diff --git a/addons/mail/models/mail_followers.py b/addons/mail/models/mail_followers.py index ccb245e18d33d..f4f16c2e9f01b 100644 --- a/addons/mail/models/mail_followers.py +++ b/addons/mail/models/mail_followers.py @@ -114,14 +114,14 @@ def _get_recipient_data(self, records, subtype_id, pids=None, cids=None): partner.active as active, partner.partner_share as pshare, NULL as ctype, users.notification_type AS notif, array_agg(groups.id) AS groups FROM res_partner partner - LEFT JOIN res_users users ON users.partner_id = partner.id + LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active LEFT JOIN res_groups_users_rel groups_rel ON groups_rel.uid = users.id LEFT JOIN res_groups groups ON groups.id = groups_rel.gid WHERE EXISTS ( SELECT partner_id FROM sub_followers WHERE sub_followers.channel_id IS NULL AND sub_followers.partner_id = partner.id - AND (sub_followers.internal <> TRUE OR partner.partner_share <> TRUE) + AND (coalesce(sub_followers.internal, false) <> TRUE OR coalesce(partner.partner_share, false) <> TRUE) ) %s GROUP BY partner.id, users.notification_type UNION @@ -148,7 +148,7 @@ def _get_recipient_data(self, records, subtype_id, pids=None, cids=None): partner.active as active, partner.partner_share as pshare, NULL as ctype, users.notification_type AS notif, NULL AS groups FROM res_partner partner -LEFT JOIN res_users users ON users.partner_id = partner.id +LEFT JOIN res_users users ON users.partner_id = partner.id AND users.active WHERE partner.id IN %s""" params.append(tuple(pids)) if cids: diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index aea140127672f..6a50a19834774 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -375,9 +375,6 @@ def _send(self, auto_commit=False, raise_exception=False, smtp_session=None): # /!\ can't use mail.state here, as mail.refresh() will cause an error # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1 mail._postprocess_sent_message(success_pids=success_pids, failure_type=failure_type) - except UnicodeEncodeError as exc: - _logger.exception('UnicodeEncodeError on text "%s" while processing mail ID %r.', exc.object, mail.id) - raise MailDeliveryException(_("Mail Delivery Failed"), "Invalid text: %s" % exc.object) except MemoryError: # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job # instead of marking the mail as failed @@ -398,10 +395,13 @@ def _send(self, auto_commit=False, raise_exception=False, smtp_session=None): mail.write({'state': 'exception', 'failure_reason': failure_reason}) mail._postprocess_sent_message(success_pids=success_pids, failure_reason=failure_reason, failure_type='UNKNOWN') if raise_exception: - if isinstance(e, AssertionError): - # get the args of the original error, wrap into a value and throw a MailDeliveryException - # that is an except_orm, with name and value as arguments - value = '. '.join(e.args) + if isinstance(e, (AssertionError, UnicodeEncodeError)): + if isinstance(e, UnicodeEncodeError): + value = "Invalid text: %s" % e.object + else: + # get the args of the original error, wrap into a value and throw a MailDeliveryException + # that is an except_orm, with name and value as arguments + value = '. '.join(e.args) raise MailDeliveryException(_("Mail Delivery Failed"), value) raise diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index a6ad7be4ee51e..a4feb61b3fb83 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1169,7 +1169,7 @@ def message_route(self, message, message_dict, model=None, thread_id=None, custo final_recipient_data = tools.decode_message_header(dsn, 'Final-Recipient') partner_address = final_recipient_data.split(';', 1)[1].strip() if partner_address: - partners = partners.sudo().search([('email', 'like', partner_address)]) + partners = partners.sudo().search([('email', '=', partner_address)]) for partner in partners: partner.message_receive_bounce(partner_address, partner, mail_id=bounced_mail_id) diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index a6cdb39e2de9b..8a533cd451557 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -211,7 +211,8 @@ var BasicComposer = Widget.extend({ * displayed to the user. If none of them match, then it will fetch for more * partner suggestions (@see _mentionFetchPartners). * - * @param {$.Deferred} prefetchedPartners + * @param {$.Deferred} prefetchedPartners list of list of + * prefetched partners. */ mentionSetPrefetchedPartners: function (prefetchedPartners) { this._mentionPrefetchedPartners = prefetchedPartners; @@ -302,21 +303,23 @@ var BasicComposer = Widget.extend({ */ _mentionFetchPartners: function (search) { var self = this; - return $.when(this._mentionPrefetchedPartners).then(function (partners) { + return $.when(this._mentionPrefetchedPartners).then(function (prefetchedPartners) { // filter prefetched partners with the given search string var suggestions = []; var limit = self.options.mentionFetchLimit; var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i'); - if (limit > 0) { - var filteredPartners = _.filter(partners, function (partner) { - return partner.email && searchRegexp.test(partner.email) || - partner.name && searchRegexp.test(mailUtils.unaccent(partner.name)); - }); - if (filteredPartners.length) { - suggestions.push(filteredPartners.slice(0, limit)); - limit -= filteredPartners.length; + _.each(prefetchedPartners, function (partners) { + if (limit > 0) { + var filteredPartners = _.filter(partners, function (partner) { + return partner.email && searchRegexp.test(partner.email) || + partner.name && searchRegexp.test(mailUtils.unaccent(partner.name)); + }); + if (filteredPartners.length) { + suggestions.push(filteredPartners.slice(0, limit)); + limit -= filteredPartners.length; + } } - } + }); if (!suggestions.length && !self.options.mentionPartnersRestricted) { // no result found among prefetched partners, fetch other suggestions suggestions = self._mentionFetchThrottled( diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js index c733b74f34735..4342b1e244afa 100644 --- a/addons/mail/static/src/js/models/messages/message.js +++ b/addons/mail/static/src/js/models/messages/message.js @@ -262,6 +262,7 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin documentID: this.getDocumentID(), id: id, imageSRC: this._getModuleIcon() || this.getAvatarSource(), + messageID: this.getID(), status: this.status, title: title, }; @@ -457,6 +458,9 @@ var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin */ setModerationStatus: function (newModerationStatus, options) { var self = this; + if (newModerationStatus === this._moderationStatus) { + return; + } this._moderationStatus = newModerationStatus; if (newModerationStatus === 'accepted' && options) { _.each(options.additionalThreadIDs, function (threadID) { diff --git a/addons/mail/static/src/js/models/threads/channel.js b/addons/mail/static/src/js/models/threads/channel.js index 695275e799e68..808e8b7fbcace 100644 --- a/addons/mail/static/src/js/models/threads/channel.js +++ b/addons/mail/static/src/js/models/threads/channel.js @@ -179,7 +179,8 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { /** * Get listeners of a channel * - * @returns {$.Promise} resolved with list of channel listeners + * @returns {$.Promise>} resolved with list of list of + * channel listeners. */ getMentionPartnerSuggestions: function () { var self = this; @@ -193,7 +194,7 @@ var Channel = SearchableThread.extend(ThreadTypingMixin, { }) .then(function (members) { self._members = members; - return members; + return [members]; }); } return this._membersDef; diff --git a/addons/mail/static/src/js/models/threads/livechat.js b/addons/mail/static/src/js/models/threads/livechat.js index b7235e7bc0ec0..f4cb58ebad363 100644 --- a/addons/mail/static/src/js/models/threads/livechat.js +++ b/addons/mail/static/src/js/models/threads/livechat.js @@ -37,7 +37,8 @@ var Livechat = TwoUserChannel.extend({ * display of a user that is typing. * * @override - * @returns {$.Promise} resolved with list of livechat members + * @returns {$.Promise>} resolved with list of list of + * livechat members. */ getMentionPartnerSuggestions: function () { var self = this; @@ -49,7 +50,7 @@ var Livechat = TwoUserChannel.extend({ name: self._WEBSITE_USER_NAME, }); } - return self._members; + return [self._members]; }); }, /** diff --git a/addons/mail/static/src/js/models/threads/mailbox.js b/addons/mail/static/src/js/models/threads/mailbox.js index 28dbc4b71634c..82d65bdd6cc92 100644 --- a/addons/mail/static/src/js/models/threads/mailbox.js +++ b/addons/mail/static/src/js/models/threads/mailbox.js @@ -45,28 +45,6 @@ var Mailbox = SearchableThread.extend({ num = _.isNumber(num) ? num : 1; this._mailboxCounter = Math.max(this._mailboxCounter - num, 0); }, - /** - * Override so that there are options to filter messages based on document - * model and ID. - * - * @override - * @param {Object} [options] - * @param {string} [options.documentModel] model of the document that the - * local messages of inbox must be linked to. - * @param {integer} [options.documentID] ID of the document that the local - * messages of inbox must be linked to. - * @returns {mail.model.Message[]} - */ - getMessages: function (options) { - var messages = this._super.apply(this, arguments); - if (options.documentModel && options.documentID) { - return _.filter(messages, function (message) { - return message.getDocumentModel() === options.documentModel && - message.getDocumentID() === options.documentID; - }); - } - return messages; - }, /** * Get the mailbox counter of this mailbox. * @@ -89,31 +67,40 @@ var Mailbox = SearchableThread.extend({ return this.fetchMessages().then(function (messages) { // pick only last message of chatter // items = list of objects - // { unreadCounter: integer, message: mail.model.Message } + // { + // unreadCounter: {integer}, + // message: {mail.model.Message}, + // messageIDs: {integer[]}, + // } var items = []; _.each(messages, function (message) { var unreadCounter = 1; + var messageIDs = [message.getID()]; var similarItem = _.find(items, function (item) { return self._areMessagesFromSameDocumentThread(item.message, message) || self._areMessagesFromSameChannel(item.message, message); }); if (similarItem) { unreadCounter = similarItem.unreadCounter + 1; + messageIDs = similarItem.messageIDs.concat(messageIDs); var index = _.findIndex(items, similarItem); items[index] = { unreadCounter: unreadCounter, message: message, + messageIDs: messageIDs }; } else { items.push({ unreadCounter: unreadCounter, message: message, + messageIDs: messageIDs, }); } }); return _.map(items, function (item) { return _.extend(item.message.getPreview(), { unreadCounter: item.unreadCounter, + messageIDs: item.messageIDs, }); }); }); diff --git a/addons/mail/static/src/js/models/threads/thread.js b/addons/mail/static/src/js/models/threads/thread.js index c9dda28dd9306..8c2beb8bbfa1c 100644 --- a/addons/mail/static/src/js/models/threads/thread.js +++ b/addons/mail/static/src/js/models/threads/thread.js @@ -15,7 +15,8 @@ var ServicesMixin = require('web.ServicesMixin'); * In particular, channels and mailboxes are two different kinds of threads. */ var Thread = AbstractThread.extend(ServicesMixin, { - + // max number of fetched messages from the server + _FETCH_LIMIT: 30, /** * @override * @param {Object} params @@ -33,8 +34,6 @@ var Thread = AbstractThread.extend(ServicesMixin, { // means that there is no message in this channel. this._previewed = false; this._type = params.data.type || params.data.channel_type; - // max number of fetched messages from the server - this._FETCH_LIMIT = 30; }, //-------------------------------------------------------------------------- @@ -111,7 +110,7 @@ var Thread = AbstractThread.extend(ServicesMixin, { * By default, a thread has not listener. * * @abstract - * @returns {$.Promise} + * @returns {$.Promise>} */ getMentionPartnerSuggestions: function () { return $.when([]); diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js index 734f12f0cf3ab..c21089b7095d2 100644 --- a/addons/mail/static/src/js/services/mail_manager.js +++ b/addons/mail/static/src/js/services/mail_manager.js @@ -199,7 +199,7 @@ var MailManager = AbstractService.extend({ * Get partners as mentions from a chatter * Typically all employees as partner suggestions. * - * @returns {Array} + * @returns {Array>} */ getMentionPartnerSuggestions: function () { return this._mentionPartnerSuggestions; @@ -1195,8 +1195,8 @@ var MailManager = AbstractService.extend({ * * @private * @param {Object} result data from server on mail/init_messaging rpc - * @param {Object[]} result.mention_partner_suggestions list of suggestions - * with all the employees + * @param {Array} result.mention_partner_suggestions list of + * suggestions. * @param {integer} result.menu_id the menu ID of discuss app */ _updateInternalStateFromServer: function (result) { diff --git a/addons/mail/static/src/js/systray/systray_messaging_menu.js b/addons/mail/static/src/js/systray/systray_messaging_menu.js index 5a68713150459..d86e9e3913b39 100644 --- a/addons/mail/static/src/js/systray/systray_messaging_menu.js +++ b/addons/mail/static/src/js/systray/systray_messaging_menu.js @@ -282,7 +282,11 @@ var MessagingMenu = Widget.extend({ // e.g. needaction message of channel var documentID = $target.data('document-id'); var documentModel = $target.data('document-model'); - this._openDocument(documentModel, documentID); + if (!documentModel) { + this._openDiscuss('mailbox_inbox'); + } else { + this._openDocument(documentModel, documentID); + } } else { // preview of thread this.call('mail_service', 'openThread', previewID); @@ -300,19 +304,11 @@ var MessagingMenu = Widget.extend({ var thread; var $preview = $(ev.currentTarget).closest('.o_mail_preview'); var previewID = $preview.data('preview-id'); - var documentModel = $preview.data('document-model'); if (previewID === 'mailbox_inbox') { - var documentID = $preview.data('document-id'); - var inbox = this.call('mail_service', 'getMailbox', 'inbox'); - var messages = inbox.getMessages({ - documentModel: documentModel, - documentID: documentID, - }); - var messageIDs = _.map(messages, function (message) { - return message.getID(); - }); + var messageIDs = [].concat($preview.data('message-ids')); this.call('mail_service', 'markMessagesAsRead', messageIDs); } else if (previewID === 'mail_failure') { + var documentModel = $preview.data('document-model'); var unreadCounter = $preview.data('unread-counter'); this.do_action('mail.mail_resend_cancel_action', { additional_context: { diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js index f6ba0ccecca74..716b86400be7e 100644 --- a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js +++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js @@ -79,7 +79,6 @@ var AbstractThreadWindow = Widget.extend({ this.$header = this.$('.o_thread_window_header'); this._threadWidget = new ThreadWidget(this, { - displayDocumentLinks: false, displayMarkAsRead: false, displayStars: this.options.displayStars, }); diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml index a09b7a6f754b4..9d97de471cef6 100644 --- a/addons/mail/static/src/xml/discuss.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -267,10 +267,11 @@ @param {string} [preview.title] @param {string} [preview.status] @param {integer} [preview.unreadCounter] + @param {integer[]} [preview.messageIDs] -->
+ t-att-data-preview-id="preview.id" t-att-data-document-id="preview.documentID" t-att-data-document-model="preview.documentModel" t-att-data-unread-counter="preview.unreadCounter" t-att-data-message-ids="preview.messageIDs">
Preview diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index b379bc9050b96..4bcce4ab3520c 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -2450,6 +2450,124 @@ QUnit.test('chatter: suggested partner auto-follow on message post', function (a form.destroy(); }); +QUnit.test('chatter: mention prefetched partners (followers & employees)', function (assert) { + // Note: employees are in prefeteched partner for mentions in chatter when + // the module hr is installed. + assert.expect(10); + + var followerSuggestions = [{ + id: 1, + name: 'FollowerUser1', + email: 'follower-user1@example.com', + }, { + id: 2, + name: 'FollowerUser2', + email: 'follower-user2@example.com', + }]; + + var nonFollowerSuggestions = [{ + id: 3, + name: 'NonFollowerUser1', + email: 'non-follower-user1@example.com', + }, { + id: 4, + name: 'NonFollowerUser2', + email: 'non-follower-user2@example.com', + }]; + + // link followers + this.data.partner.records[0].message_follower_ids = [10, 20]; + + // prefetched partners + this.data.initMessaging = { + mention_partner_suggestions: [followerSuggestions.concat(nonFollowerSuggestions)], + }; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + services: this.services, + arch: '
' + + '' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '', + res_id: 2, + mockRPC: function (route, args) { + if (route === '/mail/read_followers') { + return $.when({ + followers: [{ + id: 10, + name: 'FollowerUser1', + email: 'follower-user1@example.com', + res_model: 'res.partner', + res_id: 1, + }, { + id: 20, + name: 'FollowerUser2', + email: 'follower-user2@example.com', + res_model: 'res.partner', + res_id: 2, + }], + subtypes: [], + }); + } + if (args.method === 'message_get_suggested_recipients') { + return $.when({2: []}); + } + if (args.method === 'get_mention_suggestions') { + throw new Error('should not fetch partners for mentions'); + } + return this._super(route, args); + }, + session: {}, + }); + + assert.strictEqual(form.$('.o_followers_count').text(), '2', + "should have two followers of this document"); + assert.strictEqual(form.$('.o_followers_list > .o_partner').text().replace(/\s+/g, ''), + 'FollowerUser1FollowerUser2', + "should have correct follower names"); + assert.strictEqual(form.$('.o_composer_mention_dropdown').length, 0, + "should not show the mention suggestion dropdown"); + + form.$('.o_chatter_button_new_message').click(); + var $input = form.$('.oe_chatter .o_composer_text_field:first()'); + $input.val('@'); + // the cursor position must be set for the mention manager to detect that we are mentionning + $input[0].selectionStart = 1; + $input[0].selectionEnd = 1; + $input.trigger('keyup'); + + assert.strictEqual(form.$('.o_composer_mention_dropdown').length, 1, + "should show the mention suggestion dropdown"); + + assert.strictEqual(form.$('.o_mention_proposition').length, 4, + "should show 4 mention suggestions"); + assert.strictEqual(form.$('.o_mention_proposition').eq(0).text().replace(/\s+/g, ''), + "FollowerUser1(follower-user1@example.com)", + "should display correct 1st mention suggestion"); + assert.strictEqual(form.$('.o_mention_proposition').eq(1).text().replace(/\s+/g, ''), + "FollowerUser2(follower-user2@example.com)", + "should display correct 2nd mention suggestion"); + assert.ok(form.$('.o_mention_proposition').eq(1).next().hasClass('dropdown-divider'), + "should have a mention separator after last follower mention suggestion"); + assert.strictEqual(form.$('.o_mention_proposition').eq(2).text().replace(/\s+/g, ''), + "NonFollowerUser1(non-follower-user1@example.com)", + "should display correct 3rd mention suggestion"); + assert.strictEqual(form.$('.o_mention_proposition').eq(3).text().replace(/\s+/g, ''), + "NonFollowerUser2(non-follower-user2@example.com)", + "should display correct 4th mention suggestion"); + + //cleanup + form.destroy(); +}); + QUnit.module('FieldMany2ManyTagsEmail', { beforeEach: function () { this.data = { diff --git a/addons/mail/static/tests/discuss_moderation_tests.js b/addons/mail/static/tests/discuss_moderation_tests.js index 51fb7b55c615d..4d8e48b4dcbda 100644 --- a/addons/mail/static/tests/discuss_moderation_tests.js +++ b/addons/mail/static/tests/discuss_moderation_tests.js @@ -1,6 +1,7 @@ odoo.define('mail.discuss_moderation_tests', function (require) { "use strict"; +var Thread = require('mail.model.Thread'); var mailTestUtils = require('mail.testUtils'); var createDiscuss = mailTestUtils.createDiscuss; @@ -760,5 +761,84 @@ QUnit.test('author: sent message rejected in moderated channel', function (asser }); }); +QUnit.test('no crash when load-more fetching "accepted" message twice', function (assert) { + // This tests requires discuss not loading more messages due to having less + // messages to fetch than available height. This justifies we simply do not + // patch FETCH_LIMIT to 1, as it would detect that more messages could fit + // the empty space (it behaviour is linked to "auto load more"). + var done = assert.async(); + assert.expect(2); + + var FETCH_LIMIT = Thread.prototype._FETCH_LIMIT; + // FETCH LIMIT + 30 should be enough to cover the whole available space in + // the thread of discuss app. + var messageData = []; + _.each(_.range(1, FETCH_LIMIT+31), function (num) { + messageData.push({ + id: num, + body: "

test" + num + "

", + author_id: [100, "Someone"], + channel_ids: [1], + model: 'mail.channel', + res_id: 1, + moderation_status: 'accepted', + } + ); + }); + + this.data['mail.message'].records = messageData; + + this.data.initMessaging = { + channel_slots: { + channel_channel: [{ + id: 1, + channel_type: "channel", + name: "general", + }], + }, + }; + var count = 0; + + createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + session: { partner_id: 3 }, + mockRPC: function (route, args) { + if (args.method === 'message_fetch') { + count++; + if (count === 1) { + // inbox message_fetch + return $.when([]); + } + // general message_fetch + return $.when(messageData); + } + return this._super.apply(this, arguments); + }, + }) + .then(function (discuss) { + var $general = discuss.$('.o_mail_discuss_sidebar') + .find('.o_mail_discuss_item[data-thread-id=1]'); + assert.strictEqual($general.length, 1, + "should have the channel item with id 1"); + assert.strictEqual($general.attr('title'), 'general', + "should have the title 'general'"); + + // click on general + $general.click(); + + // simulate search + discuss.trigger_up('search', { + domains: [['author_id', '=', 100]], + }); + + discuss.destroy(); + done(); + }); +}); + }); }); diff --git a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js index 02c7ec86e6b8b..1b608f5c37be1 100644 --- a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js @@ -146,8 +146,8 @@ QUnit.test('messaging menu widget: messaging menu with 1 record', function (asse messagingMenu.destroy(); }); -QUnit.test('messaging menu widget: no crash when clicking on inbox notification not associated to a document', function (assert) { - assert.expect(3); +QUnit.test('messaging menu widget: open inbox for needaction not linked to any document', function (assert) { + assert.expect(4); var messagingMenu = new MessagingMenu(); testUtils.addMockEnvironment(messagingMenu, { @@ -156,17 +156,6 @@ QUnit.test('messaging menu widget: no crash when clicking on inbox notification session: { partner_id: 1, }, - intercepts: { - /** - * Simulate action 'mail.action_discuss' successfully performed. - * - * @param {OdooEvent} ev - * @param {function} ev.data.on_success called when success action performed - */ - do_action: function (ev) { - ev.data.on_success(); - }, - }, }); messagingMenu.appendTo($('#qunit-fixture')); @@ -195,12 +184,18 @@ QUnit.test('messaging menu widget: no crash when clicking on inbox notification assert.strictEqual($firstChannelPreview.data('preview-id'), 'mailbox_inbox', "should be a preview from channel inbox"); - try { - $firstChannelPreview.click(); - assert.ok(true, "should not have crashed when clicking on needaction preview message"); - } finally { - messagingMenu.destroy(); - } + + testUtils.intercept(messagingMenu, 'do_action', function (ev) { + if (ev.data.action === 'mail.action_discuss') { + assert.step('do_action:' + ev.data.action + ':' + ev.data.options.active_id); + } + }, true); + $firstChannelPreview.click(); + assert.verifySteps( + ['do_action:mail.action_discuss:mailbox_inbox'], + "should open Discuss with Inbox"); + + messagingMenu.destroy(); }); QUnit.test("messaging menu widget: mark as read on thread preview", function ( assert ) { @@ -432,5 +427,164 @@ QUnit.test('update messaging preview on receiving a new message in channel previ messagingMenu.destroy(); }); +QUnit.test('preview of inbox message not linked to document + mark as read', function (assert) { + assert.expect(17); + + this.data.initMessaging = { + needaction_inbox_counter: 2, + }; + + var needactionMessages = [{ + author_id: [1, "Demo"], + body: "

*Message1*

", + id: 689, + needaction: true, + needaction_partner_ids: [44], + }, { + author_id: [1, "Demo"], + body: "

*Message2*

", + id: 690, + needaction: true, + needaction_partner_ids: [44], + }]; + this.data['mail.message'].records = + this.data['mail.message'].records.concat(needactionMessages); + + var messagingMenu = new MessagingMenu(); + testUtils.addMockEnvironment(messagingMenu, { + services: this.services, + data: this.data, + session: { + partner_id: 44, + }, + mockRPC: function (route, args) { + if (args.method === 'set_message_done') { + assert.step({ + method: 'set_message_done', + messageIDs: args.args[0], + }); + } + return this._super.apply(this, arguments); + }, + }); + messagingMenu.appendTo($('#qunit-fixture')); + assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '2', + "should display a counter of 2 on the messaging menu icon"); + + messagingMenu.$('.dropdown-toggle').click(); + + assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 2, + "should display two previews"); + + var $preview1 = messagingMenu.$('.o_mail_preview').eq(0); + var $preview2 = messagingMenu.$('.o_mail_preview').eq(1); + + assert.strictEqual($preview1.data('preview-id'), + "mailbox_inbox", + "1st preview should be from the mailbox inbox"); + assert.strictEqual($preview2.data('preview-id'), + "mailbox_inbox", + "2nd preview should also be from the mailbox inbox"); + assert.ok($preview1.hasClass('o_preview_unread'), + "1st preview should be marked as unread"); + assert.ok($preview2.hasClass('o_preview_unread'), + "2nd preview should also be marked as unread"); + assert.strictEqual($preview1.find('.o_last_message_preview').text().replace(/\s/g, ''), + "Demo:*Message1*", "should correctly display the 1st preview"); + assert.strictEqual($preview2.find('.o_last_message_preview').text().replace(/\s/g, ''), + "Demo:*Message2*", "should correctly display the 2nd preview"); + + $preview1.find('.o_mail_preview_mark_as_read').click(); + assert.verifySteps([{ + method: 'set_message_done', + messageIDs: [689], + }], "should mark 1st preview as read"); + assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '1', + "should display a counter of 1 on the messaging menu icon after marking one preview as read"); + assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 1, + "should display a single preview remaining"); + assert.strictEqual(messagingMenu.$('.o_mail_preview .o_last_message_preview').text().replace(/\s/g, ''), + "Demo:*Message2*", "preview 2 should be the remaining one"); + + $preview2 = messagingMenu.$('.o_mail_preview'); + $preview2.find('.o_mail_preview_mark_as_read').click(); + assert.verifySteps([{ + method: 'set_message_done', + messageIDs: [689], + }, { + method: 'set_message_done', + messageIDs: [690], + }], "should mark 2nd preview as read"); + assert.strictEqual(messagingMenu.$('.o_notification_counter').text(), '0', + "should display a counter of 0 on the messaging menu icon after marking both previews as read"); + assert.strictEqual(messagingMenu.$('.o_mail_preview').length, 0, + "should display no preview remaining"); + + messagingMenu.destroy(); +}); + +QUnit.test('grouped preview for needaction messages linked to same document', function (assert) { + assert.expect(5); + + // simulate two (read) needaction (mention) messages in channel 'general' + var needactionMessage1 = { + author_id: [1, "Demo"], + body: "

@Administrator: ping

", + channel_ids: [1], + id: 3, + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [44], + record_name: 'general', + res_id: 1, + }; + var needactionMessage2 = { + author_id: [2, "Other"], + body: "

@Administrator: pong

", + channel_ids: [1], + id: 4, + model: 'mail.channel', + needaction: true, + needaction_partner_ids: [44], + record_name: 'general', + res_id: 1, + }; + this.data['mail.message'].records = [needactionMessage1, needactionMessage2]; + this.data.initMessaging.channel_slots.channel_channel[0].message_unread_counter = 0; + this.data.initMessaging.needaction_inbox_counter = 2; + + var messagingMenu = new MessagingMenu(); + testUtils.addMockEnvironment(messagingMenu, { + services: this.services, + data: this.data, + session: { + partner_id: 44, + }, + }); + messagingMenu.appendTo($('#qunit-fixture')); + + messagingMenu.$('.dropdown-toggle').click(); + var $previews = messagingMenu.$('.o_mail_preview'); + + assert.strictEqual($previews.length, 2, + "should display two previews (one for needaction, one for channel)"); + + var $needactionPreview = $previews.eq(0); + var $channelPreview = $previews.eq(1); + + assert.strictEqual($needactionPreview.find('.o_preview_counter').text().trim(), "(2)", + "should show two needaction messages in the needaction preview"); + assert.strictEqual($needactionPreview.find('.o_last_message_preview').text().replace(/\s/g, ""), + "Other:@Administrator:pong", + "should display last needaction message on needaction preview"); + assert.strictEqual($channelPreview.find('.o_preview_counter').text().trim(), "", + "should show no unread messages in the channel preview"); + assert.strictEqual($channelPreview.find('.o_last_message_preview').text().replace(/\s/g, ""), + "Other:@Administrator:pong", + "should display last needaction message on channel preview"); + + messagingMenu.destroy(); +}); + }); }); diff --git a/addons/mail/static/tests/thread_window/basic_thread_window_tests.js b/addons/mail/static/tests/thread_window/basic_thread_window_tests.js index f05fd3676977a..4b8935e81cf0b 100644 --- a/addons/mail/static/tests/thread_window/basic_thread_window_tests.js +++ b/addons/mail/static/tests/thread_window/basic_thread_window_tests.js @@ -353,6 +353,81 @@ QUnit.test('do not mark as read the newly open thread window from received messa parent.destroy(); }); +QUnit.test('show document link of message linked to a document', function (assert) { + assert.expect(6); + + this.data['mail.channel'] = { + fields: { + name: { + string: "Name", + type: "char", + required: true, + }, + channel_type: { + string: "Channel Type", + type: "selection", + }, + channel_message_ids: { + string: "Messages", + type: "many2many", + relation: 'mail.message' + }, + message_unread_counter: { + string: "Amount of Unread Messages", + type: "integer" + }, + }, + records: [{ + id: 2, + name: "R&D Tasks", + channel_type: "channel", + }], + }; + this.data['mail.message'].records.push({ + author_id: [5, "Someone else"], + body: "

Test message

", + id: 40, + model: 'some.document', + record_name: 'Some Document', + res_id: 10, + channel_ids: [2], + }); + + this.data.initMessaging.channel_slots.channel_channel.push({ + id: 2, + name: "R&D Tasks", + channel_type: "public", + }); + + var parent = this.createParent({ + data: this.data, + services: this.services, + session: { partner_id: 3 }, + }); + + assert.strictEqual($('.o_thread_window').length, 0, + "no thread window should be open initially"); + + // get channel instance to link to thread window + var channel = parent.call('mail_service', 'getChannel', 2); + channel.detach(); + + var $threadWindow = $('.o_thread_window'); + assert.strictEqual($threadWindow.length, 1, + "a thread window should be open"); + assert.strictEqual($threadWindow.find('.o_thread_window_title').text().trim(), + "#R&D Tasks", + "should be thread window of correct channel"); + assert.strictEqual($threadWindow.find('.o_thread_message').length, 1, + "should contain a single message in thread window"); + assert.ok($threadWindow.find('.o_mail_info').text().replace(/\s/g, "").indexOf('Someoneelse') !== -1, + "message should be from 'Someone else' user"); + assert.ok($threadWindow.find('.o_mail_info').text().replace(/\s/g, "").indexOf('onSomeDocument') !== -1, + "message should link to 'Some Document'"); + + parent.destroy(); +}); + }); }); }); diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index 0e14619726011..643c03db195a4 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -114,14 +114,16 @@ def _compute_contact_nbr(self): from mail_mass_mailing_contact_list_rel r left join mail_mass_mailing_contact c on (r.contact_id=c.id) + left join mail_blacklist bl on (LOWER(substring(c.email, %s)) = bl.email and bl.active) where + list_id in %s COALESCE(r.opt_out,FALSE) = FALSE - AND substring(c.email, '%s') IS NOT NULL - AND LOWER(substring(c.email, '%s')) NOT IN (select email from mail_blacklist where active = TRUE) + AND c.email IS NOT NULL + AND bl.id IS NULL group by list_id - ''' % (EMAIL_PATTERN, EMAIL_PATTERN)) - data = dict(self.env.cr.fetchall()) + ''') + data = dict(self.env.cr.fetchall(), [EMAIL_PATTERN, tuple(self.ids)]) for mailing_list in self: mailing_list.contact_nbr = data.get(mailing_list.id, 0) @@ -767,7 +769,8 @@ def _get_opt_out_list(self): [('list_id', 'in', self.contact_list_ids.ids)]) opt_out_contacts = target_list_contacts.filtered(lambda rel: rel.opt_out).mapped('contact_id.email') opt_in_contacts = target_list_contacts.filtered(lambda rel: not rel.opt_out).mapped('contact_id.email') - opt_out = set(c for c in opt_out_contacts if c not in opt_in_contacts) + normalized_email = [tools.email_split(c) for c in opt_out_contacts if c not in opt_in_contacts] + opt_out = set(email[0].lower() for email in normalized_email if email) _logger.info( "Mass-mailing %s targets %s, blacklist: %s emails", diff --git a/addons/mass_mailing/static/src/css/mass_mailing_popup.css b/addons/mass_mailing/static/src/css/mass_mailing_popup.css index 2408f53f2183d..1ebd8b85beba7 100644 --- a/addons/mass_mailing/static/src/css/mass_mailing_popup.css +++ b/addons/mass_mailing/static/src/css/mass_mailing_popup.css @@ -152,7 +152,6 @@ fieldset[disabled] #o_newsletter_popup .btn.btn-success.active { } #o_newsletter_popup .o_popup_modal_content { border-radius: 2px; - margin: 20px; box-shadow: 0 0 20px rgba(255, 255, 255, 0.11); -webkit-box-shadow: 0 0 20px rgba(255, 255, 255, 0.11); border: 1px solid #767676; diff --git a/addons/mass_mailing_sale/models/mass_mailing.py b/addons/mass_mailing_sale/models/mass_mailing.py index 8257d14db6f65..723bdee7b1a5d 100644 --- a/addons/mass_mailing_sale/models/mass_mailing.py +++ b/addons/mass_mailing_sale/models/mass_mailing.py @@ -14,15 +14,21 @@ class MassMailing(models.Model): @api.depends('mailing_domain') def _compute_sale_quotation_count(self): + has_so_access = self.env['sale.order'].check_access_rights('read', raise_exception=False) for mass_mailing in self: - mass_mailing.sale_quotation_count = self.env['sale.order'].search_count(self._get_sale_utm_domain()) + mass_mailing.sale_quotation_count = self.env['sale.order'].search_count(self._get_sale_utm_domain()) if has_so_access else 0 @api.depends('mailing_domain') def _compute_sale_invoiced_amount(self): + has_so_access = self.env['sale.order'].check_access_rights('read', raise_exception=False) + has_invoice_report_access = self.env['account.invoice.report'].check_access_rights('read', raise_exception=False) for mass_mailing in self: - invoices = self.env['sale.order'].search(self._get_sale_utm_domain()).mapped('invoice_ids') - res = self.env['account.invoice.report'].search_read([('invoice_id', 'in', invoices.ids)], ['user_currency_price_total']) - mass_mailing.sale_invoiced_amount = sum(r['user_currency_price_total'] for r in res) + if has_so_access and has_invoice_report_access: + invoices = self.env['sale.order'].search(self._get_sale_utm_domain()).mapped('invoice_ids') + res = self.env['account.invoice.report'].search_read([('invoice_id', 'in', invoices.ids)], ['user_currency_price_total']) + mass_mailing.sale_invoiced_amount = sum(r['user_currency_price_total'] for r in res) + else: + mass_mailing.sale_invoiced_amount = 0 @api.multi def action_redirect_to_quotations(self): diff --git a/addons/membership/views/partner_views.xml b/addons/membership/views/partner_views.xml index d7235e3998aac..739f0cff968a5 100644 --- a/addons/membership/views/partner_views.xml +++ b/addons/membership/views/partner_views.xml @@ -28,9 +28,9 @@ - + - + diff --git a/addons/membership/views/product_views.xml b/addons/membership/views/product_views.xml index 80192e9bad843..1acbfaa183cdb 100644 --- a/addons/membership/views/product_views.xml +++ b/addons/membership/views/product_views.xml @@ -12,7 +12,7 @@ - + diff --git a/addons/mrp/models/stock_warehouse.py b/addons/mrp/models/stock_warehouse.py index a03ab6ddc3622..c66d15131c653 100644 --- a/addons/mrp/models/stock_warehouse.py +++ b/addons/mrp/models/stock_warehouse.py @@ -108,6 +108,7 @@ def _get_global_route_rules_values(self): 'create_values': { 'action': 'manufacture', 'procure_method': 'make_to_order', + 'company_id': self.company_id.id, 'picking_type_id': self.manu_type_id.id, 'route_id': self._find_global_route('mrp.route_warehouse0_manufacture', _('Manufacture')).id }, diff --git a/addons/point_of_sale/data/point_of_sale_data.xml b/addons/point_of_sale/data/point_of_sale_data.xml index eef8edee6cee0..e35a32fc7068f 100644 --- a/addons/point_of_sale/data/point_of_sale_data.xml +++ b/addons/point_of_sale/data/point_of_sale_data.xml @@ -34,6 +34,7 @@ Tips TIPS + diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index b991609ca890d..59ec2f0b80177 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -437,6 +437,8 @@ td { display: flex; -webkit-flex: 1; flex: 1; + max-width: -moz-available; + max-width: -webkit-fill-available; } .pos .orders { display: -webkit-flex; diff --git a/addons/pos_restaurant/static/src/js/multiprint.js b/addons/pos_restaurant/static/src/js/multiprint.js index e61f8d1097ed6..c48ecae1c5d02 100644 --- a/addons/pos_restaurant/static/src/js/multiprint.js +++ b/addons/pos_restaurant/static/src/js/multiprint.js @@ -139,8 +139,10 @@ models.Orderline = models.Orderline.extend({ } }, set_dirty: function(dirty) { - this.mp_dirty = dirty; - this.trigger('change',this); + if (this.mp_dirty !== dirty) { + this.mp_dirty = dirty; + this.trigger('change', this); + } }, get_line_diff_hash: function(){ if (this.get_note()) { diff --git a/addons/product/i18n/product.pot b/addons/product/i18n/product.pot index b039d4603602f..a72f3ca5eaed7 100644 --- a/addons/product/i18n/product.pot +++ b/addons/product/i18n/product.pot @@ -15,15 +15,6 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" -#. module: product -#: code:addons/product/models/product_template.py:469 -#, python-format -msgid "\n" -" The number of variants to generate is too high.\n" -" You should either not generate variants for each combination or generate them on demand from the sales order.\n" -" To do so, open the form view of attributes and change the mode of *Create Variants*." -msgstr "" - #. module: product #: selection:product.pricelist.item,applied_on:0 msgid " Product Category" @@ -1220,6 +1211,11 @@ msgstr "" msgid "Never" msgstr "" +#. module: product +#: model_terms:ir.ui.view,arch_db:product.product_pricelist_item_form_view +msgid "New Price =" +msgstr "" + #. module: product #: model:ir.model.fields,field_description:product.field_product_product__activity_date_deadline #: model:ir.model.fields,field_description:product.field_product_template__activity_date_deadline @@ -2012,7 +2008,13 @@ msgid "The number of products under this category (Does not consider the childre msgstr "" #. module: product -#: code:addons/product/models/product_attribute.py:55 +#: code:addons/product/models/product_template.py:475 +#, python-format +msgid "The number of variants to generate is too high. You should either not generate variants for each combination or generate them on demand from the sales order. To do so, open the form view of attributes and change the mode of *Create Variants*." +msgstr "" + +#. module: product +#: code:addons/product/models/product_attribute.py:56 #, python-format msgid "The operation cannot be completed:\n" "You are trying to delete an attribute value with a reference on a product variant." diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 0806370c998d7..b1b2340f16dbf 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -604,6 +604,22 @@ def get_product_multiline_description_sale(self): return name + def _has_valid_attributes(self, valid_attributes, valid_values): + """ Check if a product has valid attributes. It is considered valid if: + - it uses ALL valid attributes + - it ONLY uses valid values + We must make sure that all attributes are used to take into account the case where + attributes would be added to the template. + + :param valid_attributes: a recordset of product.attribute + :param valid_values: a recordset of product.attribute.value + :return: True if the attibutes and values are correct, False instead + """ + self.ensure_one() + values = self.attribute_value_ids.filtered(lambda v: v.attribute_id.create_variant != 'no_variant') + attributes = values.mapped('attribute_id') + return attributes == valid_attributes and values <= valid_values + class ProductPackaging(models.Model): _name = "product.packaging" diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 102ba1ca10957..1ccf742bd9a52 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -434,7 +434,6 @@ def _price_get(self, products, ptype='list_price'): @api.multi def create_variant_ids(self): Product = self.env["product.product"] - AttributeValues = self.env['product.attribute.value'] variants_to_create = [] variants_to_activate = [] @@ -448,34 +447,50 @@ def create_variant_ids(self): updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id')) updated_products.write({'attribute_value_ids': [(4, value_id.id)]}) - # iterator of n-uple of product.attribute.value *ids* - variant_matrix = [ - AttributeValues.browse(value_ids) - for value_ids in itertools.product(*(line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant')) - ] - - # get the value (id) sets of existing variants - existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids) for variant in tmpl_id.product_variant_ids} - # -> for each value set, create a recordset of values to create a - # variant for if the value set isn't already a variant - for value_ids in variant_matrix: - if set(value_ids.ids) not in existing_variants and not any(value_id.attribute_id.create_variant == 'dynamic' for value_id in value_ids): - variants_to_create.append({ - 'product_tmpl_id': tmpl_id.id, - 'attribute_value_ids': [(6, 0, value_ids.ids)] - }) - - if len(variants_to_create) > 1000: - raise UserError(_(""" - The number of variants to generate is too high. - You should either not generate variants for each combination or generate them on demand from the sales order. - To do so, open the form view of attributes and change the mode of *Create Variants*.""")) - - # check product + # Determine which product variants need to be created based on the attribute + # configuration. If any attribute is set to generate variants dynamically, skip the + # process. + # Technical note: if there is no attribute, a variant is still created because + # 'not any([])' and 'set([]) not in set([])' are True. + if not any(attrib.create_variant == 'dynamic' for attrib in tmpl_id.mapped('attribute_line_ids.attribute_id')): + # Iterator containing all possible attribute values combination + # The iterator is used to avoid MemoryError in case of a huge number of combination. + all_variants = itertools.product(*( + line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant' + )) + # Set containing existing attribute values combination + existing_variants = { + frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids) + for variant in tmpl_id.product_variant_ids + } + # For each possible variant, create if it doesn't exist yet. + for value_ids in all_variants: + value_ids = frozenset(value_ids) + if value_ids not in existing_variants: + variants_to_create.append({ + 'product_tmpl_id': tmpl_id.id, + 'attribute_value_ids': [(6, 0, list(value_ids))], + }) + if len(variants_to_create) > 1000: + raise UserError(_( + 'The number of variants to generate is too high. ' + 'You should either not generate variants for each combination or generate them on demand from the sales order. ' + 'To do so, open the form view of attributes and change the mode of *Create Variants*.')) + + # Check existing variants if any needs to be activated or unlinked. + # - if the product is not active and has valid attributes and attribute values, it + # should be activated + # - if the product does not have valid attributes or attribute values, it should be + # deleted + valid_value_ids = tmpl_id.mapped('attribute_line_ids.value_ids').filtered( + lambda v: v.attribute_id.create_variant != 'no_variant' + ) + valid_attribute_ids = valid_value_ids.mapped('attribute_id') for product_id in tmpl_id.product_variant_ids: - if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') in variant_matrix: - variants_to_activate.append(product_id) - elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') not in variant_matrix: + if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids): + if not product_id.active: + variants_to_activate.append(product_id) + else: variants_to_unlink.append(product_id) if variants_to_activate: diff --git a/addons/product/report/product_pricelist.py b/addons/product/report/product_pricelist.py index 2a94a4b2a29fe..e6ccde44d8eb0 100644 --- a/addons/product/report/product_pricelist.py +++ b/addons/product/report/product_pricelist.py @@ -17,7 +17,7 @@ def _get_report_values(self, docids, data=None): quantities = self._get_quantity(data) return { 'doc_ids': data.get('ids', data.get('active_ids')), - 'doc_model': 'hr.contribution.register', + 'doc_model': 'product.pricelist', 'docs': products, 'data': dict( data, diff --git a/addons/product/tests/common.py b/addons/product/tests/common.py index 559b8b9dcfae8..50831e2a88c24 100644 --- a/addons/product/tests/common.py +++ b/addons/product/tests/common.py @@ -101,3 +101,19 @@ def setUpClass(cls): 'name': 'Stone', 'uom_id': cls.uom_unit.id, 'uom_po_id': cls.uom_unit.id}) + + +class TestAttributesCommon(common.SavepointCase): + + @classmethod + def setUpClass(cls): + super(TestAttributesCommon, cls).setUpClass() + + # create 10 attributes with 10 values each + cls.att_names = "ABCDEFGHIJ" + cls.attributes = cls.env['product.attribute'].create([{ + 'name': name, + 'create_variant': 'no_variant', + 'value_ids': [(0, 0, {'name': n}) for n in range(10)] + } for name in cls.att_names + ]) diff --git a/addons/product/tests/test_variants.py b/addons/product/tests/test_variants.py index 8fc890c31f1a8..9381868b623bb 100644 --- a/addons/product/tests/test_variants.py +++ b/addons/product/tests/test_variants.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import common +from odoo.exceptions import UserError from odoo.tests.common import TransactionCase class TestVariantsSearch(TransactionCase): @@ -367,3 +368,96 @@ def test_update_variant_with_nocreate(self): variant_id.attribute_value_ids += self.size_S template.attribute_line_ids += template.attribute_line_ids.browse() self.assertEqual(len(template.product_variant_ids), 1) + + +class TestVariantsManyAttributes(common.TestAttributesCommon): + + def test_01_create_no_variant(self): + toto = self.env['product.template'].create({ + 'name': 'Toto', + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(6, 0, attribute.value_ids.ids)], + }) for attribute in self.attributes], + }) + self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10) + self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100) + self.assertEqual(len(toto.product_variant_ids), 1) + + def test_02_create_dynamic(self): + self.attributes.write({'create_variant': 'dynamic'}) + toto = self.env['product.template'].create({ + 'name': 'Toto', + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(6, 0, attribute.value_ids.ids)], + }) for attribute in self.attributes], + }) + self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10) + self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100) + self.assertEqual(len(toto.product_variant_ids), 0) + + def test_03_create_always(self): + self.attributes.write({'create_variant': 'always'}) + with self.assertRaises(UserError): + self.env['product.template'].create({ + 'name': 'Toto', + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(6, 0, attribute.value_ids.ids)], + }) for attribute in self.attributes], + }) + + def test_04_create_no_variant_dynamic(self): + self.attributes[:5].write({'create_variant': 'dynamic'}) + toto = self.env['product.template'].create({ + 'name': 'Toto', + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(6, 0, attribute.value_ids.ids)], + }) for attribute in self.attributes], + }) + self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10) + self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100) + self.assertEqual(len(toto.product_variant_ids), 0) + + def test_05_create_no_variant_always(self): + self.attributes[:2].write({'create_variant': 'always'}) + toto = self.env['product.template'].create({ + 'name': 'Toto', + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(6, 0, attribute.value_ids.ids)], + }) for attribute in self.attributes], + }) + self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10) + self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100) + self.assertEqual(len(toto.product_variant_ids), 100) + + def test_06_create_dynamic_always(self): + self.attributes[:5].write({'create_variant': 'dynamic'}) + self.attributes[5:].write({'create_variant': 'always'}) + toto = self.env['product.template'].create({ + 'name': 'Toto', + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(6, 0, attribute.value_ids.ids)], + }) for attribute in self.attributes], + }) + self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10) + self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100) + self.assertEqual(len(toto.product_variant_ids), 0) + + def test_07_create_no_create_dynamic_always(self): + self.attributes[3:6].write({'create_variant': 'dynamic'}) + self.attributes[6:].write({'create_variant': 'always'}) + toto = self.env['product.template'].create({ + 'name': 'Toto', + 'attribute_line_ids': [(0, 0, { + 'attribute_id': attribute.id, + 'value_ids': [(6, 0, attribute.value_ids.ids)], + }) for attribute in self.attributes], + }) + self.assertEqual(len(toto.attribute_line_ids.mapped('attribute_id')), 10) + self.assertEqual(len(toto.attribute_line_ids.mapped('value_ids')), 100) + self.assertEqual(len(toto.product_variant_ids), 0) diff --git a/addons/product/views/product_pricelist_views.xml b/addons/product/views/product_pricelist_views.xml index 2e219583d1073..9f89adb2cc737 100644 --- a/addons/product/views/product_pricelist_views.xml +++ b/addons/product/views/product_pricelist_views.xml @@ -58,7 +58,7 @@ - New Price = +
\n" " \n" " \n" "
\n" +" % if object.company_id:\n" " Sent by ${object.company_id.name}\n" " % if 'website_url' in object.event_id and object.event_id.website_url:\n" "
\n" " Discover all our events.\n" " % endif\n" +" % endif\n" "
\n" "