diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index 87a47ffa455..5703e018548 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -53,8 +53,8 @@ class ResPartner(models.Model): the url state components (menu_id, id, view_type) """ res = dict.fromkeys(self.ids, False) - base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for partner in self: + base_url = partner.get_base_url() # when required, make sure the partner has a valid signup token if self.env.context.get('signup_valid') and not partner.user_ids: partner.sudo().signup_prepare() diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index b65ddd02efa..e313f7538f4 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -231,7 +231,9 @@ - + + + View diff --git a/addons/portal/models/portal_mixin.py b/addons/portal/models/portal_mixin.py index 99d8f18a266..09dc6ec9c01 100644 --- a/addons/portal/models/portal_mixin.py +++ b/addons/portal/models/portal_mixin.py @@ -35,6 +35,16 @@ class PortalMixin(models.AbstractModel): self.sudo().write({'access_token': str(uuid.uuid4())}) return self.access_token + @api.multi + def get_base_url(self): + """Get the base URL for the current model. + + Defined here to be overriden by website specific models. + The method has to be public because it is called from mail templates. + """ + self.ensure_one() + return self.env['ir.config_parameter'].sudo().get_param('web.base.url') + def _get_share_url(self, redirect=False, signup_partner=False, pid=None): """ Build the url of the record that will be sent by mail and adds additional parameters such as diff --git a/addons/portal/wizard/portal_share.py b/addons/portal/wizard/portal_share.py index 5905df4eed4..8474f2c810c 100644 --- a/addons/portal/wizard/portal_share.py +++ b/addons/portal/wizard/portal_share.py @@ -10,9 +10,10 @@ class PortalShare(models.TransientModel): @api.model def default_get(self, fields): result = super(PortalShare, self).default_get(fields) + record = self.env[result['res_model']].browse(result['res_id']) result['res_model'] = self._context.get('active_model') result['res_id'] = self._context.get('active_id') - result['share_link'] = self.env[result['res_model']].browse(result['res_id'])._get_share_url(redirect=True) + result['share_link'] = record.get_base_url() + record._get_share_url(redirect=True) return result res_model = fields.Char('Related Document Model', required=True) @@ -24,12 +25,11 @@ class PortalShare(models.TransientModel): @api.depends('res_model', 'res_id') def _compute_share_link(self): - base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for rec in self: res_model = self.env[rec.res_model] if isinstance(res_model, self.pool['portal.mixin']): record = res_model.browse(rec.res_id) - rec.share_link = base_url + record._get_share_url(redirect=True) + rec.share_link = record.get_base_url() + record._get_share_url(redirect=True) @api.depends('res_model', 'res_id') def _compute_access_warning(self): @@ -52,7 +52,7 @@ class PortalShare(models.TransientModel): partner_ids = self.partner_ids.filtered(lambda x: x.user_ids) # if partner already user or record has access token send common link in batch to all user for partner in self.partner_ids: - share_link = active_record._get_share_url(redirect=True, pid=partner.id) + share_link = active_record.get_base_url() + active_record._get_share_url(redirect=True, pid=partner.id) active_record.with_context(mail_post_autofollow=True).message_post_with_view(template, values={'partner': partner, 'note': self.note, 'record': active_record, 'share_link': share_link}, @@ -67,7 +67,7 @@ class PortalShare(models.TransientModel): share_link = partner._get_signup_url_for_action(action='/mail/view', res_id=self.res_id, model=self.model)[partner.id] active_record.with_context(mail_post_autofollow=True).message_post_with_view(template, values={'partner': partner, 'note': self.note, 'record': active_record, - 'share_link': share_link }, + 'share_link': share_link}, subject=_("You are invited to access %s" % active_record.display_name), subtype_id=note.id, notif_layout='mail.mail_notification_light', diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 2e88fcc3233..ad8f061e192 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -884,7 +884,14 @@ class SaleOrder(models.Model): self.ensure_one() return '%s %s' % (self.type_name, self.name) + @api.multi def _get_share_url(self, redirect=False, signup_partner=False, pid=None): + """Override for sales order. + + If the SO is in a state where an action is required from the partner, + return the URL with a login token. Otherwise, return the URL with a + generic access token (no login). + """ self.ensure_one() if self.state not in ['sale', 'done']: auth_param = url_encode(self.partner_id.signup_get_auth_param()[self.partner_id.id]) diff --git a/addons/website/models/res_partner.py b/addons/website/models/res_partner.py index 247804c387b..dcb0a463760 100644 --- a/addons/website/models/res_partner.py +++ b/addons/website/models/res_partner.py @@ -53,3 +53,10 @@ class Partner(models.Model): # onchange uses the cache to retrieve value, we need to copy computed_value into the initial env for record, record2 in izip(self, self2): record.display_name = record2.display_name + + @api.multi + def get_base_url(self): + """When using multi-website, we want the user to be redirected to the + most appropriate website if possible.""" + res = super(Partner, self).get_base_url() + return self.website_id and self.website_id._get_http_domain() or res diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 8dc06a4f972..759f52390bd 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -731,6 +731,19 @@ class Website(models.Model): 'target': 'self', } + @api.multi + def _get_http_domain(self): + """Get the domain of the current website, prefixed by http if no + scheme is specified. + + Empty string if no domain is specified on the website. + """ + self.ensure_one() + if not self.domain: + return '' + res = urls.url_parse(self.domain) + return 'http://' + self.domain if not res.scheme else self.domain + class SeoMetadata(models.AbstractModel): diff --git a/addons/website/static/src/js/tours/website_tour_reset_password.js b/addons/website/static/src/js/tours/website_tour_reset_password.js new file mode 100644 index 00000000000..2b304bb0103 --- /dev/null +++ b/addons/website/static/src/js/tours/website_tour_reset_password.js @@ -0,0 +1,181 @@ +odoo.define('website.tour_reset_password', function (require) { +'use strict'; + +var base = require('web_editor.base'); +var localStorage = require('web.local_storage'); +var rpc = require('web.rpc'); +var tour = require('web_tour.tour'); + +var currentDomain = window.location.protocol + '//' + window.location.hostname; +var emailsUrl = '/web#action=mail.action_view_mail_mail&view_type=list'; +var usersUrl = '/web#action=base.action_res_users&view_type=list'; +var resetLinkKey = 'website.tour_reset_password.resetLink'; + +tour.register('website_reset_password', { + test: true, + url: '/web', + wait_for: base.ready(), +}, +[ + { + content: "Change the domain of the websites and go to users page", + trigger: '.oe_topbar_name:contains("Admin")', + run: function () { + // We could do it with the UI but this is not the goal of this test, + // so we just make an RPC to be faster. + + // We change the domain of the website to test that the email that + // will be sent uses the correct domain for its links. + var def1 = rpc.query({ + 'model': 'website', + 'method': 'write', + 'args': [[1], { + 'domain': "my-test-domain.com", + }], + }); + // We need to change the domain of all the websites otherwise the + // website selector will return the website 2 since the domain we + // set on website 1 doesn't actually match our test server. + var def2 = rpc.query({ + 'model': 'website', + 'method': 'write', + 'args': [[2], { + 'domain': "https://domain-not-used.fr", + }], + }); + // Set a website on "Admin" partner to test the URL of the website. + var def3 = rpc.query({ + model: 'res.partner', + method: 'name_search', + kwargs: {'name': 'Mitchell Admin'}, + }).then(function (res) { + return rpc.query({ + 'model': 'res.partner', + 'method': 'write', + 'args': [[res[0][0]], { + 'website_id': 1, + }], + }); + }); + + return $.when(def1, def2, def3).then(function () { + window.location.href = usersUrl; + }); + }, + }, + { + content: "click on Admin", + trigger: '.o_data_cell:contains("Admin")', + }, + { + content: "click on reset password", + trigger: '.btn[name="action_reset_password"]', + }, + { + content: "wait mail to be sent, and go see it", + trigger: 'a[name="signup_url"][href^="http://my-test-domain.com"]', + run: function () { + window.location.href = emailsUrl; + }, + }, + { + content: "click on the first email", + trigger: '.o_data_cell:contains("Password reset"):eq(0)', + }, + { + content: "check page has an iframe", + trigger: 'iframe body', + run: function () { + var content = $('iframe').contents()[0]; + console.log(content && new XMLSerializer().serializeToString(content)); + }, + }, + { + content: "check iframe has the button", + trigger: 'iframe a:contains("Change password")', + run: function () {}, + }, + { + content: "check the URL is correct too", + trigger: 'iframe a:contains("Change password")[href^="http://my-test-domain.com"]', + run: function () { + // reset the domain of the websites, go to users page + return rpc.query({ + 'model': 'website', + 'method': 'write', + 'args': [[1, 2], { + 'domain': "", + }], + }).then(function () { + window.location.href = usersUrl; + }); + }, + }, + { + content: "click on Admin", + trigger: '.o_data_cell:contains("Admin")', + }, + { + content: "click on reset password", + trigger: '.btn[name="action_reset_password"]', + }, + { + content: "wait mail to be sent, and go see it", + trigger: 'a[name="signup_url"][href^="' + currentDomain + '"]', + run: function () { + window.location.href = emailsUrl; + }, + }, + { + content: "click on the first email", + trigger: '.o_data_cell:contains("Password reset"):eq(0)', + }, + { + content: "check the link has the current host, save the link, logout", + trigger: 'iframe a:contains("Change password")[href^="' + currentDomain + '"]', + run: function () { + var link = $('iframe').contents().find('a:contains("Change password")').attr('href'); + localStorage.setItem(resetLinkKey, link); + window.location.href = "/web/session/logout?redirect=/"; + }, + }, + { + content: "go to the reset link", + trigger: 'a[href="/web/login"]', + run: function () { + window.location.href = localStorage.getItem(resetLinkKey); + }, + }, + { + content: "fill new password and submit", + trigger: '.oe_reset_password_form', + run: function () { + var $form = $('.oe_reset_password_form'); + $form.find('input[name="password"]').val('adminadmin'); + // password must be at least 8 if the module `auth_password_policy` + // is installed which is the case on runbot + $form.find('input[name="confirm_password"]').val('adminadmin'); + $form.find('button[type="submit"]').click(); + }, + }, + { + content: "check logged in, and reset admin website", + trigger: '.oe_topbar_name:contains("Admin")', + run: function () { + return rpc.query({ + model: 'res.partner', + method: 'name_search', + kwargs: {'name': 'Admin'}, + }).then(function (res) { + return rpc.query({ + 'model': 'res.partner', + 'method': 'write', + 'args': [[res[0][0]], { + 'website_id': false, + }], + }); + }); + }, + }, +]); +}); diff --git a/addons/website/tests/__init__.py b/addons/website/tests/__init__.py index 1c57eeab793..d2dd7916ecc 100644 --- a/addons/website/tests/__init__.py +++ b/addons/website/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_ui from . import test_views from . import test_menu from . import test_page +from . import test_website_reset_password diff --git a/addons/website/tests/test_website_reset_password.py b/addons/website/tests/test_website_reset_password.py new file mode 100644 index 00000000000..b9fe135d35f --- /dev/null +++ b/addons/website/tests/test_website_reset_password.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + +import odoo +from odoo.tests import tagged +from odoo.tests.common import HttpCase + + +@tagged('post_install', '-at_install') +class TestWebsiteResetPassword(HttpCase): + + def test_01_website_reset_password_tour(self): + """The goal of this test is to make sure the reset password works.""" + + # We override unlink because we don't want the email to be auto deleted + # if the send works. + MailMail = odoo.addons.mail.models.mail_mail.MailMail + + # We override send_mail because in HttpCase on runbot we don't have an + # SMTP server, so if force_send is set, the test is going to fail. + MailTemplate = odoo.addons.mail.models.mail_template.MailTemplate + original_send_mail = MailTemplate.send_mail + + def my_send_mail(*args, **kwargs): + kwargs.update(force_send=False) + return original_send_mail(*args, **kwargs) + + with patch.object(MailMail, 'unlink', lambda self: None), patch.object(MailTemplate, 'send_mail', my_send_mail): + self.browser_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('website_reset_password')", "odoo.__DEBUG__.services['web_tour.tour'].tours.website_reset_password.ready", login="admin") diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index f2c9e93b0fc..9abc878412e 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -14,6 +14,12 @@ + +