diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index dc4c37f54db..9e94cc389e8 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -167,8 +167,8 @@ class AccountMove(models.Model): 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 948cebf5c23..b89ec6bcb13 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -46,7 +46,9 @@ class ReportAgedPartnerBalance(models.AbstractModel): 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 @@ class ReportAgedPartnerBalance(models.AbstractModel): 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 @@ class ReportAgedPartnerBalance(models.AbstractModel): 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 52ac52ad108..ef09176438b 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 b9504bd6ee6..3c9174546cf 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 6b47f2a7e8e..f5e5814c814 100644 --- a/addons/account/wizard/account_report_common.py +++ b/addons/account/wizard/account_report_common.py @@ -45,4 +45,4 @@ class AccountCommonReport(models.TransientModel): 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 d6161e17b91..87a47ffa455 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -57,17 +57,17 @@ class ResPartner(models.Model): 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 07c42694ef1..7591299f3e2 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -25,18 +25,58 @@ class Partner(models.Model): # 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 ca76cd53b8e..d382d360961 100644 --- a/addons/base_import_module/controllers/main.py +++ b/addons/base_import_module/controllers/main.py @@ -24,22 +24,14 @@ class ImportModule(Controller): 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 4267c7efae2..c0e4c454090 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -1789,7 +1789,15 @@ class Meeting(models.Model): 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 c3ff67d54d5..1d409d48dd7 100644 --- a/addons/calendar/tests/test_calendar.py +++ b/addons/calendar/tests/test_calendar.py @@ -314,3 +314,73 @@ class TestCalendar(TransactionCase): 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 77f34f856d0..9f07ad5c6e5 100644 --- a/addons/delivery/models/delivery_carrier.py +++ b/addons/delivery/models/delivery_carrier.py @@ -217,7 +217,7 @@ class DeliveryCarrier(models.Model): '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 c168dd065b7..f405892e692 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 c1ca30a1347..023cb9d44bc 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 9af3e6e90ed..36d5324019a 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 886472e8aba..f4086bb2006 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -658,7 +658,7 @@ class HolidaysRequest(models.Model): 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 68ef4445428..9510f75b34b 100644 --- a/addons/hr_holidays/tests/__init__.py +++ b/addons/hr_holidays/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_holidays_flow 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 cf4e8f76ad8..ca651a69010 100644 --- a/addons/hr_holidays/tests/test_access_rights.py +++ b/addons/hr_holidays/tests/test_access_rights.py @@ -6,7 +6,7 @@ from dateutil.relativedelta import relativedelta from odoo import tests from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase -from odoo.exceptions import AccessError, UserError +from odoo.exceptions import AccessError, ValidationError, UserError from odoo.tools import mute_logger @@ -117,6 +117,24 @@ class TestLeavesRights(TestHrHolidaysBase): 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 @@ class TestLeavesRights(TestHrHolidaysBase): @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 @@ class TestLeavesRights(TestHrHolidaysBase): @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 @@ class TestLeavesRights(TestHrHolidaysBase): 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 @@ class TestLeavesRights(TestHrHolidaysBase): 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 @@ class TestLeavesRights(TestHrHolidaysBase): 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 @@ class TestLeavesRights(TestHrHolidaysBase): 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 51bcafe049c..08e1be1b20d 100644 --- a/addons/hr_holidays/tests/test_holidays_flow.py +++ b/addons/hr_holidays/tests/test_holidays_flow.py @@ -13,8 +13,69 @@ from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase 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'] + HolidaysStatus = self.env['hr.leave.type'] + + # 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 + }) + self.holidays_status_hr = HolidayStatusManagerGroup.create({ + 'name': 'NotLimitedHR', + 'allocation_type': 'no', + 'validation_type': 'hr', + }) + self.holidays_status_manager = HolidayStatusManagerGroup.create({ + 'name': 'NotLimitedManager', + 'allocation_type': 'no', + 'validation_type': 'manager', + }) + + HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) + + # 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_hr.id, + 'date_from': (datetime.today() - relativedelta(days=1)), + 'date_to': datetime.today(), + 'number_of_days': 1, + }) + hol1_user_group = hol1_employee_group.sudo(self.user_hruser_id) + 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') + + # 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') + + # Employee creates a leave request in a no-limit category department manager only + hol12_employee_group = HolidaysEmployeeGroup.create({ + 'name': 'Hol12', + 'employee_id': self.employee_emp_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, + }) + hol12_user_group = hol12_employee_group.sudo(self.user_hruser_id) + 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') + + # 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') + + + @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'] @@ -29,13 +90,6 @@ class TestHolidaysFlow(TestHrHolidaysBase): 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,111 +97,19 @@ class TestHolidaysFlow(TestHrHolidaysBase): '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({ - 'name': 'NotLimitedHR', - 'allocation_type': 'no', - 'validation_type': 'hr', - }) - self.holidays_status_2 = HolidayStatusManagerGroup.create({ + + self.holidays_status_limited = HolidayStatusManagerGroup.create({ 'name': 'Limited', 'allocation_type': 'fixed', 'validation_type': 'both', }) - self.holidays_status_3 = 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, - 'date_from': (datetime.today() - relativedelta(days=1)), - 'date_to': datetime.today(), - 'number_of_days': 1, - }) - hol1_user_group = hol1_employee_group.sudo(self.user_hruser_id) - 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') - - # Employee creates a leave request in a no-limit category department manager only - hol12_employee_group = HolidaysEmployeeGroup.create({ - 'name': 'Hol12', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_3.id, - 'date_from': (datetime.today() + relativedelta(days=12)), - 'date_to': (datetime.today() + relativedelta(days=13)), - 'number_of_days': 1, - }) - hol12_user_group = hol12_employee_group.sudo(self.user_hruser_id) - 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, - }) - - # 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, - }) - - # Clean transaction - Requests.search([('name', 'in', ['Hol21', 'Hol22'])]).unlink() # 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 @@ class TestHolidaysFlow(TestHrHolidaysBase): # 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 @@ class TestHolidaysFlow(TestHrHolidaysBase): 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 @@ class TestHolidaysFlow(TestHrHolidaysBase): # 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 495d1aa2e4b..dfe1b77713d 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 @@ class TestHrLeaveType(TestHrHolidaysBase): 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 00000000000..8dad86eae39 --- /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 274fd996fc6..53c121f9de6 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 2c7b833452d..9553cc5b69e 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 beb83dc09eb..80b1399e139 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 4602fe1ff8e..d7829ae0a1a 100644 --- a/addons/mail/models/mail_activity.py +++ b/addons/mail/models/mail_activity.py @@ -422,8 +422,10 @@ class MailActivity(models.Model): @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 @@ class MailActivity(models.Model): 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 497f32fe628..17d11c89401 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -340,7 +340,7 @@ class Channel(models.Model): @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 ccb245e18d3..f4f16c2e9f0 100644 --- a/addons/mail/models/mail_followers.py +++ b/addons/mail/models/mail_followers.py @@ -114,14 +114,14 @@ SELECT partner.id as pid, NULL AS cid, 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 @@ SELECT partner.id as pid, NULL AS cid, 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 aea14012767..6a50a198347 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -375,9 +375,6 @@ class MailMail(models.Model): # /!\ 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 @@ class MailMail(models.Model): 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 a6ad7be4ee5..a4feb61b3fb 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1169,7 +1169,7 @@ class MailThread(models.AbstractModel): 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 a6cdb39e2de..8a533cd4515 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 c733b74f347..4342b1e244a 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 695275e799e..808e8b7fbca 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 b7235e7bc0e..f4cb58ebad3 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 28dbc4b7163..82d65bdd6cc 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 c9dda28dd93..8c2beb8bbfa 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 734f12f0cf3..c21089b7095 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 5a687131504..d86e9e3913b 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 f6ba0ccecca..716b86400be 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 a09b7a6f754..9d97de471ce 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 b379bc9050b..4bcce4ab352 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 51fb7b55c61..4d8e48b4dcb 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 02c7ec86e6b..1b608f5c37b 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 f05fd367697..4b8935e81cf 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 0e146197260..643c03db195 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -114,14 +114,16 @@ class MassMailingList(models.Model): 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 @@ class MassMailing(models.Model): [('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 2408f53f218..1ebd8b85beb 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 8257d14db6f..723bdee7b1a 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 d7235e3998a..739f0cff968 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 80192e9bad8..1acbfaa183c 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 a03ab6ddc36..c66d15131c6 100644 --- a/addons/mrp/models/stock_warehouse.py +++ b/addons/mrp/models/stock_warehouse.py @@ -108,6 +108,7 @@ class StockWarehouse(models.Model): '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 eef8edee6ce..e35a32fc706 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 b991609ca89..59ec2f0b801 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 e61f8d1097e..c48ecae1c5d 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 b039d460360..a72f3ca5eae 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 0806370c998..b1b2340f16d 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -604,6 +604,22 @@ class ProductProduct(models.Model): 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 102ba1ca109..1ccf742bd9a 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -434,7 +434,6 @@ class ProductTemplate(models.Model): @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 @@ class ProductTemplate(models.Model): 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')) - ] + # 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*.')) - # 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 + # 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 2a94a4b2a29..e6ccde44d8e 100644 --- a/addons/product/report/product_pricelist.py +++ b/addons/product/report/product_pricelist.py @@ -17,7 +17,7 @@ class report_product_pricelist(models.AbstractModel): 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 559b8b9dcfa..50831e2a88c 100644 --- a/addons/product/tests/common.py +++ b/addons/product/tests/common.py @@ -101,3 +101,19 @@ class TestProductCommon(common.SavepointCase): '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 8fc890c31f1..9381868b623 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 @@ class TestVariantsNoCreate(common.TestProductCommon): 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 2e219583d10..9f89adb2cc7 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" "