initial commit

This commit is contained in:
Данил Воробьев 2024-05-03 09:57:08 +00:00
commit c5833477ca
121 changed files with 81931 additions and 0 deletions

17
__init__.py Normal file
View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# Updating mako environement in order to be able to use slug
try:
from odoo.tools.rendering_tools import template_env_globals
from odoo.addons.http_routing.models.ir_http import slug
template_env_globals.update({
'slug': slug
})
except ImportError:
pass
from . import controllers
from . import models
from . import wizard

56
__manifest__.py Normal file
View File

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Customer Portal',
'summary': 'Customer Portal',
'sequence': 9000,
'category': 'Hidden',
'description': """
This module adds required base code for a fully integrated customer portal.
It contains the base controller class and base templates. Business addons
will add their specific templates and controllers to extend the customer
portal.
This module contains most code coming from odoo v10 website_portal. Purpose
of this module is to allow the display of a customer portal without having
a dependency towards website editing and customization capabilities.""",
'depends': ['web', 'web_editor', 'http_routing', 'mail', 'auth_signup'],
'data': [
'security/ir.model.access.csv',
'data/mail_template_data.xml',
'data/mail_templates.xml',
'views/mail_templates_public.xml',
'views/portal_templates.xml',
'views/res_config_settings_views.xml',
'wizard/portal_share_views.xml',
'wizard/portal_wizard_views.xml',
],
'assets': {
'web._assets_primary_variables': [
'portal/static/src/scss/primary_variables.scss',
],
'web._assets_frontend_helpers': [
('prepend', 'portal/static/src/scss/bootstrap_overridden.scss'),
],
'web.assets_backend': [
'portal/static/src/views/**/*',
],
'web.assets_frontend': [
'portal/static/src/scss/portal.scss',
'portal/static/src/js/portal.js',
'portal/static/src/js/portal_chatter.js',
'portal/static/src/xml/portal_chatter.xml',
'portal/static/src/js/portal_composer.js',
'portal/static/src/js/portal_security.js',
'portal/static/src/js/portal_sidebar.js',
'portal/static/src/xml/portal_security.xml',
'portal/static/src/js/components/**/*',
'portal/static/src/signature_form/**/*',
],
'web.assets_tests': [
'portal/static/tests/**/*',
],
},
'license': 'LGPL-3',
}

Binary file not shown.

6
controllers/__init__.py Normal file
View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import web
from . import portal
from . import mail

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

280
controllers/mail.py Normal file
View File

