diff --git a/addons/account/controllers/__init__.py b/addons/account/controllers/__init__.py index 6d24304de46..1a195b71756 100644 --- a/addons/account/controllers/__init__.py +++ b/addons/account/controllers/__init__.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import mail from . import onboarding from . import portal diff --git a/addons/account/controllers/mail.py b/addons/account/controllers/mail.py deleted file mode 100644 index 564856b909c..00000000000 --- a/addons/account/controllers/mail.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- 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): - - @classmethod - def _redirect_to_record(cls, model, res_id, access_token=None): - # If the current user doesn't have access to the invoice, but provided - # a valid access token, redirect him to the front-end view. - if model == 'account.invoice' 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=access_token) diff --git a/addons/account/controllers/portal.py b/addons/account/controllers/portal.py index c59be47e588..0da9ea570dc 100644 --- a/addons/account/controllers/portal.py +++ b/addons/account/controllers/portal.py @@ -8,7 +8,6 @@ from odoo import http, _ from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager, get_records_pager from odoo.exceptions import AccessError from odoo.http import request -from odoo.tools import consteq class PortalAccount(CustomerPortal): @@ -32,38 +31,13 @@ class PortalAccount(CustomerPortal): # My Invoices # ------------------------------------------------------------ - def _invoice_check_access(self, invoice_id, access_token=None): - invoice = request.env['account.invoice'].browse([invoice_id]) - invoice_sudo = invoice.sudo() - try: - invoice.check_access_rights('read') - invoice.check_access_rule('read') - except AccessError: - if not access_token or not consteq(invoice_sudo.access_token, access_token): - raise - return invoice_sudo - def _invoice_get_page_view_values(self, invoice, access_token, **kwargs): values = { 'page_name': 'invoice', 'invoice': invoice, } - if access_token: - # force breadcrumbs even if access_token to `invite` users to register if they click on it - values['no_breadcrumbs'] = False - values['access_token'] = access_token + return self._get_page_view_values(invoice, access_token, values, 'my_invoices_history', False, **kwargs) - if kwargs.get('error'): - values['error'] = kwargs['error'] - if kwargs.get('warning'): - values['warning'] = kwargs['warning'] - if kwargs.get('success'): - values['success'] = kwargs['success'] - - history = request.session.get('my_invoices_history', []) - values.update(get_records_pager(history, invoice)) - - return values @http.route(['/my/invoices', '/my/invoices/page/'], type='http', auth="user", website=True) def portal_my_invoices(self, page=1, date_begin=None, date_end=None, sortby=None, **kw): @@ -117,7 +91,7 @@ class PortalAccount(CustomerPortal): @http.route(['/my/invoices/'], type='http', auth="public", website=True) def portal_my_invoice_detail(self, invoice_id, access_token=None, **kw): try: - invoice_sudo = self._invoice_check_access(invoice_id, access_token) + invoice_sudo = self._document_check_access('account.invoice', invoice_id, access_token) except AccessError: return request.redirect('/my') @@ -130,7 +104,7 @@ class PortalAccount(CustomerPortal): ], type='http', auth="public", website=True) def portal_my_invoice_report(self, invoice_id, access_token=None, **kw): try: - invoice_sudo = self._invoice_check_access(invoice_id, access_token) + invoice_sudo = self._document_check_access('account.invoice', invoice_id, access_token) except AccessError: return request.redirect('/my') diff --git a/addons/account/data/account_data.xml b/addons/account/data/account_data.xml index f7fa31bd517..bd2c6a8d683 100644 --- a/addons/account/data/account_data.xml +++ b/addons/account/data/account_data.xml @@ -174,5 +174,13 @@ selection + + + Share + + + code + action = records.action_share() + diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 16b0d7d9340..fa235ee9d67 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -45,8 +45,6 @@ class AccountInvoice(models.Model): _description = "Invoice" _order = "date_invoice desc, number desc, id desc" - def _get_default_access_token(self): - return str(uuid.uuid4()) def _get_default_incoterm(self): return self.env.user.company_id.incoterm_id @@ -243,9 +241,6 @@ class AccountInvoice(models.Model): ], readonly=True, states={'draft': [('readonly', False)]}, index=True, change_default=True, default=lambda self: self._context.get('type', 'out_invoice'), track_visibility='always') - access_token = fields.Char( - 'Security Token', copy=False, - default=_get_default_access_token) refund_invoice_id = fields.Many2one('account.invoice', string="Invoice for which this invoice is the credit note") number = fields.Char(related='move_id.name', store=True, readonly=True, copy=False) @@ -428,10 +423,10 @@ class AccountInvoice(models.Model): domain += [('journal_id', '=', self.journal_id.id), ('state', 'not in', ['draft', 'cancel'])] return journal_sequence, domain - def _compute_portal_url(self): - super(AccountInvoice, self)._compute_portal_url() + def _compute_access_url(self): + super(AccountInvoice, self)._compute_access_url() for order in self: - order.portal_url = '/my/invoices/%s' % (order.id) + order.access_url = '/my/invoices/%s' % (order.id) @api.depends('state', 'journal_id', 'date_invoice') def _get_sequence_prefix(self): @@ -564,27 +559,6 @@ class AccountInvoice(models.Model): view_id = get_view_id('invoice_form', 'account.invoice.form').id return super(AccountInvoice, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) - @api.model_cr_context - def _init_column(self, column_name): - """ Initialize the value of the given column for existing rows. - - 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(AccountInvoice, 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._table, 'column_name': column_name} - self.env.cr.execute(query) - - def _generate_access_token(self): - for invoice in self: - invoice.access_token = self._get_default_access_token() - @api.multi def invoice_print(self): """ Print the invoice and mark it as sent, so that we can see more @@ -946,40 +920,6 @@ class AccountInvoice(models.Model): return groups - @api.multi - 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() - 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: - record.check_access_rule('read') - except exceptions.AccessError: - if self.env.context.get('force_website'): - return { - 'type': 'ir.actions.act_url', - 'url': '/my/invoices/%s' % self.id, - 'target': 'self', - 'res_id': self.id, - } - else: - pass - else: - return { - 'type': 'ir.actions.act_url', - 'url': '/my/invoices/%s?access_token=%s' % (self.id, self.access_token), - 'target': 'self', - 'res_id': self.id, - } - return super(AccountInvoice, self).get_access_action(access_uid) - - def get_mail_url(self): - return self.get_share_url() - @api.multi def get_formview_id(self, access_uid=None): """ Update form view id of action to open the invoice """ diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml index 5dd758a4ef4..5dbd235cf28 100644 --- a/addons/account/views/account_invoice_view.xml +++ b/addons/account/views/account_invoice_view.xml @@ -887,6 +887,5 @@ action = action_values domain="[('journal_id','=', active_id)]" res_model="account.invoice" src_model="account.journal"/> - diff --git a/addons/account/views/account_portal_templates.xml b/addons/account/views/account_portal_templates.xml index 289a1d75486..dc7d1c61f65 100644 --- a/addons/account/views/account_portal_templates.xml +++ b/addons/account/views/account_portal_templates.xml @@ -132,6 +132,8 @@ + + diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index 949a715cddb..1600189c7b9 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -49,7 +49,9 @@ class MailController(http.Controller): return comparison, record, redirect @classmethod - def _redirect_to_record(cls, model, res_id, access_token=None): + def _redirect_to_record(cls, model, res_id, access_token=None, **kwargs): + # access_token and kwargs are used in the portal controller override for the Send by email or Share Link + # to give access to the record to a recipient that has normally no access. uid = request.session.uid # no model / res_id, meaning no possible record -> redirect to login @@ -178,7 +180,7 @@ class MailController(http.Controller): """ if res_id and isinstance(res_id, pycompat.string_types): res_id = int(res_id) - return self._redirect_to_record(model, res_id, access_token) + return self._redirect_to_record(model, res_id, access_token, **kwargs) @http.route('/mail/assign', type='http', auth='user', methods=['GET']) def mail_action_assign(self, model, res_id, token=None): diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index c499dd5c5e7..7af17890aae 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -229,7 +229,7 @@ @@ -242,7 +242,7 @@ - + diff --git a/addons/portal/__manifest__.py b/addons/portal/__manifest__.py index 6f1c0186a86..e9fe1e36151 100644 --- a/addons/portal/__manifest__.py +++ b/addons/portal/__manifest__.py @@ -20,6 +20,7 @@ a dependency towards website edition and customization capabilities.""", 'data/portal_data.xml', 'views/assets.xml', 'views/portal_templates.xml', + 'wizard/portal_share_views.xml', 'wizard/portal_wizard_views.xml', ], 'qweb': [ diff --git a/addons/portal/controllers/mail.py b/addons/portal/controllers/mail.py index 89dbded3676..073e7153eeb 100644 --- a/addons/portal/controllers/mail.py +++ b/addons/portal/controllers/mail.py @@ -1,12 +1,16 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import werkzeug +from werkzeug import urls from werkzeug.exceptions import NotFound, Forbidden from odoo import http from odoo.http import request from odoo.osv import expression from odoo.tools import consteq, plaintext2html +from odoo.addons.mail.controllers.main import MailController +from odoo.exceptions import AccessError def _has_token_access(res_model, res_id, token=''): @@ -14,6 +18,7 @@ def _has_token_access(res_model, res_id, token=''): token_field = request.env[res_model]._mail_post_token_field return (token and record and consteq(record[token_field], token)) + def _message_post_helper(res_model='', res_id=None, message='', token='', nosubscribe=True, **kw): """ Generic chatter function, allowing to write on *any* object that inherits mail.thread. If a token is specified, all logged in users will be able to write a message regardless @@ -38,7 +43,12 @@ def _message_post_helper(res_model='', res_id=None, message='', token='', nosubs if access_as_sudo: record = record.sudo() if request.env.user == request.env.ref('base.public_user'): - author_id = record.partner_id.id if hasattr(record, 'partner_id') else author_id + if kw.get('pid') and consteq(kw.get('hash'), record._sign_token(int(kw.get('pid')))): + author_id = kw.get('pid') + else: + # TODO : After adding the pid and sign_token in access_url when send invoice by email, remove this line + # TODO : Author must be Public User (to rename to 'Anonymous') + author_id = record.partner_id.id if hasattr(record, 'partner_id') and record.partner_id.id else author_id else: if not author_id: raise NotFound() @@ -106,3 +116,42 @@ class PortalChatter(http.Controller): 'messages': Message.search(domain, limit=limit, offset=offset).portal_message_format(), 'message_count': Message.search_count(domain) } + + +class MailController(MailController): + + @classmethod + def _redirect_to_record(cls, model, res_id, access_token=None, **kwargs): + """ If the current user doesn't have access to the document, but provided + a valid access token, redirect him to the front-end view. + If the partner_id and hash parameters are given, add those parameters to the redirect url + to authentify the recipient in the chatter, if any. + + :param model: the model name of the record that will be visualized + :param res_id: the id of the record + :param access_token: token that gives access to the record + bypassing the rights and rules restriction of the user. + :param kwargs: Typically, it can receive a partner_id and a hash (sign_token). + If so, those two parameters are used to authentify the recipient in the chatter, if any. + :return: + """ + if issubclass(type(request.env[model]), request.env.registry['portal.mixin']): + 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 access_token and consteq(record_sudo.access_token, access_token): + record_action = record_sudo.with_context(force_website=True).get_access_action() + if record_action['type'] == 'ir.actions.act_url': + pid = kwargs.get('pid') + hash = kwargs.get('hash') + url = record_action['url'] + if pid and hash: + url = urls.url_parse(url) + url_params = url.decode_query() + url_params.update([("pid", pid), ("hash", hash)]) + url = url.replace(query=urls.url_encode(url_params)).to_url() + return werkzeug.utils.redirect(url) + return super(MailController, cls)._redirect_to_record(model, res_id, access_token=access_token) diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index 31eb8e933cc..eedf1876952 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -7,8 +7,9 @@ from werkzeug import urls from odoo import fields as odoo_fields, tools, _ from odoo.osv import expression -from odoo.exceptions import ValidationError +from odoo.exceptions import ValidationError, AccessError from odoo.http import Controller, request, route +from odoo.tools import consteq from odoo.addons.web.controllers.main import WebClient # -------------------------------------------------- @@ -75,8 +76,8 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None): def get_records_pager(ids, current): - if current.id in ids and (hasattr(current, 'website_url') or hasattr(current, 'portal_url')): - attr_name = 'portal_url' if hasattr(current, 'portal_url') else 'website_url' + if current.id in ids and (hasattr(current, 'website_url') or hasattr(current, 'access_url')): + attr_name = 'access_url' if hasattr(current, 'access_url') else 'website_url' idx = ids.index(current.id) return { 'prev_record': idx != 0 and getattr(current.browse(ids[idx - 1]), attr_name), @@ -228,3 +229,38 @@ class CustomerPortal(Controller): error_message.append("Unknown field '%s'" % ','.join(unknown)) return error, error_message + + def _document_check_access(self, model_name, document_id, access_token=None): + document = request.env[model_name].browse([document_id]) + document_sudo = document.sudo() + try: + document.check_access_rights('read') + document.check_access_rule('read') + except AccessError: + if not access_token or not consteq(document_sudo.access_token, access_token): + raise + return document_sudo + + def _get_page_view_values(self, document, access_token, values, session_history, no_breadcrumbs, **kwargs): + if access_token: + # if no_breadcrumbs = False -> force breadcrumbs even if access_token to `invite` users to register if they click on it + values['no_breadcrumbs'] = no_breadcrumbs + values['access_token'] = access_token + + # Those are used notably whenever the payment form is implied in the portal. + if kwargs.get('error'): + values['error'] = kwargs['error'] + if kwargs.get('warning'): + values['warning'] = kwargs['warning'] + if kwargs.get('success'): + values['success'] = kwargs['success'] + # Email token for posting messages in portal view with identified author + if kwargs.get('pid'): + values['pid'] = kwargs['pid'] + if kwargs.get('hash'): + values['hash'] = kwargs['hash'] + + history = request.session.get(session_history, []) + values.update(get_records_pager(history, document)) + + return values diff --git a/addons/portal/data/portal_data.xml b/addons/portal/data/portal_data.xml index 0cab34b3c01..47535af4f5e 100644 --- a/addons/portal/data/portal_data.xml +++ b/addons/portal/data/portal_data.xml @@ -94,5 +94,15 @@ + diff --git a/addons/portal/models/portal_mixin.py b/addons/portal/models/portal_mixin.py index 68a8ea55c34..5dfef905606 100644 --- a/addons/portal/models/portal_mixin.py +++ b/addons/portal/models/portal_mixin.py @@ -1,61 +1,69 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - +import uuid +import hashlib +import hmac from werkzeug.urls import url_encode - -from odoo import api, fields, models, _ +from odoo import api, exceptions, fields, models, tools, _ class PortalMixin(models.AbstractModel): _name = "portal.mixin" - portal_url = fields.Char( - 'Portal Access URL', compute='_compute_portal_url', + access_url = fields.Char( + 'Portal Access URL', compute='_compute_access_url', help='Customer Portal URL') + access_token = fields.Char('Security Token', copy=False) + + # to display the warning from specific model + access_warning = fields.Text("Access warning", compute="_compute_access_warning") + + def _compute_access_warning(self): + for mixin in self: + mixin.access_warning = '' @api.multi - def _compute_portal_url(self): + def _compute_access_url(self): for record in self: - record.portal_url = '#' + record.access_url = '#' - def _get_access_token_field(self): - """ Give the field used to fetch the customer portal access token, if - any. Override this method if the field holding the token is named - differently. """ - return 'access_token' if 'access_token' in self else False - - def _get_customer_field(self): - """ Give the field used to fetch the customer partner_id, if any. - Override this method if the field holding the token is named differently. """ - return 'partner_id' if 'partner_id' in self else False - - def _get_access_token(self): + def _portal_ensure_token(self): """ Get the current record access token """ - field = self._get_access_token_field() - return self[field] if field else False + self.access_token = self.access_token if self.access_token else str(uuid.uuid4()) + return self.access_token - def _get_customer(self): - """ Get the current record custome (res.partner record) """ - field = self._get_customer_field() - return self[field] if field else self.env['res.partner'] - - def get_share_url(self): + 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 + access_token to bypass the recipient's rights, + signup_partner to allows the user to create easily an account, + hash token to allow the user to be authenticated in the chatter of the record portal view, if applicable + :param redirect : Send the redirect url instead of the direct portal share url + :param signup_partner: allows the user to create an account with pre-filled fields. + :param pid: = partner_id - when given, a hash is generated to allow the user to be authenticated + in the portal chatter, if any in the target page, + if the user is redirected to the portal instead of the backend. + :return: the url of the record with access parameters, if any. + """ self.ensure_one() params = { 'model': self._name, 'res_id': self.id, } - if hasattr(self, 'access_token') and self.access_token: - params['access_token'] = self.access_token - if hasattr(self, 'partner_id') and self.partner_id: + if hasattr(self, 'access_token'): + params['access_token'] = self._portal_ensure_token() + if pid: + params['pid'] = pid + params['hash'] = self._sign_token(pid) + if signup_partner and hasattr(self, 'partner_id') and self.partner_id: params.update(self.partner_id.signup_get_auth_param()[self.partner_id.id]) - return '/mail/view?' + url_encode(params) + return '/mail/view?' if redirect else self.access_url + url_encode(params) @api.multi def _notify_get_groups(self, message, groups): - access_token = self._get_access_token() - customer = self._get_customer() + access_token = self._portal_ensure_token() + customer = self['partner_id'] if access_token and customer: additional_params = { @@ -76,3 +84,64 @@ class PortalMixin(models.AbstractModel): else: new_group = [] return super(PortalMixin, self)._notify_get_groups(message, new_group + groups) + + @api.multi + def get_access_action(self, access_uid=None): + """ Instead of the classic form view, redirect to the online document for + portal users or if force_website=True in the context. """ + self.ensure_one() + + user, record = self.env.user, self + if access_uid: + try: + record.check_access_rights('read') + record.check_access_rule("read") + except exceptions.AccessError: + return super(PortalMixin, self).get_access_action(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: + record.check_access_rights('read') + record.check_access_rule('read') + except exceptions.AccessError: + if self.env.context.get('force_website'): + return { + 'type': 'ir.actions.act_url', + 'url': record.access_url, + 'target': 'self', + 'res_id': record.id, + } + else: + pass + else: + return { + 'type': 'ir.actions.act_url', + 'url': record._get_share_url(), + 'target': 'self', + 'res_id': record.id, + } + return super(PortalMixin, self).get_access_action(access_uid) + + @api.model + def action_share(self): + action = self.env.ref('portal.portal_share_action').read()[0] + action['context'] = {'active_id': self.env.context['active_id'], + 'active_model': self.env.context['active_model']} + return action + + @api.multi + def _sign_token(self, pid): + """Generate a secure hash for this record with the email of the recipient with whom the record have been shared. + + This is used to determine who is opening the link + to be able for the recipient to post messages on the document's portal view. + + :param str email: + Email of the recipient that opened the link. + """ + self.ensure_one() + secret = self.env["ir.config_parameter"].sudo().get_param( + "database.secret") + token = (self.env.cr.dbname, self.access_token, pid) + return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha256).hexdigest() diff --git a/addons/portal/static/src/xml/portal_chatter.xml b/addons/portal/static/src/xml/portal_chatter.xml index dcfbf922e04..a1683dac803 100644 --- a/addons/portal/static/src/xml/portal_chatter.xml +++ b/addons/portal/static/src/xml/portal_chatter.xml @@ -32,6 +32,8 @@ + + diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index c900b426c6d..73f5cd4767e 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -92,6 +92,9 @@ project.project
+
+