From 689d6d68296ea32c2155dc1a61b593754ca3b968 Mon Sep 17 00:00:00 2001 From: Denis Vermylen Date: Fri, 12 May 2017 18:40:14 +0200 Subject: [PATCH 1/7] [IMP] website: add signup option in website settings This commit duplicates the General settings' User Signup options in the Website configuration, with a clear description of the options. We also remove the unnecessary usage of safe_eval in auth_signup's res_config.py. As stored values are repr values of a boolean field just comparing the string values is sufficient. There is no need to use safe_eval as it should be used only when necessary. --- .../models/base_config_settings.py | 17 +++++++------ .../views/base_config_settings_views.xml | 10 +++++--- .../website/models/website_config_settings.py | 25 ++++++++++++------- .../views/website_config_settings_views.xml | 13 ++++++++++ 4 files changed, 45 insertions(+), 20 deletions(-) diff --git a/addons/auth_signup/models/base_config_settings.py b/addons/auth_signup/models/base_config_settings.py index eb830c24e37..8ea0a8f2041 100644 --- a/addons/auth_signup/models/base_config_settings.py +++ b/addons/auth_signup/models/base_config_settings.py @@ -2,24 +2,27 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models -from odoo.tools.safe_eval import safe_eval + class BaseConfigSettings(models.TransientModel): _inherit = 'base.config.settings' auth_signup_reset_password = fields.Boolean(string='Enable password reset from Login page') - auth_signup_uninvited = fields.Boolean(string='Allow external users to sign up') + auth_signup_uninvited = fields.Selection([ + ('b2b', 'On invitation (B2B)'), + ('b2c', 'Free sign up (B2C)'), + ], string='Customer Account') auth_signup_template_user_id = fields.Many2one('res.users', string='Template user for new users created through signup') @api.model def get_values(self): res = super(BaseConfigSettings, self).get_values() get_param = self.env['ir.config_parameter'].sudo().get_param - # we use safe_eval on the result, since the value of the parameter is a nonempty string + # the value of the parameter is a nonempty string res.update( - auth_signup_reset_password=safe_eval(get_param('auth_signup.reset_password', 'False')), - auth_signup_uninvited=safe_eval(get_param('auth_signup.allow_uninvited', 'False')), - auth_signup_template_user_id=safe_eval(get_param('auth_signup.template_user_id', 'False')), + auth_signup_reset_password=get_param('auth_signup.reset_password', 'False').lower() == 'true', + auth_signup_uninvited='b2c' if get_param('auth_signup.allow_uninvited', 'False').lower() == 'true' else 'b2b', + auth_signup_template_user_id=get_param('auth_signup.template_user_id', 'False').lower() == 'true', ) return res @@ -29,7 +32,7 @@ class BaseConfigSettings(models.TransientModel): set_param = self.env['ir.config_parameter'].sudo().set_param # we store the repr of the values, since the value of the parameter is a required string set_param('auth_signup.reset_password', repr(self.auth_signup_reset_password)) - set_param('auth_signup.allow_uninvited', repr(self.auth_signup_uninvited)) + set_param('auth_signup.allow_uninvited', repr(self.auth_signup_uninvited == 'b2c')) set_param('auth_signup.template_user_id', repr(self.auth_signup_template_user_id.id)) @api.multi diff --git a/addons/auth_signup/views/base_config_settings_views.xml b/addons/auth_signup/views/base_config_settings_views.xml index 23c2b1f7608..aaae5a8ea22 100644 --- a/addons/auth_signup/views/base_config_settings_views.xml +++ b/addons/auth_signup/views/base_config_settings_views.xml @@ -9,14 +9,16 @@
-
-
+
+
+
+
+
+

Social Media

