[IMP] Portal - Share link : Easily share the url of a document
Purpose
=======
- Quickly share the url to someone else (a client, a colleague,...)
- Ensure that the recipient can access at least
the portal view of the shared record.
- Typically used when a client cannot retrieve the mail to access his order.
The share link can be used in this case.
Specifications
==============
For any object inheriting form portal.mixin:
- Add a button SHARE (not visible in edit mode)
- When clicking on this button, a popup opens with :
- A warning message for tasks and projects only (see below)
- the link (like in gmail) that can be copied
- Recipients
- mail composer (with preselected template) ==> see below
- button [Send Link] [Copy Link] Discard
- After sharing document, put internal note like
"Document shared to xyz,...." with template message
- Anyone with the link, even anonymous user (not logged in) can have access
to the document with the access token provided in the url.
Impacted models:
- account.invoice (Community)
- project.project (Community)
- project.task (Community)
- purchase.order (Community)
- sale.order (Community)
- helpdesk.ticket (Enterprise)
Warning messages and access rules:
Allowed :
- SO canceled or draft will be accessible with the link
with access_token
- If the customer account is B2B (signup not enabled), the recipient
will anyway see the document as the user specifically wants the
recipient to see the document.
Restrictions :
- For Project and Task, if the privacy is not public, then, there is a
contradiction between the access_token mechanism
and the privacy of the document.
- A warning message will be displayed in the share wizard to inform the
user if the document cannot be visible by the recipients and to
ask him to set the privacy to 'Visible by following customer'.
The send button will, in that case, be hidden.
- To avoid to block the share for a new project, default privacy value
is now set to 'Visible by followong customer'
Technical implementation
========================
- Move the access_token mechanism (field + methods + mail controller)
to the portal.mixin to be able to use it in a generic way for each object
inheriting the portal.mixin
- Generalise a part of the _*model*_get_page_view_values method
into a single one in portal
- Generalize the _*model*_check_access into the portal controller of the
portal module
- Remove the init_column + default value for the access_token
> old records have an access_token,
> new one won't but it will be generated on demand via the get_access_token
Done for performance reasons
- Add share button into action menu separately. + kanban view context menu
(except for task and project where button not in action menu but 'simple'
button for task and project because other modules already provide action
to send documents by email, which is not the case for project and task.)
- Add a sign_token used to authentify the recipient in the portal view chatter,
if any. The message will be posted as if the user was logged in.
- Set the _get_share_url as private for security reason
- Add a redirect parameter to _get_share_url to get
If false : The direct portal view url
If True : The redirect url (mail/view/?)
- Cleaning up unnecessary code
- Bug fix :
- Before, if user was not logged and record had partner_id,
if partner id was null, post message was done as admin.
Now, the post message is done as public user.
- If the user had an uid but had no access_token, he could be able
to gain the access token of the record.
check_access_rights was missing in the get_access_action.
Task ID : 30985
Closes #25629
This commit is contained in:
committed by
David Beguin
parent
766a1025b1
commit
84f528bcff
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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/<int: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/<int:invoice_id>'], 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')
|
||||
|
||||
|
||||
@@ -174,5 +174,13 @@
|
||||
<field name="type">selection</field>
|
||||
</record>
|
||||
|
||||
<!-- Share Button in action menu -->
|
||||
<record id="model_account_invoice_action_share" model="ir.actions.server">
|
||||
<field name="name">Share</field>
|
||||
<field name="model_id" ref="account.model_account_invoice"/>
|
||||
<field name="binding_model_id" ref="account.model_account_invoice"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_share()</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -887,6 +887,5 @@ action = action_values
|
||||
domain="[('journal_id','=', active_id)]"
|
||||
res_model="account.invoice"
|
||||
src_model="account.journal"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
<t t-call="portal.message_thread">
|
||||
<t t-set="token" t-value="invoice.access_token"/>
|
||||
<t t-set="object" t-value="invoice"/>
|
||||
<t t-set="pid" t-value="pid"/>
|
||||
<t t-set="hash" t-value="hash"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
|
||||
<!-- Information on model to use this notification template
|
||||
* if the record has an online access defined in get_access_action, having
|
||||
a get_mail_url methods is required (like sale order and invoice);
|
||||
a _get_share_url methods is required (like sale order and invoice);
|
||||
* this template works best with portal-enable models although it is not
|
||||
a complete requirement currently;
|
||||
-->
|
||||
@@ -242,7 +242,7 @@
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<t t-set="access_action" t-value="record.with_context(force_website=True).get_access_action()"/>
|
||||
<t t-set="is_online" t-value="access_action and access_action['type'] == 'ir.actions.act_url'"/>
|
||||
<t t-set="access_url" t-value="is_online and record.get_mail_url() or ''"/>
|
||||
<t t-set="access_url" t-value="is_online and record._get_share_url(redirect=True, signup_partner=True) or ''"/>
|
||||
<t t-set="access_code" t-value="'view'"/>
|
||||
<t t-set="access_name" t-value="'View %s' % (record._description.lower())"/>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
|
||||
@@ -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': [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,5 +94,15 @@
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="user_signature" eval="False"/>
|
||||
</record>
|
||||
<template id="portal_share_template">
|
||||
<div>
|
||||
<p>Dear <span t-esc="partner.name"/>,</p>
|
||||
<p>You have been invited to access the following document:</p>
|
||||
<br/>
|
||||
<a t-attf-href="#{share_link}" style="background-color: #875A7B; padding: 10px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 12px;"><strong>Open </strong><strong t-esc="record.display_name"/></a><br/>
|
||||
<br/>
|
||||
<p t-if="note" t-esc="note"/>
|
||||
</div>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
<input type="hidden" name="res_model" t-att-value="widget.options['res_model']"/>
|
||||
<input type="hidden" name="res_id" t-att-value="widget.options['res_id']"/>
|
||||
<input type="hidden" name="token" t-att-value="widget.options['token']" t-if="widget.options['token']"/>
|
||||
<input type='hidden' name="pid" t-att-value="widget.options['pid']" t-if="widget.options['pid']"/>
|
||||
<input type='hidden' name="hash" t-att-value="widget.options['hash']" t-if="widget.options['hash']"/>
|
||||
<input type="hidden" name="sha_in" t-att-value="widget.options['sha_in']" t-if="widget.options['sha_in']"/>
|
||||
<input type="hidden" name="sha_time" t-att-value="widget.options['sha_time']" t-if="widget.options['sha_time']"/>
|
||||
<div class="alert alert-danger mt8 mb0 o_portal_chatter_composer_error" style="display:none;" role="alert">
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
-->
|
||||
<template id="message_thread">
|
||||
<div id="discussion" class="d-print-none o_portal_chatter o_not_editable"
|
||||
t-att-data-token="token" t-att-data-res_model="object._name" t-att-data-res_id="object.id" t-att-data-pager_step="message_per_page or 10" t-att-data-allow_composer="'0' if disable_composer else '1'">
|
||||
t-att-data-token="token" t-att-data-res_model="object._name" t-att-data-pid="pid" t-att-data-hash="hash" t-att-data-res_id="object.id" t-att-data-pager_step="message_per_page or 10" t-att-data-allow_composer="'0' if disable_composer else '1'">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import portal_share
|
||||
from . import portal_wizard
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class PortalShare(models.TransientModel):
|
||||
_name = 'portal.share'
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
result = super(PortalShare, self).default_get(fields)
|
||||
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)
|
||||
return result
|
||||
|
||||
res_model = fields.Char('Related Document Model', required=True)
|
||||
res_id = fields.Integer('Related Document ID', required=True)
|
||||
partner_ids = fields.Many2many('res.partner', string="Recipients", required=True)
|
||||
note = fields.Text(help="Add extra content to display in the email")
|
||||
share_link = fields.Char(string="Link", compute='_compute_share_link')
|
||||
access_warning = fields.Text("Access warning", compute="_compute_access_warning")
|
||||
|
||||
@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)
|
||||
|
||||
@api.depends('res_model', 'res_id')
|
||||
def _compute_access_warning(self):
|
||||
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.access_warning = record.access_warning
|
||||
|
||||
@api.multi
|
||||
def action_send_mail(self):
|
||||
active_record = self.env[self.res_model].browse(self.res_id)
|
||||
template = self.env.ref('portal.portal_share_template', False)
|
||||
note = self.env.ref('mail.mt_note')
|
||||
signup_enabled = self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope') == 'b2c'
|
||||
|
||||
if hasattr(active_record, 'access_token') and active_record.access_token or not signup_enabled:
|
||||
partner_ids = self.partner_ids
|
||||
else:
|
||||
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)
|
||||
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},
|
||||
subject=_("You are invited to access %s" % active_record.display_name),
|
||||
subtype_id=note.id,
|
||||
notif_layout='mail.mail_notification_light',
|
||||
partner_ids=[(6, 0, partner.ids)])
|
||||
# when partner not user send individual mail with signup token
|
||||
for partner in self.partner_ids - partner_ids:
|
||||
# prepare partner for signup and send singup url with redirect url
|
||||
partner.signup_get_auth_param()
|
||||
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 },
|
||||
subject=_("You are invited to access %s" % active_record.display_name),
|
||||
subtype_id=note.id,
|
||||
notif_layout='mail.mail_notification_light',
|
||||
partner_ids=[(6, 0, partner.ids)])
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="portal_share_wizard" model="ir.ui.view">
|
||||
<field name="name">portal.share.wizard</field>
|
||||
<field name="model">portal.share</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Share Document">
|
||||
<p class="alert alert-warning" attrs="{'invisible': [('access_warning', '=', '')]}" role="alert"><field name="access_warning"/></p>
|
||||
<group name="share_link">
|
||||
<field name="res_model" invisible="1"/>
|
||||
<field name="res_id" invisible="1"/>
|
||||
<field name="share_link" widget="CopyClipboardChar" options="{'string': 'Copy Link'}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_ids" widget="many2many_tags_email" placeholder="Add contacts to share the document..."/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="note" placeholder="Add a note"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Send" name="action_send_mail" attrs="{'invisible': [('access_warning', '!=', '')]}" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-default" special="cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="portal_share_action" model="ir.actions.act_window">
|
||||
<field name="name">Share Document</field>
|
||||
<field name="res_model">portal.share</field>
|
||||
<field name="src_model">portal.share</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,6 +5,7 @@ from collections import OrderedDict
|
||||
from operator import itemgetter
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import get_records_pager, CustomerPortal, pager as portal_pager
|
||||
from odoo.tools import groupby as groupbyelem
|
||||
@@ -24,6 +25,16 @@ class CustomerPortal(CustomerPortal):
|
||||
values['task_count'] = Task.search_count([('project_id', 'in', projects.ids)])
|
||||
return values
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# My Project
|
||||
# ------------------------------------------------------------
|
||||
def _project_get_page_view_values(self, project, access_token, **kwargs):
|
||||
values = {
|
||||
'page_name': 'project',
|
||||
'project': project,
|
||||
}
|
||||
return self._get_page_view_values(project, access_token, values, 'my_projects_history', False, **kwargs)
|
||||
|
||||
@http.route(['/my/projects', '/my/projects/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_projects(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
|
||||
values = self._prepare_portal_layout_values()
|
||||
@@ -70,13 +81,26 @@ class CustomerPortal(CustomerPortal):
|
||||
})
|
||||
return request.render("project.portal_my_projects", values)
|
||||
|
||||
@http.route(['/my/project/<int:project_id>'], type='http', auth="user", website=True)
|
||||
def portal_my_project(self, project_id=None, **kw):
|
||||
project = request.env['project.project'].browse(project_id)
|
||||
vals = {'project': project}
|
||||
history = request.session.get('my_projects_history', [])
|
||||
vals.update(get_records_pager(history, project))
|
||||
return request.render("project.portal_my_project", vals)
|
||||
@http.route(['/my/project/<int:project_id>'], type='http', auth="public", website=True)
|
||||
def portal_my_project(self, project_id=None, access_token=None, **kw):
|
||||
try:
|
||||
project_sudo = self._document_check_access('project.project', project_id, access_token)
|
||||
except AccessError:
|
||||
return request.redirect('/my')
|
||||
|
||||
values = self._project_get_page_view_values(project_sudo, access_token, **kw)
|
||||
return request.render("project.portal_my_project", values)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# My Task
|
||||
# ------------------------------------------------------------
|
||||
def _task_get_page_view_values(self, task, access_token, **kwargs):
|
||||
values = {
|
||||
'page_name': 'task',
|
||||
'task': task,
|
||||
'user': request.env.user
|
||||
}
|
||||
return self._get_page_view_values(task, access_token, values, 'my_tasks_history', False, **kwargs)
|
||||
|
||||
@http.route(['/my/tasks', '/my/tasks/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_tasks(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, search=None, search_in='content', groupby='project', **kw):
|
||||
@@ -177,16 +201,12 @@ class CustomerPortal(CustomerPortal):
|
||||
})
|
||||
return request.render("project.portal_my_tasks", values)
|
||||
|
||||
@http.route(['/my/task/<int:task_id>'], type='http', auth="user", website=True)
|
||||
def portal_my_task(self, task_id=None, **kw):
|
||||
task = request.env['project.task'].browse(task_id)
|
||||
task.check_access_rights('read')
|
||||
task.check_access_rule('read')
|
||||
@http.route(['/my/task/<int:task_id>'], type='http', auth="public", website=True)
|
||||
def portal_my_task(self, task_id, access_token=None, **kw):
|
||||
try:
|
||||
task_sudo = self._document_check_access('project.task', task_id, access_token)
|
||||
except AccessError:
|
||||
return request.redirect('/my')
|
||||
|
||||
vals = {
|
||||
'task': task,
|
||||
'user': request.env.user
|
||||
}
|
||||
history = request.session.get('my_tasks_history', [])
|
||||
vals.update(get_records_pager(history, task))
|
||||
return request.render("project.portal_my_task", vals)
|
||||
values = self._task_get_page_view_values(task_sudo, access_token, **kw)
|
||||
return request.render("project.portal_my_task", values)
|
||||
|
||||
@@ -208,7 +208,7 @@ class Project(models.Model):
|
||||
('portal', _('Visible by following customers')),
|
||||
],
|
||||
string='Privacy', required=True,
|
||||
default='employees',
|
||||
default='portal',
|
||||
help="Holds visibility of the tasks or issues that belong to the current project:\n"
|
||||
"- On invitation only: Employees may only see the followed project, tasks or issues\n"
|
||||
"- Visible by all employees: Employees may see all project, tasks or issues\n"
|
||||
@@ -242,10 +242,16 @@ class Project(models.Model):
|
||||
('project_date_greater', 'check(date >= date_start)', 'Error! project start-date must be lower than project end-date.')
|
||||
]
|
||||
|
||||
def _compute_portal_url(self):
|
||||
super(Project, self)._compute_portal_url()
|
||||
def _compute_access_url(self):
|
||||
super(Project, self)._compute_access_url()
|
||||
for project in self:
|
||||
project.portal_url = '/my/project/%s' % project.id
|
||||
project.access_url = '/my/project/%s' % project.id
|
||||
|
||||
def _compute_access_warning(self):
|
||||
super(Project, self)._compute_access_warning()
|
||||
for project in self.filtered(lambda x: x.privacy_visibility != 'portal'):
|
||||
project.access_warning = _(
|
||||
"The project cannot be shared with the recipient(s) because the privacy of the project is too restricted. Set the privacy to 'Visible by following customers' in order to make it accessible by the recipient(s).")
|
||||
|
||||
@api.depends('percentage_satisfaction_task')
|
||||
def _compute_percentage_satisfaction_project(self):
|
||||
@@ -319,30 +325,6 @@ class Project(models.Model):
|
||||
project.message_subscribe(project.partner_id.ids)
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
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()
|
||||
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:
|
||||
record.check_access_rule('read')
|
||||
except AccessError:
|
||||
pass
|
||||
else:
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/my/project/%s' % self.id,
|
||||
'target': 'self',
|
||||
'res_id': self.id,
|
||||
}
|
||||
return super(Project, self).get_access_action(access_uid)
|
||||
|
||||
@api.multi
|
||||
def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
|
||||
""" Subscribe to all existing active tasks when subscribing to a project """
|
||||
@@ -568,10 +550,16 @@ class Task(models.Model):
|
||||
else:
|
||||
task.kanban_state_label = task.legend_done
|
||||
|
||||
def _compute_portal_url(self):
|
||||
super(Task, self)._compute_portal_url()
|
||||
def _compute_access_url(self):
|
||||
super(Task, self)._compute_access_url()
|
||||
for task in self:
|
||||
task.portal_url = '/my/task/%s' % task.id
|
||||
task.access_url = '/my/task/%s' % task.id
|
||||
|
||||
def _compute_access_warning(self):
|
||||
super(Task, self)._compute_access_warning()
|
||||
for task in self.filtered(lambda x: x.project_id.privacy_visibility != 'portal'):
|
||||
task.access_warning = _(
|
||||
"The task cannot be shared with the recipient(s) because the privacy of the project is too restricted. Set the privacy of the project to 'Visible by following customers' in order to make it accessible by the recipient(s).")
|
||||
|
||||
@api.depends('child_ids.planned_hours')
|
||||
def _compute_subtask_planned_hours(self):
|
||||
@@ -771,30 +759,6 @@ class Task(models.Model):
|
||||
return {'date_end': fields.Datetime.now()}
|
||||
return {'date_end': False}
|
||||
|
||||
@api.multi
|
||||
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()
|
||||
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:
|
||||
record.check_access_rule('read')
|
||||
except AccessError:
|
||||
pass
|
||||
else:
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/my/task/%s' % self.id,
|
||||
'target': 'self',
|
||||
'res_id': self.id,
|
||||
}
|
||||
return super(Task, self).get_access_action(access_uid)
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Subtasks
|
||||
# ---------------------------------------------------
|
||||
|
||||
@@ -234,6 +234,9 @@
|
||||
<div class="col-lg-10 offset-lg-1 mt16">
|
||||
<t t-call="portal.message_thread">
|
||||
<t t-set="object" t-value="task"/>
|
||||
<t t-set="token" t-value="task.access_token"/>
|
||||
<t t-set="pid" t-value="pid"/>
|
||||
<t t-set="hash" t-value="hash"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
<field name="model">project.project</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Project">
|
||||
<header>
|
||||
<button name="%(portal.portal_share_action)d" string="Share" type="action" class="oe_highlight oe_read_only"/>
|
||||
</header>
|
||||
<sheet string="Project">
|
||||
<div class="oe_button_box" name="button_box" groups="base.group_user">
|
||||
<button class="oe_stat_button" name="attachment_tree_view" type="object" icon="fa-files-o">
|
||||
@@ -327,6 +330,9 @@
|
||||
</div>
|
||||
<div class="o_kanban_card_manage_pane dropdown-menu" groups="project.group_project_manager" role="menu">
|
||||
<div class="o_kanban_card_manage_section o_kanban_manage_reports">
|
||||
<div role="menuitem">
|
||||
<a name="%(portal.portal_share_action)d" type="action">Share</a>
|
||||
</div>
|
||||
<div role="menuitem">
|
||||
<a type="edit">Edit</a>
|
||||
</div>
|
||||
@@ -412,7 +418,7 @@
|
||||
<header>
|
||||
<button name="action_assign_to_me" string="Assign to Me" type="object" class="oe_highlight"
|
||||
attrs="{'invisible' : [('user_id', '!=', False)]}"/>
|
||||
|
||||
<button name="%(portal.portal_share_action)d" string="Share" type="action" class="oe_highlight oe_read_only"/>
|
||||
<field name="stage_id" widget="statusbar" options="{'clickable': '1', 'fold_field': 'fold'}"/>
|
||||
</header>
|
||||
<sheet string="Task">
|
||||
@@ -557,6 +563,7 @@
|
||||
</a>
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<a t-if="widget.editable" role="menuitem" type="set_cover" class="dropdown-item">Set Cover Image</a>
|
||||
<a name="%(portal.portal_share_action)d" role="menuitem" type="action" class="dropdown-item">Share</a>
|
||||
<a t-if="widget.editable" role="menuitem" type="edit" class="dropdown-item">Edit Task</a>
|
||||
<a t-if="widget.deletable" role="menuitem" type="delete" class="dropdown-item">Delete</a>
|
||||
<div role="separator" class="dropdown-divider"></div>
|
||||
|
||||
@@ -23,6 +23,12 @@ class CustomerPortal(CustomerPortal):
|
||||
])
|
||||
return values
|
||||
|
||||
def _purchase_order_get_page_view_values(self, order, access_token, **kwargs):
|
||||
values = {
|
||||
'order': order,
|
||||
}
|
||||
return self._get_page_view_values(order, access_token, values, 'my_purchases_history', True, **kwargs)
|
||||
|
||||
@http.route(['/my/purchase', '/my/purchase/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_purchase_orders(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
|
||||
values = self._prepare_portal_layout_values()
|
||||
@@ -93,17 +99,12 @@ class CustomerPortal(CustomerPortal):
|
||||
})
|
||||
return request.render("purchase.portal_my_purchase_orders", values)
|
||||
|
||||
@http.route(['/my/purchase/<int:order_id>'], type='http', auth="user", website=True)
|
||||
def portal_my_purchase_order(self, order_id=None, **kw):
|
||||
order = request.env['purchase.order'].browse(order_id)
|
||||
@http.route(['/my/purchase/<int:order_id>'], type='http', auth="public", website=True)
|
||||
def portal_my_purchase_order(self, order_id=None, access_token=None, **kw):
|
||||
try:
|
||||
order.check_access_rights('read')
|
||||
order.check_access_rule('read')
|
||||
order_sudo = self._document_check_access('purchase.order', order_id, access_token=access_token)
|
||||
except AccessError:
|
||||
return request.redirect('/my')
|
||||
history = request.session.get('my_purchases_history', [])
|
||||
values = {
|
||||
'order': order.sudo(),
|
||||
}
|
||||
values.update(get_records_pager(history, order))
|
||||
return request.render("purchase.portal_my_purchase_order", values)
|
||||
|
||||
values = self._purchase_order_get_page_view_values(order_sudo, access_token, **kw)
|
||||
return request.render("purchase.portal_my_purchase_order", values)
|
||||
@@ -27,5 +27,13 @@
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Share Button in action menu -->
|
||||
<record id="model_purchase_order_action_share" model="ir.actions.server">
|
||||
<field name="name">Share</field>
|
||||
<field name="model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="binding_model_id" ref="purchase.model_purchase_order"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_share()</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -14,7 +14,7 @@ from odoo.addons import decimal_precision as dp
|
||||
|
||||
class PurchaseOrder(models.Model):
|
||||
_name = "purchase.order"
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin']
|
||||
_description = "Purchase Order"
|
||||
_order = 'date_order desc, id desc'
|
||||
|
||||
@@ -125,13 +125,10 @@ class PurchaseOrder(models.Model):
|
||||
user_id = fields.Many2one('res.users', string='Purchase Representative', index=True, track_visibility='onchange', default=lambda self: self.env.user)
|
||||
company_id = fields.Many2one('res.company', 'Company', required=True, index=True, states=READONLY_STATES, default=lambda self: self.env.user.company_id.id)
|
||||
|
||||
website_url = fields.Char(
|
||||
'Website URL', compute='_website_url',
|
||||
help='The full URL to access the document through the website.')
|
||||
|
||||
def _website_url(self):
|
||||
def _compute_access_url(self):
|
||||
super(PurchaseOrder, self)._compute_access_url()
|
||||
for order in self:
|
||||
order.website_url = '/my/purchase/%s' % (order.id)
|
||||
order.access_url = '/my/purchase/%s' % (order.id)
|
||||
|
||||
@api.model
|
||||
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
|
||||
|
||||
@@ -489,5 +489,4 @@
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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=access_token)
|
||||
@@ -6,7 +6,6 @@ import base64
|
||||
from odoo import http, _
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request
|
||||
from odoo.tools import consteq
|
||||
from odoo.addons.portal.controllers.mail import _message_post_helper
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager, get_records_pager
|
||||
|
||||
@@ -37,39 +36,14 @@ class CustomerPortal(CustomerPortal):
|
||||
# Quotations and Sales Orders
|
||||
#
|
||||
|
||||
def _order_check_access(self, order_id, access_token=None):
|
||||
order = request.env['sale.order'].browse([order_id])
|
||||
order_sudo = order.sudo()
|
||||
try:
|
||||
order.check_access_rights('read')
|
||||
order.check_access_rule('read')
|
||||
except AccessError:
|
||||
if not access_token or not consteq(order_sudo.access_token, access_token):
|
||||
raise
|
||||
return order_sudo
|
||||
|
||||
def _order_get_page_view_values(self, order, access_token, **kwargs):
|
||||
order_invoice_lines = {il.product_id.id: il.invoice_id for il in order.invoice_ids.mapped('invoice_line_ids')}
|
||||
values = {
|
||||
'order': order,
|
||||
'order_invoice_lines': order_invoice_lines,
|
||||
'portal_confirmation': order.get_portal_confirmation_action(),
|
||||
}
|
||||
if access_token:
|
||||
values['no_breadcrumbs'] = True
|
||||
values['access_token'] = access_token
|
||||
values['portal_confirmation'] = order.get_portal_confirmation_action()
|
||||
|
||||
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_orders_history', [])
|
||||
values.update(get_records_pager(history, order))
|
||||
|
||||
return values
|
||||
return self._get_page_view_values(order, access_token, values, 'my_orders_history', True, **kwargs)
|
||||
|
||||
@http.route(['/my/quotes', '/my/quotes/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_quotes(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
|
||||
@@ -174,10 +148,10 @@ class CustomerPortal(CustomerPortal):
|
||||
})
|
||||
return request.render("sale.portal_my_orders", values)
|
||||
|
||||
@http.route(['/my/orders/<int:order>'], type='http', auth="public", website=True)
|
||||
def portal_order_page(self, order=None, access_token=None, **kw):
|
||||
@http.route(['/my/orders/<int:order_id>'], type='http', auth="public", website=True)
|
||||
def portal_order_page(self, order_id=None, access_token=None, **kw):
|
||||
try:
|
||||
order_sudo = self._order_check_access(order, access_token=access_token)
|
||||
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
|
||||
except AccessError:
|
||||
return request.redirect('/my')
|
||||
|
||||
@@ -187,7 +161,7 @@ class CustomerPortal(CustomerPortal):
|
||||
@http.route(['/my/orders/pdf/<int:order_id>'], type='http', auth="public", website=True)
|
||||
def portal_order_report(self, order_id, access_token=None, **kw):
|
||||
try:
|
||||
order_sudo = self._order_check_access(order_id, access_token)
|
||||
order_sudo = self._document_check_access('sale.order', order_id, access_token)
|
||||
except AccessError:
|
||||
return request.redirect('/my')
|
||||
|
||||
@@ -206,7 +180,7 @@ class CustomerPortal(CustomerPortal):
|
||||
@http.route(['/my/quotes/accept'], type='json', auth="public", website=True)
|
||||
def portal_quote_accept(self, res_id, access_token=None, partner_name=None, signature=None):
|
||||
try:
|
||||
order_sudo = self._order_check_access(res_id, access_token=access_token)
|
||||
order_sudo = self._document_check_access('sale.order', res_id, access_token=access_token)
|
||||
except AccessError:
|
||||
return {'error': _('Invalid order')}
|
||||
if order_sudo.state != 'sent':
|
||||
|
||||
@@ -6,5 +6,14 @@
|
||||
<field name="use_invoices" eval="True"/>
|
||||
<field name="dashboard_graph_model">sale.report</field>
|
||||
</record>
|
||||
|
||||
<!-- Share Button in action menu -->
|
||||
<record id="model_sale_order_action_share" model="ir.actions.server">
|
||||
<field name="name">Share</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.action_share()</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -91,9 +91,6 @@ class SaleOrder(models.Model):
|
||||
)
|
||||
return super(SaleOrder, self).get_empty_list_help(help)
|
||||
|
||||
def _get_default_access_token(self):
|
||||
return str(uuid.uuid4())
|
||||
|
||||
@api.model
|
||||
def _default_note(self):
|
||||
return self.env['ir.config_parameter'].sudo().get_param('sale.use_sale_note') and self.env.user.company_id.sale_note or ''
|
||||
@@ -113,9 +110,6 @@ class SaleOrder(models.Model):
|
||||
name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New'))
|
||||
origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.")
|
||||
client_order_ref = fields.Char(string='Customer Reference', copy=False)
|
||||
access_token = fields.Char(
|
||||
'Security Token', copy=False,
|
||||
default=_get_default_access_token)
|
||||
state = fields.Selection([
|
||||
('draft', 'Quotation'),
|
||||
('sent', 'Quotation Sent'),
|
||||
@@ -163,10 +157,10 @@ class SaleOrder(models.Model):
|
||||
signature = fields.Binary('Signature', help='Signature received through the portal.', copy=False, attachment=True)
|
||||
signed_by = fields.Char('Signed by', help='Name of the person that signed the SO.', copy=False)
|
||||
|
||||
def _compute_portal_url(self):
|
||||
super(SaleOrder, self)._compute_portal_url()
|
||||
def _compute_access_url(self):
|
||||
super(SaleOrder, self)._compute_access_url()
|
||||
for order in self:
|
||||
order.portal_url = '/my/orders/%s' % (order.id)
|
||||
order.access_url = '/my/orders/%s' % (order.id)
|
||||
|
||||
def _compute_is_expired(self):
|
||||
now = datetime.now()
|
||||
@@ -344,27 +338,6 @@ class SaleOrder(models.Model):
|
||||
return self.browse(order_ids).name_get()
|
||||
return super(SaleOrder, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
|
||||
|
||||
@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(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._table, 'column_name': column_name}
|
||||
self.env.cr.execute(query)
|
||||
|
||||
def _generate_access_token(self):
|
||||
for order in self:
|
||||
order.access_token = self._get_default_access_token()
|
||||
|
||||
@api.multi
|
||||
def _prepare_invoice(self):
|
||||
"""
|
||||
@@ -623,43 +596,6 @@ class SaleOrder(models.Model):
|
||||
res = [(l[0].name, l[1]['amount'], l[1]['base'], len(res)) for l in res]
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def get_access_action(self, access_uid=None):
|
||||
""" 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' and (self.state != 'draft' or self.env.context.get('mark_so_as_sent')):
|
||||
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 AccessError:
|
||||
if self.env.context.get('force_website'):
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/my/orders/%s' % self.id,
|
||||
'target': 'self',
|
||||
'res_id': self.id,
|
||||
}
|
||||
else:
|
||||
pass
|
||||
else:
|
||||
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):
|
||||
return self.get_share_url()
|
||||
|
||||
def get_portal_confirmation_action(self):
|
||||
if self.company_id.portal_confirmation_sign and not self.signature:
|
||||
return 'sign'
|
||||
|
||||
@@ -406,13 +406,16 @@
|
||||
// Copy to clipboard
|
||||
&.o_field_copy {
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
border-radius: 5px;
|
||||
border: 1px solid $primary;
|
||||
font-size: $font-size-sm;
|
||||
color: $o-brand-primary;
|
||||
font-weight: $badge-font-weight;
|
||||
text-align: center;
|
||||
padding-right: 100px;
|
||||
.o_clipboard_button {
|
||||
@include o-position-absolute($top: 0, $right: 0);
|
||||
&.o_btn_text_copy {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -420,6 +423,7 @@
|
||||
}
|
||||
&.o_btn_char_copy {
|
||||
padding-top: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -947,12 +947,12 @@
|
||||
</t>
|
||||
<t t-name="CopyClipboardText">
|
||||
<button class="btn btn-sm btn-primary o_clipboard_button o_btn_text_copy">
|
||||
<span class="fa fa-clipboard">Copy Text</span>
|
||||
<span class="fa fa-clipboard"></span><span> Copy Text</span>
|
||||
</button>
|
||||
</t>
|
||||
<t t-name="CopyClipboardChar">
|
||||
<button class="btn btn-sm btn-primary o_clipboard_button o_btn_char_copy">
|
||||
<span class="fa fa-clipboard">Copy Text</span>
|
||||
<span class="fa fa-clipboard"></span><span> Copy Text</span>
|
||||
</button>
|
||||
</t>
|
||||
<t t-name="FieldBinaryFile">
|
||||
|
||||
@@ -1366,10 +1366,10 @@ QUnit.module('basic_fields', {
|
||||
}
|
||||
});
|
||||
|
||||
QUnit.test('im_livechat: Copy to clipboard button', function (assert) {
|
||||
QUnit.test('Char & Text Fields: Copy to clipboard button', function (assert) {
|
||||
assert.expect(2);
|
||||
|
||||
var form = createView({
|
||||
var done = assert.async();
|
||||
testUtils.createAsyncView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
@@ -1381,10 +1381,12 @@ QUnit.module('basic_fields', {
|
||||
'</div>' +
|
||||
'</sheet>' +
|
||||
'</form>',
|
||||
}).then(function (form) {
|
||||
assert.strictEqual(form.$('.o_clipboard_button.o_btn_text_copy').length, 1,"Should have copy button on text type field");
|
||||
assert.strictEqual(form.$('.o_clipboard_button.o_btn_char_copy').length, 1,"Should have copy button on char type field");
|
||||
form.destroy();
|
||||
done();
|
||||
});
|
||||
assert.strictEqual(form.$('.o_clipboard_button.o_btn_text_copy').length, 1,"Should have copy button on text type field");
|
||||
assert.strictEqual(form.$('.o_clipboard_button.o_btn_char_copy').length, 1,"Should have copy button on char type field");
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('FieldText');
|
||||
|
||||
@@ -14,15 +14,15 @@ from odoo.osv import expression
|
||||
class CustomerPortal(CustomerPortal):
|
||||
|
||||
@http.route()
|
||||
def portal_order_page(self, order=None, access_token=None, **kw):
|
||||
def portal_order_page(self, order_id=None, access_token=None, **kw):
|
||||
try:
|
||||
order_sudo = self._order_check_access(order, access_token=access_token)
|
||||
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
|
||||
except exceptions.AccessError:
|
||||
pass
|
||||
else:
|
||||
if order_sudo.template_id and order_sudo.template_id.active:
|
||||
return request.redirect('/quote/%s/%s' % (order, access_token or ''))
|
||||
return super(CustomerPortal, self).portal_order_page(order=order, access_token=access_token, **kw)
|
||||
return request.redirect('/quote/%s/%s' % (order_id, access_token or ''))
|
||||
return super(CustomerPortal, self).portal_order_page(order_id=order_id, access_token=access_token, **kw)
|
||||
|
||||
def _portal_quote_user_can_accept(self, order):
|
||||
result = super(CustomerPortal, self)._portal_quote_user_can_accept(order)
|
||||
@@ -46,7 +46,7 @@ class sale_quote(http.Controller):
|
||||
else:
|
||||
Order = request.env['sale.order'].search([('id', '=', order_id)])
|
||||
# Log only once a day
|
||||
if Order and request.session.get('view_quote_%s' % Order.id) != now and request.env.user.share:
|
||||
if Order and request.session.get('view_quote_%s' % Order.id) != now and request.env.user.share and token:
|
||||
request.session['view_quote_%s' % Order.id] = now
|
||||
body = _('Quotation viewed by customer')
|
||||
_message_post_helper(res_model='sale.order', res_id=Order.id, message=body, token=Order.access_token, message_type='notification', subtype="mail.mt_note", partner_ids=Order.user_id.sudo().partner_id.ids)
|
||||
|
||||
@@ -217,12 +217,12 @@ class SaleOrder(models.Model):
|
||||
'res_id': self.id,
|
||||
}
|
||||
|
||||
def get_mail_url(self):
|
||||
def _get_share_url(self, redirect=False, signup_partner=False):
|
||||
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()
|
||||
return super(SaleOrder, self)._get_share_url(redirect)
|
||||
|
||||
def get_portal_confirmation_action(self):
|
||||
""" Template override default behavior of pay / sign chosen in sales settings """
|
||||
|
||||
@@ -27,10 +27,10 @@ class WebsiteSaleDigital(CustomerPortal):
|
||||
orders_page = '/my/orders'
|
||||
|
||||
@http.route([
|
||||
'/my/orders/<int:order>',
|
||||
'/my/orders/<int:order_id>',
|
||||
], type='http', auth='public', website=True)
|
||||
def portal_order_page(self, order=None, **post):
|
||||
response = super(WebsiteSaleDigital, self).portal_order_page(order=order, **post)
|
||||
def portal_order_page(self, order_id=None, **post):
|
||||
response = super(WebsiteSaleDigital, self).portal_order_page(order_id=order_id, **post)
|
||||
if not 'order' in response.qcontext:
|
||||
return response
|
||||
order = response.qcontext['order']
|
||||
|
||||
Reference in New Issue
Block a user