[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:
Mitali Patel
2018-08-03 15:20:42 +02:00
committed by David Beguin
parent 766a1025b1
commit 84f528bcff
38 changed files with 473 additions and 409 deletions
-1
View File
@@ -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
-30
View File
@@ -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)
+3 -29
View File
@@ -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')
+8
View File
@@ -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>
+3 -63
View File
@@ -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>
+4 -2
View File
@@ -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):
+2 -2
View File
@@ -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;">
+1
View File
@@ -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': [
+50 -1
View File
@@ -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)
+39 -3
View File
@@ -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
+10
View File
@@ -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>
+102 -33
View File
@@ -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">
+1 -1
View File
@@ -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
View File
@@ -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
+74
View File
@@ -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>
+39 -19
View File
@@ -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)
+19 -55
View File
@@ -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>
+8 -1
View File
@@ -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>
+12 -11
View File
@@ -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)
+8
View File
@@ -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>
+4 -7
View File
@@ -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):
-1
View File
@@ -489,5 +489,4 @@
</search>
</field>
</record>
</odoo>
-1
View File
@@ -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
-30
View File
@@ -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)
+7 -33
View File
@@ -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':
+9
View File
@@ -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>
+3 -67
View File
@@ -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'
+4
View File
@@ -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%;
}
}
}
+2 -2
View File
@@ -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');
+5 -5
View File
@@ -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)
+2 -2
View File
@@ -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']