From 676022e3afdd774c3e0c78cb26c0062133043308 Mon Sep 17 00:00:00 2001 From: Denis Vermylen Date: Mon, 15 May 2017 15:14:49 +0200 Subject: [PATCH 2/7] [IMP] auth_signup: display meaningfull and translated error messages Since 5425316eff19e7ae716aafdb730c92d1b3ea2abb errors when signing up will only display two messages: * "Another user is already registered using this email address." or * "Could not create a new account." While Odoo creates a multitude of other comprehensible error messages such as * "Passwords do not match; please retype them." * "Signup token '%s' is no longer valid" This commit now separate UserError and AssertionError from SignupErrors. Those are still hidden in a general message (see 5425316eff19e7ae716aafdb730c92d1b3ea2abb for reasons) while the other ones are fully displayed. This commit also improves translations of messages. --- addons/auth_oauth/models/res_users.py | 4 ++-- addons/auth_signup/controllers/main.py | 11 ++++++++--- addons/auth_signup/models/res_partner.py | 6 +++--- addons/auth_signup/models/res_users.py | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/addons/auth_oauth/models/res_users.py b/addons/auth_oauth/models/res_users.py index dd2496bdea7..dd61505b485 100644 --- a/addons/auth_oauth/models/res_users.py +++ b/addons/auth_oauth/models/res_users.py @@ -6,7 +6,7 @@ import json import requests from odoo import api, fields, models -from odoo.exceptions import AccessDenied +from odoo.exceptions import AccessDenied, UserError from odoo.addons.auth_signup.models.res_users import SignupError from odoo.addons import base @@ -82,7 +82,7 @@ class ResUsers(models.Model): try: _, login, _ = self.signup(values, token) return login - except SignupError: + except (SignupError, UserError): raise access_denied_exception @api.model diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py index 533f84a8d82..0b546011b27 100644 --- a/addons/auth_signup/controllers/main.py +++ b/addons/auth_signup/controllers/main.py @@ -6,6 +6,7 @@ import werkzeug from odoo import http, _ from odoo.addons.auth_signup.models.res_users import SignupError from odoo.addons.web.controllers.main import ensure_db, Home +from odoo.exceptions import UserError from odoo.http import request from odoo.tools import pycompat @@ -34,6 +35,8 @@ class AuthSignupHome(Home): try: self.do_signup(qcontext) return super(AuthSignupHome, self).web_login(*args, **kw) + except UserError as e: + qcontext['error'] = str(e) except (SignupError, AssertionError) as e: if request.env["res.users"].sudo().search([("login", "=", qcontext.get("login"))]): qcontext["error"] = _("Another user is already registered using this email address.") @@ -57,7 +60,7 @@ class AuthSignupHome(Home): return super(AuthSignupHome, self).web_login(*args, **kw) else: login = qcontext.get('login') - assert login, "No login provided." + assert login, _("No login provided.") _logger.info( "Password reset attempt for <%s> by user <%s> from %s", login, request.env.user.login, request.httprequest.remote_addr) @@ -100,8 +103,10 @@ class AuthSignupHome(Home): def do_signup(self, qcontext): """ Shared helper that creates a res.partner out of a token """ values = { key: qcontext.get(key) for key in ('login', 'name', 'password') } - assert values, "The form was not properly filled in." - assert values.get('password') == qcontext.get('confirm_password'), "Passwords do not match; please retype them." + if not values: + raise UserError(_("The form was not properly filled in.")) + if values.get('password') != qcontext.get('confirm_password'): + raise UserError(_("Passwords do not match; please retype them.")) supported_langs = [lang['code'] for lang in request.env['res.lang'].sudo().search_read([], ['code'])] if request.lang in supported_langs: values['lang'] = request.lang diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index d8111420a3c..f0da06f7cbd 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -6,7 +6,7 @@ import werkzeug.urls from datetime import datetime, timedelta -from odoo import api, fields, models, _ +from odoo import api, exceptions, fields, models, _ from odoo.tools import pycompat @@ -125,11 +125,11 @@ class ResPartner(models.Model): partner = self.search([('signup_token', '=', token)], limit=1) if not partner: if raise_exception: - raise SignupError("Signup token '%s' is not valid" % token) + raise exceptions.UserError(_("Signup token '%s' is not valid") % token) return False if check_validity and not partner.signup_valid: if raise_exception: - raise SignupError("Signup token '%s' is no longer valid" % token) + raise exceptions.UserError(_("Signup token '%s' is no longer valid") % token) return False return partner diff --git a/addons/auth_signup/models/res_users.py b/addons/auth_signup/models/res_users.py index 05d52596c85..b58606b73ab 100644 --- a/addons/auth_signup/models/res_users.py +++ b/addons/auth_signup/models/res_users.py @@ -85,7 +85,7 @@ class ResUsers(models.Model): # check that uninvited users may sign up if 'partner_id' not in values: if not literal_eval(get_param('auth_signup.allow_uninvited', 'False')): - raise SignupError('Signup is not allowed for uninvited users') + raise SignupError(_('Signup is not allowed for uninvited users')) assert values.get('login'), "Signup: no login given for new user" assert values.get('partner_id') or values.get('name'), "Signup: no name or partner given for new user" From 6977a363e45778f04f1786c690f312250c5767de Mon Sep 17 00:00:00 2001 From: Denis Vermylen Date: Fri, 19 May 2017 15:41:13 +0200 Subject: [PATCH 3/7] [IMP] web, auth_signup: add signup and login values to session When you receive an url with parameters * auth_signup_token: uuid * auth_login: login those will be stored in the session and used * when the user will want to sign up in order to be linked to the right partner; * when he logs in so he's sure to log in with the right account + autofill is nice This commit only adds the support, future commits will support its use. --- addons/auth_signup/controllers/main.py | 3 +++ addons/auth_signup/models/res_partner.py | 16 ++++++++++++++++ addons/web/controllers/main.py | 3 +++ addons/website/models/ir_http.py | 7 +++++++ 4 files changed, 29 insertions(+) diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py index 0b546011b27..1ec853f3dee 100644 --- a/addons/auth_signup/controllers/main.py +++ b/addons/auth_signup/controllers/main.py @@ -12,6 +12,7 @@ from odoo.tools import pycompat _logger = logging.getLogger(__name__) + class AuthSignupHome(Home): @http.route() @@ -89,6 +90,8 @@ class AuthSignupHome(Home): """ Shared helper returning the rendering context for signup and reset password """ qcontext = request.params.copy() qcontext.update(self.get_auth_signup_config()) + if not qcontext.get('token') and request.session.get('auth_signup_token'): + qcontext['token'] = request.session.get('auth_signup_token') if qcontext.get('token'): try: # retrieve the user info (name, login or email) corresponding to a signup token diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py index f0da06f7cbd..302d69f173e 100644 --- a/addons/auth_signup/models/res_partner.py +++ b/addons/auth_signup/models/res_partner.py @@ -4,6 +4,7 @@ import random import werkzeug.urls +from collections import defaultdict from datetime import datetime, timedelta from odoo import api, exceptions, fields, models, _ @@ -97,6 +98,21 @@ class ResPartner(models.Model): def action_signup_prepare(self): return self.signup_prepare() + def signup_get_auth_param(self): + """ Get a signup token related to the partner if signup is enabled. + If the partner already has a user, get the login parameter. + """ + res = defaultdict(dict) + + allow_signup = self.env['ir.config_parameter'].get_param('auth_signup.allow_uninvited', 'False').lower() == 'true' + for partner in self: + if allow_signup and not partner.user_ids: + partner.signup_prepare() + res[partner.id]['auth_signup_token'] = partner.signup_token + elif partner.user_ids: + res[partner.id]['auth_login'] = partner.user_ids[0].login + return res + @api.multi def signup_cancel(self): return self.write({'signup_token': False, 'signup_type': False, 'signup_expiration': False}) diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 4184d8b4987..e3265bd08a2 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -488,6 +488,9 @@ class Home(http.Controller): if 'error' in request.params and request.params.get('error') == 'access': values['error'] = _('Only employee can access this database. Please contact the administrator.') + if 'login' not in values and request.session.get('auth_login'): + values['login'] = request.session.get('auth_login') + response = request.render('web.login', values) response.headers['X-Frame-Options'] = 'DENY' return response diff --git a/addons/website/models/ir_http.py b/addons/website/models/ir_http.py index a97cebe76d5..4349eaa1774 100644 --- a/addons/website/models/ir_http.py +++ b/addons/website/models/ir_http.py @@ -134,6 +134,13 @@ class Http(models.AbstractModel): first_pass = not hasattr(request, 'website') request.website = None func = None + + # add signup token or login to the session if given + if 'auth_signup_token' in request.params: + request.session['auth_signup_token'] = request.params['auth_signup_token'] + if 'auth_login' in request.params: + request.session['auth_login'] = request.params['auth_login'] + try: if request.httprequest.method == 'GET' and '//' in request.httprequest.path: new_url = request.httprequest.path.replace('//', '/') + '?' + request.httprequest.query_string From e0a1549307769be379c06a37fb3347d609128c7c Mon Sep 17 00:00:00 2001 From: Denis Vermylen Date: Wed, 17 May 2017 11:31:03 +0200 Subject: [PATCH 4/7] [IMP] models: add access_user to get_access_action In order to be able to identify what action to return based on the access_user. Typically portal users will need to be redirected to the front-end, while internal users will need to be redirected to the back-end. --- .../website_account/models/account_invoice.py | 13 +++++++--- addons/website_blog/models/website_blog.py | 7 ++--- addons/website_forum/models/forum.py | 2 +- .../website_portal_sale/models/sale_order.py | 15 +++++++---- addons/website_project/models/project.py | 26 +++++++++++++------ .../models/project_issue.py | 13 +++++++--- addons/website_quote/models/sale_order.py | 8 +++--- addons/website_slides/models/slides.py | 4 +-- odoo/models.py | 15 +++++++---- 9 files changed, 68 insertions(+), 35 deletions(-) diff --git a/addons/website_account/models/account_invoice.py b/addons/website_account/models/account_invoice.py index 088131d83d5..5d25cc1964a 100644 --- a/addons/website_account/models/account_invoice.py +++ b/addons/website_account/models/account_invoice.py @@ -17,12 +17,17 @@ class AccountInvoice(models.Model): return groups @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the online invoice for portal users. """ self.ensure_one() - if self.env.user.share or self.env.context.get('force_website'): + user, record = self.env.user, self + if access_uid: + user = self.env['res.users'].sudo().browse(access_uid) + record = self.sudo(user) + + if user.share or self.env.context.get('force_website'): try: - self.check_access_rule('read') + record.check_access_rule('read') except exceptions.AccessError: pass else: @@ -32,7 +37,7 @@ class AccountInvoice(models.Model): 'target': 'self', 'res_id': self.id, } - return super(AccountInvoice, self).get_access_action() + return super(AccountInvoice, self).get_access_action(access_uid) @api.multi def get_signup_url(self): diff --git a/addons/website_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py index f10f3fe0745..22456302949 100644 --- a/addons/website_blog/models/website_blog.py +++ b/addons/website_blog/models/website_blog.py @@ -216,12 +216,13 @@ class BlogPost(models.Model): return result @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the post on website directly if user is an employee or if the post is published. """ self.ensure_one() - if self.env.user.share and not self.sudo().website_published: - return super(BlogPost, self).get_access_action() + user = access_uid and self.env['res.users'].sudo().browse(access_uid) or self.env.user + if user.share and not self.sudo().website_published: + return super(BlogPost, self).get_access_action(access_uid) return { 'type': 'ir.actions.act_url', 'url': self.url, diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py index a550c679a72..4e5a7489ca6 100644 --- a/addons/website_forum/models/forum.py +++ b/addons/website_forum/models/forum.py @@ -776,7 +776,7 @@ class Post(models.Model): return True @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the post on the website directly """ self.ensure_one() return { diff --git a/addons/website_portal_sale/models/sale_order.py b/addons/website_portal_sale/models/sale_order.py index 3852b660652..cbdcb70e4ef 100644 --- a/addons/website_portal_sale/models/sale_order.py +++ b/addons/website_portal_sale/models/sale_order.py @@ -24,16 +24,21 @@ class SaleOrder(models.Model): order.payment_transaction_count = mapped_data.get(order.id, 0) @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the online quote for portal users that have access to a confirmed order. """ # TDE note: read access on sales order to portal users granted to followed sales orders self.ensure_one() if self.state == 'cancel' or (self.state == 'draft' and not self.env.context.get('mark_so_as_sent')): - return super(SaleOrder, self).get_access_action() - if self.env.user.share or self.env.context.get('force_website'): + return super(SaleOrder, self).get_access_action(access_uid) + + user, record = self.env.user, self + if access_uid: + user = self.env['res.users'].sudo().browse(access_uid) + record = self.sudo(user) + if user.share or self.env.context.get('force_website'): try: - self.check_access_rule('read') + record.check_access_rule('read') except exceptions.AccessError: pass else: @@ -43,7 +48,7 @@ class SaleOrder(models.Model): 'target': 'self', 'res_id': self.id, } - return super(SaleOrder, self).get_access_action() + return super(SaleOrder, self).get_access_action(access_uid) @api.multi def _notification_recipients(self, message, groups): diff --git a/addons/website_project/models/project.py b/addons/website_project/models/project.py index 47c03e34540..ea22670fd98 100644 --- a/addons/website_project/models/project.py +++ b/addons/website_project/models/project.py @@ -9,13 +9,18 @@ class Project(models.Model): _inherit = ['project.project'] @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website for portal users that can read the project. """ self.ensure_one() - if self.env.user.share: + user, record = self.env.user, self + if access_uid: + user = self.env['res.users'].sudo().browse(access_uid) + record = self.sudo(user) + + if user.share: try: - self.check_access_rule('read') + record.check_access_rule('read') except exceptions.AccessError: pass else: @@ -25,7 +30,7 @@ class Project(models.Model): 'target': 'self', 'res_id': self.id, } - return super(Project, self).get_access_action() + return super(Project, self).get_access_action(access_uid) @api.multi def _notification_recipients(self, message, groups): @@ -47,13 +52,18 @@ class Task(models.Model): task.website_url = '/my/task/%s' % task.id @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website for portal users that can read the task. """ self.ensure_one() - if self.env.user.share: + user, record = self.env.user, self + if access_uid: + user = self.env['res.users'].sudo().browse(access_uid) + record = self.sudo(user) + + if user.share: try: - self.check_access_rule('read') + record.check_access_rule('read') except exceptions.AccessError: pass else: @@ -63,7 +73,7 @@ class Task(models.Model): 'target': 'self', 'res_id': self.id, } - return super(Task, self).get_access_action() + return super(Task, self).get_access_action(access_uid) @api.multi def _notification_recipients(self, message, groups): diff --git a/addons/website_project_issue/models/project_issue.py b/addons/website_project_issue/models/project_issue.py index 50027a9319f..0f96eda11c3 100644 --- a/addons/website_project_issue/models/project_issue.py +++ b/addons/website_project_issue/models/project_issue.py @@ -22,13 +22,18 @@ class Issue(models.Model): issue.attachment_ids = list(set(attachment_ids) - set(message_attachment_ids)) @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website for portal users that can read the issue. """ self.ensure_one() - if self.env.user.share: + user, record = self.env.user, self + if access_uid: + user = self.env['res.users'].sudo().browse(access_uid) + record = self.sudo(user) + + if user.share: try: - self.check_access_rule('read') + record.check_access_rule('read') except exceptions.AccessError: pass else: @@ -38,7 +43,7 @@ class Issue(models.Model): 'target': 'self', 'res_id': self.id, } - return super(Issue, self).get_access_action() + return super(Issue, self).get_access_action(access_uid) @api.multi def _notification_recipients(self, message, groups): diff --git a/addons/website_quote/models/sale_order.py b/addons/website_quote/models/sale_order.py index b8f271102b6..b0c53dee28a 100644 --- a/addons/website_quote/models/sale_order.py +++ b/addons/website_quote/models/sale_order.py @@ -167,11 +167,13 @@ class SaleOrder(models.Model): } @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the online quote if it exists. """ self.ensure_one() - if not self.template_id or (not self.env.user.share and not self.env.context.get('force_website')): - return super(SaleOrder, self).get_access_action() + user = access_uid and self.env['res.users'].sudo().browse(access_uid) or self.env.user + + if not self.template_id or (not user.share and not self.env.context.get('force_website')): + return super(SaleOrder, self).get_access_action(access_uid) return { 'type': 'ir.actions.act_url', 'url': '/quote/%s/%s' % (self.id, self.access_token), diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py index 6607e5cb4ac..83252665e96 100644 --- a/addons/website_slides/models/slides.py +++ b/addons/website_slides/models/slides.py @@ -434,7 +434,7 @@ class Slide(models.Model): return fields @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website if it is published. """ self.ensure_one() if self.website_published: @@ -444,7 +444,7 @@ class Slide(models.Model): 'target': 'self', 'res_id': self.id, } - return super(Slide, self).get_access_action() + return super(Slide, self).get_access_action(access_uid) @api.multi def _notification_recipients(self, message, groups): diff --git a/odoo/models.py b/odoo/models.py index 6be8a3d443e..fa644aac363 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -1339,12 +1339,14 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): return False @api.multi - def get_formview_action(self): + def get_formview_action(self, access_uid=None): """ Return an action to open the document ``self``. This method is meant to be overridden in addons that want to give specific view ids for example. - """ - view_id = self.sudo().get_formview_id(access_uid=self.env.uid) + + An optional access_uid holds the user that will access the document + that could be different from the current user. """ + view_id = self.sudo().get_formview_id(access_uid=access_uid) return { 'type': 'ir.actions.act_window', 'res_model': self._name, @@ -1357,12 +1359,15 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): } @api.multi - def get_access_action(self): + def get_access_action(self, access_uid=None): """ Return an action to open the document. This method is meant to be overridden in addons that want to give specific access to the document. By default it opens the formview of the document. + + An optional access_uid holds the user that will access the document + that could be different from the current user. """ - return self[0].get_formview_action() + return self[0].get_formview_action(access_uid=access_uid) @api.model def search_count(self, args): From 7dd6225f0bbee29f4a138633edd0c16298743518 Mon Sep 17 00:00:00 2001 From: Denis Vermylen Date: Fri, 19 May 2017 15:45:15 +0200 Subject: [PATCH 5/7] [IMP] mail, account, sale: improve sales order mail link Mail now can handles a generic access_token in /mail/view route. Mail does not do anything with it. Addons can override the controller and add their specific management of this token according to some specific business logic. Sale order emails now contains the access token to grant access from the notification email url without logging in. Sale portal now allow customers to log in using an access token without having to use Online Quote. If the user doesn't have an account yet and signup is allowed (B2C) an extra parameter is added to the url to link the correct partner to the user upon signup. If the user already has an account an extra parameter is added to auto-fill the user's login if he wants to login from that session. Account is also updated to prepare accepting access tokens. However the complete implementation of accounting customer portal will be done in another task coming soon. --- .../data/email_template_data_invoice.xml | 2 +- .../data/mail_template_data.xml | 2 +- addons/mail/controllers/main.py | 13 +++--- addons/sale/data/mail_template_data.xml | 2 +- addons/sale/models/sale.py | 13 ++++-- .../website_account/models/account_invoice.py | 12 +++++- .../controllers/__init__.py | 1 + .../website_portal_sale/controllers/mail.py | 29 +++++++++++++ .../website_portal_sale/controllers/main.py | 13 +++--- .../website_portal_sale/models/sale_order.py | 41 ++++++++++--------- addons/website_quote/models/sale_order.py | 15 ++++--- 11 files changed, 100 insertions(+), 43 deletions(-) create mode 100644 addons/website_portal_sale/controllers/mail.py diff --git a/addons/account/data/email_template_data_invoice.xml b/addons/account/data/email_template_data_invoice.xml index 87b42b829cc..b0e8dd1d175 100644 --- a/addons/account/data/email_template_data_invoice.xml +++ b/addons/account/data/email_template_data_invoice.xml @@ -19,7 +19,7 @@