@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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 import mail
from odoo.exceptions import AccessError
def _check_special_access(res_model, res_id, token='', _hash='', pid=False):
record = request.env[res_model].browse(res_id).sudo()
if _hash and pid: # Signed Token Case: hash implies token is signed by partner pid
return consteq(_hash, record._sign_token(pid))
elif token: # Token Case: token is the global one of the document
token_field = request.env[res_model]._mail_post_token_field
return (token and record and consteq(record[token_field], token))
else:
raise Forbidden()
def _message_post_helper(res_model, res_id, message, token='', _hash=False, pid=False, nosubscribe=True, **kw):
""" Generic chatter function, allowing to write on *any* object that inherits mail.thread. We
distinguish 2 cases:
1/ If a token is specified, all logged in users will be able to write a message regardless
of access rights; if the user is the public user, the message will be posted under the name
of the partner_id of the object (or the public user if there is no partner_id on the object).
2/ If a signed token is specified (`hash`) and also a partner_id (`pid`), all post message will
be done under the name of the partner_id (as it is signed). This should be used to avoid leaking
token to all users.
Required parameters
:param string res_model: model name of the object
:param int res_id: id of the object
:param string message: content of the message
Optional keywords arguments:
:param string token: access token if the object's model uses some kind of public access
using tokens (usually a uuid4) to bypass access rules
:param string hash: signed token by a partner if model uses some token field to bypass access right
post messages.
:param string pid: identifier of the res.partner used to sign the hash
:param bool nosubscribe: set False if you want the partner to be set as follower of the object when posting (default to True)
The rest of the kwargs are passed on to message_post()
"""
record = request.env[res_model].browse(res_id)
# check if user can post with special token/signed token. The "else" will try to post message with the
# current user access rights (_mail_post_access use case).
if token or (_hash and pid):
pid = int(pid) if pid else False
if _check_special_access(res_model, res_id, token=token, _hash=_hash, pid=pid):
record = record.sudo()
else:
raise Forbidden()
else: # early check on access to avoid useless computation
record.check_access_rights('read')
record.check_access_rule('read')
# deduce author of message
author_id = request.env.user.partner_id.id if request.env.user.partner_id else False
# Signed Token Case: author_id is forced
if _hash and pid:
author_id = pid
# Token Case: author is document customer (if not logged) or itself even if user has not the access
elif token:
if request.env.user._is_public():
# 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()
email_from = None
if author_id and 'email_from' not in kw:
partner = request.env['res.partner'].sudo().browse(author_id)
email_from = partner.email_formatted if partner.email else None
message_post_args = dict(
body=message,
message_type=kw.pop('message_type', "comment"),
subtype_xmlid=kw.pop('subtype_xmlid', "mail.mt_comment"),
author_id=author_id,
**kw
)
# This is necessary as mail.message checks the presence
# of the key to compute its default email from
if email_from:
message_post_args['email_from'] = email_from
return record.with_context(mail_create_nosubscribe=nosubscribe).message_post(**message_post_args)
class PortalChatter(http.Controller):
def _portal_post_filter_params(self):
return ['token', 'pid']
def _portal_post_check_attachments(self, attachment_ids, attachment_tokens):
request.env['ir.attachment'].browse(attachment_ids)._check_attachments_access(attachment_tokens)
def _portal_post_has_content(self, res_model, res_id, message, attachment_ids=None, **kw):
""" Tells if we can effectively post on the model based on content. """
return bool(message) or bool(attachment_ids)
@http.route('/mail/avatar/mail.message/<int:res_id>/author_avatar/<int:width>x<int:height>', type='http', auth='public')
def portal_avatar(self, res_id=None, height=50, width=50, access_token=None, _hash=None, pid=None):
""" Get the avatar image in the chatter of the portal """
if access_token or (_hash and pid):
message = request.env['mail.message'].browse(int(res_id)).exists().filtered(
lambda msg: _check_special_access(msg.model, msg.res_id, access_token, _hash, pid and int(pid))
).sudo()
else:
message = request.env.ref('web.image_placeholder').sudo()
# in case there is no message, it creates a stream with the placeholder image
stream = request.env['ir.binary']._get_image_stream_from(
message, field_name='author_avatar', width=int(width), height=int(height),
)
return stream.get_response()
@http.route(['/mail/chatter_post'], type='json', methods=['POST'], auth='public', website=True)
def portal_chatter_post(self, res_model, res_id, message, attachment_ids=None, attachment_tokens=None, **kw):
"""Create a new `mail.message` with the given `message` and/or `attachment_ids` and return new message values.
The message will be associated to the record `res_id` of the model
`res_model`. The user must have access rights on this target document or
must provide valid identifiers through `kw`. See `_message_post_helper`.
"""
if not self._portal_post_has_content(res_model, res_id, message,
attachment_ids=attachment_ids, attachment_tokens=attachment_tokens,
**kw):
return
res_id = int(res_id)
self._portal_post_check_attachments(attachment_ids or [], attachment_tokens or [])
result = {'default_message': message}
# message is received in plaintext and saved in html
if message:
message = plaintext2html(message)
post_values = {
'res_model': res_model,
'res_id': res_id,
'message': message,
'send_after_commit': False,
'attachment_ids': False, # will be added afterward
}
post_values.update((fname, kw.get(fname)) for fname in self._portal_post_filter_params())
post_values['_hash'] = kw.get('hash')
message = _message_post_helper(**post_values)
result.update({'default_message_id': message.id})
if attachment_ids:
# _message_post_helper already checks for pid/hash/token -> use message
# environment to keep the sudo mode when activated
record = message.env[res_model].browse(res_id)
attachments = record._process_attachments_for_post(
[], attachment_ids,
{'res_id': res_id, 'model': res_model}
)
# sudo write the attachment to bypass the read access verification in
# mail message
if attachments.get('attachment_ids'):
message.sudo().write(attachments)
result.update({'default_attachment_ids': message.attachment_ids.sudo().read(['id', 'name', 'mimetype', 'file_size', 'access_token'])})
return result
@http.route('/mail/chatter_init', type='json', auth='public', website=True)
def portal_chatter_init(self, res_model, res_id, domain=False, limit=False, **kwargs):
is_user_public = request.env.user.has_group('base.group_public')
message_data = self.portal_message_fetch(res_model, res_id, domain=domain, limit=limit, **kwargs)
display_composer = False
if kwargs.get('allow_composer'):
display_composer = kwargs.get('token') or not is_user_public
return {
'messages': message_data['messages'],
'options': {
'message_count': message_data['message_count'],
'is_user_public': is_user_public,
'is_user_employee': request.env.user._is_internal(),
'is_user_publisher': request.env.user.has_group('website.group_website_restricted_editor'),
'display_composer': display_composer,
'partner_id': request.env.user.partner_id.id
}
}
@http.route('/mail/chatter_fetch', type='json', auth='public', website=True)
def portal_message_fetch(self, res_model, res_id, domain=False, limit=10, offset=0, **kw):
if not domain:
domain = []
# Only search into website_message_ids, so apply the same domain to perform only one search
# extract domain from the 'website_message_ids' field
model = request.env[res_model]
field = model._fields['website_message_ids']
field_domain = field.get_domain_list(model)
domain = expression.AND([
domain,
field_domain,
[('res_id', '=', res_id), '|', ('body', '!=', ''), ('attachment_ids', '!=', False)]
])
# Check access
Message = request.env['mail.message']
if kw.get('token'):
access_as_sudo = _check_special_access(res_model, res_id, token=kw.get('token'))
if not access_as_sudo: # if token is not correct, raise Forbidden
raise Forbidden()
# Non-employee see only messages with not internal subtype (aka, no internal logs)
if not request.env['res.users'].has_group('base.group_user'):
domain = expression.AND([Message._get_search_domain_share(), domain])
Message = request.env['mail.message'].sudo()
return {
'messages': Message.search(domain, limit=limit, offset=offset).portal_message_format(options=kw),
'message_count': Message.search_count(domain)
}
@http.route(['/mail/update_is_internal'], type='json', auth="user", website=True)
def portal_message_update_is_internal(self, message_id, is_internal):
message = request.env['mail.message'].browse(int(message_id))
message.write({'is_internal': is_internal})
return message.is_internal
class MailController(mail.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 them 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:
"""
# no model / res_id, meaning no possible record -> direct skip to super
if not model or not res_id or model not in request.env:
return super(MailController, cls)._redirect_to_record(model, res_id, access_token=access_token, **kwargs)
if isinstance(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.with_user(uid).check_access_rights('read')
record_sudo.with_user(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._get_access_action(force_website=True)
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 request.redirect(url)
return super(MailController, cls)._redirect_to_record(model, res_id, access_token=access_token, **kwargs)
# Add website=True to support the portal layout
@http.route('/mail/unfollow', type='http', website=True)
def mail_action_unfollow(self, model, res_id, pid, token, **kwargs):
return super().mail_action_unfollow(model, res_id, pid, token, **kwargs)

524
controllers/portal.py Normal file
View File

@ -0,0 +1,524 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
import math
import re
from werkzeug import urls
from odoo import http, tools, _, SUPERUSER_ID
from odoo.exceptions import AccessDenied, AccessError, MissingError, UserError, ValidationError
from odoo.http import content_disposition, Controller, request, route
from odoo.tools import consteq
# --------------------------------------------------
# Misc tools
# --------------------------------------------------
def pager(url, total, page=1, step=30, scope=5, url_args=None):
""" Generate a dict with required value to render `website.pager` template.
This method computes url, page range to display, ... in the pager.
:param str url : base url of the page link
:param int total : number total of item to be splitted into pages
:param int page : current page
:param int step : item per page
:param int scope : number of page to display on pager
:param dict url_args : additionnal parameters to add as query params to page url
:returns dict
"""
# Compute Pager
page_count = int(math.ceil(float(total) / step))
page = max(1, min(int(page if str(page).isdigit() else 1), page_count))
scope -= 1
pmin = max(page - int(math.floor(scope/2)), 1)
pmax = min(pmin + scope, page_count)
if pmax - pmin < scope:
pmin = pmax - scope if pmax - scope > 0 else 1
def get_url(page):
_url = "%s/page/%s" % (url, page) if page > 1 else url
if url_args:
_url = "%s?%s" % (_url, urls.url_encode(url_args))
return _url
return {
"page_count": page_count,
"offset": (page - 1) * step,
"page": {
'url': get_url(page),
'num': page
},
"page_first": {
'url': get_url(1),
'num': 1
},
"page_start": {
'url': get_url(pmin),
'num': pmin
},
"page_previous": {
'url': get_url(max(pmin, page - 1)),
'num': max(pmin, page - 1)
},
"page_next": {
'url': get_url(min(pmax, page + 1)),
'num': min(pmax, page + 1)
},
"page_end": {
'url': get_url(pmax),
'num': pmax
},
"page_last": {
'url': get_url(page_count),
'num': page_count
},
"pages": [
{'url': get_url(page_num), 'num': page_num} for page_num in range(pmin, pmax+1)
]
}
def get_records_pager(ids, current):
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)
prev_record = idx != 0 and current.browse(ids[idx - 1])
next_record = idx < len(ids) - 1 and current.browse(ids[idx + 1])
if prev_record and prev_record[attr_name] and attr_name == "access_url":
prev_url = '%s?access_token=%s' % (prev_record[attr_name], prev_record._portal_ensure_token())
elif prev_record and prev_record[attr_name]:
prev_url = prev_record[attr_name]
else:
prev_url = prev_record
if next_record and next_record[attr_name] and attr_name == "access_url":
next_url = '%s?access_token=%s' % (next_record[attr_name], next_record._portal_ensure_token())
elif next_record and next_record[attr_name]:
next_url = next_record[attr_name]
else:
next_url = next_record
return {
'prev_record': prev_url,
'next_record': next_url,
}
return {}
def _build_url_w_params(url_string, query_params, remove_duplicates=True):
""" Rebuild a string url based on url_string and correctly compute query parameters
using those present in the url and those given by query_params. Having duplicates in
the final url is optional. For example:
* url_string = '/my?foo=bar&error=pay'
* query_params = {'foo': 'bar2', 'alice': 'bob'}
* if remove duplicates: result = '/my?foo=bar2&error=pay&alice=bob'
* else: result = '/my?foo=bar&foo=bar2&error=pay&alice=bob'
"""
url = urls.url_parse(url_string)
url_params = url.decode_query()
if remove_duplicates: # convert to standard dict instead of werkzeug multidict to remove duplicates automatically
url_params = url_params.to_dict()
url_params.update(query_params)
return url.replace(query=urls.url_encode(url_params)).to_url()
class CustomerPortal(Controller):
MANDATORY_BILLING_FIELDS = ["name", "phone", "email", "street", "city", "country_id"]
OPTIONAL_BILLING_FIELDS = ["zipcode", "state_id", "vat", "company_name"]
_items_per_page = 80
def _prepare_portal_layout_values(self):
"""Values for /my/* templates rendering.
Does not include the record counts.
"""
# get customer sales rep
sales_user_sudo = request.env['res.users']
partner_sudo = request.env.user.partner_id
if partner_sudo.user_id and not partner_sudo.user_id._is_public():
sales_user_sudo = partner_sudo.user_id
else:
fallback_sales_user = partner_sudo.commercial_partner_id.user_id
if fallback_sales_user and not fallback_sales_user._is_public():
sales_user_sudo = fallback_sales_user
return {
'sales_user': sales_user_sudo,
'page_name': 'home',
}
def _prepare_home_portal_values(self, counters):
"""Values for /my & /my/home routes template rendering.
Includes the record count for the displayed badges.
where 'counters' is the list of the displayed badges
and so the list to compute.
"""
return {}
@route(['/my/counters'], type='json', auth="user", website=True)
def counters(self, counters, **kw):
return self._prepare_home_portal_values(counters)
@route(['/my', '/my/home'], type='http', auth="user", website=True)
def home(self, **kw):
values = self._prepare_portal_layout_values()
return request.render("portal.portal_my_home", values)
@route(['/my/account'], type='http', auth='user', website=True)
def account(self, redirect=None, **post):
values = self._prepare_portal_layout_values()
partner = request.env.user.partner_id
values.update({
'error': {},
'error_message': [],
})
if post and request.httprequest.method == 'POST':
if not partner.can_edit_vat():
post['country_id'] = str(partner.country_id.id)
error, error_message = self.details_form_validate(post)
values.update({'error': error, 'error_message': error_message})
values.update(post)
if not error:
values = {key: post[key] for key in self._get_mandatory_fields()}
values.update({key: post[key] for key in self._get_optional_fields() if key in post})
for field in set(['country_id', 'state_id']) & set(values.keys()):
try:
values[field] = int(values[field])
except:
values[field] = False
values.update({'zip': values.pop('zipcode', '')})
self.on_account_update(values, partner)
partner.sudo().write(values)
if redirect:
return request.redirect(redirect)
return request.redirect('/my/home')
countries = request.env['res.country'].sudo().search([])
states = request.env['res.country.state'].sudo().search([])
values.update({
'partner': partner,
'countries': countries,
'states': states,
'has_check_vat': hasattr(request.env['res.partner'], 'check_vat'),
'partner_can_edit_vat': partner.can_edit_vat(),
'redirect': redirect,
'page_name': 'my_details',
})
response = request.render("portal.portal_my_details", values)
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
response.headers['Content-Security-Policy'] = "frame-ancestors 'self'"
return response
def on_account_update(self, values, partner):
pass
@route('/my/security', type='http', auth='user', website=True, methods=['GET', 'POST'])
def security(self, **post):
values = self._prepare_portal_layout_values()
values['get_error'] = get_error
values['allow_api_keys'] = bool(request.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys'))
values['open_deactivate_modal'] = False
if request.httprequest.method == 'POST':
values.update(self._update_password(
post['old'].strip(),
post['new1'].strip(),
post['new2'].strip()
))
return request.render('portal.portal_my_security', values, headers={
'X-Frame-Options': 'SAMEORIGIN',
'Content-Security-Policy': "frame-ancestors 'self'"
})
def _update_password(self, old, new1, new2):
for k, v in [('old', old), ('new1', new1), ('new2', new2)]:
if not v:
return {'errors': {'password': {k: _("You cannot leave any password empty.")}}}
if new1 != new2:
return {'errors': {'password': {'new2': _("The new password and its confirmation must be identical.")}}}
try:
request.env['res.users'].change_password(old, new1)
except AccessDenied as e:
msg = e.args[0]
if msg == AccessDenied().args[0]:
msg = _('The old password you provided is incorrect, your password was not changed.')
return {'errors': {'password': {'old': msg}}}
except UserError as e:
return {'errors': {'password': str(e)}}
# update session token so the user does not get logged out (cache cleared by passwd change)
new_token = request.env.user._compute_session_token(request.session.sid)
request.session.session_token = new_token
return {'success': {'password': True}}
@route('/my/deactivate_account', type='http', auth='user', website=True, methods=['POST'])
def deactivate_account(self, validation, password, **post):
values = self._prepare_portal_layout_values()
values['get_error'] = get_error
values['open_deactivate_modal'] = True
if validation != request.env.user.login:
values['errors'] = {'deactivate': 'validation'}
else:
try:
request.env['res.users']._check_credentials(password, {'interactive': True})
request.env.user.sudo()._deactivate_portal_user(**post)
request.session.logout()
return request.redirect('/web/login?message=%s' % urls.url_quote(_('Account deleted!')))
except AccessDenied:
values['errors'] = {'deactivate': 'password'}
except UserError as e:
values['errors'] = {'deactivate': {'other': str(e)}}
return request.render('portal.portal_my_security', values, headers={
'X-Frame-Options': 'SAMEORIGIN',
'Content-Security-Policy': "frame-ancestors 'self'",
})
@http.route('/portal/attachment/add', type='http', auth='public', methods=['POST'], website=True)
def attachment_add(self, name, file, res_model, res_id, access_token=None, **kwargs):
"""Process a file uploaded from the portal chatter and create the
corresponding `ir.attachment`.
The attachment will be created "pending" until the associated message
is actually created, and it will be garbage collected otherwise.
:param name: name of the file to save.
:type name: string
:param file: the file to save
:type file: werkzeug.FileStorage
:param res_model: name of the model of the original document.
To check access rights only, it will not be saved here.
:type res_model: string
:param res_id: id of the original document.
To check access rights only, it will not be saved here.
:type res_id: int
:param access_token: access_token of the original document.
To check access rights only, it will not be saved here.
:type access_token: string
:return: attachment data {id, name, mimetype, file_size, access_token}
:rtype: dict
"""
try:
self._document_check_access(res_model, int(res_id), access_token=access_token)
except (AccessError, MissingError) as e:
raise UserError(_("The document does not exist or you do not have the rights to access it."))
IrAttachment = request.env['ir.attachment']
# Avoid using sudo when not necessary: internal users can create attachments,
# as opposed to public and portal users.
if not request.env.user._is_internal():
IrAttachment = IrAttachment.sudo()
# At this point the related message does not exist yet, so we assign
# those specific res_model and res_is. They will be correctly set
# when the message is created: see `portal_chatter_post`,
# or garbage collected otherwise: see `_garbage_collect_attachments`.
attachment = IrAttachment.create({
'name': name,
'datas': base64.b64encode(file.read()),
'res_model': 'mail.compose.message',
'res_id': 0,
'access_token': IrAttachment._generate_access_token(),
})
return request.make_response(
data=json.dumps(attachment.read(['id', 'name', 'mimetype', 'file_size', 'access_token'])[0]),
headers=[('Content-Type', 'application/json')]
)
@http.route('/portal/attachment/remove', type='json', auth='public')
def attachment_remove(self, attachment_id, access_token=None):
"""Remove the given `attachment_id`, only if it is in a "pending" state.
The user must have access right on the attachment or provide a valid
`access_token`.
"""
try:
attachment_sudo = self._document_check_access('ir.attachment', int(attachment_id), access_token=access_token)
except (AccessError, MissingError) as e:
raise UserError(_("The attachment does not exist or you do not have the rights to access it."))
if attachment_sudo.res_model != 'mail.compose.message' or attachment_sudo.res_id != 0:
raise UserError(_("The attachment %s cannot be removed because it is not in a pending state.", attachment_sudo.name))
if attachment_sudo.env['mail.message'].search([('attachment_ids', 'in', attachment_sudo.ids)]):
raise UserError(_("The attachment %s cannot be removed because it is linked to a message.", attachment_sudo.name))
return attachment_sudo.unlink()
def details_form_validate(self, data, partner_creation=False):
error = dict()
error_message = []
# Validation
for field_name in self._get_mandatory_fields():
if not data.get(field_name):
error[field_name] = 'missing'
# email validation
if data.get('email') and not tools.single_email_re.match(data.get('email')):
error["email"] = 'error'
error_message.append(_('Invalid Email! Please enter a valid email address.'))
# vat validation
partner = request.env.user.partner_id
if data.get("vat") and partner and partner.vat != data.get("vat"):
# Check the VAT if it is the public user too.
if partner_creation or partner.can_edit_vat():
if hasattr(partner, "check_vat"):
if data.get("country_id"):
data["vat"] = request.env["res.partner"].fix_eu_vat_number(int(data.get("country_id")), data.get("vat"))
partner_dummy = partner.new({
'vat': data['vat'],
'country_id': (int(data['country_id'])
if data.get('country_id') else False),
})
try:
partner_dummy.check_vat()
except ValidationError as e:
error["vat"] = 'error'
error_message.append(e.args[0])
else:
error_message.append(_('Changing VAT number is not allowed once document(s) have been issued for your account. Please contact us directly for this operation.'))
# error message for empty required fields
if [err for err in error.values() if err == 'missing']:
error_message.append(_('Some required fields are empty.'))
unknown = [k for k in data if k not in self._get_mandatory_fields() + self._get_optional_fields()]
if unknown:
error['common'] = 'Unknown field'
error_message.append("Unknown field '%s'" % ','.join(unknown))
return error, error_message
def _get_mandatory_fields(self):
""" This method is there so that we can override the mandatory fields """
return self.MANDATORY_BILLING_FIELDS
def _get_optional_fields(self):
""" This method is there so that we can override the optional fields """
return self.OPTIONAL_BILLING_FIELDS
def _document_check_access(self, model_name, document_id, access_token=None):
"""Check if current user is allowed to access the specified record.
:param str model_name: model of the requested record
:param int document_id: id of the requested record
:param str access_token: record token to check if user isn't allowed to read requested record
:return: expected record, SUDOED, with SUPERUSER context
:raise MissingError: record not found in database, might have been deleted
:raise AccessError: current user isn't allowed to read requested document (and no valid token was given)
"""
document = request.env[model_name].browse([document_id])
document_sudo = document.with_user(SUPERUSER_ID).exists()
if not document_sudo:
raise MissingError(_("This document does not exist."))
try:
document.check_access_rights('read')
document.check_access_rule('read')
except AccessError:
if not access_token or not document_sudo.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):
"""Include necessary values for portal chatter & pager setup (see template portal.message_thread).
:param document: record to display on portal
:param str access_token: provided document access token
:param dict values: base dict of values where chatter rendering values should be added
:param str session_history: key used to store latest records browsed on the portal in the session
:param bool no_breadcrumbs:
:return: updated values
:rtype: dict
"""
values['object'] = document
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
values['token'] = access_token # for portal chatter
# 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
def _show_report(self, model, report_type, report_ref, download=False):
if report_type not in ('html', 'pdf', 'text'):
raise UserError(_("Invalid report type: %s", report_type))
ReportAction = request.env['ir.actions.report'].sudo()
if hasattr(model, 'company_id'):
if len(model.company_id) > 1:
raise UserError(_('Multi company reports are not supported.'))
ReportAction = ReportAction.with_company(model.company_id)
method_name = '_render_qweb_%s' % (report_type)
report = getattr(ReportAction, method_name)(report_ref, list(model.ids), data={'report_type': report_type})[0]
headers = self._get_http_headers(model, report_type, report, download)
return request.make_response(report, headers=list(headers.items()))
def _get_http_headers(self, model, report_type, report, download):
headers = {
'Content-Type': 'application/pdf' if report_type == 'pdf' else 'text/html',
'Content-Length': len(report),
}
if report_type == 'pdf' and download:
filename = "%s.pdf" % (re.sub('\W+', '-', model._get_report_base_filename()))
headers['Content-Disposition'] = content_disposition(filename)
return headers
def get_error(e, path=''):
""" Recursively dereferences `path` (a period-separated sequence of dict
keys) in `e` (an error dict or value), returns the final resolution IIF it's
an str, otherwise returns None
"""
for k in (path.split('.') if path else []):
if not isinstance(e, dict):
return None
e = e.get(k)
return e if isinstance(e, str) else None

27
controllers/web.py Normal file
View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.addons.web.controllers.home import Home as WebHome
from odoo.addons.web.controllers.utils import is_user_internal
from odoo.http import request
class Home(WebHome):
@http.route()
def index(self, *args, **kw):
if request.session.uid and not is_user_internal(request.session.uid):
return request.redirect_query('/my', query=request.params)
return super().index(*args, **kw)
def _login_redirect(self, uid, redirect=None):
if not redirect and not is_user_internal(uid):
redirect = '/my'
return super()._login_redirect(uid, redirect=redirect)
@http.route()
def web_client(self, s_action=None, **kw):
if request.session.uid and not is_user_internal(request.session.uid):
return request.redirect_query('/my', query=request.params)
return super().web_client(s_action, **kw)

View File

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_template_data_portal_welcome" model="mail.template">
<field name="name">Portal: User Invite</field>
<field name="model_id" ref="portal.model_portal_wizard_user"/>
<field name="subject">Your account at {{ object.user_id.company_id.name }}</field>
<field name="email_to">{{ object.user_id.email_formatted }}</field>
<field name="description">Invitation email to contacts to create a user account</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle">
<span style="font-size: 10px;">Your Account</span><br/>
<span style="font-size: 20px; font-weight: bold;" t-out="object.user_id.name or ''">Marc Demo</span>
</td><td valign="middle" align="right" t-if="not object.user_id.company_id.uses_default_logo">
<img t-attf-src="/logo.png?company={{ object.user_id.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.user_id.company_id.name"/>
</td></tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin:16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="top" style="font-size: 13px;">
<div>
Dear <t t-out="object.user_id.name or ''">Marc Demo</t>,<br/> <br/>
Welcome to <t t-out="object.user_id.company_id.name">YourCompany</t>'s Portal!<br/><br/>
An account has been created for you with the following login: <t t-out="object.user_id.login">demo</t><br/><br/>
Click on the button below to pick a password and activate your account.
<div style="margin: 16px 0px 16px 0px; text-align: center;">
<a t-att-href="object.user_id.signup_url" style="display: inline-block; padding: 10px; text-decoration: none; font-size: 12px; background-color: #875A7B; color: #fff; border-radius: 5px;">
<strong>Activate Account</strong>
</a>
</div>
<t t-out="object.wizard_id.welcome_message or ''">Welcome to our company's portal.</t>
</div>
</td></tr>
<tr><td style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
</td></tr>
</table>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
<tr><td valign="middle" align="left">
<t t-out="object.user_id.company_id.name or ''">YourCompany</t>
</td></tr>
<tr><td valign="middle" align="left" style="opacity: 0.7;">
<t t-out="object.user_id.company_id.phone or ''">+1 650-123-4567</t>
<t t-if="object.user_id.company_id.email">
| <a t-attf-href="'mailto:%s' % {{ object.user_id.company_id.email }}" style="text-decoration:none; color: #454748;" t-out="object.user_id.company_id.email or ''">info@yourcompany.com</a>
</t>
<t t-if="object.user_id.company_id.website">
| <a t-attf-href="'%s' % {{ object.user_id.company_id.website }}" style="text-decoration:none; color: #454748;" t-out="object.user_id.company_id.website or ''">http://www.example.com</a>
</t>
</td></tr>
</table>
</td>
</tr>
</tbody>
</table>
</td></tr>
<!-- POWERED BY -->
<tr><td align="center" style="min-width: 590px;">
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: #F1F1F1; color: #454748; padding: 8px; border-collapse:separate;">
<tr><td style="text-align: center; font-size: 13px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=portalinvite" style="color: #875A7B;">Odoo</a>
</td></tr>
</table>
</td></tr>
</table>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>

16
data/mail_templates.xml Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<template id="portal_share_template">
<div>
<br/>
<p>Dear <span t-esc="partner.name"/>,</p>
<p><span t-out="user.name"/> has invited you to access the following <span t-out="model_description"/>:</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" style="white-space: pre-wrap;" t-esc="note"/>
</div>
</template>
</data></odoo>

1606
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

1422
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

1503
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

1424
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

1545
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

1607
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

1585
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

1627
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

1427
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

1627
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

1626
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

1415
i18n/es_BO.po Normal file

File diff suppressed because it is too large Load Diff

1596
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

1497
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

1546
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

1630
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

1421
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

1506
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

1436
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

1532
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

1482
i18n/hy.po Normal file

File diff suppressed because it is too large Load Diff

1615
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

1486
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

1621
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

1587
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

1424
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

1586
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

1417
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

1518
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

1493
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

1434
i18n/mn.po Normal file

File diff suppressed because it is too large Load Diff

1432
i18n/nb.po Normal file

File diff suppressed because it is too large Load Diff

1636
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

1532
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

1482
i18n/portal.pot Normal file

File diff suppressed because it is too large Load Diff

1555
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

1617
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

1432
i18n/ro.po Normal file

File diff suppressed because it is too large Load Diff

1637
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

1506
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

1501
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

1532
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

1423
i18n/sr@latin.po Normal file

File diff suppressed because it is too large Load Diff

1546
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

1605
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

1631
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

1627
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

1627
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

1587
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

1582
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

12
models/__init__.py Normal file
View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_http
from . import ir_ui_view
from . import ir_qweb
from . import mail_thread
from . import mail_message
from . import portal_mixin
from . import res_config_settings
from . import res_partner
from . import res_users_apikeys_description

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
models/ir_http.py Normal file
View File

@ -0,0 +1,24 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.http import request
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _get_translation_frontend_modules_name(cls):
mods = super(IrHttp, cls)._get_translation_frontend_modules_name()
return mods + ['portal']
@classmethod
def _get_frontend_langs(cls):
# _get_frontend_langs() is used by @http_routing:IrHttp._match
# where is_frontend is not yet set and when no backend endpoint
# matched. We have to assume we are going to match a frontend
# route, hence the default True. Elsewhere, request.is_frontend
# is set.
if request and getattr(request, 'is_frontend', True):
return [lang[0] for lang in filter(lambda l: l[3], request.env['res.lang'].get_available())]
return super()._get_frontend_langs()

26
models/ir_qweb.py Normal file
View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.tools import is_html_empty, lazy
class IrQWeb(models.AbstractModel):
_inherit = "ir.qweb"
def _prepare_frontend_environment(self, values):
""" Returns ir.qweb with context and update values with portal specific
value (required to render portal layout template)
"""
irQweb = super()._prepare_frontend_environment(values)
values.update(
is_html_empty=is_html_empty,
languages=lazy(lambda: [lang for
lang in irQweb.env['res.lang'].get_available()
if lang[0] in irQweb.env['ir.http']._get_frontend_langs()])
)
for key in irQweb.env.context:
if key not in values:
values[key] = irQweb.env.context[key]
return irQweb

10
models/ir_ui_view.py Normal file
View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class View(models.Model):
_inherit = "ir.ui.view"
customize_show = fields.Boolean("Show As Optional Inherit", default=False)

124
models/mail_message.py Normal file
View File

@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.http import request
from odoo.tools import format_datetime
class MailMessage(models.Model):
_inherit = 'mail.message'
def portal_message_format(self, options=None):
""" Simpler and portal-oriented version of 'message_format'. Purpose
is to prepare, organize and format values required by frontend widget
(frontend Chatter).
This public API asks for read access on messages before doing the
actual computation in the private implementation.
:param dict options: options, used notably for inheritance and adding
specific fields or properties to compute;
:return list: list of dict, one per message in self. Each dict contains
values for either fields, either properties derived from fields.
"""
self.check_access_rule('read')
return self._portal_message_format(
self._portal_get_default_format_properties_names(options=options),
options=options,
)
def _portal_get_default_format_properties_names(self, options=None):
""" Fields and values to compute for portal format.
:param dict options: options, used notably for inheritance and adding
specific fields or properties to compute;
:return set: fields or properties derived from fields
"""
return {
'attachment_ids',
'author_avatar_url',
'author_id',
'body',
'date',
'id',
'is_internal',
'is_message_subtype_note',
'published_date_str',
'subtype_id',
}
def _portal_message_format(self, properties_names, options=None):
""" Format messages for portal frontend. This private implementation
does not check for access that should be checked beforehand.
Notes:
* when asking for attachments: ensure an access token is present then
access them (using sudo);
:param set properties_names: fields or properties derived from fields
for which we are going to compute values;
:return list: list of dict, one per message in self. Each dict contains
values for either fields, either properties derived from fields.
"""
message_to_attachments = {}
if 'attachment_ids' in properties_names:
properties_names.remove('attachment_ids')
attachments_sudo = self.sudo().attachment_ids
attachments_sudo.generate_access_token()
related_attachments = {
att_read_values['id']: att_read_values
for att_read_values in attachments_sudo.read(
["access_token", "checksum", "id", "mimetype", "name", "res_id", "res_model"]
)
}
message_to_attachments = {
message.id: [
self._portal_message_format_attachments(related_attachments[att_id])
for att_id in message.attachment_ids.ids
]
for message in self.sudo()
}
fnames = {
property_name for property_name in properties_names
if property_name in self._fields
}
vals_list = self._read_format(fnames)
note_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')
for message, values in zip(self, vals_list):
if message_to_attachments:
values['attachment_ids'] = message_to_attachments.get(message.id, {})
if 'author_avatar_url' in properties_names:
if options and 'token' in options:
values['author_avatar_url'] = f'/mail/avatar/mail.message/{message.id}/author_avatar/50x50?access_token={options["token"]}'
elif options and options.keys() >= {"hash", "pid"}:
values['author_avatar_url'] = f'/mail/avatar/mail.message/{message.id}/author_avatar/50x50?_hash={options["hash"]}&pid={options["pid"]}'
else:
values['author_avatar_url'] = f'/web/image/mail.message/{message.id}/author_avatar/50x50'
if 'is_message_subtype_note' in properties_names:
values['is_message_subtype_note'] = (values.get('subtype_id') or [False, ''])[0] == note_id
if 'published_date_str' in properties_names:
values['published_date_str'] = format_datetime(self.env, values['date']) if values.get('date') else ''
return vals_list
def _portal_message_format_attachments(self, attachment_values):
""" From 'attachment_values' get an updated version formatted for
frontend display.
:param dict attachment_values: values coming from reading attachments
in database;
:return dict: updated attachment_values
"""
safari = request and request.httprequest.user_agent and request.httprequest.user_agent.browser == 'safari'
attachment_values['filename'] = attachment_values['name']
attachment_values['mimetype'] = (
'application/octet-stream' if safari and
'video' in (attachment_values["mimetype"] or "")
else attachment_values["mimetype"])
return attachment_values

81
models/mail_thread.py Normal file
View File

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import hmac
from odoo import fields, models, _
class MailThread(models.AbstractModel):
_inherit = 'mail.thread'
_mail_post_token_field = 'access_token' # token field for external posts, to be overridden
website_message_ids = fields.One2many('mail.message', 'res_id', string='Website Messages',
domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ('comment', 'email', 'email_outgoing'))],
auto_join=True,
help="Website communication history")
def _notify_get_recipients_groups(self, message, model_description, msg_vals=None):
groups = super()._notify_get_recipients_groups(
message, model_description, msg_vals=msg_vals
)
if not self:
return groups
portal_enabled = isinstance(self, self.env.registry['portal.mixin'])
if not portal_enabled:
return groups
customer = self._mail_get_partners(introspect_fields=False)[self.id]
if customer:
access_token = self._portal_ensure_token()
local_msg_vals = dict(msg_vals or {})
local_msg_vals['access_token'] = access_token
local_msg_vals['pid'] = customer.id
local_msg_vals['hash'] = self._sign_token(customer.id)
local_msg_vals.update(customer.signup_get_auth_param()[customer.id])
access_link = self._notify_get_action_link('view', **local_msg_vals)
new_group = [
('portal_customer', lambda pdata: pdata['id'] == customer.id, {
'active': True,
'button_access': {
'url': access_link,
},
'has_button_access': True,
})
]
else:
new_group = []
# enable portal users that should have access through portal (if not access rights
# will do their duty)
portal_group = next(group for group in groups if group[0] == 'portal')
portal_group[2]['active'] = True
portal_group[2]['has_button_access'] = True
return new_group + groups
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()
# check token field exists
if self._mail_post_token_field not in self._fields:
raise NotImplementedError(_(
"Model %(model_name)s does not support token signature, as it does not have %(field_name)s field.",
model_name=self._name,
field_name=self._mail_post_token_field
))
# sign token
secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
token = (self.env.cr.dbname, self[self._mail_post_token_field], pid)
return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha256).hexdigest()

136
models/portal_mixin.py Normal file
View File

@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
from ast import literal_eval
from werkzeug.urls import url_encode
from odoo import api, exceptions, fields, models, _
class PortalMixin(models.AbstractModel):
_name = "portal.mixin"
_description = 'Portal Mixin'
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 = ''
def _compute_access_url(self):
for record in self:
record.access_url = '#'
def _portal_ensure_token(self):
""" Get the current record access token """
if not self.access_token:
# we use a `write` to force the cache clearing otherwise `return self.access_token` will return False
self.sudo().write({'access_token': str(uuid.uuid4())})
return self.access_token
def _get_share_url(self, redirect=False, signup_partner=False, pid=None, share_token=True):
"""
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()
if redirect:
# model / res_id used by mail/view to check access on record
params = {
'model': self._name,
'res_id': self.id,
}
else:
params = {}
if share_token and 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 '%s?%s' % ('/mail/view' if redirect else self.access_url, url_encode(params))
def _get_access_action(self, access_uid=None, force_website=False):
""" Instead of the classic form view, redirect to the online document for
portal users or if force_website=True. """
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=access_uid, force_website=force_website
)
user = self.env['res.users'].sudo().browse(access_uid)
record = self.with_user(user)
if user.share or force_website:
try:
record.check_access_rights('read')
record.check_access_rule('read')
except exceptions.AccessError:
if 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=access_uid, force_website=force_website
)
@api.model
def action_share(self):
action = self.env["ir.actions.actions"]._for_xml_id("portal.portal_share_action")
action['context'] = {'active_id': self.env.context['active_id'],
'active_model': self.env.context['active_model'],
**literal_eval(action['context'])}
return action
def get_portal_url(self, suffix=None, report_type=None, download=None, query_string=None, anchor=None):
"""
Get a portal url for this model, including access_token.
The associated route must handle the flags for them to have any effect.
- suffix: string to append to the url, before the query string
- report_type: report_type query string, often one of: html, pdf, text
- download: set the download query string to true
- query_string: additional query string
- anchor: string to append after the anchor #
"""
self.ensure_one()
url = self.access_url + '%s?access_token=%s%s%s%s%s' % (
suffix if suffix else '',
self._portal_ensure_token(),
'&report_type=%s' % report_type if report_type else '',
'&download=true' if download else '',
query_string if query_string else '',
'#%s' % anchor if anchor else ''
)
return url

View File

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
portal_allow_api_keys = fields.Boolean(
string='Customer API Keys',
compute='_compute_portal_allow_api_keys',
inverse='_inverse_portal_allow_api_keys',
)
def _compute_portal_allow_api_keys(self):
for setting in self:
setting.portal_allow_api_keys = self.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys')
def _inverse_portal_allow_api_keys(self):
self.env['ir.config_parameter'].sudo().set_param('portal.allow_api_keys', self.portal_allow_api_keys)
@api.model
def get_values(self):
res = super(ResConfigSettings, self).get_values()
res['portal_allow_api_keys'] = bool(self.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys'))
return res

20
models/res_partner.py Normal file
View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResPartner(models.Model):
_inherit = 'res.partner'
def _can_edit_name(self):
""" Name can be changed more often than the VAT """
self.ensure_one()
return True
def can_edit_vat(self):
""" `vat` is a commercial field, synced between the parent (commercial
entity) and the children. Only the commercial entity should be able to
edit it (as in backend)."""
self.ensure_one()
return not self.parent_id

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo.exceptions import AccessError
class APIKeyDescription(models.TransientModel):
_inherit = 'res.users.apikeys.description'
def check_access_make_key(self):
try:
return super().check_access_make_key()
except AccessError:
if self.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys'):
if self.user_has_groups('base.group_portal'):
return
else:
raise AccessError(_("Only internal and portal users can create API keys"))
raise

View File

@ -0,0 +1,4 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
"access_portal_share","access.portal.share","model_portal_share","base.group_partner_manager",1,1,1,0
"access_portal_wizard","access.portal.wizard","model_portal_wizard","base.group_partner_manager",1,1,1,0
"access_portal_wizard_user","access.portal.wizard.user","model_portal_wizard_user","base.group_partner_manager",1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_portal_share access.portal.share model_portal_share base.group_partner_manager 1 1 1 0
3 access_portal_wizard access.portal.wizard model_portal_wizard base.group_partner_manager 1 1 1 0
4 access_portal_wizard_user access.portal.wizard.user model_portal_wizard_user base.group_partner_manager 1 1 1 0

View File

@ -0,0 +1,6 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.7366 3.2766C39.6798 1.50012 35.4465 1.75104 30.7766 4.44716C21.4986 9.80391 14.0014 22.7894 14.0317 33.4603C14.0619 44.1214 30.0964 58.0101 30.9403 58.9143L35.984 61.8578L47.7803 6.22019L42.7366 3.2766Z" fill="#FBDBD0"/>
<path d="M35.8202 7.3908C26.5422 12.7475 19.045 25.733 19.0753 36.4039C19.1054 47.065 35.14 60.9538 35.984 61.8579C36.8204 59.9835 52.7133 27.6616 52.6831 17.0005C52.6655 10.8105 50.116 6.76586 46.1671 5.50602C43.3085 4.594 39.7163 5.14141 35.8202 7.3908Z" fill="white"/>
<path d="M47.1275 25.7108C44.1739 34.1529 36.8024 41.3561 30.6627 41.7993C24.5231 42.2427 21.9404 35.7584 24.894 27.3162C27.8476 18.8742 35.2191 11.671 41.3587 11.2277C47.4984 10.7844 50.0811 17.2687 47.1275 25.7108Z" fill="#C1DBF6" stroke="#374874" stroke-linecap="square" stroke-linejoin="round"/>
<path d="M38.3178 2.14219C39.9616 2.14219 41.4529 2.53069 42.7366 3.27668L47.7803 6.22032L47.7798 6.22256C50.796 7.97579 52.668 11.7008 52.6831 17.0005C52.7132 27.6615 36.8204 59.9836 35.984 61.8579L30.9403 58.9143C30.0964 58.0101 14.0619 44.1214 14.0317 33.4603C14.0014 22.7895 21.4986 9.80404 30.7765 4.44724C33.4855 2.88329 36.0471 2.14219 38.3178 2.14219ZM38.3179 1.22793C35.8006 1.22793 33.1095 2.04466 30.3194 3.65538C25.6949 6.32546 21.363 10.8885 18.1217 16.5039C14.8796 22.1209 13.1023 28.1437 13.1174 33.4629C13.1296 37.7625 15.5482 42.9875 20.3062 48.993C24.0297 53.6928 28.1483 57.5443 29.6988 58.9942C29.9636 59.2418 30.2136 59.4757 30.2719 59.5381C30.3326 59.6032 30.4026 59.6591 30.4795 59.7039L35.5231 62.6476C35.6644 62.73 35.8237 62.7722 35.984 62.7722C36.0782 62.7722 36.1726 62.7577 36.2641 62.7282C36.5113 62.6487 36.7131 62.4678 36.8189 62.2305C36.8929 62.0648 37.1126 61.6026 37.4167 60.9629C48.0204 38.6569 53.6156 23.4541 53.5974 16.9979C53.5819 11.569 51.6999 7.4768 48.2959 5.4653C48.2782 5.45325 48.2599 5.44164 48.2412 5.43069L43.1975 2.48706C41.7593 1.65124 40.118 1.22793 38.3179 1.22793Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,13 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.6091 58.1611C38.9578 58.1611 43.2937 56.3324 43.2937 54.0766C43.2937 51.8207 38.9578 49.992 33.6091 49.992C28.2605 49.992 23.9246 51.8207 23.9246 54.0766C23.9246 56.3324 28.2605 58.1611 33.6091 58.1611Z" fill="#C1DBF6"/>
<path d="M45.7715 21.9257C44.6789 18.3486 42.3429 15.4983 39.1612 13.9006C39.0151 13.8271 38.8539 13.7887 38.6903 13.7886C38.3191 13.7866 37.9732 13.9768 37.7761 14.2914C37.5063 14.7272 37.6408 15.2992 38.0766 15.569C38.1036 15.5857 38.1315 15.601 38.1601 15.6149C40.9029 16.9589 42.9235 19.4046 43.8743 22.504C44.6369 25.0794 44.644 27.8196 43.8949 30.3989C43.1675 33.0055 41.7722 35.377 39.8469 37.2789C39.4809 37.6267 39.4662 38.2054 39.814 38.5714C39.8158 38.5733 39.8177 38.5753 39.8195 38.5772L39.8995 38.6617C40.0772 38.851 40.3256 38.9578 40.5852 38.9566C40.8282 38.958 41.0618 38.8626 41.2343 38.6914C45.7441 34.248 47.5246 27.6674 45.7715 21.9257Z" fill="#C1DBF6"/>
<path d="M27.2754 40.7326C26.8674 40.6043 26.4674 40.4517 26.0777 40.2754C23.2046 38.9611 21.0811 36.4697 20.1028 33.2606C18.7314 28.776 19.8537 23.5783 23.0286 19.6949C23.3567 19.3111 23.3116 18.7339 22.9278 18.4058C22.8655 18.3526 22.7964 18.308 22.7223 18.2731C22.5729 18.1996 22.4087 18.1613 22.2423 18.1611C21.9152 18.1599 21.6054 18.308 21.4011 18.5634C17.8903 22.9406 16.6674 28.7943 18.2011 33.8389C19.344 37.5966 21.8583 40.5223 25.2457 42.0674C25.747 42.2958 26.2623 42.492 26.7886 42.6549C26.8774 42.6822 26.9699 42.6961 27.0628 42.696C27.4779 42.6944 27.8424 42.4201 27.9588 42.0217C28.1199 41.4776 27.8162 40.9046 27.2754 40.7326Z" fill="#C1DBF6"/>
<path d="M53.3463 18.392C51.6549 12.856 48.0252 8.44458 43.1223 5.97372C42.9463 5.8857 42.7523 5.83955 42.5555 5.83887C42.113 5.84104 41.7024 6.06961 41.4675 6.44458C41.1546 6.94275 41.3048 7.60022 41.803 7.91308C41.8356 7.93359 41.8694 7.95231 41.904 7.96915C46.3178 10.136 49.5886 14.0812 51.1132 19.0732C52.3395 23.2079 52.3522 27.6079 51.1498 31.7497C49.9818 35.9289 47.7426 39.7305 44.6538 42.7783C44.2364 43.1841 44.2221 43.8498 44.6218 44.2732L44.7475 44.408C44.9516 44.6231 45.2349 44.7453 45.5315 44.7463C45.8122 44.7479 46.0819 44.6377 46.2812 44.44C49.6643 41.1012 52.1177 36.9374 53.3989 32.36C54.7178 27.6469 54.7041 22.8194 53.3463 18.392Z" fill="#C1DBF6"/>
<path d="M24.4411 48.3188C23.7823 48.1091 23.1366 47.8603 22.5074 47.5737C17.872 45.4525 14.448 41.4365 12.8754 36.2663C10.6766 29.0685 12.4731 20.7234 17.5657 14.5017C17.9416 14.0475 17.8782 13.3747 17.424 12.9988C17.3608 12.9465 17.2918 12.9016 17.2183 12.8651C17.037 12.7755 16.8377 12.7286 16.6354 12.728C16.2393 12.7268 15.8641 12.906 15.616 13.2148C10.1486 20.0171 8.24686 29.112 10.64 36.9474C12.4183 42.7645 16.288 47.2903 21.5383 49.6925C22.3124 50.0484 23.1088 50.3538 23.9223 50.6068C24.0244 50.6387 24.1307 50.6549 24.2377 50.6548C24.7161 50.6529 25.1363 50.3368 25.2709 49.8777C25.4699 49.2183 25.0993 48.522 24.4411 48.3188Z" fill="#C1DBF6"/>
<path d="M32.6058 52.7509C32.6058 52.5589 33.0972 32.1269 33.1498 30.0103H32.5326C32.5326 30.0103 31.9863 52.728 31.9863 52.9315C31.9863 53.3086 32.7109 53.6172 33.6023 53.6172C34.0131 53.6255 34.4212 53.5476 34.8 53.3886C34.6066 53.4205 34.4109 53.4365 34.2149 53.4366C33.3303 53.4343 32.6058 53.128 32.6058 52.7509Z" fill="#C1DBF6"/>
<path d="M35.2251 52.9315C35.2251 52.728 34.6789 30.0103 34.6789 30.0103H33.1497C33.0971 32.1269 32.6057 52.5589 32.6057 52.7509C32.6057 53.128 33.3303 53.4366 34.2217 53.4366C34.4216 53.4371 34.6211 53.4211 34.8183 53.3886C35.0651 53.2652 35.2251 53.1075 35.2251 52.9315Z" fill="white"/>
<path d="M34.6789 30.0103C34.6789 30.0103 35.2252 52.728 35.2252 52.9314C35.2252 53.1074 35.0652 53.2652 34.8183 53.3886C34.8091 53.3901 34.7998 53.3905 34.7906 53.392C34.4325 53.5408 34.0488 53.6177 33.6613 53.6177C33.6417 53.6177 33.622 53.6175 33.6023 53.6171C32.7109 53.6171 31.9863 53.3086 31.9863 52.9314C31.9863 52.728 32.5326 30.0103 32.5326 30.0103H34.6789ZM35.5715 29.096H31.64L31.6186 29.9883C31.5273 33.7837 31.072 52.7273 31.072 52.9314C31.072 53.8716 32.1082 54.5288 33.5932 54.5314C33.6159 54.5318 33.6386 54.5321 33.6613 54.5321C34.1317 54.5321 34.5921 54.4473 35.0313 54.2801L35.1035 54.2682L35.2272 54.2064C36.0209 53.8095 36.1394 53.2372 36.1394 52.9315C36.1394 52.7274 35.6842 33.7838 35.5929 29.9883L35.5715 29.096Z" fill="#374874"/>
<path d="M33.6023 21.4343C36.4853 21.4353 38.8222 23.7722 38.8232 26.6553C38.8243 29.5398 36.4868 31.879 33.6023 31.88C33.2678 31.8795 32.9341 31.8474 32.6057 31.784C30.5122 31.3794 28.8752 29.7424 28.4706 27.649C27.9235 24.8181 29.7749 22.0797 32.6057 21.5326C32.9339 21.4673 33.2677 21.4344 33.6023 21.4343ZM33.6026 20.52H33.602C33.2085 20.5201 32.8133 20.5591 32.4273 20.6359C29.1109 21.2768 26.931 24.5012 27.573 27.8225C28.0511 30.2962 29.9584 32.2036 32.4322 32.6817C32.8166 32.7559 33.2097 32.7938 33.6009 32.7943C36.9866 32.7931 39.7387 30.039 39.7375 26.655C39.7363 23.2734 36.9842 20.5213 33.6026 20.52Z" fill="#374874"/>
<path d="M33.6137 31.9221C36.5214 31.9221 38.8786 29.565 38.8786 26.6572C38.8786 23.7495 36.5214 21.3923 33.6137 21.3923C30.7059 21.3923 28.3488 23.7495 28.3488 26.6572C28.3488 29.565 30.7059 31.9221 33.6137 31.9221Z" fill="white"/>
<path d="M33.6023 21.4343C33.2677 21.4344 32.9339 21.4673 32.6057 21.5326C35.4366 22.0797 37.2879 24.8181 36.7408 27.6489C36.3361 29.7424 34.6991 31.3794 32.6057 31.784C32.9341 31.8474 33.2678 31.8795 33.6023 31.88C36.4868 31.879 38.8243 29.5398 38.8233 26.6552C38.8222 23.7722 36.4853 21.4353 33.6023 21.4343Z" fill="#FBDBD0"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -0,0 +1,47 @@
/** @odoo-module */
import { useEffect } from "@odoo/owl";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
export class InputConfirmationDialog extends ConfirmationDialog {
static props = {
...ConfirmationDialog.props,
onInput: Function,
};
static template = "portal.InputConfirmationDialog";
setup() {
super.setup();
const onInput = () => {
if (this.props.onInput) {
this.props.onInput({ inputEl: this.inputEl });
}
};
const onKeydown = (ev) => {
if (ev.key && ev.key.toLowerCase() === "enter") {
ev.preventDefault();
this._confirm();
}
};
useEffect(
(inputEl) => {
this.inputEl = inputEl;
if (this.inputEl) {
this.inputEl.focus();
this.inputEl.addEventListener("keydown", onKeydown);
this.inputEl.addEventListener("input", onInput);
return () => {
this.inputEl.removeEventListener("keydown", onKeydown);
this.inputEl.removeEventListener("input", onInput);
};
}
},
() => [this.modalRef.el?.querySelector("input")]
);
}
_confirm() {
this.execButton(() => this.props.confirm({ inputEl: this.inputEl }));
}
}

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="portal.InputConfirmationDialog" t-inherit="web.ConfirmationDialog">
<xpath expr="//p[hasclass('text-prewrap')]" position="attributes">
<attribute name="class"></attribute>
</xpath>
</t>
</templates>

175
static/src/js/portal.js Normal file
View File

@ -0,0 +1,175 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.portalDetails = publicWidget.Widget.extend({
selector: '.o_portal_details',
events: {
'change select[name="country_id"]': '_onCountryChange',
},
/**
* @override
*/
start: function () {
var def = this._super.apply(this, arguments);
this.$state = this.$('select[name="state_id"]');
this.$stateOptions = this.$state.filter(':enabled').find('option:not(:first)');
this._adaptAddressForm();
return def;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_adaptAddressForm: function () {
var $country = this.$('select[name="country_id"]');
var countryID = ($country.val() || 0);
this.$stateOptions.detach();
var $displayedState = this.$stateOptions.filter('[data-country_id=' + countryID + ']');
var nb = $displayedState.appendTo(this.$state).show().length;
this.$state.parent().toggle(nb >= 1);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onCountryChange: function () {
this._adaptAddressForm();
},
});
export const PortalHomeCounters = publicWidget.Widget.extend({
selector: '.o_portal_my_home',
init() {
this._super(...arguments);
this.rpc = this.bindService("rpc");
},
/**
* @override
*/
start: function () {
var def = this._super.apply(this, arguments);
this._updateCounters();
return def;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Return a list of counters name linked to a line that we want to keep
* regardless of the number of documents present
* @private
* @returns {Array}
*/
_getCountersAlwaysDisplayed() {
return [];
},
/**
* @private
*/
async _updateCounters(elem) {
const numberRpc = 3;
const needed = Object.values(this.el.querySelectorAll('[data-placeholder_count]'))
.map(documentsCounterEl => documentsCounterEl.dataset['placeholder_count']);
const counterByRpc = Math.ceil(needed.length / numberRpc); // max counter, last can be less
const countersAlwaysDisplayed = this._getCountersAlwaysDisplayed();
const proms = [...Array(Math.min(numberRpc, needed.length)).keys()].map(async i => {
const documentsCountersData = await this.rpc("/my/counters", {
counters: needed.slice(i * counterByRpc, (i + 1) * counterByRpc)
});
Object.keys(documentsCountersData).forEach(counterName => {
const documentsCounterEl = this.el.querySelector(`[data-placeholder_count='${counterName}']`);
documentsCounterEl.textContent = documentsCountersData[counterName];
// The element is hidden by default, only show it if its counter is > 0 or if it's in the list of counters always shown
if (documentsCountersData[counterName] !== 0 || countersAlwaysDisplayed.includes(counterName)) {
documentsCounterEl.closest('.o_portal_index_card').classList.remove('d-none');
}
});
return documentsCountersData;
});
return Promise.all(proms).then((results) => {
this.el.querySelector('.o_portal_doc_spinner').remove();
});
},
});
publicWidget.registry.PortalHomeCounters = PortalHomeCounters;
publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({
selector: '.o_portal_search_panel',
events: {
'click .dropdown-item': '_onDropdownItemClick',
'submit': '_onSubmit',
},
/**
* @override
*/
start: function () {
var def = this._super.apply(this, arguments);
this._adaptSearchLabel(this.$('.dropdown-item.active'));
return def;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_adaptSearchLabel: function (elem) {
var $label = $(elem).clone();
$label.find('span.nolabel').remove();
this.$('input[name="search"]').attr('placeholder', $label.text().trim());
},
/**
* @private
*/
_search: function () {
var search = new URL(window.location).searchParams;
search.set("search_in", this.$('.dropdown-item.active').attr('href')?.replace('#', '') || "");
search.set("search", this.$('input[name="search"]').val());
window.location.search = search.toString();
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onDropdownItemClick: function (ev) {
ev.preventDefault();
var $item = $(ev.currentTarget);
$item.closest('.dropdown-menu').find('.dropdown-item').removeClass('active');
$item.addClass('active');
this._adaptSearchLabel(ev.currentTarget);
},
/**
* @private
*/
_onSubmit: function (ev) {
ev.preventDefault();
this._search();
},
});

View File

@ -0,0 +1,338 @@
/** @odoo-module **/
import { renderToElement } from "@web/core/utils/render";
import dom from "@web/legacy/js/core/dom";
import publicWidget from "@web/legacy/js/public/public_widget";
import portalComposer from "@portal/js/portal_composer";
import { range } from "@web/core/utils/numbers";
import { Component, markup } from "@odoo/owl";
/**
* Widget PortalChatter
*
* - Fetch message fron controller
* - Display chatter: pager, total message, composer (according to access right)
* - Provider API to filter displayed messages
*/
var PortalChatter = publicWidget.Widget.extend({
template: 'portal.Chatter',
events: {
'click .o_portal_chatter_pager_btn': '_onClickPager',
'click .o_portal_chatter_js_is_internal': 'async _onClickUpdateIsInternal',
},
/**
* @constructor
*/
init: function (parent, options) {
this.options = {};
this._super.apply(this, arguments);
this._setOptions(options);
this.set('messages', []);
this.set('message_count', this.options['message_count']);
this.set('pager', {});
this.set('domain', this.options['domain']);
this._currentPage = this.options['pager_start'];
this.rpc = this.bindService("rpc");
},
/**
* @override
*/
willStart: function () {
return Promise.all([
this._super.apply(this, arguments),
this._chatterInit()
]);
},
/**
* @override
*/
start: function () {
// bind events
this.on("change:messages", this, this._renderMessages);
this.on("change:message_count", this, function () {
this._renderMessageCount();
this.set('pager', this._pager(this._currentPage));
});
this.on("change:pager", this, this._renderPager);
this.on("change:domain", this, this._onChangeDomain);
// set options and parameters
this.set('message_count', this.options['message_count']);
this.set('messages', this.preprocessMessages(this.result['messages']));
// bind bus event: this (portal.chatter) and 'portal.rating.composer' in portal_rating
// are separate and sibling widgets, this event is to be triggered from portal.rating.composer,
// hence bus event is bound to achieve usage of the event in another widget.
Component.env.bus.addEventListener('reload_chatter_content', (ev) => this._reloadChatterContent(ev.detail));
return Promise.all([this._super.apply(this, arguments), this._reloadComposer()]);
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Fetch the messages and the message count from the server for the
* current page and current domain.
*
* @param {Array} domain
* @returns {Promise}
*/
messageFetch: function (domain) {
var self = this;
return this.rpc('/mail/chatter_fetch', self._messageFetchPrepareParams()).then(function (result) {
self.set('messages', self.preprocessMessages(result['messages']));
self.set('message_count', result['message_count']);
return result;
});
},
/**
* Update the messages format
*
* @param {Array<Object>} messages
* @returns {Array}
*/
preprocessMessages(messages) {
messages.forEach((m) => {
m['body'] = markup(m.body);
});
return messages;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Set options
*
* @param {Array<string>} options: new options to set
*/
_setOptions: function (options) {
// underscorize the camelcased option keys
const defaultOptions = Object.assign({
'allow_composer': true,
'display_composer': false,
'csrf_token': odoo.csrf_token,
'message_count': 0,
'pager_step': 10,
'pager_scope': 5,
'pager_start': 1,
'is_user_public': true,
'is_user_employee': false,
'is_user_publisher': false,
'hash': false,
'pid': false,
'domain': [],
'two_columns': false,
}, this.options || {});
this.options = Object.entries(options).reduce((acc, [key, value]) => {
acc[
//Camelized to Underscored key
key
.split(/\.?(?=[A-Z])/)
.join("_")
.toLowerCase()
] = value;
return acc;
}, defaultOptions);
},
/**
* Reloads chatter and message count after posting message
*
* @private
*/
_reloadChatterContent: function (data) {
this.messageFetch();
this._reloadComposer();
},
_createComposerWidget: function () {
return new portalComposer.PortalComposer(this, this.options);
},
/**
* Destroy current composer widget and initialize and insert new widget
*
* @private
*/
_reloadComposer: async function () {
if (this._composer) {
this._composer.destroy();
}
if (this.options.display_composer) {
this._composer = this._createComposerWidget();
await this._composer.appendTo(this.$('.o_portal_chatter_composer'));
}
},
/**
* @private
* @returns {Deferred}
*/
_chatterInit: function () {
var self = this;
return this.rpc('/mail/chatter_init', this._messageFetchPrepareParams()).then(function (result) {
self.result = result;
self.options = Object.assign(self.options, self.result['options'] || {});
return result;
});
},
/**
* Change the current page by refreshing current domain
*
* @private
* @param {Number} page
* @param {Array} domain
*/
_changeCurrentPage: function (page, domain) {
this._currentPage = page;
var d = domain ? domain : Object.assign({}, this.get("domain"));
this.set('domain', d); // trigger fetch message
},
_messageFetchPrepareParams: function () {
var self = this;
var data = {
'res_model': this.options['res_model'],
'res_id': this.options['res_id'],
'limit': this.options['pager_step'],
'offset': (this._currentPage - 1) * this.options['pager_step'],
'allow_composer': this.options['allow_composer'],
};
// add token field to allow to post comment without being logged
if (self.options['token']) {
data['token'] = self.options['token'];
}
if (self.options['hash'] && self.options['pid']) {
Object.assign(data, {
'hash': self.options['hash'],
'pid': self.options['pid'],
});
}
// add domain
if (this.get('domain')) {
data['domain'] = this.get('domain');
}
return data;
},
/**
* Generate the pager data for the given page number
*
* @private
* @param {Number} page
* @returns {Object}
*/
_pager: function (page) {
page = page || 1;
var total = this.get('message_count');
var scope = this.options['pager_scope'];
var step = this.options['pager_step'];
// Compute Pager
var pageCount = Math.ceil(parseFloat(total) / step);
page = Math.max(1, Math.min(parseInt(page), pageCount));
scope -= 1;
var pmin = Math.max(page - parseInt(Math.floor(scope / 2)), 1);
var pmax = Math.min(pmin + scope, pageCount);
if (pmax - scope > 0) {
pmin = pmax - scope;
} else {
pmin = 1;
}
var pages = [];
range(pmin, pmax + 1).forEach(index => pages.push(index));
return {
"page_count": pageCount,
"offset": (page - 1) * step,
"page": page,
"page_start": pmin,
"page_previous": Math.max(pmin, page - 1),
"page_next": Math.min(pmax, page + 1),
"page_end": pmax,
"pages": pages
};
},
_renderMessages: function () {
this.$('.o_portal_chatter_messages').empty().append(renderToElement("portal.chatter_messages", {widget: this}));
},
_renderMessageCount: function () {
this.$('.o_message_counter').replaceWith(renderToElement("portal.chatter_message_count", {widget: this}));
},
_renderPager: function () {
this.$('.o_portal_chatter_pager').replaceWith(renderToElement("portal.pager", {widget: this}));
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
_onChangeDomain: function () {
var self = this;
return this.messageFetch().then(function () {
var p = self._currentPage;
self.set('pager', self._pager(p));
});
},
/**
* @private
* @param {MouseEvent} event
*/
_onClickPager: function (ev) {
ev.preventDefault();
var page = $(ev.currentTarget).data('page');
this._changeCurrentPage(page);
},
/**
* Toggle is_internal state of message. Update both node data and
* classes to ensure DOM is updated accordingly to RPC call result.
* @private
* @returns {Promise}
*/
_onClickUpdateIsInternal: function (ev) {
ev.preventDefault();
var $elem = $(ev.currentTarget);
return this.rpc('/mail/update_is_internal', {
message_id: $elem.data('message-id'),
is_internal: ! $elem.data('is-internal'),
}).then(function (result) {
$elem.data('is-internal', result);
if (result === true) {
$elem.addClass('o_portal_message_internal_on');
$elem.removeClass('o_portal_message_internal_off');
} else {
$elem.addClass('o_portal_message_internal_off');
$elem.removeClass('o_portal_message_internal_on');
}
});
},
});
publicWidget.registry.portalChatter = publicWidget.Widget.extend({
selector: '.o_portal_chatter',
/**
* @override
*/
async start() {
const proms = [this._super.apply(this, arguments)];
const chatter = new PortalChatter(this, this.$el.data());
proms.push(chatter.appendTo(this.$el));
await Promise.all(proms);
// scroll to the right place after chatter loaded
if (window.location.hash === `#${this.el.id}`) {
dom.scrollTo(this.el, {duration: 0});
}
},
});
export default PortalChatter

View File

@ -0,0 +1,210 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { escape } from "@web/core/utils/strings";
import { renderToElement } from "@web/core/utils/render";
import publicWidget from "@web/legacy/js/public/public_widget";
import { post } from "@web/core/network/http_service";
import { Component } from "@odoo/owl";
import { RPCError } from "@web/core/network/rpc_service";
/**
* Widget PortalComposer
*
* Display the composer (according to access right)
*
*/
var PortalComposer = publicWidget.Widget.extend({
template: 'portal.Composer',
events: {
'change .o_portal_chatter_file_input': '_onFileInputChange',
'click .o_portal_chatter_attachment_btn': '_onAttachmentButtonClick',
'click .o_portal_chatter_attachment_delete': 'async _onAttachmentDeleteClick',
'click .o_portal_chatter_composer_btn': 'async _onSubmitButtonClick',
},
/**
* @constructor
*/
init: function (parent, options) {
this._super.apply(this, arguments);
this.options = Object.assign({
'allow_composer': true,
'display_composer': false,
'csrf_token': odoo.csrf_token,
'token': false,
'res_model': false,
'res_id': false,
}, options || {});
this.attachments = [];
this.rpc = this.bindService("rpc");
this.notification = this.bindService("notification");
},
/**
* @override
*/
start: function () {
var self = this;
this.$attachmentButton = this.$('.o_portal_chatter_attachment_btn');
this.$fileInput = this.$('.o_portal_chatter_file_input');
this.$sendButton = this.$('.o_portal_chatter_composer_btn');
this.$attachments = this.$('.o_portal_chatter_composer_input .o_portal_chatter_attachments');
this.$inputTextarea = this.$('.o_portal_chatter_composer_input textarea[name="message"]');
return this._super.apply(this, arguments).then(function () {
if (self.options.default_attachment_ids) {
self.attachments = self.options.default_attachment_ids || [];
self.attachments.forEach((attachment) => {
attachment.state = 'done';
});
self._updateAttachments();
}
return Promise.resolve();
});
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
*/
_onAttachmentButtonClick: function () {
this.$fileInput.click();
},
/**
* @private
* @param {Event} ev
* @returns {Promise}
*/
_onAttachmentDeleteClick: function (ev) {
var self = this;
var attachmentId = $(ev.currentTarget).closest('.o_portal_chatter_attachment').data('id');
var accessToken = this.attachments.find(attachment => attachment.id === attachmentId).access_token;
ev.preventDefault();
ev.stopPropagation();
this.$sendButton.prop('disabled', true);
return this.rpc('/portal/attachment/remove', {
'attachment_id': attachmentId,
'access_token': accessToken,
}).then(function () {
self.attachments = self.attachments.filter(attachment => attachment.id !== attachmentId);
self._updateAttachments();
self.$sendButton.prop('disabled', false);
});
},
_prepareAttachmentData: function (file) {
return {
'name': file.name,
'file': file,
'res_id': this.options.res_id,
'res_model': this.options.res_model,
'access_token': this.options.token,
};
},
/**
* @private
* @returns {Promise}
*/
_onFileInputChange: function () {
var self = this;
this.$sendButton.prop('disabled', true);
return Promise.all([...this.$fileInput[0].files].map((file) => {
return new Promise(function (resolve, reject) {
var data = self._prepareAttachmentData(file);
if (odoo.csrf_token) {
data.csrf_token = odoo.csrf_token;
}
post('/portal/attachment/add', data).then(function (attachment) {
attachment.state = 'pending';
self.attachments.push(attachment);
self._updateAttachments();
resolve();
}).catch(function (error) {
if (error instanceof RPCError) {
self.notification.add(
_t("Could not save file <strong>%s</strong>", escape(file.name)),
{ type: 'warning', sticky: true }
);
resolve();
}
});
});
})).then(function () {
// ensures any selection triggers a change, even if the same files are selected again
self.$fileInput[0].value = null;
self.$sendButton.prop('disabled', false);
});
},
/**
* prepares data to send message
*
* @private
*/
_prepareMessageData: function () {
return Object.assign(this.options || {}, {
'message': this.$('textarea[name="message"]').val(),
attachment_ids: this.attachments.map((a) => a.id),
attachment_tokens: this.attachments.map((a) => a.access_token),
});
},
/**
* @private
* @param {Event} ev
*/
_onSubmitButtonClick: function (ev) {
ev.preventDefault();
const error = this._onSubmitCheckContent();
if (error) {
this.$inputTextarea.addClass('border-danger');
this.$(".o_portal_chatter_composer_error").text(error).removeClass('d-none');
return Promise.reject();
} else {
return this._chatterPostMessage(ev.currentTarget.getAttribute('data-action'));
}
},
/**
* @private
*/
_onSubmitCheckContent: function () {
if (!this.$inputTextarea.val().trim() && !this.attachments.length) {
return _t('Some fields are required. Please make sure to write a message or attach a document');
};
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
*/
_updateAttachments: function () {
this.$attachments.empty().append(renderToElement('portal.Chatter.Attachments', {
attachments: this.attachments,
showDelete: true,
}));
},
/**
* post message using rpc call and display new message and message count
*
* @private
* @param {String} route
* @returns {Promise}
*/
_chatterPostMessage: async function (route) {
const result = await this.rpc(route, this._prepareMessageData());
Component.env.bus.trigger('reload_chatter_content', result);
return result;
},
});
export default {
PortalComposer: PortalComposer,
};

View File

@ -0,0 +1,215 @@
/** @odoo-module **/
import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog';
import { renderToMarkup } from "@web/core/utils/render";
import publicWidget from '@web/legacy/js/public/public_widget';
import { session } from "@web/session";
import { InputConfirmationDialog } from './components/input_confirmation_dialog/input_confirmation_dialog';
import { _t } from "@web/core/l10n/translation";
publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({
selector: '.o_portal_new_api_key',
events: {
click: '_onClick'
},
init() {
this._super(...arguments);
this.orm = this.bindService("orm");
this.dialog = this.bindService("dialog");
},
async _onClick(e){
e.preventDefault();
// This call is done just so it asks for the password confirmation before starting displaying the
// dialog forms, to mimic the behavior from the backend, in which it asks for the password before
// displaying the wizard.
// The result of the call is unused. But it's required to call a method with the decorator `@check_identity`
// in order to use `handleCheckIdentity`.
await handleCheckIdentity(
this.orm.call("res.users", "api_key_wizard", [session.user_id]),
this.orm,
this.dialog
);
this.call("dialog", "add", InputConfirmationDialog, {
title: _t("New API Key"),
body: renderToMarkup("portal.keydescription"),
confirmLabel: _t("Confirm"),
confirm: async ({ inputEl }) => {
const description = inputEl.value;
const wizard_id = await this.orm.create("res.users.apikeys.description", [{ name: description }]);
const res = await handleCheckIdentity(
this.orm.call("res.users.apikeys.description", "make_key", [wizard_id]),
this.orm,
this.dialog
);
this.call("dialog", "add", ConfirmationDialog, {
title: _t("API Key Ready"),
body: renderToMarkup("portal.keyshow", { key: res.context.default_key }),
confirmLabel: _t("Close"),
}, {
onClose: () => {
window.location = window.location;
},
})
}
});
}
});
publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({
selector: '.o_portal_remove_api_key',
events: {
click: '_onClick'
},
init() {
this._super(...arguments);
this.orm = this.bindService("orm");
this.dialog = this.bindService("dialog");
},
async _onClick(e){
e.preventDefault();
await handleCheckIdentity(
this.orm.call("res.users.apikeys", "remove", [parseInt(this.el.id)]),
this.orm,
this.dialog
);
window.location = window.location;
}
});
publicWidget.registry.portalSecurity = publicWidget.Widget.extend({
selector: '.o_portal_security_body',
/**
* @override
*/
init: function () {
// Show the "deactivate your account" modal if needed
$('.modal.show#portal_deactivate_account_modal').removeClass('d-block').modal('show');
// Remove the error messages when we close the modal,
// so when we re-open it again we get a fresh new form
$('.modal#portal_deactivate_account_modal').on('hide.bs.modal', (event) => {
const $target = $(event.currentTarget);
$target.find('.alert').remove();
$target.find('.invalid-feedback').remove();
$target.find('.is-invalid').removeClass('is-invalid');
});
return this._super(...arguments);
},
});
/**
* Defining what happens when you click the "Log out from all devices"
* on the "/my/security" page.
*/
publicWidget.registry.RevokeSessionsButton = publicWidget.Widget.extend({
selector: '#portal_revoke_all_sessions_popup',
events: {
click: '_onClick',
},
init() {
this._super(...arguments);
this.orm = this.bindService("orm");
},
async _onClick() {
const { res_id: checkId } = await this.orm.call("res.users", "api_key_wizard", [
session.user_id,
]);
this.call("dialog", "add", InputConfirmationDialog, {
title: _t("Log out from all devices?"),
body: renderToMarkup("portal.revoke_all_devices_popup_template"),
confirmLabel: _t("Log out from all devices"),
confirm: async ({ inputEl }) => {
if (!inputEl.reportValidity()) {
inputEl.classList.add("is-invalid");
return false;
}
await this.orm.write("res.users.identitycheck", [checkId], { password: inputEl.value });
try {
await this.orm.call(
"res.users.identitycheck",
"revoke_all_devices",
[checkId]
);
} catch {
inputEl.classList.add("is-invalid");
inputEl.setCustomValidity(_t("Check failed"));
inputEl.reportValidity();
return false;
}
window.location.href = "/web/session/logout?redirect=/";
return true;
},
cancel: () => {},
onInput: ({ inputEl }) => {
inputEl.classList.remove("is-invalid");
inputEl.setCustomValidity("");
},
});
},
});
/**
* Wraps an RPC call in a check for the result being an identity check action
* descriptor. If no such result is found, just returns the wrapped promise's
* result as-is; otherwise shows an identity check dialog and resumes the call
* on success.
*
* Warning: does not in and of itself trigger an identity check, a promise which
* never triggers and identity check internally will do nothing of use.
*
* @param {Promise} wrapped promise to check for an identity check request
* @param {Function} ormService bound do the widget
* @param {Function} dialogService dialog service
* @returns {Promise} result of the original call
*/
export async function handleCheckIdentity(wrapped, ormService, dialogService) {
return wrapped.then((r) => {
if (!(r.type === "ir.actions.act_window" && r.res_model === "res.users.identitycheck")) {
return r;
}
const checkId = r.res_id;
return new Promise((resolve) => {
dialogService.add(InputConfirmationDialog, {
title: _t("Security Control"),
body: renderToMarkup("portal.identitycheck"),
confirmLabel: _t("Confirm Password"),
confirm: async ({ inputEl }) => {
if (!inputEl.reportValidity()) {
inputEl.classList.add("is-invalid");
return false;
}
let result;
await ormService.write("res.users.identitycheck", [checkId], { password: inputEl.value });
try {
result = await ormService.call("res.users.identitycheck", "run_check", [checkId]);
} catch {
inputEl.classList.add("is-invalid");
inputEl.setCustomValidity(_t("Check failed"));
inputEl.reportValidity();
return false;
}
resolve(result);
return true;
},
cancel: () => {},
onInput: ({ inputEl }) => {
inputEl.classList.remove("is-invalid");
inputEl.setCustomValidity("");
},
});
});
});
}

View File

@ -0,0 +1,64 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import publicWidget from "@web/legacy/js/public/public_widget";
import { deserializeDateTime } from "@web/core/l10n/dates";
const { DateTime } = luxon;
var PortalSidebar = publicWidget.Widget.extend({
/**
* @override
*/
start: function () {
this._setDelayLabel();
return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
// Private
//---------------------------------------------------------------------------
/**
* Set the due/delay information according to the given date
* like : <span class="o_portal_sidebar_timeago" t-att-datetime="invoice.date_due"/>
*
* @private
*/
_setDelayLabel: function () {
var $sidebarTimeago = this.$el.find('.o_portal_sidebar_timeago').toArray();
$sidebarTimeago.forEach((el) => {
var dateTime = deserializeDateTime($(el).attr('datetime')),
today = DateTime.now().startOf('day'),
diff = dateTime.diff(today).as("days"),
displayStr;
if (diff === 0) {
displayStr = _t('Due today');
} else if (diff > 0) {
// Workaround: force uniqueness of these two translations. We use %1d because the string
// with %d is already used in mail and mail's translations are not sent to the frontend.
displayStr = _t('Due in %s days', Math.abs(diff).toFixed(1));
} else {
displayStr = _t('%s days overdue', Math.abs(diff).toFixed(1));
}
$(el).text(displayStr);
});
},
/**
* @private
* @param {string} href
*/
_printIframeContent: function (href) {
if (!this.printContent) {
this.printContent = $('<iframe id="print_iframe_content" src="' + href + '" style="display:none"></iframe>');
this.$el.append(this.printContent);
this.printContent.on('load', function () {
$(this).get(0).contentWindow.print();
});
} else {
this.printContent.get(0).contentWindow.print();
}
},
});
export default PortalSidebar;

View File

@ -0,0 +1,50 @@
// The contrast ratio to reach against white, to determine if color changes from "light" to "dark". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.
// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast
// Overridden here as it is a dependency for some variables/functions afterwards
$min-contrast-ratio: $o-frontend-min-contrast-ratio !default;
//Restore BS4 Colors
$blue: #007bff !default;
$pink: #e83e8c !default;
$green: #28a745 !default;
$cyan: #17a2b8 !default;
$gray-900: #212529 !default;
//End Restore BS4 Colors
$gray-300: #dee2e6 !default;
$light: #eeeeee !default;
// Body
//
// Settings for the `<body>` element.
$body-bg: $o-portal-default-body-bg !default;
// Fonts
//
// Font, line-height, and color for body text, headings, and more.
$font-size-sm: (12 / 16) * 1rem !default;
// Table
$table-border-color: $gray-300 !default;
$table-group-separator-color: $gray-300 !default;
// Buttons
//
// For each of Bootstrap's buttons, define text, background, and border color.
$btn-padding-y-sm: (1 / 16) * 1rem !default;
$btn-padding-x-sm: (5 / 16) * 1rem !default;
// Navbar
$navbar-dark-toggler-border-color: transparent;
$navbar-light-toggler-border-color: transparent;
// Modals
$modal-lg: $o-modal-lg;
$modal-md: $o-modal-md;

491
static/src/scss/portal.scss Normal file
View File

@ -0,0 +1,491 @@
///
/// This file regroups the frontend general design rules and portal design
/// rules.
///
// ====== Variables =========
$o-theme-navbar-logo-height: $nav-link-height !default;
$o-theme-navbar-fixed-logo-height: $nav-link-height !default;
// Portal toolbar (filters, search bar)
$o-portal-mobile-toolbar: true; // Enable/Disable custom design
$o-portal-mobile-toolbar-border: $border-color;
$o-portal-mobile-toolbar-bg: $gray-200;
// Portal Tables
$o-portal-table-th-pt: map-get($spacers, 1) !default;
$o-portal-table-th-pb: map-get($spacers, 1) !default;
$o-portal-table-td-pt: map-get($spacers, 2) !default;
$o-portal-table-td-pb: map-get($spacers, 2) !default;
// Frontend general
body {
// Set frontend direction that will be flipped with
// rtlcss for right-to-left text direction.
direction: ltr;
}
header {
.navbar-brand {
flex: 0 0 auto;
max-width: 75%;
&.logo {
padding-top: 0;
padding-bottom: 0;
img {
// object-fit does not work on IE but is only used as a fallback
object-fit: contain;
display: block;
width: auto;
height: $o-theme-navbar-logo-height;
@include media-breakpoint-down(md) {
max-height: min($o-theme-navbar-logo-height, 5rem);
}
}
}
}
.nav-link {
white-space: nowrap;
}
}
.navbar {
margin-bottom: 0;
.nav.navbar-nav.float-end {
@include media-breakpoint-down(md) {
float: none!important;
}
}
}
@include media-breakpoint-up(md) {
.navbar-expand-md ul.nav > li.divider {
display: list-item;
}
}
ul.flex-column > li > a {
padding: 2px 15px;
}
// Link without text but an icon
a, .btn-link {
&.fa:hover {
text-decoration: $o-theme-btn-icon-hover-decoration;
}
}
// Odoo options classes
.jumbotron {
margin-bottom: 0;
}
// Typography
li > p {
margin: 0;
}
// Bootstrap hacks
%o-double-container-no-padding {
padding-right: 0;
padding-left: 0;
}
.container {
.container, .container-fluid {
@extend %o-double-container-no-padding;
}
}
.container-fluid .container-fluid {
@extend %o-double-container-no-padding;
}
#wrap {
.container, .container-fluid {
// BS3 used to do this on all containers so that margins and floats are
// cleared inside containers. As lots of current odoo layouts may rely
// on this for some alignments, this is restored (at least for a while)
// here only for main containers of the frontend.
&::before, &::after {
content: "";
display: table;
clear: both;
}
}
.navbar %container-flex-properties {
// Bootstrap set-up flex alignment to "space-between" here. The hack
// made above to restore the BS3 protection has to be disabled.
&::before, &::after {
display: none;
}
}
}
[class^="col-lg-"] {
min-height: 24px;
}
.input-group {
flex-flow: row nowrap;
}
.list-group-item:not([class*="list-group-item-"]):not(.active) {
color: color-contrast($list-group-bg);
}
%o-portal-breadcrumbs {
background-color: inherit;
}
// Replaces old BS3 page-header class
%o-page-header {
margin-bottom: $headings-margin-bottom * 2;
padding-bottom: $headings-margin-bottom;
border-bottom-width: $border-width;
border-bottom-style: solid;
border-bottom-color: $border-color;
}
.o_page_header {
@extend %o-page-header;
}
// Images spacing
img, .media_iframe_video, .o_image {
&.float-end {
margin-left: $grid-gutter-width / 2;
}
&.float-start {
margin-right: $grid-gutter-width / 2;
}
}
// Others
::-moz-selection {
background: rgba(150, 150, 220, 0.3);
}
::selection {
background: rgba(150, 150, 220, 0.3);
}
.oe_search_box {
padding-right: 23px;
text-overflow: ellipsis;
}
// Kept for (up to) saas-12 compatibility
.para_large {
font-size: 120%;
}
.jumbotron .para_large p {
font-size: 150%;
}
.readable {
font-size: 120%;
max-width: 700px;
margin-left: auto;
margin-right: auto;
.container {
padding-left: 0;
padding-right: 0;
width: auto;
}
}
// Background (kept for 8.0 compatibility) (! some are still used by website_blog)
.oe_dark {
background-color: rgba(200, 200, 200, 0.14);
}
.oe_black {
background-color: rgba(0, 0, 0, 0.9);
color: white;
}
.oe_green {
background-color: #169C78;
color: white;
.text-muted {
color: #ddd !important;
}
}
.oe_blue_light {
background-color: #41b6ab;
color: white;
.text-muted {
color: #ddd !important;
}
}
.oe_blue {
background-color: #34495e;
color: white;
}
.oe_orange {
background-color: #f05442;
color: white;
.text-muted {
color: #ddd !important;
}
}
.oe_purple {
background-color: #b163a3;
color: white;
.text-muted {
color: #ddd !important;
}
}
.oe_red {
background-color: #9C1b31;
color: white;
.text-muted {
color: #ddd !important;
}
}
.oe_none {
background-color: #FFFFFF;
}
.oe_yellow {
background-color: #A2A51B;
}
.oe_green {
background-color: #149F2C;
}
.o_portal {
.breadcrumb {
@extend %o-portal-breadcrumbs;
}
> tbody.o_portal_report_tbody {
vertical-align: middle;
}
}
.o_portal_wrap {
.o_portal_my_home > .o_page_header > a:hover {
text-decoration: none;
}
.o_portal_navbar {
.breadcrumb {
padding-left: 0;
padding-right: 0;
@extend %o-portal-breadcrumbs;
}
}
.o_portal_my_doc_table {
th {
padding-top: $o-portal-table-th-pt;
padding-bottom: $o-portal-table-th-pb;
max-width: 500px;
}
td {
padding-top: $o-portal-table-td-pt;
padding-bottom: $o-portal-table-td-pb;
max-width: 10rem;
}
td, th {
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:has(a) {
color: $primary;
}
}
}
.o_my_sidebar div[itemprop="address"] > div {
margin-top: 0.5em;
}
@if ($o-portal-mobile-toolbar) {
#o_portal_navbar_content {
@include media-breakpoint-down(lg) {
margin: $navbar-padding-y (-$navbar-padding-x) 0;
padding: $navbar-padding-y $navbar-padding-x ;
border-top: $border-width solid $o-portal-mobile-toolbar-border;
background-color: $o-portal-mobile-toolbar-bg;
}
}
}
table.table tr {
word-wrap: break-word;
}
}
.o_portal_address {
span[itemprop="name"] {
margin-bottom: 0.3em;
}
div[itemprop="address"] > div {
position: relative;
span[itemprop="streetAddress"] {
line-height: 1.2;
margin-bottom: 0.3em;
}
.fa {
line-height: $line-height-base;
color: $o-gray-500;
+ span, + div {
display: block;
}
}
}
}
.oe_attachments .o_image_small {
height: 40px;
width: 50px;
background-repeat: no-repeat;
}
.o_portal_sidebar {
.o_portal_html_view {
overflow: hidden;
background: white;
position: relative;
.o_portal_html_loader {
@include o-position-absolute(45%, 0, auto, 0);
}
iframe {
position: relative;
}
}
.o_portal_sidebar_content {
@include media-breakpoint-up(lg) {
@include o-position-sticky($top: $spacer * 5);
}
}
}
// ------------------------------------------------------------
// Frontend Discuss widget
// ------------------------------------------------------------
// Readonly display
.o_portal_chatter {
padding: 10px;
.o_portal_chatter_avatar{
--Avatar-size: 45px;
}
.o_portal_chatter_header {
margin-bottom: 15px;
}
.o_portal_chatter_composer {
margin-bottom: 15px;
}
.o_portal_chatter_composer_body {
textarea {
border: 0;
}
> div {
border: 1px solid var(--o-border-color);
}
}
.o_portal_chatter_messages {
margin-bottom: 15px;
overflow-wrap: break-word;
word-break: break-word;
.o_portal_chatter_message {
div.flex-grow-1 > p:not(.o_portal_chatter_puslished_date):last-of-type {
margin-bottom: 5px;
}
}
.o_portal_chatter_message_title {
p {
font-size: 85%;
color: $text-muted;
margin: 0px;
}
}
}
.o_portal_chatter_pager {
text-align: center;
}
}
// Readonly / Composer mix display
.o_portal_chatter,
.o_portal_chatter_composer {
.o_portal_chatter_attachment {
.o_portal_chatter_attachment_name {
max-width: 200px;
}
.o_portal_chatter_attachment_delete {
@include o-position-absolute($top: 0, $right: 0);
opacity: 0;
}
&:hover .o_portal_chatter_attachment_delete {
opacity: 1;
}
}
.o_portal_message_internal_off {
.o_portal_chatter_visibility_on {
display: none;
}
}
.o_portal_message_internal_on {
.o_portal_chatter_visibility_off {
display: none;
}
}
}
.o_portal_security_body {
section {
margin-top: map-get($spacers, 5);
border-top: $border-width solid $border-color;
padding-top: map-get($spacers, 4);
form.oe_reset_password_form {
max-width: initial;
margin: initial;
}
label, button {
white-space: nowrap;
}
}
section[name="portal_deactivate_account"] {
label {
white-space: normal!important;
}
}
}
// Copyright
.o_footer_copyright {
.o_footer_copyright_name {
vertical-align: middle;
}
.js_language_selector {
display: inline-block;
}
@include media-breakpoint-up(md) {
.row {
display: flex;
> div {
margin: auto 0;
}
}
}
}

View File

@ -0,0 +1,5 @@
$o-portal-default-body-bg: white;
$o-theme-navbar-logo-height: null;
$o-theme-btn-icon-hover-decoration: none;

View File

@ -0,0 +1,93 @@
/** @odoo-module **/
import { Component, onMounted, useRef, useState } from "@odoo/owl";
import dom from "@web/legacy/js/core/dom";
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { redirect } from "@web/core/utils/urls";
import { NameAndSignature } from "@web/core/signature/name_and_signature";
import { useService } from "@web/core/utils/hooks";
/**
* This Component is a signature request form. It uses
* @see NameAndSignature for the input fields, adds a submit
* button, and handles the RPC to save the result.
*/
class SignatureForm extends Component {
static template = "portal.SignatureForm"
static components = { NameAndSignature }
setup() {
this.rootRef = useRef("root");
this.rpc = useService("rpc");
this.csrfToken = odoo.csrf_token;
this.state = useState({
error: false,
success: false,
});
this.signature = useState({ name: this.props.defaultName });
this.nameAndSignatureProps = {
signature: this.signature,
fontColor: this.props.fontColor || "black",
};
if (this.props.signatureRatio) {
this.nameAndSignatureProps.displaySignatureRatio = this.props.signatureRatio;
}
if (this.props.signatureType) {
this.nameAndSignatureProps.signatureType = this.props.signatureType;
}
if (this.props.mode) {
this.nameAndSignatureProps.mode = this.props.mode;
}
// Correctly set up the signature area if it is inside a modal
onMounted(() => {
this.rootRef.el.closest('.modal').addEventListener('shown.bs.modal', () => {
this.signature.resetSignature();
});
});
}
get sendLabel() {
return this.props.sendLabel || _t("Accept & Sign");
}
/**
* Handles click on the submit button.
*
* This will get the current name and signature and validate them.
* If they are valid, they are sent to the server, and the reponse is
* handled. If they are invalid, it will display the errors to the user.
*
* @returns {Promise}
*/
async onClickSubmit() {
const button = document.querySelector('.o_portal_sign_submit')
const icon = button.removeChild(button.firstChild)
const restoreBtnLoading = dom.addButtonLoadingEffect(button);
const name = this.signature.name;
const signature = this.signature.getSignatureImage()[1];
const data = await this.rpc(this.props.callUrl, { name, signature });
if (data.force_refresh) {
restoreBtnLoading();
button.prepend(icon)
if (data.redirect_url) {
redirect(data.redirect_url);
} else {
window.location.reload();
}
// do not resolve if we reload the page
return new Promise(() => {});
}
this.state.error = data.error || false;
this.state.success = !data.error && {
message: data.message,
redirectUrl: data.redirect_url,
redirectMessage: data.redirect_message,
};
}
}
registry.category("public_components").add("portal.signature_form", SignatureForm);

View File

@ -0,0 +1,35 @@
<templates id="template" xml:space="preserve">
<!-- Template for the widget SignatureForm. -->
<t t-name="portal.SignatureForm">
<div t-ref="root">
<div t-if="state.success" class="alert alert-success" role="status">
<span t-if="state.success.message" t-esc="state.success.message"/>
<span t-else="">Thank You!</span>
<a t-if="state.success.redirect_url" t-att-href="state.success.redirect_url">
<t t-if="state.success.redirect_message" t-esc="state.success.redirect_message"/>
<t t-else="">Click here to see your document.</t>
</a>
</div>
<t t-else="">
<NameAndSignature t-props="nameAndSignatureProps"/>
<form method="POST">
<input type="hidden" name="csrf_token" t-att-value="csrfToken"/>
<div class="o_web_sign_name_and_signature"/>
<div class="o_portal_sign_controls my-3">
<div t-if="state.error" class="o_portal_sign_error_msg alert alert-danger" role="status">
<t t-esc="state.error"/>
</div>
<div class="text-end my-3">
<button type="submit" class="o_portal_sign_submit btn btn-primary" t-on-click.prevent="onClickSubmit" t-att-disabled="signature.isSignatureEmpty ? 'disabled' : ''">
<i class="fa fa-check me-1"/>
<t t-esc="sendLabel"/>
</button>
</div>
</div>
</form>
</t>
</div>
</t>
</templates>

View File

@ -0,0 +1,18 @@
/** @odoo-module **/
import { PortalWizardUserListController } from "../list/portal_wizard_user_list_controller";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
import { registry } from "@web/core/registry";
export class PortalUserX2ManyField extends X2ManyField {}
PortalUserX2ManyField.components = {
...X2ManyField.components,
Controller: PortalWizardUserListController,
};
export const portalUserX2ManyField = {
...x2ManyField,
component: PortalUserX2ManyField,
};
registry.category("fields").add("portal_wizard_user_one2many", portalUserX2ManyField);

View File

@ -0,0 +1,3 @@
.o_portal_wizard_user_one2many td {
width: 1%;
}

Some files were not shown because too many files have changed in this diff Show More