diff options
Diffstat (limited to 'addons')
110 files changed, 1870 insertions, 508 deletions
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 = """ <div> <field name="country_enforce_cities" invisible="1"/> - <field name='city' placeholder="%s" attrs="{'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)], 'readonly': [('type', '=', 'contact'), ('parent_id', '!=', False)]}"/> - <field name='city_id' placeholder="%s" string="%s" attrs="{'invisible': [('country_enforce_cities', '=', False)], 'readonly': [('type', '=', 'contact'), ('parent_id', '!=', False)]}" context="{'default_country_id': country_id}" domain="[('country_id', '=', country_id)]"/> + <field name='city' placeholder="%(placeholder)s" + attrs="{ + 'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)], + 'readonly': [('type', '=', 'contact')%(parent_condition)s] + }" + /> + <field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s" + context="{'default_country_id': country_id}" + domain="[('country_id', '=', country_id)]" + attrs="{ + 'invisible': [('country_enforce_cities', '=', False)], + 'readonly': [('type', '=', 'contact')%(parent_condition)s] + }" + /> </div> - """ % (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 @@ <table style="width: 100%; max-width: 600px; margin-top: 5px; border: 1px solid #e7e7e7;"> <tr> <td style="border-collapse: collapse; background-color: #ffffff; line-height: 21px; padding: 0 20px 10px 20px; text-align: center;"><br/> - <div style="color: #3d466e; font-size: 16px; font-weight: 600; line-height: 23px;">Run your bussiness from anywhere with Odoo Mobile.</div> + <div style="color: #3d466e; font-size: 16px; font-weight: 600; line-height: 23px;">Run your business from anywhere with Odoo Mobile.</div> </td> </tr> <tr> @@ -145,7 +145,7 @@ % endif <p style="font-size: 11px; margin-top: 10px;"> <strong> - Send by + Sent by <a href="https://www.odoo.com" style="text-decoration: none; color: #875A7B;">Odoo</a> - <a href="/web#view_type=form&model=digest.digest&id=${object.id}" target="_blank" style="color: #888888;">Unsubscribe</a> </strong> </p> 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 @@ </table> </td></tr> <!-- FOOTER BY --> +% if object.company_id <tr><td align="center" style="min-width: 590px;"> <table width="590" border="0" cellpadding="0" cellspacing="0" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;"> <tr><td style="text-align: center; font-size: 14px;"> @@ -216,6 +217,7 @@ </td></tr> </table> </td></tr> +% endif </table> </field> <field name="lang">${object.partner_id.lang}</field> @@ -399,6 +401,7 @@ </table> </td></tr> <!-- FOOTER BY --> +% if object.company_id <tr><td align="center" style="min-width: 590px;"> <table width="590" border="0" cellpadding="0" cellspacing="0" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;"> <tr><td style="text-align: center; font-size: 14px;"> @@ -410,6 +413,7 @@ </td></tr> </table> </td></tr> +% endif </table> </field> <field name="lang">${object.partner_id.lang}</field> 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 "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding-t "<tr><td align=\"center\" style=\"min-width: 590px;\">\n" " <table width=\"590\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;\">\n" " <tr><td style=\"text-align: center; font-size: 14px;\">\n" +" % if object.company_id\n" " Sent by <a target=\"_blank\" href=\"${object.company_id.website}\" style=\"color: #875A7B;\">${object.company_id.name}</a>\n" " % if 'website_url' in object.event_id and object.event_id.website_url:\n" " <br/>\n" " Discover <a href=\"/event\" style=\"text-decoration:none;color:#717188;\">all our events</a>.\n" " % endif\n" +" % endif\n" " </td></tr>\n" " </table>\n" "</td></tr>\n" @@ -502,11 +504,13 @@ msgid "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding-t "<tr><td align=\"center\" style=\"min-width: 590px;\">\n" " <table width=\"590\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;\">\n" " <tr><td style=\"text-align: center; font-size: 14px;\">\n" +" % if object.company_id:\n" " Sent by <a target=\"_blank\" href=\"${object.company_id.website}\" style=\"color: #875A7B;\">${object.company_id.name}</a>\n" " % if 'website_url' in object.event_id and object.event_id.website_url:\n" " <br/>\n" " Discover <a href=\"/event\" style=\"text-decoration:none;color:#717188;\">all our events</a>.\n" " % endif\n" +" % endif\n" " </td></tr>\n" " </table>\n" "</td></tr>\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 @@ -118,6 +118,24 @@ class TestLeavesRights(TestHrHolidaysBase): 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,29 +13,11 @@ 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'] - Allocations = self.env['hr.leave.allocation'] HolidaysStatus = self.env['hr.leave.type'] - def _check_holidays_status(holiday_status, ml, lt, rl, vrl): - self.assertEqual(holiday_status.max_leaves, ml, - 'hr_holidays: wrong type days computation') - self.assertEqual(holiday_status.leaves_taken, lt, - 'hr_holidays: wrong type days computation') - self.assertEqual(holiday_status.remaining_leaves, rl, - 'hr_holidays: wrong type days computation') - self.assertEqual(holiday_status.virtual_remaining_leaves, vrl, - 'hr_holidays: wrong type days computation') - - # HrUser creates some holiday statuses -> crash because only HrManagers should do this - with self.assertRaises(AccessError): - HolidaysStatus.sudo(self.user_hruser_id).create({ - 'name': 'UserCheats', - 'allocation_type': 'no', - }) - # HrManager creates some holiday statuses HolidayStatusManagerGroup = HolidaysStatus.sudo(self.user_hrmanager_id) HolidayStatusManagerGroup.create({ @@ -43,42 +25,24 @@ 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({ + self.holidays_status_hr = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedHR', 'allocation_type': 'no', 'validation_type': 'hr', }) - self.holidays_status_2 = HolidayStatusManagerGroup.create({ - 'name': 'Limited', - 'allocation_type': 'fixed', - 'validation_type': 'both', - }) - self.holidays_status_3 = HolidayStatusManagerGroup.create({ + self.holidays_status_manager = HolidayStatusManagerGroup.create({ 'name': 'NotLimitedManager', 'allocation_type': 'no', 'validation_type': 'manager', }) - self.holiday_status_4 = HolidayStatusManagerGroup.create({ - 'name': 'TimeNotLimited', - 'allocation_type': 'no', - 'validation_type': 'manager', - 'validity_start': fields.Datetime.from_string('2017-01-01 00:00:00'), - 'validity_stop': fields.Datetime.from_string('2017-06-01 00:00:00'), - }) - - # -------------------------------------------------- - # Case1: unlimited type of leave request - # -------------------------------------------------- - # Employee creates a leave request for another employee -> should crash HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) - Requests.search([('name', '=', 'Hol10')]).unlink() # Employee creates a leave request in a no-limit category hr manager only hol1_employee_group = HolidaysEmployeeGroup.create({ 'name': 'Hol11', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_1.id, + 'holiday_status_id': self.holidays_status_hr.id, 'date_from': (datetime.today() - relativedelta(days=1)), 'date_to': datetime.today(), 'number_of_days': 1, @@ -87,9 +51,6 @@ class TestHolidaysFlow(TestHrHolidaysBase): hol1_manager_group = hol1_employee_group.sudo(self.user_hrmanager_id) self.assertEqual(hol1_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state') - # Employee validates its leave request -> should not work - self.assertEqual(hol1_manager_group.state, 'confirm', 'hr_holidays: employee should not be able to validate its own leave request') - # HrUser validates the employee leave request -> should work hol1_user_group.action_approve() self.assertEqual(hol1_manager_group.state, 'validate', 'hr_holidays: validated leave request should be in validate state') @@ -98,7 +59,7 @@ class TestHolidaysFlow(TestHrHolidaysBase): hol12_employee_group = HolidaysEmployeeGroup.create({ 'name': 'Hol12', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_3.id, + 'holiday_status_id': self.holidays_status_manager.id, 'date_from': (datetime.today() + relativedelta(days=12)), 'date_to': (datetime.today() + relativedelta(days=13)), 'number_of_days': 1, @@ -107,47 +68,48 @@ class TestHolidaysFlow(TestHrHolidaysBase): hol12_manager_group = hol12_employee_group.sudo(self.user_hrmanager_id) self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: newly created leave request should be in confirm state') - # Employee validates its leave request -> should not work - self.assertEqual(hol12_user_group.state, 'confirm', 'hr_holidays: employee should not be able to validate its own leave request') - # HrManager validate the employee leave request hol12_manager_group.action_approve() self.assertEqual(hol1_user_group.state, 'validate', 'hr_holidays: validates leave request should be in validate state') - # -------------------------------------------------- - # Case2: limited type of leave request - # -------------------------------------------------- - # Employee creates a new leave request at the same time -> crash, avoid interlapping - with self.assertRaises(ValidationError): - HolidaysEmployeeGroup.create({ - 'name': 'Hol21', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_1.id, - 'date_from': (datetime.today() - relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'), - 'date_to': datetime.today(), - 'number_of_days': 1, - }) + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.models') + def test_01_leave_request_flow_limited(self): + """ Testing leave request flow: limited type of leave request """ + Requests = self.env['hr.leave'] + Allocations = self.env['hr.leave.allocation'] + HolidaysStatus = self.env['hr.leave.type'] + + def _check_holidays_status(holiday_status, ml, lt, rl, vrl): + self.assertEqual(holiday_status.max_leaves, ml, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.leaves_taken, lt, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.remaining_leaves, rl, + 'hr_holidays: wrong type days computation') + self.assertEqual(holiday_status.virtual_remaining_leaves, vrl, + 'hr_holidays: wrong type days computation') - # Employee creates a leave request in a limited category -> crash, not enough days left - with self.assertRaises(ValidationError): - HolidaysEmployeeGroup.create({ - 'name': 'Hol22', - 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_2.id, - 'date_from': (datetime.today() + relativedelta(days=1)).strftime('%Y-%m-%d %H:%M'), - 'date_to': (datetime.today() + relativedelta(days=2)), - 'number_of_days': 1, - }) + # HrManager creates some holiday statuses + HolidayStatusManagerGroup = HolidaysStatus.sudo(self.user_hrmanager_id) + HolidayStatusManagerGroup.create({ + 'name': 'WithMeetingType', + 'allocation_type': 'no', + 'categ_id': self.env['calendar.event.type'].sudo(self.user_hrmanager_id).create({'name': 'NotLimitedMeetingType'}).id + }) - # Clean transaction - Requests.search([('name', 'in', ['Hol21', 'Hol22'])]).unlink() + self.holidays_status_limited = HolidayStatusManagerGroup.create({ + 'name': 'Limited', + 'allocation_type': 'fixed', + 'validation_type': 'both', + }) + HolidaysEmployeeGroup = Requests.sudo(self.user_employee_id) # HrUser allocates some leaves to the employee aloc1_user_group = Allocations.sudo(self.user_hruser_id).create({ 'name': 'Days for limited category', 'employee_id': self.employee_emp_id, - 'holiday_status_id': self.holidays_status_2.id, + 'holiday_status_id': self.holidays_status_limited.id, 'number_of_days': 2, }) # HrUser validates the first step @@ -156,14 +118,14 @@ 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 @@ </ul> <div t-if="signature" t-raw="signature" class="font-size: 13px;"/> <p style="color: #555555; margin-top:32px;"> - Sent by + Sent + <span t-if="company.name"> + by <a t-if="website_url" t-att-href="website_url" style="text-decoration:none; color: #875A7B;"> <span t-esc="company.name"/> </a> <span t-if="not website_url" t-esc="company.name"/> + </span> using <a target="_blank" href="https://www.odoo.com?utm_source=db&utm_medium=email" style="text-decoration:none; color: #875A7B;">Odoo</a>. </p> 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<Object[]>} prefetchedPartners + * @param {$.Deferred<Object[]>} 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<Object[]>} resolved with list of channel listeners + * @returns {$.Promise<Array<Object[]>>} 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<Object[]>} resolved with list of livechat members + * @returns {$.Promise<Array<Object[]>>} 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 @@ -46,28 +46,6 @@ var Mailbox = SearchableThread.extend({ 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. * * @returns {integer} @@ -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<Object[]>} + * @returns {$.Promise<Array<Object[]>>} */ 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<Object[]>} + * @returns {Array<Array<Object[]>>} */ 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<Object[]>} 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 t-name="mail.Preview"> <div t-attf-class="o_mail_preview #{preview.unreadCounter > 0 ? 'o_preview_unread' : ''}" - 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-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"> <div t-attf-class="o_mail_preview_image #{preview.documentModel ? 'o_mail_preview_app' : ''}"> <img class="o_mail_preview_image" t-att-src="preview.imageSRC" alt="Preview"/> <i t-if="preview.status === 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/> 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: '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '<div class="oe_chatter">' + + '<field name="message_follower_ids" widget="mail_followers"/>' + + '<field name="message_ids" widget="mail_thread" options="{\'display_log_button\': True}"/>' + + '</div>' + + '</form>', + 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: "<p>test" + num + "</p>", + 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: "<p>*Message1*</p>", + id: 689, + needaction: true, + needaction_partner_ids: [44], + }, { + author_id: [1, "Demo"], + body: "<p>*Message2*</p>", + 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: "<p>@Administrator: ping</p>", + 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: "<p>@Administrator: pong</p>", + 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: "<p>Test message</p>", + 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 @@ <field name="category_id"/> <field name="membership_start" invisible="1"/> <field name="membership_stop" string="End Membership Date"/> - <filter string="Customers" name="customer" domain="[('customer','=',1)]"/> + <filter string="Customers" name="customer" domain="[('customer','=',True)]"/> <separator/> - <filter string="Vendors" name="supplier" domain="[('supplier','=',1)]"/> + <filter string="Vendors" name="supplier" domain="[('supplier','=',True)]"/> <separator/> <filter name="all_non_members" domain="[('membership_state', 'in', ['none','canceled','old','waiting'])]" string="All non Members" help="None/Canceled/Old/Waiting"/> <filter name="all_members" domain="[('membership_state', 'in', ['invoiced','paid','free'])]" string="All Members" help="Invoiced/Paid/Free"/> 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 @@ <field name="arch" type="xml"> <search string="Membership Products"> <field name="name" string="Membership Product"/> - <filter string="Inactive" name="inactive" domain="[('active','=',0)]"/> + <filter string="Inactive" name="inactive" domain="[('active','=',False)]"/> <field name="categ_id" operator="child_of"/> <group expand='0' string='Group by...'> <filter string='Category' name="category" domain="[]" context="{'group_by' : 'categ_id'}"/> 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 @@ <field name="name">Tips</field> <field name="categ_id" ref="point_of_sale.product_category_pos"/> <field name="default_code">TIPS</field> + <field name="available_in_pos" eval="True"/> </record> <record model="pos.config" id="pos_config_main"> 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 @@ -16,15 +16,6 @@ msgstr "" "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" msgstr "" @@ -1221,6 +1212,11 @@ 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 msgid "Next Activity 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')) - ] - - # get the value (id) sets of existing variants - existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids) for variant in tmpl_id.product_variant_ids} - # -> for each value set, create a recordset of values to create a - # variant for if the value set isn't already a variant - for value_ids in variant_matrix: - if set(value_ids.ids) not in existing_variants and not any(value_id.attribute_id.create_variant == 'dynamic' for value_id in value_ids): - variants_to_create.append({ - 'product_tmpl_id': tmpl_id.id, - 'attribute_value_ids': [(6, 0, value_ids.ids)] - }) - - if len(variants_to_create) > 1000: - raise UserError(_(""" - The number of variants to generate is too high. - You should either not generate variants for each combination or generate them on demand from the sales order. - To do so, open the form view of attributes and change the mode of *Create Variants*.""")) - - # check product + # Determine which product variants need to be created based on the attribute + # configuration. If any attribute is set to generate variants dynamically, skip the + # process. + # Technical note: if there is no attribute, a variant is still created because + # 'not any([])' and 'set([]) not in set([])' are True. + if not any(attrib.create_variant == 'dynamic' for attrib in tmpl_id.mapped('attribute_line_ids.attribute_id')): + # Iterator containing all possible attribute values combination + # The iterator is used to avoid MemoryError in case of a huge number of combination. + all_variants = itertools.product(*( + line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant != 'no_variant' + )) + # Set containing existing attribute values combination + existing_variants = { + frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant').ids) + for variant in tmpl_id.product_variant_ids + } + # For each possible variant, create if it doesn't exist yet. + for value_ids in all_variants: + value_ids = frozenset(value_ids) + if value_ids not in existing_variants: + variants_to_create.append({ + 'product_tmpl_id': tmpl_id.id, + 'attribute_value_ids': [(6, 0, list(value_ids))], + }) + if len(variants_to_create) > 1000: + raise UserError(_( + 'The number of variants to generate is too high. ' + 'You should either not generate variants for each combination or generate them on demand from the sales order. ' + 'To do so, open the form view of attributes and change the mode of *Create Variants*.')) + + # Check existing variants if any needs to be activated or unlinked. + # - if the product is not active and has valid attributes and attribute values, it + # should be activated + # - if the product does not have valid attributes or attribute values, it should be + # deleted + valid_value_ids = tmpl_id.mapped('attribute_line_ids.value_ids').filtered( + lambda v: v.attribute_id.create_variant != 'no_variant' + ) + valid_attribute_ids = valid_value_ids.mapped('attribute_id') for product_id in tmpl_id.product_variant_ids: - if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') in variant_matrix: - variants_to_activate.append(product_id) - elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant != 'no_variant') not in variant_matrix: + if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids): + if not product_id.active: + variants_to_activate.append(product_id) + else: variants_to_unlink.append(product_id) if variants_to_activate: diff --git a/addons/product/report/product_pricelist.py b/addons/product/report/product_pricelist.py index 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 @@ <group col="6" attrs="{'invisible':[('compute_price', '!=', 'formula')]}"> <field name="base" colspan="6"/> - <span class="o_form_label">New Price =</span> + <label for="" string="New Price ="/> <div> <span attrs="{'invisible':[('base', '!=', 'list_price')]}">Public Price - </span> <span attrs="{'invisible':[('base', '!=', 'standard_price')]}">Cost - </span> diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index cf3127ca22d..0afcfcfa727 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -138,8 +138,8 @@ <filter string="Services" name="services" domain="[('type','=','service')]"/> <filter string="Products" name="consumable" domain="[('type', 'in', ['consu', 'product'])]"/> <separator/> - <filter string="Can be Sold" name="filter_to_sell" domain="[('sale_ok','=',1)]"/> - <filter string="Can be Purchased" name="filter_to_purchase" domain="[('purchase_ok', '=', 1)]"/> + <filter string="Can be Sold" name="filter_to_sell" domain="[('sale_ok','=',True)]"/> + <filter string="Can be Purchased" name="filter_to_purchase" domain="[('purchase_ok', '=', True)]"/> <separator/> <filter string="Archived" name="inactive" domain="[('active','=',False)]"/> <field string="Attributes" name="attribute_line_ids" groups="product.group_product_variant"/> diff --git a/addons/project/controllers/portal.py b/addons/project/controllers/portal.py index 03830451911..72636feea62 100644 --- a/addons/project/controllers/portal.py +++ b/addons/project/controllers/portal.py @@ -131,7 +131,7 @@ class CustomerPortal(CustomerPortal): # extends filterby criteria with project (criteria name is the project id) # Note: portal users can't view projects they don't follow - project_groups = request.env['project.task'].read_group([('project_id', 'not in', project.ids)], + project_groups = request.env['project.task'].read_group([('project_id', 'not in', projects.ids)], ['project_id'], ['project_id']) for group in project_groups: proj_id = group['project_id'][0] if group['project_id'] else False diff --git a/addons/purchase/models/product.py b/addons/purchase/models/product.py index 724fdb45069..49897429a4e 100644 --- a/addons/purchase/models/product.py +++ b/addons/purchase/models/product.py @@ -43,7 +43,7 @@ class ProductTemplate(models.Model): @api.multi def action_view_po(self): action = self.env.ref('purchase.action_purchase_order_report_all').read()[0] - action['domain'] = [('state', 'in', ['purchase', 'done']), '&', ('product_tmpl_id', 'in', self.ids)] + action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_tmpl_id', 'in', self.ids)] action['context'] = { 'search_default_last_year_purchase': 1, 'search_default_status': 1, 'search_default_order_month': 1, @@ -75,7 +75,7 @@ class ProductProduct(models.Model): @api.multi def action_view_po(self): action = self.env.ref('purchase.action_purchase_order_report_all').read()[0] - action['domain'] = [('state', 'in', ['purchase', 'done']), '&', ('product_id', 'in', self.ids)] + action['domain'] = ['&', ('state', 'in', ['purchase', 'done']), ('product_id', 'in', self.ids)] action['context'] = { 'search_default_last_year_purchase': 1, 'search_default_status': 1, 'search_default_order_month': 1, diff --git a/addons/purchase_requisition/models/purchase_requisition.py b/addons/purchase_requisition/models/purchase_requisition.py index 6778c47ebc3..7e899b2e928 100644 --- a/addons/purchase_requisition/models/purchase_requisition.py +++ b/addons/purchase_requisition/models/purchase_requisition.py @@ -492,3 +492,15 @@ class StockMove(models.Model): return [(requisition_line.requisition_id, requisition_line.requisition_id.user_id, visited) for requisition_line in self.requisition_line_ids if requisition_line.state not in ('done', 'cancel')] else: return super(StockMove, self)._get_upstream_documents_and_responsibles(visited) + + +class Orderpoint(models.Model): + _inherit = "stock.warehouse.orderpoint" + + def _quantity_in_progress(self): + res = super(Orderpoint, self)._quantity_in_progress() + for op in self: + for pr in self.env['purchase.requisition'].search([('state','=','draft'),('origin','=',op.name)]): + for prline in pr.line_ids.filtered(lambda l: l.product_id.id == op.product_id.id): + res[op.id] += prline.product_uom_id._compute_quantity(prline.product_qty, op.product_uom, round=False) + return res diff --git a/addons/purchase_stock/models/stock.py b/addons/purchase_stock/models/stock.py index 75599c59dbc..6fefdbb3335 100644 --- a/addons/purchase_stock/models/stock.py +++ b/addons/purchase_stock/models/stock.py @@ -112,6 +112,8 @@ class StockWarehouse(models.Model): 'create_values': { 'action': 'buy', 'picking_type_id': self.in_type_id.id, + 'group_propagation_option': 'none', + 'company_id': self.company_id.id, 'route_id': self._find_global_route('purchase_stock.route_warehouse0_buy', _('Buy')).id }, 'update_values': { diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 3102bb3b210..a09fa8711e8 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -549,10 +549,11 @@ class SaleOrder(models.Model): raise UserError(_('There is no invoiceable line. If a product has a Delivered quantities invoicing policy, please make sure that a quantity has been delivered.')) for invoice in invoices.values(): + invoice.compute_taxes() if not invoice.invoice_line_ids: raise UserError(_('There is no invoiceable line. If a product has a Delivered quantities invoicing policy, please make sure that a quantity has been delivered.')) # If invoice is negative, do a refund invoice instead - if invoice.amount_untaxed < 0: + if invoice.amount_total < 0: invoice.type = 'out_refund' for line in invoice.invoice_line_ids: line.quantity = -line.quantity @@ -563,7 +564,10 @@ class SaleOrder(models.Model): # by onchanges, which are not triggered when doing a create. invoice.compute_taxes() # Idem for partner + so_payment_term_id = invoice.payment_term_id.id invoice._onchange_partner_id() + # To keep the payment terms set on the SO + invoice.payment_term_id = so_payment_term_id invoice.message_post_with_view('mail.message_origin_link', values={'self': invoice, 'origin': references[invoice]}, subtype_id=self.env.ref('mail.mt_note').id) diff --git a/addons/sale/report/sale_report_views.xml b/addons/sale/report/sale_report_views.xml index ef419e6eb0a..0b098040bd5 100644 --- a/addons/sale/report/sale_report_views.xml +++ b/addons/sale/report/sale_report_views.xml @@ -18,7 +18,7 @@ <field name="model">sale.report</field> <field name="arch" type="xml"> <graph string="Sales Analysis" type="line"> - <field name="date" type="row" interval="day"/> + <field name="confirmation_date" type="row" interval="day"/> <field name="price_subtotal" type="measure"/> </graph> </field> diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js index 5908e151155..aa1066a0c24 100644 --- a/addons/sale/static/src/js/product_configurator_mixin.js +++ b/addons/sale/static/src/js/product_configurator_mixin.js @@ -418,7 +418,7 @@ var ProductConfiguratorMixin = { var $optional_price = $parent.find(".oe_optional:first .oe_currency_value"); $price.html(self._priceToStr(combination.price)); $default_price.html(self._priceToStr(combination.list_price)); - if (combination.list_price - combination.price > 0.01) { + if (combination.list_price - combination.price >= 0.01) { $default_price .closest('.oe_website_sale') .addClass("discount"); @@ -567,4 +567,4 @@ var ProductConfiguratorMixin = { return ProductConfiguratorMixin; -});
\ No newline at end of file +}); diff --git a/addons/sale_margin/models/sale_order.py b/addons/sale_margin/models/sale_order.py index cf5902d6b2b..866890ff822 100644 --- a/addons/sale_margin/models/sale_order.py +++ b/addons/sale_margin/models/sale_order.py @@ -60,14 +60,6 @@ class SaleOrderLine(models.Model): for line in self: currency = line.order_id.pricelist_id.currency_id price = line.purchase_price - if not price: - from_cur = line.env.user.company_id.currency_id - price = from_cur._convert( - line.product_id.standard_price, - currency, - line.order_id.company_id or self.env.user.company_id, - line.order_id.date_order or fields.Date.today(), round=False) - line.margin = currency.round(line.price_subtotal - (price * line.product_uom_qty)) diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index 9d84752dd24..430c3cc52d9 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -327,7 +327,10 @@ class SaleOrderLine(models.Model): self.ensure_one() qty = 0.0 for move in self.move_ids.filtered(lambda r: r.state != 'cancel'): - qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') + if move.picking_code == 'outgoing': + qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') + elif move.picking_code == 'incoming': + qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') return qty @api.multi @@ -433,7 +436,8 @@ class SaleOrderLine(models.Model): return is_available def _update_line_quantity(self, values): - if self.mapped('qty_delivered') and values['product_uom_qty'] < max(self.mapped('qty_delivered')): + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + if self.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(self.mapped('qty_delivered')), precision_digits=precision) == -1: raise UserError(_('You cannot decrease the ordered quantity below the delivered quantity.\n' 'Create a return first.')) super(SaleOrderLine, self)._update_line_quantity(values) diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index a38c6212b3a..84ad2f1b1e2 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -6,7 +6,7 @@ <field name="order_id" ref="sale.sale_order_3"/> <field name="name">Advance</field> <field name="product_id" ref="sale.advance_product_0"/> - <field name="product_uom" ref="uom.product_uom_day"/> + <field name="product_uom" ref="uom.product_uom_unit"/> <field name="price_unit">150.0</field> <field name="product_uom_qty">5.0</field> </record> diff --git a/addons/stock_account/views/stock_account_views.xml b/addons/stock_account/views/stock_account_views.xml index f92c5af295d..7dde572369a 100644 --- a/addons/stock_account/views/stock_account_views.xml +++ b/addons/stock_account/views/stock_account_views.xml @@ -87,6 +87,7 @@ <record id="view_inventory_valuation_search" model="ir.ui.view"> <field name="name">product.product</field> <field name="model">product.product</field> + <field name="priority" eval="17"/> <field name="arch" type="xml"> <search string="Inventory Valuation"> <filter string="Category" name="categ_id" context="{'group_by':'categ_id'}"/> diff --git a/addons/test_mail/tests/test_mail_mail.py b/addons/test_mail/tests/test_mail_mail.py index 207cc80d26f..aebb4734fc3 100644 --- a/addons/test_mail/tests/test_mail_mail.py +++ b/addons/test_mail/tests/test_mail_mail.py @@ -48,4 +48,4 @@ class TestMail(common.SavepointCase, mail_common.MockEmails): 'partner_ids': [(4, self.user_employee.partner_id.id)] }) - self.assertRaises(MailDeliveryException, mail.send) + self.assertRaises(MailDeliveryException, lambda: mail.send(raise_exception=True)) diff --git a/addons/web/models/models.py b/addons/web/models/models.py index 572f07333d2..2e3bd54788d 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -40,11 +40,14 @@ class Base(models.AbstractModel): records_values = self.search_read(domain or [], [progress_bar['field'], group_by]) data = {} + field_type = self._fields[group_by].type + if field_type == 'selection': + selection_labels = dict(self.fields_get()[group_by]['selection']) + for record_values in records_values: group_by_value = record_values[group_by] # Again, imitating what _read_group_format_result and _read_group_prepare_data do - field_type = self._fields[group_by].type if group_by_value and field_type in ['date', 'datetime']: locale = self._context.get('lang') or 'en_US' group_by_value = fields.Datetime.to_datetime(group_by_value) @@ -61,7 +64,7 @@ class Base(models.AbstractModel): locale=locale) if field_type == 'selection': - group_by_value = dict(self.fields_get()[group_by]['selection'])[group_by_value] + group_by_value = selection_labels[group_by_value] if type(group_by_value) == tuple: group_by_value = group_by_value[1] # FIXME should use technical value (0) diff --git a/addons/web/static/src/js/chrome/abstract_action.js b/addons/web/static/src/js/chrome/abstract_action.js index 5b0f7e3aeca..c4755338bf0 100644 --- a/addons/web/static/src/js/chrome/abstract_action.js +++ b/addons/web/static/src/js/chrome/abstract_action.js @@ -32,6 +32,13 @@ var AbstractAction = Widget.extend({ * Called each time the action is detached from the DOM. */ on_detach_callback: function () {}, + /** + * Called by the action manager when action is restored (typically, when the + * user clicks on the action in the breadcrumb) + * + * @returns {Deferred|undefined} + */ + willRestore: function () {}, //-------------------------------------------------------------------------- // Public diff --git a/addons/web/static/src/js/chrome/action_manager_act_window.js b/addons/web/static/src/js/chrome/action_manager_act_window.js index 7b87cb45dd3..e66ae801bc1 100644 --- a/addons/web/static/src/js/chrome/action_manager_act_window.js +++ b/addons/web/static/src/js/chrome/action_manager_act_window.js @@ -646,8 +646,10 @@ ActionManager.include({ }); } else { viewOptions = _.extend(viewOptions || {}, action.env); - return controller.widget.reload(viewOptions).then(function () { - return controller; + return $.when(controller.widget.willRestore()).then(function () { + return controller.widget.reload(viewOptions).then(function () { + return controller; + }); }); } }); diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js index 725658a351b..2a961596862 100644 --- a/addons/web/static/src/js/core/dom.js +++ b/addons/web/static/src/js/core/dom.js @@ -397,9 +397,10 @@ return { if (options.maxWidth) { maxWidth = options.maxWidth(); } else { - var rect = $el[0].getBoundingClientRect(); + var mLeft = $el.is('.ml-auto, .mx-auto, .m-auto'); + var mRight = $el.is('.mr-auto, .mx-auto, .m-auto'); + maxWidth = computeFloatOuterWidthWithMargins($el[0], mLeft, mRight); var style = window.getComputedStyle($el[0]); - maxWidth = (rect.right - rect.left); maxWidth -= (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth) + parseFloat(style.borderRightWidth)); maxWidth -= _.reduce($unfoldableItems, function (sum, el) { return sum + computeFloatOuterWidthWithMargins(el); @@ -434,10 +435,17 @@ return { $extraItemsToggle.find('.nav-link').toggleClass('active', $extraItems.children().hasClass('active')); } - function computeFloatOuterWidthWithMargins(el) { + function computeFloatOuterWidthWithMargins(el, mLeft, mRight) { var rect = el.getBoundingClientRect(); var style = window.getComputedStyle(el); - return rect.right - rect.left + parseFloat(style.marginLeft) + parseFloat(style.marginRight); + var outerWidth = rect.right - rect.left; + if (mLeft !== false) { + outerWidth += parseFloat(style.marginLeft); + } + if (mRight !== false) { + outerWidth += parseFloat(style.marginRight); + } + return outerWidth; } }, /** diff --git a/addons/web/static/src/js/views/basic/basic_controller.js b/addons/web/static/src/js/views/basic/basic_controller.js index 0aff337dcbf..183885b9fb8 100644 --- a/addons/web/static/src/js/views/basic/basic_controller.js +++ b/addons/web/static/src/js/views/basic/basic_controller.js @@ -264,7 +264,9 @@ var BasicController = AbstractController.extend(FieldManagerMixin, { resIDs: record.res_ids, }, on_success: def.resolve.bind(def), - on_fail: def.reject.bind(def), + on_fail: function () { + self.update({}, {reload: false}).always(def.reject.bind(def)); + }, on_closed: reload, }); return this.alive(def); diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 7587e52fcd0..9c354be6765 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -49,6 +49,15 @@ var FormController = BasicController.extend({ this._super.apply(this, arguments); this.autofocus(); }, + /** + * Force mode back to readonly. Whenever we leave a form view, it is saved, + * and should no longer be in edit mode. + * + * @override + */ + willRestore: function () { + this.mode = 'readonly'; + }, //-------------------------------------------------------------------------- // Public @@ -241,7 +250,7 @@ var FormController = BasicController.extend({ * @override */ update: function (params, options) { - params = _.extend({viewType: 'form'}, params); + params = _.extend({viewType: 'form', mode: this.mode}, params); return this._super(params, options); }, diff --git a/addons/web/static/src/js/views/graph/graph_controller.js b/addons/web/static/src/js/views/graph/graph_controller.js index 09992695506..27bd59e476d 100644 --- a/addons/web/static/src/js/views/graph/graph_controller.js +++ b/addons/web/static/src/js/views/graph/graph_controller.js @@ -179,7 +179,6 @@ var GraphController = AbstractController.extend(GroupByMenuInterfaceMixin,{ */ _onButtonClick: function (ev) { var $target = $(ev.target); - var parent; var field; if ($target.hasClass('o_graph_button')) { this._setMode($target.data('mode')); diff --git a/addons/web/static/src/js/views/graph/graph_model.js b/addons/web/static/src/js/views/graph/graph_model.js index adefe1e8953..26c1aec6545 100644 --- a/addons/web/static/src/js/views/graph/graph_model.js +++ b/addons/web/static/src/js/views/graph/graph_model.js @@ -6,11 +6,8 @@ odoo.define('web.GraphModel', function (require) { * server. It basically just do a read_group and format/normalize data. */ -var core = require('web.core'); var AbstractModel = require('web.AbstractModel'); -var _t = core._t; - return AbstractModel.extend({ /** * @override diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js index fd21ed4df86..bfb6f5e0cc9 100644 --- a/addons/web/static/src/js/views/kanban/kanban_model.js +++ b/addons/web/static/src/js/views/kanban/kanban_model.js @@ -305,7 +305,7 @@ var KanbanModel = BasicModel.extend({ * @override */ _load: function (dataPoint, options) { - if (dataPoint.progressBar) { + if (dataPoint.groupedBy.length && dataPoint.progressBar) { return this._readProgressBarGroup(dataPoint, options); } return this._super.apply(this, arguments); diff --git a/addons/web/static/src/js/views/view_dialogs.js b/addons/web/static/src/js/views/view_dialogs.js index d6ea10d1243..ee11afbc3bd 100644 --- a/addons/web/static/src/js/views/view_dialogs.js +++ b/addons/web/static/src/js/views/view_dialogs.js @@ -186,9 +186,12 @@ var FormViewDialog = ViewDialog.extend({ } fields_view_def.then(function (viewInfo) { + var refinedContext = _.pick(self.context, function (value, key) { + return key.indexOf('_view_ref') === -1; + }); var formview = new FormView(viewInfo, { modelName: self.res_model, - context: self.context, + context: refinedContext, ids: self.res_id ? [self.res_id] : [], currentId: self.res_id || undefined, index: 0, diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index 9233229a984..7424d31f365 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -2878,6 +2878,31 @@ QUnit.module('ActionManager', { actionManager.destroy(); }); + QUnit.test('form views are restored in readonly when coming back in breadcrumbs', function (assert) { + assert.expect(2); + + var actionManager = createActionManager({ + actions: this.actions, + archs: this.archs, + data: this.data, + }); + actionManager.doAction(3); + + // open a record in form view + actionManager.$('.o_list_view .o_data_row:first').click(); + // switch to edit mode + $('.o_control_panel .o_form_button_edit').click(); + + assert.ok(actionManager.$('.o_form_view').hasClass('o_form_editable')); + // do some other action + actionManager.doAction(4); + // go back to form view + $('.o_control_panel .breadcrumb a').last().click(); + assert.ok(actionManager.$('.o_form_view').hasClass('o_form_readonly')); + + actionManager.destroy(); + }); + QUnit.test('honor group_by specified in actions context', function (assert) { assert.expect(5); diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index cb864afdf7e..f1640b06c82 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -1247,10 +1247,7 @@ QUnit.module('basic_fields', { form.destroy(); }); - QUnit.skip('input field: change password value', function (assert) { - // password policy needs an RPC call to initialize & - // presents somewhat differently (custom widget), need way - // to augment/override tests + QUnit.test('input field: change password value', function (assert) { assert.expect(4); var form = createView({ @@ -1278,7 +1275,7 @@ QUnit.module('basic_fields', { form.destroy(); }); - QUnit.skip('input field: empty password', function (assert) { + QUnit.test('input field: empty password', function (assert) { assert.expect(3); this.data.partner.records[0].foo = false; diff --git a/addons/web/static/tests/qweb_tests.js b/addons/web/static/tests/qweb_tests.js index 6e40dd1b1cb..b31c604b4bf 100644 --- a/addons/web/static/tests/qweb_tests.js +++ b/addons/web/static/tests/qweb_tests.js @@ -49,9 +49,10 @@ function loadTest(assert, template, context) { var args = params ? JSON.parse(params.textContent) : (context ? _.clone(context) : {}); var results = doc.querySelector('result#' + template); + assert.equal( trim(qweb.render(template, args)), - trim(results.textContent), + trim(results.textContent.replace(new RegExp(String.fromCharCode(13), 'g'), '')), template); } catch (error) { assert.notOk(error.stack || error, 'Rendering error'); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index fce865ac29f..ada27d09afc 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -5993,10 +5993,7 @@ QUnit.module('Views', { form.destroy(); }); - QUnit.skip('support password attribute', function (assert) { - // password policy needs an RPC call to initialize & - // presents somewhat differently (custom widget), need way - // to augment/override tests + QUnit.test('support password attribute', function (assert) { assert.expect(3); var form = createView({ @@ -6989,6 +6986,69 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('keep editing after call_button fail', function (assert) { + assert.expect(4); + + var values; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form>' + + '<button name="post" class="p" string="Raise Error" type="object"/>' + + '<field name="p">' + + '<tree editable="top">' + + '<field name="display_name"/>' + + '<field name="product_id"/>' + + '</tree>' + + '</field>' + + '</form>', + res_id: 1, + intercepts: { + execute_action: function (ev) { + assert.ok(true, 'the action is correctly executed'); + ev.data.on_fail(); + }, + }, + mockRPC: function (route, args) { + if (args.method === 'write') { + assert.deepEqual(args.args[1].p[0][2], values); + } + return this._super.apply(this, arguments); + }, + viewOptions: { + mode: 'edit', + }, + }); + + // add a row and partially fill it + form.$('.o_field_x2many_list_row_add a').click(); + form.$('input:nth(0)').val('abc').trigger('input'); + + // click button which will trigger_up 'execute_action' (this will save) + values = { + display_name: 'abc', + product_id: false, + }; + form.$('button.p').click(); + + // edit the new row again and set a many2one value + form.$('.o_form_view .o_field_one2many .o_data_row .o_data_cell').click(); + var $dropdown = form.$('.o_field_many2one input').autocomplete('widget'); + form.$('.o_field_many2one input').click(); + $dropdown.find('li:first()').click(); + + assert.strictEqual(form.$('.o_field_many2one input').val(), 'xphone', + "value of the m2o should have been correctly updated"); + + values = { + product_id: 37, + }; + form.$buttons.find('.o_form_button_save').click(); + + form.destroy(); + }); + QUnit.test('asynchronous rendering of a widget tag', function (assert) { assert.expect(1); @@ -7286,7 +7346,6 @@ QUnit.module('Views', { '</sheet>' + '</form>', res_id: 2, - //debug:1, }); assert.strictEqual(form.$buttons.find('.o_form_button_edit')[0],document.activeElement, "in read mode, when there are no primary buttons on the form, the default button with the focus should be edit"); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index 305dda05701..435c13cd3f1 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -4364,6 +4364,37 @@ QUnit.module('Views', { kanban.destroy(); }); + QUnit.test('column progressbars should not crash in non grouped views', function (assert) { + assert.expect(3); + + var kanban = createView({ + View: KanbanView, + model: 'partner', + data: this.data, + arch: + '<kanban>' + + '<field name="bar"/>' + + '<field name="int_field"/>' + + '<progressbar field="foo" colors=\'{"yop": "success", "gnap": "warning", "blip": "danger"}\' sum_field="int_field"/>' + + '<templates><t t-name="kanban-box">' + + '<div>' + + '<field name="name"/>' + + '</div>' + + '</t></templates>' + + '</kanban>', + mockRPC: function (route, args) { + assert.step(route) + return this._super(route, args); + }, + }); + + assert.strictEqual(kanban.$('.o_kanban_record').text(), 'namenamenamename', + "should have renderer 4 records"); + + assert.verifySteps(['/web/dataset/search_read'], "no read on progress bar data is done"); + kanban.destroy(); + }); + QUnit.test('column progressbars: creating a new column should create a new progressbar', function (assert) { assert.expect(1); @@ -4459,8 +4490,7 @@ QUnit.module('Views', { kanban.destroy(); }); - // XXX test deactivated in saas~11.2 as kanban archiving has been removed (and readded in saas~11.4) - QUnit.skip('column progressbars on archiving records update counter', function (assert) { + QUnit.test('column progressbars on archiving records update counter', function (assert) { assert.expect(4); // add active field on partner model and make all records active @@ -4491,7 +4521,9 @@ QUnit.module('Views', { "the counter progressbars should be correctly displayed"); // archive all records of the second columns - kanban.$('.o_kanban_group:eq(1) .o_column_archive').click(); + kanban.$('.o_kanban_group:eq(1) .o_kanban_config a.o-no-caret').click(); + kanban.$('.o_column_archive_records:visible').click(); + $('.modal-footer button:first').click(); assert.strictEqual(kanban.$('.o_kanban_group:eq(1) .o_kanban_counter_side').text(), "0", "counter should contain the correct value"); @@ -4501,7 +4533,7 @@ QUnit.module('Views', { kanban.destroy(); }); - QUnit.skip('kanban with progressbars: correctly update env when archiving records', function (assert) { + QUnit.test('kanban with progressbars: correctly update env when archiving records', function (assert) { assert.expect(3); // add active field on partner model and make all records active @@ -4526,13 +4558,15 @@ QUnit.module('Views', { groupBy: ['bar'], intercepts: { env_updated: function (ev) { - assert.step(ev.data.ids); + assert.step(ev.data.env.ids); }, }, }); // archive all records of the first column - kanban.$('.o_kanban_group:first .o_column_archive').click(); + kanban.$('.o_kanban_group:first .o_kanban_config a.o-no-caret').click(); + kanban.$('.o_column_archive_records:visible').click(); + $('.modal-footer button:first').click(); assert.verifySteps([ [1, 2, 3, 4], diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 4540b34c725..030a676f4eb 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2849,72 +2849,6 @@ QUnit.module('Views', { list.destroy(); }); - QUnit.skip('navigation: moving left/right with keydown', function (assert) { - assert.expect(8); - - this.data.foo.fields.foo.type = 'text'; - var list = createView({ - View: ListView, - model: 'foo', - data: this.data, - arch: - '<tree editable="bottom">' + - '<field name="m2m" widget="many2many_tags"/>' + - '<field name="foo"/>' + - '<field name="bar"/>' + - '<field name="m2o"/>' + - '<field name="qux"/>' + - '</tree>', - }); - - list.$('td:contains(13)').click(); - var $m2m = list.$('[name="m2m"] input'); - var $foo = list.$('textarea[name="foo"]'); - var $bar = list.$('[name="bar"] input'); - var $m2o = list.$('[name="m2o"] input'); - var $qux = list.$('input[name="qux"]'); - - assert.strictEqual(document.activeElement, $qux[0], - "'qux' input should be focused"); - - $qux[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect) - $qux.trigger({type: 'keydown', which: $.ui.keyCode.LEFT}); - assert.strictEqual(document.activeElement, $m2o[0], - "'m2o' input should be focused"); - - // forget unselecting and try leaving - $m2o.trigger({type: 'keydown', which: $.ui.keyCode.LEFT}); - assert.strictEqual(document.activeElement, $m2o[0], - "'m2o' input should still be focused"); - - $m2o[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect) - $m2o.trigger({type: 'keydown', which: $.ui.keyCode.LEFT}); - assert.strictEqual(document.activeElement, $bar[0], - "'bar' input should be focused"); - - // no unselect here as it is a checkbox - $bar.trigger({type: 'keydown', which: $.ui.keyCode.LEFT}); - assert.strictEqual(document.activeElement, $foo[0], - "'foo' input should be focused"); - - // forget unselecting and try leaving - $foo.trigger({type: 'keydown', which: $.ui.keyCode.LEFT}); - assert.strictEqual(document.activeElement, $foo[0], - "'foo' input should still be focused"); - - $foo[0].selectionEnd = 0; // Simulate browser keyboard left behavior (unselect) - $foo.trigger({type: 'keydown', which: $.ui.keyCode.LEFT}); - assert.strictEqual(document.activeElement, $m2m[0], - "'m2m' input should be focused"); - - $m2m[0].selectionStart = $m2m[0].value.length; // Simulate browser keyboard right behavior (unselect) - $m2m.trigger({type: 'keydown', which: $.ui.keyCode.RIGHT}); - assert.strictEqual(document.activeElement, $foo[0], - "'foo' input should be focused"); - - list.destroy(); - }); - QUnit.test('discarding changes in a row properly updates the rendering', function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index b45c27e4d60..372edbc0f24 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -364,6 +364,61 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('Form dialog and subview with _view_ref contexts', function (assert) { + assert.expect(2); + + this.data.instrument.records = [{id: 1, name: 'Tromblon', badassery: [1]}]; + this.data.partner.records[0].instrument = 1; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form>' + + '<field name="name"/>' + + '<field name="instrument" context="{\'tree_view_ref\': \'some_tree_view\'}"/>' + + '</form>', + res_id: 1, + archs: { + 'instrument,false,form': '<form>'+ + '<field name="name"/>'+ + '<field name="badassery" context="{\'tree_view_ref\': \'some_other_tree_view\'}"/>' + + '</form>', + + 'badassery,false,list': '<tree>'+ + '<field name="level"/>'+ + '</tree>', + }, + viewOptions: { + mode: 'edit', + }, + + mockRPC: function(route, args) { + if (args.method === 'get_formview_id') { + return $.when(false); + } + return this._super(route, args); + }, + + interceptsPropagate: { + load_views: function (ev) { + var evaluatedContext = ev.data.context; + if (ev.data.modelName === 'instrument') { + assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_tree_view'}, + 'The correct _view_ref should have been sent to the server, first time'); + } + if (ev.data.modelName === 'badassery') { + assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_other_tree_view'}, + 'The correct _view_ref should have been sent to the server for the subview'); + } + }, + }, + }); + + form.$('.o_field_widget[name="instrument"] button.o_external_button').click(); + form.destroy(); + }); + QUnit.test('SelectCreateDialog: save current search', function (assert) { assert.expect(4); diff --git a/addons/web_editor/static/src/js/editor/summernote.js b/addons/web_editor/static/src/js/editor/summernote.js index a2f0f6a308f..36f5becce06 100644 --- a/addons/web_editor/static/src/js/editor/summernote.js +++ b/addons/web_editor/static/src/js/editor/summernote.js @@ -1936,6 +1936,15 @@ $.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTa if (!r) { return; } + // select content since container (that firefox selects) may be removed + if (r.so === 0) { + r.sc = dom.firstChild(r.sc); + } + if (dom.nodeLength(r.ec) >= r.eo) { + r.ec = dom.lastChild(r.ec); + r.eo = dom.nodeLength(r.ec); + } + r = range.create(r.sc, r.so, r.ec, r.eo); r.reRange().select(); if (sTagName === "blockquote" || sTagName === "pre") { @@ -1949,6 +1958,10 @@ $.summernote.pluginEvents.formatBlock = function (event, editor, layoutInfo, sTa for (var i=0; i<nodes.length; i++) { if (dom.isBR(nodes[i]) || (dom.isText(nodes[i]) && dom.isVisibleText(nodes[i])) || dom.isB(nodes[i]) || dom.isU(nodes[i]) || dom.isS(nodes[i]) || dom.isI(nodes[i]) || dom.isFont(nodes[i])) { var ancestor = dom.ancestor(nodes[i], isFormatNode); + if ($(ancestor).parent().is('blockquote')) { + // firefox may wrap formatting block in blockquote + $(ancestor).unwrap(); + } if (!ancestor) { dom.wrap(nodes[i], sTagName); } else if (ancestor.tagName.toLowerCase() !== sTagName) { @@ -2272,7 +2285,8 @@ options.onCreateLink = function (sLinkUrl) { // pass } else if (sLinkUrl.indexOf('@') !== -1 && sLinkUrl.indexOf(':') === -1) { sLinkUrl = 'mailto:' + sLinkUrl; - } else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl.indexOf('/') !== 0 && sLinkUrl.indexOf('#') !== 0) { + } else if (sLinkUrl.indexOf('://') === -1 && sLinkUrl[0] !== '/' + && sLinkUrl[0] !== '#' && sLinkUrl.slice(0, 2) !== '${') { sLinkUrl = 'http://' + sLinkUrl; } return sLinkUrl; diff --git a/addons/web_editor/static/src/scss/web_editor.variables.scss b/addons/web_editor/static/src/scss/web_editor.variables.scss index 6c4336581a9..9bb6b705c8d 100644 --- a/addons/web_editor/static/src/scss/web_editor.variables.scss +++ b/addons/web_editor/static/src/scss/web_editor.variables.scss @@ -71,7 +71,7 @@ $o-we-switch-inactive-color: $o-we-color-paper !default; color: $o-we-color-divider; .text-muted { - color: $o-we-color-text-normal; + color: $o-we-color-text-normal !important; } } diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml index 85cf336130d..631bd069e8b 100644 --- a/addons/web_editor/static/src/xml/editor.xml +++ b/addons/web_editor/static/src/xml/editor.xml @@ -81,11 +81,12 @@ <!-- Image choosing part of the Media Dialog --> <t t-name="web_editor.dialog.image"> + <t t-set="iframeName" t-value="_.uniqueId('fileframe-')"/> <form class="form-inline" method="POST" action="/web_editor/attachment/add" enctype="multipart/form-data" - target="fileframe"> + t-att-target="iframeName"> <input type="hidden" name="csrf_token" t-att-value="csrf_token"/> <input type="hidden" name="filters" t-att-value="widget.firstFilters.join('_')"/> <input t-if="widget.options.res_id" type="hidden" name="res_id" t-att-value="widget.options.res_id"/> @@ -119,7 +120,7 @@ <div class="existing-attachments"> <t t-call="web_editor.dialog.image.existing"/> </div> - <iframe class="d-none" name="fileframe"/> + <iframe class="d-none" t-att-name="iframeName"/> </form> </t> <t t-name="web_editor.dialog.image.existing"> diff --git a/addons/web_tour/__manifest__.py b/addons/web_tour/__manifest__.py index 6e89cf26673..80f44db9165 100644 --- a/addons/web_tour/__manifest__.py +++ b/addons/web_tour/__manifest__.py @@ -13,6 +13,7 @@ Odoo Web tours. 'depends': ['web'], 'data': [ 'security/ir.model.access.csv', + 'security/ir.rule.csv', 'views/tour_templates.xml', 'views/tour_views.xml' ], diff --git a/addons/web_tour/security/ir.rule.csv b/addons/web_tour/security/ir.rule.csv new file mode 100644 index 00000000000..6afd916bf7f --- /dev/null +++ b/addons/web_tour/security/ir.rule.csv @@ -0,0 +1,2 @@ +"id","model_id/id","name","active","perm_create","perm_unlink","perm_read","perm_write","domain_force"
+"own_tours","model_web_tour_tour","own tours","True","True","True","True","True","[('user_id','=', user.id)]"
diff --git a/addons/website/controllers/backend.py b/addons/website/controllers/backend.py index da8a9118a4a..b558620a0f5 100644 --- a/addons/website/controllers/backend.py +++ b/addons/website/controllers/backend.py @@ -61,6 +61,6 @@ class WebsiteBackend(http.Controller): request.env['res.config.settings'].create({ 'google_management_client_id': ga_client_id, 'google_analytics_key': ga_analytics_key, - 'website_id': current_website, + 'website_id': current_website.id, }).execute() return True diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index 28c0a0f8e85..3a54d5ce29c 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -78,7 +78,7 @@ </div> </section> </div> - <div class="text-center mt-3" name="mail_button"> + <div class="text-center my-3" name="mail_button"> <a role="button" t-attf-href="mailto:{{ res_company.email }}" class="btn btn-primary" id="o_contact_mail">Send us an email</a> </div> </div> diff --git a/addons/website/models/ir_actions.py b/addons/website/models/ir_actions.py index f26d6263004..4add5587d7f 100644 --- a/addons/website/models/ir_actions.py +++ b/addons/website/models/ir_actions.py @@ -38,7 +38,7 @@ class ServerAction(models.Model): def _get_website_url(self): for action in self: if action.state == 'code' and action.website_published: - action.website_url = self._compute_website_url(action.website_path, action.xml_id) + action.website_url = action._compute_website_url(action.website_path, action.xml_id) @api.model def _get_eval_context(self, action): diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index 84089d5c0c6..75b1a8d4fe4 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -45,17 +45,20 @@ class View(models.Model): view.with_context(no_cow=True).key = 'website.key_%s' % str(uuid.uuid4())[:6] if not view.website_id and current_website_id and not self._context.get('no_cow'): # If already a specific view for this generic view, write on it - website_specific_view = self.env['ir.ui.view'].search([ + website_specific_view = view.search([ ('key', '=', view.key), ('website_id', '=', current_website_id) ], limit=1) if not website_specific_view: # Set key to avoid copy() to generate an unique key as we want the specific view to have the same key - website_specific_view = view.copy({'website_id': current_website_id, 'key': view.key}) + copy_vals = {'website_id': current_website_id, 'key': view.key} + if vals.get('inherit_id'): + copy_vals['inherit_id'] = vals['inherit_id'] + website_specific_view = view.copy(copy_vals) view._create_website_specific_pages_for_view(website_specific_view, view.env['website'].browse(current_website_id)) - for inherit_child in view.inherit_children_ids: + for inherit_child in view.inherit_children_ids.filter_duplicate(): # COW won't be triggered if there is already a website_id on the view, we should copy the view ourself if inherit_child.website_id.id == current_website_id: inherit_child.copy({'inherit_id': website_specific_view.id, 'key': inherit_child.key}) diff --git a/addons/website/static/src/js/backend/dashboard.js b/addons/website/static/src/js/backend/dashboard.js index b4fe22ba84e..1a83e8dc39a 100644 --- a/addons/website/static/src/js/backend/dashboard.js +++ b/addons/website/static/src/js/backend/dashboard.js @@ -44,6 +44,9 @@ var Dashboard = AbstractAction.extend(ControlPanelMixin, { var self = this; return $.when(ajax.loadLibs(this), this._super()).then(function() { return self.fetch_data(); + }).then(function(){ + var website = _.findWhere(self.websites, {selected: true}); + self.website_id = website ? website.id : false; }); }, diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index c5371e3c5fb..42a12414557 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -98,6 +98,17 @@ sAnimation.registry.autohideMenu = sAnimation.Class.extend({ def.resolve(); }); }); + + // The previous code will make sure we wait for images to be fully + // loaded before initializing the auto more menu. But in some cases, + // it is not enough, we also have to wait for fonts or even extra + // scripts. Those will have no impact on the feature in most cases + // though, so we will only update the auto more menu at that time, + // no wait for it to initialize the feature. + var $window = $(window); + $window.on('load.autohideMenu', function () { + $window.trigger('resize'); + }); } return $.when.apply($, defs).then(function () { if (!self.noAutohide) { @@ -112,6 +123,7 @@ sAnimation.registry.autohideMenu = sAnimation.Class.extend({ destroy: function () { this._super.apply(this, arguments); if (!this.noAutohide) { + $(window).off('.autohideMenu'); dom.destroyAutoMoreMenu(this.$el); } }, diff --git a/addons/website/static/src/js/menu/customize.js b/addons/website/static/src/js/menu/customize.js index d8ce436248e..b931e938cc6 100644 --- a/addons/website/static/src/js/menu/customize.js +++ b/addons/website/static/src/js/menu/customize.js @@ -18,8 +18,14 @@ var CustomizeMenu = Widget.extend({ /** * @override */ - start: function () { + willStart: function () { this.viewName = $(document.documentElement).data('view-xmlid'); + return this._super.apply(this, arguments); + }, + /** + * @override + */ + start: function () { if (!this.viewName) { _.defer(this.destroy.bind(this)); } @@ -27,6 +33,7 @@ var CustomizeMenu = Widget.extend({ if (this.$el.is('.show')) { this._loadCustomizeOptions(); } + return this._super.apply(this, arguments); }, //-------------------------------------------------------------------------- diff --git a/addons/website/static/src/scss/compatibility/bs3_for_12_0.scss b/addons/website/static/src/scss/compatibility/bs3_for_12_0.scss new file mode 100644 index 00000000000..82beeec2cbf --- /dev/null +++ b/addons/website/static/src/scss/compatibility/bs3_for_12_0.scss @@ -0,0 +1,300 @@ +/** + * This file's purpose is to *ease* migration from 11.0. + */ + +// Restore gray utilities +$-compat-gray-map: ( + 'gray-darker': '900', + 'gray-dark': '900', + 'gray': '700', + 'gray-light': '600', + 'gray-lighter': '200', +); +@each $old, $new in $-compat-gray-map { + @include bg-variant(".bg-#{$old}", gray($new)); + @include text-emphasis-variant(".text-#{$old}", gray($new)); +} + +// Restore media ? + +// Restore progress bars +@each $color, $value in $theme-colors { + @include bg-variant(".progress-bar-#{$color}", $value); +} + +// Adapt radio ? + +// Adapt labels +.label { + @extend .badge; +} +@each $color, $value in $theme-colors { + .label-#{$color} { + @include badge-variant($value); + } +} +.label-default { + @include badge-variant(theme-color('secondary')); +} + +// Adapt center-block +.center-block { + display: block; + margin: auto; +} + +// Adapt pull-* classes +.pull-left { + float: left; +} +.pull-right { + float: right; +} + +// Adapt pagination +.pagination > li { + @extend .page-item; + > a { + @extend .page-link; + } +} + +// Adapt carousel +.carousel .item { + @extend .carousel-item; +} + +// Adapt checkboxes ? + +// Adapt tables +.table-condensed { + @extend .table-sm; +} + +// Adapt forms +.control-label { + @extend .col-form-label; +} +.help-block { + @extend .form-text; +} +.has-error .form-control { + @extend .is-invalid; +} +.has-success .form-control { + @extend .is-valid; +} +.form-horizontal .form-group { + @extend .row; +} + +// Adapt list-inline +.list-inline > li { + @extend .list-inline-item; +} + +// Adapt list-group +.panel .list-group { + @extend .list-group-flush; +} + +// Adapt image utilies +.img-rounded { + @extend .rounded; +} +.img-circle { + @extend .rounded-circle; +} + +// Adapt input group +.input-group { + .input-group-btn:first-child { + @extend .input-group-prepend; + } + .form-control ~ .input-group-btn { + @extend .input-group-append; + } + .input-group-addon { + @extend .input-group-append; + @extend .input-group-text; + } +} + +// Adapt panels +.panel { + @extend .card; +} +@each $color, $value in $theme-colors { + @include bg-variant(".panel-#{$color}", $value); +} +@include bg-variant(".panel-default", $white); +.panel-heading { + @extend .card-header; +} +.panel-body { + @extend .card-body; +} +.panel-footer { + @extend .card-footer; +} +.well { + @extend .card; + @extend .card-body; +} + +// Adapt grid (push-pull, xs ?) +@each $breakpoint in map-keys($grid-breakpoints) { + $infix: breakpoint-infix($breakpoint, $grid-breakpoints); + $infix: if($infix != "", $infix, "-xs"); + + @include media-breakpoint-up($breakpoint, $grid-breakpoints) { + // `$grid-columns - 1` because offsetting by the width of an entire row isn't possible + @for $i from 0 through ($grid-columns - 1) { + .col#{$infix}-offset-#{$i} { + @include make-col-offset($i, $grid-columns); + } + } + } +} + +// Adapt breadcrumb +.breadcrumb > li { + @extend .breadcrumb-item; +} + +// Adapt nav +.nav > li { + @extend .nav-item; + > a { + @extend .nav-link; + } +} +.nav-stacked { + flex-direction: column; +} +@include bg-variant(".navbar-default", $light); + +// Adapt img-responsive +.img-responsive { + @extend .img-fluid; +} + +// Adapt dropdowns +.dropdown-menu { + a { + @extend .dropdown-item; + } + .divider { + @extend .dropdown-divider; + } +} +.dropdown-toggle .caret { + display: none; +} + +// Adapt buttons +.btn-default { + @include button-variant(theme-color('secondary'), theme-color('secondary')); +} + +// Adapt display classes +.hidden, .hide { + display: none !important; +} +.hidden { + &-xs { + @include media-breakpoint-down(sm) { + display: none; + } + } + &-sm { + @include media-breakpoint-only(md) { + display: none; + } + } + &-md { + @include media-breakpoint-only(lg) { + display: none; + } + } + &-lg { + @include media-breakpoint-only(xl) { + display: none; + } + } + &-print { + @media print { + display: none; + } + } +} +.show { + display: block !important; +} +.visible { + &-xs { + &-block, &-inline-block { + display: none; + } + @include media-breakpoint-down(sm) { + &-block { + display: block; + } + &-inline-block { + display: inline-block; + } + } + } + &-sm { + &-block, &-inline-block { + display: none; + } + @include media-breakpoint-only(md) { + &-block { + display: block; + } + &-inline-block { + display: inline-block; + } + } + } + &-md { + &-block, &-inline-block { + display: none; + } + @include media-breakpoint-only(lg) { + &-block { + display: block; + } + &-inline-block { + display: inline-block; + } + } + } + &-lg { + &-block, &-inline-block { + display: none; + } + @include media-breakpoint-only(xl) { + &-block { + display: block; + } + &-inline-block { + display: inline-block; + } + } + } + &-print { + &-block, &-inline-block { + display: none; + } + @media print { + &-block { + display: block; + } + &-inline-block { + display: inline-block; + } + } + } +} diff --git a/addons/website/static/src/scss/website.ui.scss b/addons/website/static/src/scss/website.ui.scss index 1d1c42203b4..d9554a20a1c 100644 --- a/addons/website/static/src/scss/website.ui.scss +++ b/addons/website/static/src/scss/website.ui.scss @@ -298,6 +298,7 @@ body .modal { } #o_new_content_menu_choices { + @include o-w-preserve-base; @include o-position-absolute($o-navbar-height, 0, 0, 0); position: fixed; display: flex; diff --git a/addons/website/tests/test_views.py b/addons/website/tests/test_views.py index b16740ad619..49a76cf78ab 100644 --- a/addons/website/tests/test_views.py +++ b/addons/website/tests/test_views.py @@ -421,10 +421,13 @@ class TestCowViewSaving(common.TransactionCase): 'arch': '<div>content</div>', }) - total_views = View.search_count([]) + total_views = View.with_context(active_test=False).search_count([]) base_view.with_context(website_id=1).write({'name': 'New Name'}) # This will not write on `base_view` but will copy it to a specific view on which the `name` change will be applied + specific_view = View.search([['name', '=', 'New Name'], ['website_id', '=', 1]]) base_view.with_context(website_id=1).write({'name': 'Another New Name'}) - self.assertEqual(total_views + 1, View.search_count([]), "Second write should have wrote on the view copied during first write") + specific_view.active = False + base_view.with_context(website_id=1).write({'name': 'Yet Another New Name'}) + self.assertEqual(total_views + 1, View.with_context(active_test=False).search_count([]), "Subsequent writes should have written on the view copied during first write") # 2. Test with calling save() from ir.ui.view view_arch = '''<t name="Second View" t-name="website.second_view"> @@ -444,12 +447,12 @@ class TestCowViewSaving(common.TransactionCase): 'arch': view_arch, }) - total_views = View.search_count([]) + total_views = View.with_context(active_test=False).search_count([]) second_view.with_context(website_id=1).save('<div class="editable_part" data-oe-id="%s" data-oe-xpath="/t[1]/t[1]/div[1]/div[1]" data-oe-field="arch" data-oe-model="ir.ui.view">First editable_part</div>' % second_view.id, "/t[1]/t[1]/div[1]/div[1]") second_view.with_context(website_id=1).save('<div class="editable_part" data-oe-id="%s" data-oe-xpath="/t[1]/t[1]/div[1]/div[3]" data-oe-field="arch" data-oe-model="ir.ui.view">Second editable_part</div>' % second_view.id, "/t[1]/t[1]/div[1]/div[3]") - self.assertEqual(total_views + 1, View.search_count([]), "Second save should have wrote on the view copied during first save") + self.assertEqual(total_views + 1, View.with_context(active_test=False).search_count([]), "Second save should have written on the view copied during first save") - total_specific_view = View.search_count([('arch_db', 'like', 'First editable_part'), ('arch_db', 'like', 'Second editable_part')]) + total_specific_view = View.with_context(active_test=False).search_count([('arch_db', 'like', 'First editable_part'), ('arch_db', 'like', 'Second editable_part')]) self.assertEqual(total_specific_view, 1, "both editable_part should have been replaced on a created specific view") def test_cow_complete_flow(self): @@ -520,3 +523,43 @@ class TestCowViewSaving(common.TransactionCase): base_arch_w1 = self.base_view.with_context(website_id=1).read_combined(['arch'])['arch'] self.assertEqual('Hello World' in base_arch, True) self.assertEqual(base_arch, base_arch_w1, "Reading a top level view with or without a website_id in the context should render that exact view..") # ..even if there is a specific view for that one, as read_combined is supposed to render specific inherited view over generic but not specific top level instead of generic top level + + def test_cow_cross_inherit(self): + View = self.env['ir.ui.view'] + total_views = View.search_count([]) + + main_view = View.create({ + 'name': 'Main View', + 'type': 'qweb', + 'arch': '<body>GENERIC<div>A</div></body>', + 'key': 'website.main_view', + }).with_context(load_all_views=True) + + View.create({ + 'name': 'Child View', + 'mode': 'extension', + 'inherit_id': main_view.id, + 'arch': '<xpath expr="//div" position="replace"><div>VIEW<p>B</p></div></xpath>', + 'key': 'website.child_view', + }) + + child_view_2 = View.with_context(load_all_views=True).create({ + 'name': 'Child View 2', + 'mode': 'extension', + 'inherit_id': main_view.id, + 'arch': '<xpath expr="//p" position="replace"><span>C</span></xpath>', + 'key': 'website.child_view_2', + }) + + # These line doing `write()` are the real tests, it should not be changed and should not crash on xpath. + child_view_2.with_context(website_id=1).write({'arch': '<xpath expr="//p" position="replace"><span>D</span></xpath>'}) + self.assertEqual(total_views + 3 + 1, View.search_count([]), "It should have created the 3 initial generic views and created a child_view_2 specific view") + main_view.with_context(website_id=1).write({'arch': '<body>SPECIFIC<div>Z</div></body>'}) + self.assertEqual(total_views + 3 + 3, View.search_count([]), "It should have duplicated the Main View tree as a specific tree and then removed the specific view from the generic tree as no more needed") + + generic_view = View.with_context(website_id=None).get_view_id('website.main_view') + specific_view = View.with_context(website_id=1).get_view_id('website.main_view') + generic_view_arch = View.browse(generic_view).with_context(load_all_views=True).read_combined(['arch'])['arch'] + specific_view_arch = View.browse(specific_view).with_context(load_all_views=True, website_id=1).read_combined(['arch'])['arch'] + self.assertEqual(generic_view_arch == '<body>GENERIC<div>VIEW<span>C</span></div></body>', True) + self.assertEqual(specific_view_arch == '<body>SPECIFIC<div>VIEW<span>D</span></div></body>', True, "Writing on top level view hierarchy with a website in context should write on the view and clone it's inherited views") diff --git a/addons/website/views/snippets.xml b/addons/website/views/snippets.xml index d2b3cffbf60..c9a76741fad 100644 --- a/addons/website/views/snippets.xml +++ b/addons/website/views/snippets.xml @@ -567,7 +567,7 @@ <div class="col-lg-12 pt16 pb16"> <div id="myCollapse" class="accordion" role="tablist"> <div class="card bg-white"> - <a href="#" role="tab" data-toggle="collapse" aria-expanded="true" class="card-header text-primary">Terms of service</a> + <a href="#" role="tab" data-toggle="collapse" aria-expanded="true" class="card-header">Terms of service</a> <div class="collapse show" role="tabpanel"> <div class="card-body"> <p class="card-text">These terms of service ("Terms", "Agreement") are an agreement between the website ("Website operator", "us", "we" or "our") and you ("User", "you" or "your"). This Agreement sets forth the general terms and conditions of your use of this website and any of its products or services (collectively, "Website" or "Services").</p> @@ -575,7 +575,7 @@ </div> </div> <div class="card bg-white"> - <a href="#" role="tab" data-toggle="collapse" aria-expanded="false" class="collapsed card-header text-primary">Links to other Websites</a> + <a href="#" role="tab" data-toggle="collapse" aria-expanded="false" class="collapsed card-header">Links to other Websites</a> <div class="collapse" role="tabpanel"> <div class="card-body"> <p class="card-text">Although this Website may be linked to other websites, we are not, directly or indirectly, implying any approval, association, sponsorship, endorsement, or affiliation with any linked website, unless specifically stated herein.</p> @@ -584,7 +584,7 @@ </div> </div> <div class="card bg-white"> - <a href="#" role="tab" data-toggle="collapse" aria-expanded="false" class="collapsed card-header text-primary">Use of Cookies</a> + <a href="#" role="tab" data-toggle="collapse" aria-expanded="false" class="collapsed card-header">Use of Cookies</a> <div class="collapse" role="tabpanel"> <div class="card-body"> <p class="card-text">Website may use cookies to personalize and facilitate maximum navigation of the User by this site. The User may configure his / her browser to notify and reject the installation of the cookies sent by us.</p> diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 0b52b91bdb1..81cbff1d0cd 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -60,6 +60,12 @@ </xpath> </template> +<template id="assets_frontend_compatibility_for_12_0" inherit_id="website.assets_frontend" active="False"> + <xpath expr="//link[last()]" position="after"> + <link rel="stylesheet" type="text/scss" href="/website/static/src/scss/compatibility/bs3_for_12_0.scss"/> + </xpath> +</template> + <template id="website.assets_editor" name="Website Editor Assets (used in website editor)"> <t t-call="web._assets_helpers"/> diff --git a/addons/website_event/models/event.py b/addons/website_event/models/event.py index c9f9554b149..3b02869cf2e 100644 --- a/addons/website_event/models/event.py +++ b/addons/website_event/models/event.py @@ -81,7 +81,7 @@ class Event(models.Model): event.menu_id.unlink() elif event.website_menu: if not event.menu_id: - root_menu = self.env['website.menu'].create({'name': event.name, 'website_id': event.id}) + root_menu = self.env['website.menu'].create({'name': event.name, 'website_id': event.website_id.id}) event.menu_id = root_menu for sequence, (name, url, xml_id) in enumerate(event._get_menu_entries()): event._create_menu(sequence, name, url, xml_id) @@ -96,7 +96,7 @@ class Event(models.Model): 'url': url, 'parent_id': self.menu_id.id, 'sequence': sequence, - 'website_id': self.id, + 'website_id': self.website_id.id, }) return menu diff --git a/addons/website_event/views/event_templates.xml b/addons/website_event/views/event_templates.xml index 20608d9e0d7..fc70e2ee4b0 100644 --- a/addons/website_event/views/event_templates.xml +++ b/addons/website_event/views/event_templates.xml @@ -74,14 +74,14 @@ </li> </ul> </div> - <div class="row"> - <div class="col-md-6 offset-3"> - <t t-call="website.pager"/> - </div> - </div> <div class="col-lg-3 oe_structure" id="right_column"> </div> </div> + <div class="row"> + <div class="col-md-6 offset-3"> + <t t-call="website.pager"/> + </div> + </div> </div> <div class="oe_structure" id="oe_structure_website_event_index_2"/> </div> diff --git a/addons/website_event/views/event_views.xml b/addons/website_event/views/event_views.xml index 7e4df97c36a..566a391772a 100644 --- a/addons/website_event/views/event_views.xml +++ b/addons/website_event/views/event_views.xml @@ -31,7 +31,7 @@ <field name="inherit_id" ref="event.view_event_form"/> <field name="arch" type="xml"> <field name="organizer_id" position="before"> - <field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/> + <field name="website_id" options="{'no_create': True}" attrs="{'required': [('website_menu','=',True)]}" groups="website.group_multi_website"/> </field> <button name="toggle_active" position="before"> <field name="website_url" invisible="1"/> diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index 41a120008b4..f8a75af3130 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -221,10 +221,10 @@ class WebsiteForm(http.Controller): else: orphan_attachment_ids.append(attachment_id.id) - # If some attachments didn't match a field on the model, - # we create a mail.message to link them to the record - if orphan_attachment_ids: - if model_name != 'mail.mail': + if model_name != 'mail.mail': + # If some attachments didn't match a field on the model, + # we create a mail.message to link them to the record + if orphan_attachment_ids: values = { 'body': _('<p>Attached files : </p>'), 'model': model_name, diff --git a/addons/website_mass_mailing/static/src/js/website_mass_mailing.js b/addons/website_mass_mailing/static/src/js/website_mass_mailing.js index 1820f2f0d8f..e30f824693d 100644 --- a/addons/website_mass_mailing/static/src/js/website_mass_mailing.js +++ b/addons/website_mass_mailing/static/src/js/website_mass_mailing.js @@ -67,8 +67,23 @@ sAnimation.registry.newsletter_popup = sAnimation.Class.extend({ selector: ".o_newsletter_popup", start: function () { var self = this; + + // Compatibility: rebuilding a correct modal from user database + // content. Since the first version, the modal is saved in databases + // directly but has never used a correct structure. While it was working + // with BS3, it is not with BS4. + // TODO review the newsletter popup creation, save and loading. + var $modal = this.$('.modal'); + if ($modal.is('.modal-dialog')) { + $modal.removeClass('modal-md modal-dialog'); + var $modalContent = $modal.find('.modal-content'); + $modalContent.wrapAll($('<div/>', {class: 'modal-dialog'})); + } + var popupcontent = self.$target.find(".o_popup_content_dev").empty(); - if (!self.$target.data('list-id')) return; + if (!self.$target.data('list-id')) { + return; + } this._rpc({ route: '/website_mass_mailing/get_content', |