Dear ${object.partner_id.name} % set access_action = object.with_context(force_website=True).get_access_action() % set is_online = access_action and access_action['type'] == 'ir.actions.act_url' -% set access_url = is_online and '/mail/view?model=account.invoice&res_id=%d' % (object.id) or '/report/pdf/account.report_invoice/' + str(object.id) +% set access_url = is_online and object.get_mail_url() or '/report/pdf/account.report_invoice/' + str(object.id) % if object.partner_id.parent_id: (${object.partner_id.parent_id.name}) diff --git a/addons/l10n_be_invoice_bba/data/mail_template_data.xml b/addons/l10n_be_invoice_bba/data/mail_template_data.xml index f0327ae72d1..1f52714dcd9 100644 --- a/addons/l10n_be_invoice_bba/data/mail_template_data.xml +++ b/addons/l10n_be_invoice_bba/data/mail_template_data.xml @@ -6,7 +6,7 @@

Dear ${object.partner_id.name} % set access_action = object.with_context(force_website=True).get_access_action() % set is_online = access_action and access_action['type'] == 'ir.actions.act_url' -% set access_url = is_online and '/mail/view?model=account.invoice&res_id=%d' % (object.id) or '/report/pdf/account.report_invoice/' + str(object.id) +% set access_url = is_online and object.get_mail_url() or '/report/pdf/account.report_invoice/' + str(object.id) % if object.partner_id.parent_id: (${object.partner_id.parent_id.name}) diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index de58beed4cd..95165441dfd 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -52,7 +52,7 @@ class MailController(http.Controller): return comparison, record, redirect @classmethod - def _redirect_to_record(cls, model, res_id): + def _redirect_to_record(cls, model, res_id, access_token=None): uid = request.session.uid # no model / res_id, meaning no possible record -> redirect to login @@ -68,7 +68,6 @@ class MailController(http.Controller): # the record has a window redirection: check access rights if uid is not None: - record = record_sudo.sudo(uid) if not RecordModel.sudo(uid).check_access_rights('read', raise_exception=False): return cls._redirect_to_messaging() try: @@ -76,7 +75,7 @@ class MailController(http.Controller): except AccessError: return cls._redirect_to_messaging() else: - record_action = record.get_access_action() + record_action = record_sudo.get_access_action(access_uid=uid) else: record_action = record_sudo.get_access_action() @@ -162,15 +161,17 @@ class MailController(http.Controller): return subtypes_list @http.route('/mail/view', type='http', auth='none') - def mail_action_view(self, model=None, res_id=None, message_id=None): + def mail_action_view(self, model=None, res_id=None, message_id=None, access_token=None, **kwargs): """ Generic access point from notification emails. The heuristic to - choose where to redirect the user is the following : + choose where to redirect the user is the following : - find a public URL - if none found - users with a read access are redirected to the document - users without read access are redirected to the Messaging - not logged users are redirected to the login page + + models that have an access_token may apply variations on this. """ if message_id: try: @@ -185,7 +186,7 @@ class MailController(http.Controller): elif res_id and isinstance(res_id, basestring): res_id = int(res_id) - return self._redirect_to_record(model, res_id) + return self._redirect_to_record(model, res_id, access_token) @http.route('/mail/follow', type='http', auth='user', methods=['GET']) def mail_action_follow(self, model, res_id, token=None): diff --git a/addons/sale/data/mail_template_data.xml b/addons/sale/data/mail_template_data.xml index 03de4047962..49c205f102b 100644 --- a/addons/sale/data/mail_template_data.xml +++ b/addons/sale/data/mail_template_data.xml @@ -20,7 +20,7 @@ % set doc_name = 'quotation' if object.state in ('draft', 'sent') else 'order confirmation' % set pay_sign_name = ('require_payment' in object and object.require_payment and 'pay') or 'sign' % set access_name = is_online and object.template_id and object.state in ('draft', 'sent') and 'Accept and %s online' % pay_sign_name or 'View %s' % doc_name -% set access_url = is_online and access_action['url'] or '' +% set access_url = is_online and object.get_mail_url() or '' % if object.partner_id.parent_id: (${object.partner_id.parent_id.name}) diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 18bc98806fc..e2f53d70819 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -293,12 +293,19 @@ class SaleOrder(models.Model): @api.model_cr_context def _init_column(self, column_name): """ Initialize the value of the given column for existing rows. - Overridden here because we skip generating unique access tokens - for potentially tons of existing sale orders, should they be needed, - they will be generated on the fly. + + Overridden here because we need to generate different access tokens + and by default _init_column calls the default method once and applies + it for every record. """ if column_name != 'access_token': super(SaleOrder, self)._init_column(column_name) + else: + query = """UPDATE %(table_name)s + SET %(column_name)s = md5(md5(random()::varchar || id::varchar) || clock_timestamp()::varchar)::uuid::varchar + WHERE %(column_name)s IS NULL + """ % {'table_name': self._name, 'column_name': column_name} + self.env.cr.execute(query) def _generate_access_token(self): for order in self: diff --git a/addons/website_account/models/account_invoice.py b/addons/website_account/models/account_invoice.py index 5d25cc1964a..3c2c086f454 100644 --- a/addons/website_account/models/account_invoice.py +++ b/addons/website_account/models/account_invoice.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, exceptions, models +from werkzeug.urls import url_encode class AccountInvoice(models.Model): @@ -33,12 +34,21 @@ class AccountInvoice(models.Model): else: return { 'type': 'ir.actions.act_url', - 'url': '/my/invoices', # No controller /my/invoices/, only a report pdf + 'url': '/my/invoices?', # No controller /my/invoices/, only a report pdf 'target': 'self', 'res_id': self.id, } return super(AccountInvoice, self).get_access_action(access_uid) + def get_mail_url(self): + self.ensure_one() + params = { + 'model': self._name, + 'res_id': self.id, + } + params.update(self.partner_id.signup_get_auth_param()[self.partner_id.id]) + return '/mail/view?' + url_encode(params) + @api.multi def get_signup_url(self): self.ensure_one() diff --git a/addons/website_portal_sale/controllers/__init__.py b/addons/website_portal_sale/controllers/__init__.py index 5d4b25db9c0..ef077859bb9 100644 --- a/addons/website_portal_sale/controllers/__init__.py +++ b/addons/website_portal_sale/controllers/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import mail from . import main diff --git a/addons/website_portal_sale/controllers/mail.py b/addons/website_portal_sale/controllers/mail.py new file mode 100644 index 00000000000..e88277f4312 --- /dev/null +++ b/addons/website_portal_sale/controllers/mail.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import werkzeug + +from odoo.addons.mail.controllers.main import MailController +from odoo.exceptions import AccessError +from odoo.http import request +from odoo.tools.misc import consteq + + +class MailController(MailController): + + def _redirect_to_record(cls, model, res_id, access_token=None): + # If the current user doesn't have access to the sales order, but provided + # a valid access token, redirect him to the front-end view. + if model == 'sale.order' and res_id and access_token: + uid = request.session.uid or request.env.ref('base.public_user').id + record_sudo = request.env[model].sudo().browse(res_id).exists() + try: + record_sudo.sudo(uid).check_access_rights('read') + record_sudo.sudo(uid).check_access_rule('read') + except AccessError: + if record_sudo.access_token and consteq(record_sudo.access_token, access_token): + record_action = record_sudo.with_context( + force_website=True).get_access_action(uid) + if record_action['type'] == 'ir.actions.act_url': + return werkzeug.utils.redirect(record_action['url']) + return super(MailController, cls)._redirect_to_record(model, res_id, access_token=None) diff --git a/addons/website_portal_sale/controllers/main.py b/addons/website_portal_sale/controllers/main.py index 0b7a1e18724..9778b4a4c94 100644 --- a/addons/website_portal_sale/controllers/main.py +++ b/addons/website_portal_sale/controllers/main.py @@ -4,6 +4,7 @@ from odoo import http, _ from odoo.exceptions import AccessError from odoo.http import request +from odoo.tools import consteq from odoo.addons.website_portal.controllers.main import website_account, get_records_pager @@ -135,15 +136,17 @@ class website_account(website_account): }) return request.render("website_portal_sale.portal_my_orders", values) - @http.route(['/my/orders/'], type='http', auth="user", website=True) - def orders_followup(self, order=None, **kw): + @http.route(['/my/orders/'], type='http', auth="public", website=True) + def orders_followup(self, order=None, access_token=None, **kw): order = request.env['sale.order'].browse([order]) + order_sudo = order.sudo() try: order.check_access_rights('read') order.check_access_rule('read') except AccessError: - return request.render("website.403") - order_sudo = order.sudo() + if not access_token or not consteq(order_sudo.access_token, access_token): + return request.render("website.403") + order_invoice_lines = {il.product_id.id: il.invoice_id for il in order_sudo.invoice_ids.mapped('invoice_line_ids')} history = request.session.get('my_orders_history', []) @@ -151,5 +154,5 @@ class website_account(website_account): 'order': order_sudo, 'order_invoice_lines': order_invoice_lines, } - values.update(get_records_pager(history, order)) + values.update(get_records_pager(history, order_sudo)) return request.render("website_portal_sale.orders_followup", values) diff --git a/addons/website_portal_sale/models/sale_order.py b/addons/website_portal_sale/models/sale_order.py index cbdcb70e4ef..0f8f42f97d4 100644 --- a/addons/website_portal_sale/models/sale_order.py +++ b/addons/website_portal_sale/models/sale_order.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, exceptions, fields, models, _ +from odoo import api, fields, models -from odoo.exceptions import ValidationError +from werkzeug.urls import url_encode +import uuid class SaleOrder(models.Model): @@ -25,31 +26,33 @@ class SaleOrder(models.Model): @api.multi def get_access_action(self, access_uid=None): - """ Instead of the classic form view, redirect to the online quote for - portal users that have access to a confirmed order. """ + """ Instead of the classic form view, redirect to the online order for + portal users or if force_website=True in the context. """ # TDE note: read access on sales order to portal users granted to followed sales orders self.ensure_one() if self.state == 'cancel' or (self.state == 'draft' and not self.env.context.get('mark_so_as_sent')): return super(SaleOrder, self).get_access_action(access_uid) - user, record = self.env.user, self - if access_uid: - user = self.env['res.users'].sudo().browse(access_uid) - record = self.sudo(user) + user = self.env['res.users'].sudo().browse(access_uid) if access_uid else self.env.user if user.share or self.env.context.get('force_website'): - try: - record.check_access_rule('read') - except exceptions.AccessError: - pass - else: - return { - 'type': 'ir.actions.act_url', - 'url': '/my/orders/%s' % self.id, - 'target': 'self', - 'res_id': self.id, - } + return { + 'type': 'ir.actions.act_url', + 'url': '/my/orders/%s?access_token=%s' % (self.id, self.access_token), + 'target': 'self', + 'res_id': self.id, + } return super(SaleOrder, self).get_access_action(access_uid) + def get_mail_url(self): + self.ensure_one() + params = { + 'model': self._name, + 'res_id': self.id, + 'access_token': self.access_token, + } + params.update(self.partner_id.signup_get_auth_param()[self.partner_id.id]) + return '/mail/view?' + url_encode(params) + @api.multi def _notification_recipients(self, message, groups): groups = super(SaleOrder, self)._notification_recipients(message, groups) diff --git a/addons/website_quote/models/sale_order.py b/addons/website_quote/models/sale_order.py index b0c53dee28a..3bc3d37cc23 100644 --- a/addons/website_quote/models/sale_order.py +++ b/addons/website_quote/models/sale_order.py @@ -7,6 +7,8 @@ from odoo import api, fields, models, _ from odoo.tools.translate import html_translate from odoo.addons import decimal_precision as dp +from werkzeug.urls import url_encode + class SaleOrderLine(models.Model): _inherit = "sale.order.line" @@ -45,12 +47,6 @@ class SaleOrderLine(models.Model): class SaleOrder(models.Model): _inherit = 'sale.order' - def _website_url(self): - super(SaleOrder, self)._website_url() - for so in self: - if so.state not in ['sale', 'done']: - so.website_url = '/quote/%s' % (so.id) - template_id = fields.Many2one( 'sale.quote.template', 'Quotation Template', readonly=True, @@ -181,6 +177,13 @@ class SaleOrder(models.Model): 'res_id': self.id, } + def get_mail_url(self): + 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]) + return '/quote/%s/%s?' % (self.id, self.access_token) + auth_param + return super(SaleOrder, self).get_mail_url() + @api.multi def _confirm_online_quote(self, transaction): """ Payment callback: validate the order and write transaction details in chatter """ From 054c68689b58f554e9433938644f2960720e08ef Mon Sep 17 00:00:00 2001 From: Denis Vermylen Date: Fri, 9 Jun 2017 18:45:24 +0200 Subject: [PATCH 6/7] [IMP] auth_signup: various usability improvements in signup process * allow new signup on invalid token * relabel signup buttons * send a welcome email upon signup with a signup token. This way, should the token somehow be usurped by someone else, the original partner's email address will be notified. (before the usurper can change the email) --- addons/auth_signup/controllers/main.py | 10 +++++ addons/auth_signup/data/auth_signup_data.xml | 44 +++++++++++++++++++ .../views/auth_signup_login_templates.xml | 6 +-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/addons/auth_signup/controllers/main.py b/addons/auth_signup/controllers/main.py index 1ec853f3dee..8f8db0fab0d 100644 --- a/addons/auth_signup/controllers/main.py +++ b/addons/auth_signup/controllers/main.py @@ -35,6 +35,16 @@ class AuthSignupHome(Home): if 'error' not in qcontext and request.httprequest.method == 'POST': try: self.do_signup(qcontext) + # Send an account creation confirmation email + if qcontext.get('token'): + user_sudo = request.env['res.users'].sudo().search([('login', '=', qcontext.get('login'))]) + template = request.env.ref('auth_signup.mail_template_user_signup_account_created', raise_if_not_found=False) + if user_sudo and template: + template.sudo().with_context( + lang=user_sudo.lang, + auth_login=werkzeug.url_encode({'auth_login': user_sudo.email}), + password=request.params.get('password') + ).send_mail(user_sudo.id, force_send=True) return super(AuthSignupHome, self).web_login(*args, **kw) except UserError as e: qcontext['error'] = str(e) diff --git a/addons/auth_signup/data/auth_signup_data.xml b/addons/auth_signup/data/auth_signup_data.xml index 2b6ceeeed79..b9f88893d2a 100644 --- a/addons/auth_signup/data/auth_signup_data.xml +++ b/addons/auth_signup/data/auth_signup_data.xml @@ -91,5 +91,49 @@ + + + Auth Signup: Odoo Account Created + + "${object.company_id.name|safe}" <${(object.company_id.email or user.email)|safe}> + ${object.email|safe} + Welcome to ${object.company_id.name}! + +

+ + + + +
+ ${user.company_id.name} +
+
+
+

Dear ${object.name},

+

+ Your account has been successfully created! +

+

+ Your login: ${object.email} +
+ Your password: ${ctx['password']} +

+

+ To gain access to your account, you can use the following link: +

+ +

Best regards,

+
+
+ ${user.signature | safe} +

+ Sent by ${user.company_id.name} using Odoo +

+
+ + + diff --git a/addons/auth_signup/views/auth_signup_login_templates.xml b/addons/auth_signup/views/auth_signup_login_templates.xml index b3e17efac90..a38596abe9c 100644 --- a/addons/auth_signup/views/auth_signup_login_templates.xml +++ b/addons/auth_signup/views/auth_signup_login_templates.xml @@ -2,7 +2,7 @@ @@ -40,7 +40,7 @@ - +

@@ -49,7 +49,7 @@

From 505686ea4fe8a07fabe40ac83910405750160d41 Mon Sep 17 00:00:00 2001 From: Denis Vermylen Date: Wed, 21 Jun 2017 18:33:15 +0200 Subject: [PATCH 7/7] [IMP] website_portal,*: clean breadcrumbs We make the breadcrumbs of the portal uniform and remove them when we access the document with an access token without being logged in (for sales orders). --- .../views/account_templates.xml | 18 +- .../website_crm_partner_assign_templates.xml | 916 +++++++++--------- .../static/src/less/website_portal.less | 4 + .../views/website_portal_templates.xml | 55 +- .../website_portal_purchase_templates.xml | 188 ++-- .../website_portal_sale/controllers/main.py | 6 +- .../views/website_portal_sale_templates.xml | 358 ++++--- .../views/project_templates.xml | 272 +++--- .../views/project_issue_templates.xml | 147 ++- addons/website_quote/controllers/main.py | 2 +- .../views/website_quote_templates.xml | 14 +- 11 files changed, 939 insertions(+), 1041 deletions(-) diff --git a/addons/website_account/views/account_templates.xml b/addons/website_account/views/account_templates.xml index ad9cd8c46b6..0d0a1b98daa 100644 --- a/addons/website_account/views/account_templates.xml +++ b/addons/website_account/views/account_templates.xml @@ -1,19 +1,17 @@