mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 15:12:02 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -1,7 +1,17 @@
|
|||
# -*- coding: utf-8 -*
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import bus
|
||||
from . import discuss
|
||||
from . import home
|
||||
from . import attachment
|
||||
from . import google_translate
|
||||
from . import guest
|
||||
from . import im_status
|
||||
from . import link_preview
|
||||
from . import mail
|
||||
from . import mailbox
|
||||
from . import message_reaction
|
||||
from . import thread
|
||||
from . import webclient
|
||||
from . import webmanifest
|
||||
from . import websocket
|
||||
|
||||
# after mail specifically as discuss module depends on mail
|
||||
from . import discuss
|
||||
|
|
|
|||
169
odoo-bringout-oca-ocb-mail/mail/controllers/attachment.py
Normal file
169
odoo-bringout-oca-ocb-mail/mail/controllers/attachment.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import zipfile
|
||||
|
||||
from werkzeug.exceptions import NotFound, UnsupportedMediaType
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.addons.mail.controllers.thread import ThreadController
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.http import request, content_disposition
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
from odoo.tools.misc import file_open
|
||||
from odoo.tools.pdf import DependencyError, PdfReadError, extract_page
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AttachmentController(ThreadController):
|
||||
def _make_zip(self, name, attachments):
|
||||
streams = (request.env['ir.binary']._get_stream_from(record, 'raw') for record in attachments)
|
||||
# TODO: zip on-the-fly while streaming instead of loading the
|
||||
# entire zip in memory and sending it all at once.
|
||||
stream = io.BytesIO()
|
||||
try:
|
||||
with zipfile.ZipFile(stream, 'w') as attachment_zip:
|
||||
for binary_stream in streams:
|
||||
if not binary_stream:
|
||||
continue
|
||||
attachment_zip.writestr(
|
||||
binary_stream.download_name,
|
||||
binary_stream.read(),
|
||||
compress_type=zipfile.ZIP_DEFLATED
|
||||
)
|
||||
except zipfile.BadZipFile:
|
||||
logger.exception("BadZipfile exception")
|
||||
|
||||
content = stream.getvalue()
|
||||
headers = [
|
||||
('Content-Type', 'zip'),
|
||||
('X-Content-Type-Options', 'nosniff'),
|
||||
('Content-Length', len(content)),
|
||||
('Content-Disposition', content_disposition(name))
|
||||
]
|
||||
return request.make_response(content, headers)
|
||||
|
||||
@http.route("/mail/attachment/upload", methods=["POST"], type="http", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_attachment_upload(self, ufile, thread_id, thread_model, is_pending=False, **kwargs):
|
||||
thread = self._get_thread_with_access_for_post(thread_model, thread_id, **kwargs)
|
||||
if not thread:
|
||||
raise NotFound()
|
||||
vals = {
|
||||
"name": ufile.filename,
|
||||
"raw": ufile.read(),
|
||||
"res_id": int(thread_id),
|
||||
"res_model": thread_model,
|
||||
}
|
||||
if is_pending and is_pending != "false":
|
||||
# Add this point, the message related to the uploaded file does
|
||||
# not exist yet, so we use those placeholder values instead.
|
||||
vals.update(
|
||||
{
|
||||
"res_id": 0,
|
||||
"res_model": "mail.compose.message",
|
||||
}
|
||||
)
|
||||
try:
|
||||
# sudo: ir.attachment - posting a new attachment on an accessible thread
|
||||
attachment = request.env["ir.attachment"].sudo().create(vals)
|
||||
attachment._post_add_create(**kwargs)
|
||||
res = {
|
||||
"data": {
|
||||
|
||||
"store_data": Store().add(
|
||||
attachment,
|
||||
extra_fields=request.env["ir.attachment"]._get_store_ownership_fields(),
|
||||
).get_result(),
|
||||
"attachment_id": attachment.id,
|
||||
}
|
||||
}
|
||||
except AccessError:
|
||||
res = {"error": _("You are not allowed to upload an attachment here.")}
|
||||
return request.make_json_response(res)
|
||||
|
||||
@http.route("/mail/attachment/delete", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_attachment_delete(self, attachment_id, access_token=None):
|
||||
attachment = request.env["ir.attachment"].browse(int(attachment_id)).exists()
|
||||
if not attachment or not attachment._has_attachments_ownership([access_token]):
|
||||
request.env.user._bus_send("ir.attachment/delete", {"id": attachment_id})
|
||||
raise NotFound()
|
||||
message = request.env["mail.message"].sudo().search(
|
||||
[("attachment_ids", "in", attachment.ids)], limit=1)
|
||||
# sudo: ir.attachment: access is validated with _has_attachments_ownership
|
||||
attachment.sudo()._delete_and_notify(message)
|
||||
|
||||
@http.route(['/mail/attachment/zip'], methods=["POST"], type="http", auth="public")
|
||||
def mail_attachment_get_zip(self, file_ids, zip_name, **kw):
|
||||
"""route to get the zip file of the attachments.
|
||||
:param file_ids: ids of the files to zip.
|
||||
:param zip_name: name of the zip file.
|
||||
"""
|
||||
ids_list = list(map(int, file_ids.split(',')))
|
||||
attachments = request.env['ir.attachment'].browse(ids_list)
|
||||
return self._make_zip(zip_name, attachments)
|
||||
|
||||
@http.route(
|
||||
"/mail/attachment/pdf_first_page/<int:attachment_id>",
|
||||
auth="public",
|
||||
methods=["GET"],
|
||||
readonly=True,
|
||||
type="http",
|
||||
)
|
||||
@add_guest_to_context
|
||||
def mail_attachment_pdf_first_page(self, attachment_id, access_token=None):
|
||||
"""Returns the first page of a pdf."""
|
||||
attachment = request.env["ir.attachment"].browse(int(attachment_id)).exists()
|
||||
if not attachment or (
|
||||
not attachment.has_access("read")
|
||||
and not attachment._has_attachments_ownership([access_token])
|
||||
):
|
||||
raise request.not_found()
|
||||
# sudo: ir.attachment: access check is done above, sudo necessary for guests
|
||||
return self._get_pdf_first_page_response(attachment.sudo())
|
||||
|
||||
@http.route(
|
||||
"/mail/attachment/update_thumbnail",
|
||||
auth="public",
|
||||
methods=["POST"],
|
||||
type="jsonrpc",
|
||||
)
|
||||
@add_guest_to_context
|
||||
def mail_attachement_update_thumbnail(self, attachment_id, thumbnail=None, access_token=None):
|
||||
"""Updates the thumbnail of an attachment."""
|
||||
attachment = request.env["ir.attachment"].browse(int(attachment_id)).exists()
|
||||
if not attachment or (
|
||||
not attachment.has_access("write")
|
||||
and not attachment._has_attachments_ownership([access_token])
|
||||
):
|
||||
raise request.not_found()
|
||||
# sudo: ir.attachment: access check is done above, sudo necessary for guests
|
||||
attachment_sudo = attachment.sudo()
|
||||
if attachment_sudo.mimetype != "application/pdf":
|
||||
raise UserError(request.env._("Only PDF files can have thumbnail."))
|
||||
if not thumbnail:
|
||||
with file_open("web/static/img/mimetypes/unknown.svg") as unknown_svg:
|
||||
thumbnail = base64.b64encode(unknown_svg.read().encode())
|
||||
attachment_sudo.thumbnail = thumbnail
|
||||
Store(bus_channel=attachment_sudo).add(attachment_sudo, ["has_thumbnail"]).bus_send()
|
||||
|
||||
def _get_pdf_first_page_response(self, attachment):
|
||||
try:
|
||||
page_stream = extract_page(attachment, 0)
|
||||
except (PdfReadError, DependencyError, UnicodeDecodeError) as e:
|
||||
raise UnsupportedMediaType() from e
|
||||
if not page_stream:
|
||||
raise UnsupportedMediaType()
|
||||
content = page_stream.getvalue()
|
||||
headers = [
|
||||
("Content-Type", "attachment/pdf"),
|
||||
("X-Content-Type-Options", "nosniff"),
|
||||
("Content-Length", len(content)),
|
||||
]
|
||||
if attachment.name:
|
||||
headers.append(("Content-Disposition", content_disposition(attachment.name)))
|
||||
return request.make_response(content, headers)
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import SUPERUSER_ID, tools
|
||||
from odoo.http import request, route
|
||||
from odoo.addons.bus.controllers.main import BusController
|
||||
|
||||
|
||||
class MailChatController(BusController):
|
||||
|
||||
def _default_request_uid(self):
|
||||
""" For Anonymous people, they receive the access right of SUPERUSER_ID since they have NO access (auth=none)
|
||||
!!! Each time a method from this controller is call, there is a check if the user (who can be anonymous and Sudo access)
|
||||
can access to the resource.
|
||||
"""
|
||||
return request.session.uid and request.session.uid or SUPERUSER_ID
|
||||
|
||||
# --------------------------
|
||||
# Anonymous routes (Common Methods)
|
||||
# --------------------------
|
||||
@route('/mail/chat_post', type="json", auth="public", cors="*")
|
||||
def mail_chat_post(self, uuid, message_content, **kwargs):
|
||||
mail_channel = request.env["mail.channel"].sudo().search([('uuid', '=', uuid)], limit=1)
|
||||
if not mail_channel:
|
||||
return False
|
||||
|
||||
# find the author from the user session
|
||||
if request.session.uid:
|
||||
author = request.env['res.users'].sudo().browse(request.session.uid).partner_id
|
||||
author_id = author.id
|
||||
email_from = author.email_formatted
|
||||
else: # If Public User, use catchall email from company
|
||||
author_id = False
|
||||
email_from = mail_channel.anonymous_name or mail_channel.create_uid.company_id.catchall_formatted
|
||||
# post a message without adding followers to the channel. email_from=False avoid to get author from email data
|
||||
body = tools.plaintext2html(message_content)
|
||||
message = mail_channel.with_context(mail_create_nosubscribe=True).message_post(
|
||||
author_id=author_id,
|
||||
email_from=email_from,
|
||||
body=body,
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return message.id if message else False
|
||||
|
||||
@route(['/mail/chat_history'], type="json", auth="public", cors="*")
|
||||
def mail_chat_history(self, uuid, last_id=False, limit=20):
|
||||
channel = request.env["mail.channel"].sudo().search([('uuid', '=', uuid)], limit=1)
|
||||
if not channel:
|
||||
return []
|
||||
else:
|
||||
return channel._channel_fetch_message(last_id, limit)
|
||||
|
|
@ -1,609 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from psycopg2 import IntegrityError
|
||||
from psycopg2.errorcodes import UNIQUE_VIOLATION
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
from odoo.http import request
|
||||
from odoo.tools import consteq, file_open
|
||||
from odoo.tools.misc import get_lang
|
||||
from odoo.tools.translate import _
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
|
||||
class DiscussController(http.Controller):
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Public Pages
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route([
|
||||
'/chat/<string:create_token>',
|
||||
'/chat/<string:create_token>/<string:channel_name>',
|
||||
], methods=['GET'], type='http', auth='public')
|
||||
def discuss_channel_chat_from_token(self, create_token, channel_name=None, **kwargs):
|
||||
return self._response_discuss_channel_from_token(create_token=create_token, channel_name=channel_name)
|
||||
|
||||
@http.route([
|
||||
'/meet/<string:create_token>',
|
||||
'/meet/<string:create_token>/<string:channel_name>',
|
||||
], methods=['GET'], type='http', auth='public')
|
||||
def discuss_channel_meet_from_token(self, create_token, channel_name=None, **kwargs):
|
||||
return self._response_discuss_channel_from_token(create_token=create_token, channel_name=channel_name, default_display_mode='video_full_screen')
|
||||
|
||||
@http.route('/chat/<int:channel_id>/<string:invitation_token>', methods=['GET'], type='http', auth='public')
|
||||
def discuss_channel_invitation(self, channel_id, invitation_token, **kwargs):
|
||||
channel_sudo = request.env['mail.channel'].browse(channel_id).sudo().exists()
|
||||
if not channel_sudo or not channel_sudo.uuid or not consteq(channel_sudo.uuid, invitation_token):
|
||||
raise NotFound()
|
||||
return self._response_discuss_channel_invitation(channel_sudo=channel_sudo)
|
||||
|
||||
@http.route('/discuss/channel/<int:channel_id>', methods=['GET'], type='http', auth='public')
|
||||
def discuss_channel(self, channel_id, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
return self._response_discuss_public_channel_template(channel_sudo=channel_member_sudo.channel_id)
|
||||
|
||||
def _response_discuss_channel_from_token(self, create_token, channel_name=None, default_display_mode=False):
|
||||
if not request.env['ir.config_parameter'].sudo().get_param('mail.chat_from_token'):
|
||||
raise NotFound()
|
||||
channel_sudo = request.env['mail.channel'].sudo().search([('uuid', '=', create_token)])
|
||||
if not channel_sudo:
|
||||
try:
|
||||
channel_sudo = channel_sudo.create({
|
||||
'channel_type': 'channel',
|
||||
'default_display_mode': default_display_mode,
|
||||
'group_public_id': None,
|
||||
'name': channel_name or create_token,
|
||||
'uuid': create_token,
|
||||
})
|
||||
except IntegrityError as e:
|
||||
if e.pgcode != UNIQUE_VIOLATION:
|
||||
raise
|
||||
# concurrent insert attempt: another request created the channel.
|
||||
# commit the current transaction and get the channel.
|
||||
request.env.cr.commit()
|
||||
channel_sudo = channel_sudo.search([('uuid', '=', create_token)])
|
||||
return self._response_discuss_channel_invitation(channel_sudo=channel_sudo, is_channel_token_secret=False)
|
||||
|
||||
def _response_discuss_channel_invitation(self, channel_sudo, is_channel_token_secret=True):
|
||||
if channel_sudo.channel_type == 'chat':
|
||||
raise NotFound()
|
||||
discuss_public_view_data = {
|
||||
'isChannelTokenSecret': is_channel_token_secret,
|
||||
}
|
||||
add_guest_cookie = False
|
||||
channel_member_sudo = channel_sudo.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_sudo.id)
|
||||
if channel_member_sudo:
|
||||
channel_sudo = channel_member_sudo.channel_id # ensure guest is in context
|
||||
else:
|
||||
if not channel_sudo.env.user._is_public():
|
||||
try:
|
||||
channel_sudo.add_members([channel_sudo.env.user.partner_id.id])
|
||||
except UserError:
|
||||
raise NotFound()
|
||||
else:
|
||||
guest = channel_sudo.env['mail.guest']._get_guest_from_request(request)
|
||||
if guest:
|
||||
channel_sudo = channel_sudo.with_context(guest=guest)
|
||||
try:
|
||||
channel_sudo.add_members(guest_ids=[guest.id])
|
||||
except UserError:
|
||||
raise NotFound()
|
||||
else:
|
||||
if channel_sudo.group_public_id:
|
||||
raise NotFound()
|
||||
guest = channel_sudo.env['mail.guest'].create({
|
||||
'country_id': channel_sudo.env['res.country'].search([('code', '=', request.geoip.get('country_code'))], limit=1).id,
|
||||
'lang': get_lang(channel_sudo.env).code,
|
||||
'name': _("Guest"),
|
||||
'timezone': channel_sudo.env['mail.guest']._get_timezone_from_request(request),
|
||||
})
|
||||
add_guest_cookie = True
|
||||
discuss_public_view_data.update({
|
||||
'shouldAddGuestAsMemberOnJoin': True,
|
||||
'shouldDisplayWelcomeViewInitially': True,
|
||||
})
|
||||
channel_sudo = channel_sudo.with_context(guest=guest)
|
||||
response = self._response_discuss_public_channel_template(channel_sudo=channel_sudo, discuss_public_view_data=discuss_public_view_data)
|
||||
if add_guest_cookie:
|
||||
# Discuss Guest ID: every route in this file will make use of it to authenticate
|
||||
# the guest through `_get_as_sudo_from_request` or `_get_as_sudo_from_request_or_raise`.
|
||||
expiration_date = datetime.now() + timedelta(days=365)
|
||||
response.set_cookie(guest._cookie_name, f"{guest.id}{guest._cookie_separator}{guest.access_token}", httponly=True, expires=expiration_date)
|
||||
return response
|
||||
|
||||
def _response_discuss_public_channel_template(self, channel_sudo, discuss_public_view_data=None):
|
||||
discuss_public_view_data = discuss_public_view_data or {}
|
||||
return request.render('mail.discuss_public_channel_template', {
|
||||
'data': {
|
||||
'channelData': channel_sudo.channel_info()[0],
|
||||
'discussPublicViewData': dict({
|
||||
'channel': [('insert', {'id': channel_sudo.id, 'model': 'mail.channel'})],
|
||||
'shouldDisplayWelcomeViewInitially': channel_sudo.default_display_mode == 'video_full_screen',
|
||||
}, **discuss_public_view_data),
|
||||
},
|
||||
'session_info': channel_sudo.env['ir.http'].session_info(),
|
||||
})
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Semi-Static Content (GET requests with possible cache)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/channel/<int:channel_id>/partner/<int:partner_id>/avatar_128', methods=['GET'], type='http', auth='public')
|
||||
def mail_channel_partner_avatar_128(self, channel_id, partner_id, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_id)
|
||||
partner_sudo = channel_member_sudo.env['res.partner'].browse(partner_id).exists()
|
||||
placeholder = partner_sudo._avatar_get_placeholder_path()
|
||||
if channel_member_sudo and channel_member_sudo.env['mail.channel.member'].search([('channel_id', '=', channel_id), ('partner_id', '=', partner_id)], limit=1):
|
||||
return request.env['ir.binary']._get_image_stream_from(partner_sudo, field_name='avatar_128', placeholder=placeholder).get_response()
|
||||
if request.env.user.share:
|
||||
return request.env['ir.binary']._get_placeholder_stream(placeholder).get_response()
|
||||
return request.env['ir.binary']._get_image_stream_from(partner_sudo.sudo(False), field_name='avatar_128', placeholder=placeholder).get_response()
|
||||
|
||||
@http.route('/mail/channel/<int:channel_id>/guest/<int:guest_id>/avatar_128', methods=['GET'], type='http', auth='public')
|
||||
def mail_channel_guest_avatar_128(self, channel_id, guest_id, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_id)
|
||||
guest_sudo = channel_member_sudo.env['mail.guest'].browse(guest_id).exists()
|
||||
placeholder = guest_sudo._avatar_get_placeholder_path()
|
||||
if channel_member_sudo and channel_member_sudo.env['mail.channel.member'].search([('channel_id', '=', channel_id), ('guest_id', '=', guest_id)], limit=1):
|
||||
return request.env['ir.binary']._get_image_stream_from(guest_sudo, field_name='avatar_128', placeholder=placeholder).get_response()
|
||||
if request.env.user.share:
|
||||
return request.env['ir.binary']._get_placeholder_stream(placeholder).get_response()
|
||||
return request.env['ir.binary']._get_image_stream_from(guest_sudo.sudo(False), field_name='avatar_128', placeholder=placeholder).get_response()
|
||||
|
||||
@http.route('/mail/channel/<int:channel_id>/attachment/<int:attachment_id>', methods=['GET'], type='http', auth='public')
|
||||
def mail_channel_attachment(self, channel_id, attachment_id, download=None, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
attachment_sudo = channel_member_sudo.env['ir.attachment'].search([
|
||||
('id', '=', int(attachment_id)),
|
||||
('res_id', '=', int(channel_id)),
|
||||
('res_model', '=', 'mail.channel')
|
||||
], limit=1)
|
||||
if not attachment_sudo:
|
||||
raise NotFound()
|
||||
return request.env['ir.binary']._get_stream_from(attachment_sudo).get_response(as_attachment=download)
|
||||
|
||||
@http.route([
|
||||
'/mail/channel/<int:channel_id>/image/<int:attachment_id>',
|
||||
'/mail/channel/<int:channel_id>/image/<int:attachment_id>/<int:width>x<int:height>',
|
||||
], methods=['GET'], type='http', auth='public')
|
||||
def fetch_image(self, channel_id, attachment_id, width=0, height=0, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
attachment_sudo = channel_member_sudo.env['ir.attachment'].search([
|
||||
('id', '=', int(attachment_id)),
|
||||
('res_id', '=', int(channel_id)),
|
||||
('res_model', '=', 'mail.channel'),
|
||||
], limit=1)
|
||||
|
||||
if not attachment_sudo:
|
||||
raise NotFound()
|
||||
|
||||
return request.env['ir.binary']._get_image_stream_from(
|
||||
attachment_sudo, width=int(width), height=int(height)
|
||||
).get_response(as_attachment=kwargs.get('download'))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Client Initialization
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/init_messaging', methods=['POST'], type='json', auth='public')
|
||||
def mail_init_messaging(self, **kwargs):
|
||||
if not request.env.user.sudo()._is_public():
|
||||
return request.env.user.sudo(request.env.user.has_group('base.group_portal'))._init_messaging()
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
if guest:
|
||||
return guest.sudo()._init_messaging()
|
||||
raise NotFound()
|
||||
|
||||
@http.route('/mail/load_message_failures', methods=['POST'], type='json', auth='user')
|
||||
def mail_load_message_failures(self, **kwargs):
|
||||
return request.env.user.partner_id._message_fetch_failed()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mailbox
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/inbox/messages', methods=['POST'], type='json', auth='user')
|
||||
def discuss_inbox_messages(self, max_id=None, min_id=None, limit=30, **kwargs):
|
||||
return request.env['mail.message']._message_fetch(domain=[('needaction', '=', True)], max_id=max_id, min_id=min_id, limit=limit).message_format()
|
||||
|
||||
@http.route('/mail/history/messages', methods=['POST'], type='json', auth='user')
|
||||
def discuss_history_messages(self, max_id=None, min_id=None, limit=30, **kwargs):
|
||||
return request.env['mail.message']._message_fetch(domain=[('needaction', '=', False)], max_id=max_id, min_id=min_id, limit=limit).message_format()
|
||||
|
||||
@http.route('/mail/starred/messages', methods=['POST'], type='json', auth='user')
|
||||
def discuss_starred_messages(self, max_id=None, min_id=None, limit=30, **kwargs):
|
||||
return request.env['mail.message']._message_fetch(domain=[('starred_partner_ids', 'in', [request.env.user.partner_id.id])], max_id=max_id, min_id=min_id, limit=limit).message_format()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Thread API (channel/chatter common)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _get_allowed_message_post_params(self):
|
||||
return {'attachment_ids', 'body', 'message_type', 'partner_ids', 'subtype_xmlid', 'parent_id'}
|
||||
|
||||
@http.route('/mail/message/post', methods=['POST'], type='json', auth='public')
|
||||
def mail_message_post(self, thread_model, thread_id, post_data, **kwargs):
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
guest.env['ir.attachment'].browse(post_data.get('attachment_ids', []))._check_attachments_access(post_data.get('attachment_tokens'))
|
||||
if thread_model == 'mail.channel':
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(thread_id))
|
||||
thread = channel_member_sudo.channel_id
|
||||
else:
|
||||
thread = request.env[thread_model].browse(int(thread_id)).exists()
|
||||
return thread.message_post(**{key: value for key, value in post_data.items() if key in self._get_allowed_message_post_params()}).message_format()[0]
|
||||
|
||||
@http.route('/mail/message/update_content', methods=['POST'], type='json', auth='public')
|
||||
def mail_message_update_content(self, message_id, body, attachment_ids, attachment_tokens=None, **kwargs):
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
guest.env['ir.attachment'].browse(attachment_ids)._check_attachments_access(attachment_tokens)
|
||||
message_sudo = guest.env['mail.message'].browse(message_id).sudo().exists()
|
||||
if not message_sudo.is_current_user_or_guest_author and not guest.env.user._is_admin():
|
||||
raise NotFound()
|
||||
if not message_sudo.model or not message_sudo.res_id:
|
||||
raise NotFound()
|
||||
guest.env[message_sudo.model].browse([message_sudo.res_id])._message_update_content(
|
||||
message_sudo,
|
||||
body,
|
||||
attachment_ids=attachment_ids
|
||||
)
|
||||
return {
|
||||
'id': message_sudo.id,
|
||||
'body': message_sudo.body,
|
||||
'attachments': message_sudo.attachment_ids.sorted()._attachment_format(),
|
||||
}
|
||||
|
||||
@http.route('/mail/attachment/upload', methods=['POST'], type='http', auth='public')
|
||||
def mail_attachment_upload(self, ufile, thread_id, thread_model, is_pending=False, **kwargs):
|
||||
channel_member = request.env['mail.channel.member']
|
||||
if thread_model == 'mail.channel':
|
||||
channel_member = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(thread_id))
|
||||
vals = {
|
||||
'name': ufile.filename,
|
||||
'raw': ufile.read(),
|
||||
'res_id': int(thread_id),
|
||||
'res_model': thread_model,
|
||||
}
|
||||
if is_pending and is_pending != 'false':
|
||||
# Add this point, the message related to the uploaded file does
|
||||
# not exist yet, so we use those placeholder values instead.
|
||||
vals.update({
|
||||
'res_id': 0,
|
||||
'res_model': 'mail.compose.message',
|
||||
})
|
||||
if channel_member.env.user.share:
|
||||
# Only generate the access token if absolutely necessary (= not for internal user).
|
||||
vals['access_token'] = channel_member.env['ir.attachment']._generate_access_token()
|
||||
try:
|
||||
attachment = channel_member.env['ir.attachment'].create(vals)
|
||||
attachment._post_add_create()
|
||||
attachmentData = {
|
||||
'filename': ufile.filename,
|
||||
'id': attachment.id,
|
||||
'mimetype': attachment.mimetype,
|
||||
'name': attachment.name,
|
||||
'size': attachment.file_size
|
||||
}
|
||||
if attachment.access_token:
|
||||
attachmentData['accessToken'] = attachment.access_token
|
||||
except AccessError:
|
||||
attachmentData = {'error': _("You are not allowed to upload an attachment here.")}
|
||||
return request.make_json_response(attachmentData)
|
||||
|
||||
@http.route('/mail/attachment/delete', methods=['POST'], type='json', auth='public')
|
||||
def mail_attachment_delete(self, attachment_id, access_token=None, **kwargs):
|
||||
attachment_sudo = request.env['ir.attachment'].browse(int(attachment_id)).sudo().exists()
|
||||
if not attachment_sudo:
|
||||
target = request.env.user.partner_id
|
||||
request.env['bus.bus']._sendone(target, 'ir.attachment/delete', {'id': attachment_id})
|
||||
return
|
||||
if not request.env.user.share:
|
||||
# Check through standard access rights/rules for internal users.
|
||||
attachment_sudo.sudo(False)._delete_and_notify()
|
||||
return
|
||||
# For non-internal users 2 cases are supported:
|
||||
# - Either the attachment is linked to a message: verify the request is made by the author of the message (portal user or guest).
|
||||
# - Either a valid access token is given: also verify the message is pending (because unfortunately in portal a token is also provided to guest for viewing others' attachments).
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
message_sudo = guest.env['mail.message'].sudo().search([('attachment_ids', 'in', attachment_sudo.ids)], limit=1)
|
||||
if message_sudo:
|
||||
if not message_sudo.is_current_user_or_guest_author:
|
||||
raise NotFound()
|
||||
else:
|
||||
if not access_token or not attachment_sudo.access_token or not consteq(access_token, attachment_sudo.access_token):
|
||||
raise NotFound()
|
||||
if attachment_sudo.res_model != 'mail.compose.message' or attachment_sudo.res_id != 0:
|
||||
raise NotFound()
|
||||
attachment_sudo._delete_and_notify()
|
||||
|
||||
@http.route('/mail/message/add_reaction', methods=['POST'], type='json', auth='public')
|
||||
def mail_message_add_reaction(self, message_id, content):
|
||||
guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo()
|
||||
message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists()
|
||||
if not message_sudo:
|
||||
raise NotFound()
|
||||
if request.env.user.sudo()._is_public():
|
||||
if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids:
|
||||
raise NotFound()
|
||||
message_sudo._message_add_reaction(content=content)
|
||||
guests = [('insert', {'id': guest_sudo.id})]
|
||||
partners = []
|
||||
else:
|
||||
message_sudo.sudo(False)._message_add_reaction(content=content)
|
||||
guests = []
|
||||
partners = [('insert', {'id': request.env.user.partner_id.id})]
|
||||
reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)])
|
||||
return {
|
||||
'id': message_sudo.id,
|
||||
'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', {
|
||||
'content': content,
|
||||
'count': len(reactions),
|
||||
'guests': guests,
|
||||
'message': {'id', message_sudo.id},
|
||||
'partners': partners,
|
||||
})],
|
||||
}
|
||||
|
||||
@http.route('/mail/message/remove_reaction', methods=['POST'], type='json', auth='public')
|
||||
def mail_message_remove_reaction(self, message_id, content):
|
||||
guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo()
|
||||
message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists()
|
||||
if not message_sudo:
|
||||
raise NotFound()
|
||||
if request.env.user.sudo()._is_public():
|
||||
if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids:
|
||||
raise NotFound()
|
||||
message_sudo._message_remove_reaction(content=content)
|
||||
guests = [('insert-and-unlink', {'id': guest_sudo.id})]
|
||||
partners = []
|
||||
else:
|
||||
message_sudo.sudo(False)._message_remove_reaction(content=content)
|
||||
guests = []
|
||||
partners = [('insert-and-unlink', {'id': request.env.user.partner_id.id})]
|
||||
reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)])
|
||||
return {
|
||||
'id': message_sudo.id,
|
||||
'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', {
|
||||
'content': content,
|
||||
'count': len(reactions),
|
||||
'guests': guests,
|
||||
'message': {'id': message_sudo.id},
|
||||
'partners': partners,
|
||||
})],
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Channel API
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/channel/add_guest_as_member', methods=['POST'], type='json', auth='public')
|
||||
def mail_channel_add_guest_as_member(self, channel_id, channel_uuid, **kwargs):
|
||||
channel_sudo = request.env['mail.channel'].browse(int(channel_id)).sudo().exists()
|
||||
if not channel_sudo or not channel_sudo.uuid or not consteq(channel_sudo.uuid, channel_uuid):
|
||||
raise NotFound()
|
||||
if channel_sudo.channel_type == 'chat':
|
||||
raise NotFound()
|
||||
guest = channel_sudo.env['mail.guest']._get_guest_from_request(request)
|
||||
# Only guests should take this route.
|
||||
if not guest:
|
||||
raise NotFound()
|
||||
channel_member = channel_sudo.env['mail.channel.member']._get_as_sudo_from_request(request=request, channel_id=channel_id)
|
||||
# Do not add the guest to channel members if they are already member.
|
||||
if not channel_member:
|
||||
channel_sudo = channel_sudo.with_context(guest=guest)
|
||||
try:
|
||||
channel_sudo.add_members(guest_ids=[guest.id])
|
||||
except UserError:
|
||||
raise NotFound()
|
||||
|
||||
@http.route('/mail/channel/messages', methods=['POST'], type='json', auth='public')
|
||||
def mail_channel_messages(self, channel_id, max_id=None, min_id=None, limit=30, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
messages = channel_member_sudo.env['mail.message']._message_fetch(domain=[
|
||||
('res_id', '=', channel_id),
|
||||
('model', '=', 'mail.channel'),
|
||||
('message_type', '!=', 'user_notification'),
|
||||
], max_id=max_id, min_id=min_id, limit=limit)
|
||||
if not request.env.user._is_public():
|
||||
messages.set_message_done()
|
||||
return messages.message_format()
|
||||
|
||||
@http.route('/mail/channel/set_last_seen_message', methods=['POST'], type='json', auth='public')
|
||||
def mail_channel_mark_as_seen(self, channel_id, last_message_id, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
return channel_member_sudo.channel_id._channel_seen(int(last_message_id))
|
||||
|
||||
@http.route('/mail/channel/notify_typing', methods=['POST'], type='json', auth='public')
|
||||
def mail_channel_notify_typing(self, channel_id, is_typing, **kwargs):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
channel_member_sudo._notify_typing(is_typing)
|
||||
|
||||
@http.route('/mail/channel/ping', methods=['POST'], type='json', auth='public')
|
||||
def channel_ping(self, channel_id, rtc_session_id=None, check_rtc_session_ids=None):
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
if rtc_session_id:
|
||||
channel_member_sudo.channel_id.rtc_session_ids.filtered_domain([
|
||||
('id', '=', int(rtc_session_id)),
|
||||
('channel_member_id', '=', channel_member_sudo.id),
|
||||
]).write({}) # update write_date
|
||||
current_rtc_sessions, outdated_rtc_sessions = channel_member_sudo._rtc_sync_sessions(check_rtc_session_ids=check_rtc_session_ids)
|
||||
return {'rtcSessions': [
|
||||
('insert', [rtc_session_sudo._mail_rtc_session_format() for rtc_session_sudo in current_rtc_sessions]),
|
||||
('insert-and-unlink', [{'id': missing_rtc_session_sudo.id} for missing_rtc_session_sudo in outdated_rtc_sessions]),
|
||||
]}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Chatter API
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/thread/data', methods=['POST'], type='json', auth='user')
|
||||
def mail_thread_data(self, thread_model, thread_id, request_list, **kwargs):
|
||||
thread = request.env[thread_model].with_context(active_test=False).search([('id', '=', thread_id)])
|
||||
return thread._get_mail_thread_data(request_list)
|
||||
|
||||
@http.route('/mail/thread/messages', methods=['POST'], type='json', auth='user')
|
||||
def mail_thread_messages(self, thread_model, thread_id, max_id=None, min_id=None, limit=30, **kwargs):
|
||||
messages = request.env['mail.message']._message_fetch(domain=[
|
||||
('res_id', '=', int(thread_id)),
|
||||
('model', '=', thread_model),
|
||||
('message_type', '!=', 'user_notification'),
|
||||
], max_id=max_id, min_id=min_id, limit=limit)
|
||||
if not request.env.user._is_public():
|
||||
messages.set_message_done()
|
||||
return messages.message_format()
|
||||
|
||||
@http.route('/mail/read_subscription_data', methods=['POST'], type='json', auth='user')
|
||||
def read_subscription_data(self, follower_id):
|
||||
""" Computes:
|
||||
- message_subtype_data: data about document subtypes: which are
|
||||
available, which are followed if any """
|
||||
request.env['mail.followers'].check_access_rights("read")
|
||||
follower = request.env['mail.followers'].sudo().browse(follower_id)
|
||||
follower.ensure_one()
|
||||
request.env[follower.res_model].check_access_rights("read")
|
||||
record = request.env[follower.res_model].browse(follower.res_id)
|
||||
record.check_access_rule("read")
|
||||
|
||||
# find current model subtypes, add them to a dictionary
|
||||
subtypes = record._mail_get_message_subtypes()
|
||||
|
||||
followed_subtypes_ids = set(follower.subtype_ids.ids)
|
||||
subtypes_list = [{
|
||||
'name': subtype.name,
|
||||
'res_model': subtype.res_model,
|
||||
'sequence': subtype.sequence,
|
||||
'default': subtype.default,
|
||||
'internal': subtype.internal,
|
||||
'followed': subtype.id in followed_subtypes_ids,
|
||||
'parent_model': subtype.parent_id.res_model,
|
||||
'id': subtype.id
|
||||
} for subtype in subtypes]
|
||||
return sorted(subtypes_list,
|
||||
key=lambda it: (it['parent_model'] or '', it['res_model'] or '', it['internal'], it['sequence']))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# RTC API TODO move check logic in routes.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/rtc/session/notify_call_members', methods=['POST'], type="json", auth="public")
|
||||
def session_call_notify(self, peer_notifications):
|
||||
""" Sends content to other session of the same channel, only works if the user is the user of that session.
|
||||
This is used to send peer to peer information between sessions.
|
||||
|
||||
:param peer_notifications: list of tuple with the following elements:
|
||||
- int sender_session_id: id of the session from which the content is sent
|
||||
- list target_session_ids: list of the ids of the sessions that should receive the content
|
||||
- string content: the content to send to the other sessions
|
||||
"""
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
notifications_by_session = defaultdict(list)
|
||||
for sender_session_id, target_session_ids, content in peer_notifications:
|
||||
session_sudo = guest.env['mail.channel.rtc.session'].sudo().browse(int(sender_session_id)).exists()
|
||||
if not session_sudo or (session_sudo.guest_id and session_sudo.guest_id != guest) or (session_sudo.partner_id and session_sudo.partner_id != request.env.user.partner_id):
|
||||
continue
|
||||
notifications_by_session[session_sudo].append(([int(sid) for sid in target_session_ids], content))
|
||||
for session_sudo, notifications in notifications_by_session.items():
|
||||
session_sudo._notify_peers(notifications)
|
||||
|
||||
@http.route('/mail/rtc/session/update_and_broadcast', methods=['POST'], type="json", auth="public")
|
||||
def session_update_and_broadcast(self, session_id, values):
|
||||
""" Update a RTC session and broadcasts the changes to the members of its channel,
|
||||
only works of the user is the user of that session.
|
||||
:param int session_id: id of the session to update
|
||||
:param dict values: write dict for the fields to update
|
||||
"""
|
||||
if request.env.user._is_public():
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
if guest:
|
||||
session = guest.env['mail.channel.rtc.session'].sudo().browse(int(session_id)).exists()
|
||||
if session and session.guest_id == guest:
|
||||
session._update_and_broadcast(values)
|
||||
return
|
||||
return
|
||||
session = request.env['mail.channel.rtc.session'].sudo().browse(int(session_id)).exists()
|
||||
if session and session.partner_id == request.env.user.partner_id:
|
||||
session._update_and_broadcast(values)
|
||||
|
||||
@http.route('/mail/rtc/channel/join_call', methods=['POST'], type="json", auth="public")
|
||||
def channel_call_join(self, channel_id, check_rtc_session_ids=None):
|
||||
""" Joins the RTC call of a channel if the user is a member of that channel
|
||||
:param int channel_id: id of the channel to join
|
||||
"""
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
return channel_member_sudo._rtc_join_call(check_rtc_session_ids=check_rtc_session_ids)
|
||||
|
||||
@http.route('/mail/rtc/channel/leave_call', methods=['POST'], type="json", auth="public")
|
||||
def channel_call_leave(self, channel_id):
|
||||
""" Disconnects the current user from a rtc call and clears any invitation sent to that user on this channel
|
||||
:param int channel_id: id of the channel from which to disconnect
|
||||
"""
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
return channel_member_sudo._rtc_leave_call()
|
||||
|
||||
@http.route('/mail/rtc/channel/cancel_call_invitation', methods=['POST'], type="json", auth="public")
|
||||
def channel_call_cancel_invitation(self, channel_id, member_ids=None):
|
||||
""" Sends invitations to join the RTC call to all connected members of the thread who are not already invited,
|
||||
if member_ids is provided, only the specified ids will be invited.
|
||||
|
||||
:param list member_ids: list of member ids to invite
|
||||
"""
|
||||
channel_member_sudo = request.env['mail.channel.member']._get_as_sudo_from_request_or_raise(request=request, channel_id=int(channel_id))
|
||||
return channel_member_sudo.channel_id._rtc_cancel_invitations(member_ids=member_ids)
|
||||
|
||||
@http.route('/mail/rtc/audio_worklet_processor', methods=['GET'], type='http', auth='public')
|
||||
def audio_worklet_processor(self):
|
||||
""" Returns a JS file that declares a WorkletProcessor class in
|
||||
a WorkletGlobalScope, which means that it cannot be added to the
|
||||
bundles like other assets.
|
||||
"""
|
||||
return request.make_response(
|
||||
file_open('mail/static/src/worklets/audio_processor.js', 'rb').read(),
|
||||
headers=[
|
||||
('Content-Type', 'application/javascript'),
|
||||
('Cache-Control', 'max-age=%s' % http.STATIC_CACHE),
|
||||
]
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Guest API
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/guest/update_name', methods=['POST'], type='json', auth='public')
|
||||
def mail_guest_update_name(self, guest_id, name):
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
guest_to_rename_sudo = guest.env['mail.guest'].browse(guest_id).sudo().exists()
|
||||
if not guest_to_rename_sudo:
|
||||
raise NotFound()
|
||||
if guest_to_rename_sudo != guest and not request.env.user._is_admin():
|
||||
raise NotFound()
|
||||
guest_to_rename_sudo._update_name(name)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Link preview API
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@http.route('/mail/link_preview', methods=['POST'], type='json', auth='public')
|
||||
def mail_link_preview(self, message_id):
|
||||
if not request.env['mail.link.preview'].sudo()._is_link_preview_enabled():
|
||||
return
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
message = guest.env['mail.message'].search([('id', '=', int(message_id))])
|
||||
if not message:
|
||||
return
|
||||
if not message.is_current_user_or_guest_author and not guest.env.user._is_admin():
|
||||
return
|
||||
guest.env['mail.link.preview'].sudo()._create_link_previews(message)
|
||||
|
||||
@http.route('/mail/link_preview/delete', methods=['POST'], type='json', auth='public')
|
||||
def mail_link_preview_delete(self, link_preview_id):
|
||||
guest = request.env['mail.guest']._get_guest_from_request(request)
|
||||
link_preview_sudo = guest.env['mail.link.preview'].sudo().search([('id', '=', int(link_preview_id))])
|
||||
if not link_preview_sudo:
|
||||
return
|
||||
if not link_preview_sudo.message_id.is_current_user_or_guest_author and not guest.env.user._is_admin():
|
||||
return
|
||||
link_preview_sudo._delete_and_notify()
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import channel
|
||||
from . import gif
|
||||
from . import public_page
|
||||
from . import rtc
|
||||
from . import search
|
||||
from . import settings
|
||||
from . import voice
|
||||
222
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/channel.py
Normal file
222
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/channel.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from markupsafe import Markup
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.controllers.webclient import WebclientController
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
|
||||
|
||||
class DiscussChannelWebclientController(WebclientController):
|
||||
"""Override to add discuss channel specific features."""
|
||||
|
||||
@classmethod
|
||||
def _process_request_loop(self, store: Store, fetch_params):
|
||||
"""Override to add discuss channel specific features."""
|
||||
# aggregate of channels to return, to batch them in a single query when all the fetch params
|
||||
# have been processed
|
||||
request.update_context(
|
||||
channels=request.env["discuss.channel"], add_channels_last_message=False
|
||||
)
|
||||
super()._process_request_loop(store, fetch_params)
|
||||
channels = request.env.context["channels"]
|
||||
if channels:
|
||||
store.add(channels)
|
||||
if request.env.context["add_channels_last_message"]:
|
||||
# fetch channels data before messages to benefit from prefetching (channel info might
|
||||
# prefetch a lot of data that message format could use)
|
||||
store.add(channels._get_last_messages())
|
||||
|
||||
@classmethod
|
||||
def _process_request_for_all(self, store: Store, name, params):
|
||||
"""Override to return channel as member and last messages."""
|
||||
super()._process_request_for_all(store, name, params)
|
||||
if name == "init_messaging":
|
||||
member_domain = [("is_self", "=", True), ("rtc_inviting_session_id", "!=", False)]
|
||||
channel_domain = [("channel_member_ids", "any", member_domain)]
|
||||
channels = request.env["discuss.channel"].search(channel_domain)
|
||||
request.update_context(channels=request.env.context["channels"] | channels)
|
||||
if name == "channels_as_member":
|
||||
channels = request.env["discuss.channel"]._get_channels_as_member()
|
||||
request.update_context(
|
||||
channels=request.env.context["channels"] | channels, add_channels_last_message=True
|
||||
)
|
||||
if name == "discuss.channel":
|
||||
channels = request.env["discuss.channel"].search([("id", "in", params)])
|
||||
request.update_context(channels=request.env.context["channels"] | channels)
|
||||
if name == "/discuss/get_or_create_chat":
|
||||
channel = request.env["discuss.channel"]._get_or_create_chat(
|
||||
params["partners_to"], params.get("pin", True)
|
||||
)
|
||||
store.add(channel).resolve_data_request(channel=Store.One(channel, []))
|
||||
if name == "/discuss/create_channel":
|
||||
channel = request.env["discuss.channel"]._create_channel(params["name"], params["group_id"])
|
||||
store.add(channel).resolve_data_request(channel=Store.One(channel, []))
|
||||
if name == "/discuss/create_group":
|
||||
channel = request.env["discuss.channel"]._create_group(
|
||||
params["partners_to"],
|
||||
params.get("default_display_mode", False),
|
||||
params.get("name", ""),
|
||||
)
|
||||
store.add(channel).resolve_data_request(channel=Store.One(channel, []))
|
||||
|
||||
|
||||
class ChannelController(http.Controller):
|
||||
@http.route("/discuss/channel/members", methods=["POST"], type="jsonrpc", auth="public", readonly=True)
|
||||
@add_guest_to_context
|
||||
def discuss_channel_members(self, channel_id, known_member_ids):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
unknown_members = self.env["discuss.channel.member"].search(
|
||||
domain=[("id", "not in", known_member_ids), ("channel_id", "=", channel.id)],
|
||||
limit=100,
|
||||
)
|
||||
store = Store().add(channel, "member_count").add(unknown_members)
|
||||
return store.get_result()
|
||||
|
||||
@http.route("/discuss/channel/update_avatar", methods=["POST"], type="jsonrpc")
|
||||
def discuss_channel_avatar_update(self, channel_id, data):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel or not data:
|
||||
raise NotFound()
|
||||
channel.write({"image_128": data})
|
||||
|
||||
@http.route("/discuss/channel/messages", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_messages(self, channel_id, fetch_params=None):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
res = request.env["mail.message"]._message_fetch(domain=None, thread=channel, **(fetch_params or {}))
|
||||
messages = res.pop("messages")
|
||||
if not request.env.user._is_public():
|
||||
messages.set_message_done()
|
||||
return {
|
||||
**res,
|
||||
"data": Store().add(messages).get_result(),
|
||||
"messages": messages.ids,
|
||||
}
|
||||
|
||||
@http.route("/discuss/channel/pinned_messages", methods=["POST"], type="jsonrpc", auth="public", readonly=True)
|
||||
@add_guest_to_context
|
||||
def discuss_channel_pins(self, channel_id):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
messages = channel.pinned_message_ids.sorted(key="pinned_at", reverse=True)
|
||||
return Store().add(messages).get_result()
|
||||
|
||||
@http.route("/discuss/channel/mark_as_read", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_mark_as_read(self, channel_id, last_message_id):
|
||||
member = request.env["discuss.channel.member"].search([
|
||||
("channel_id", "=", channel_id),
|
||||
("is_self", "=", True),
|
||||
])
|
||||
if not member:
|
||||
return # ignore if the member left in the meantime
|
||||
member._mark_as_read(last_message_id)
|
||||
|
||||
@http.route("/discuss/channel/set_new_message_separator", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_set_new_message_separator(self, channel_id, message_id):
|
||||
member = request.env["discuss.channel.member"].search([
|
||||
("channel_id", "=", channel_id),
|
||||
("is_self", "=", True),
|
||||
])
|
||||
if not member:
|
||||
raise NotFound()
|
||||
return member._set_new_message_separator(message_id)
|
||||
|
||||
@http.route("/discuss/channel/notify_typing", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_notify_typing(self, channel_id, is_typing):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise request.not_found()
|
||||
if is_typing:
|
||||
member = channel._find_or_create_member_for_self()
|
||||
else:
|
||||
# Do not create member automatically when setting typing to `False`
|
||||
# as it could be resulting from the user leaving.
|
||||
member = request.env["discuss.channel.member"].search(
|
||||
[
|
||||
("channel_id", "=", channel_id),
|
||||
("is_self", "=", True),
|
||||
]
|
||||
)
|
||||
if member:
|
||||
member._notify_typing(is_typing)
|
||||
|
||||
@http.route("/discuss/channel/attachments", methods=["POST"], type="jsonrpc", auth="public", readonly=True)
|
||||
@add_guest_to_context
|
||||
def load_attachments(self, channel_id, limit=30, before=None):
|
||||
"""Load attachments of a channel. If before is set, load attachments
|
||||
older than the given id.
|
||||
:param channel_id: id of the channel
|
||||
:param limit: maximum number of attachments to return
|
||||
:param before: id of the attachment from which to load older attachments
|
||||
"""
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
domain = [
|
||||
["res_id", "=", channel_id],
|
||||
["res_model", "=", "discuss.channel"],
|
||||
]
|
||||
if before:
|
||||
domain.append(["id", "<", before])
|
||||
# sudo: ir.attachment - reading attachments of a channel that the current user can access
|
||||
attachments = request.env["ir.attachment"].sudo().search(domain, limit=limit, order="id DESC")
|
||||
return {
|
||||
|
||||
"store_data": Store().add(attachments).get_result(),
|
||||
"count": len(attachments),
|
||||
}
|
||||
|
||||
@http.route("/discuss/channel/join", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_join(self, channel_id):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
channel._find_or_create_member_for_self()
|
||||
return Store().add(channel).get_result()
|
||||
|
||||
@http.route("/discuss/channel/sub_channel/create", methods=["POST"], type="jsonrpc", auth="public")
|
||||
def discuss_channel_sub_channel_create(self, parent_channel_id, from_message_id=None, name=None):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", parent_channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
sub_channel = channel._create_sub_channel(from_message_id, name)
|
||||
return {"store_data": Store().add(sub_channel).get_result(), "sub_channel": sub_channel.id}
|
||||
|
||||
@http.route("/discuss/channel/sub_channel/fetch", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_sub_channel_fetch(self, parent_channel_id, search_term=None, before=None, limit=30):
|
||||
channel = request.env["discuss.channel"].search([("id", "=", parent_channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
domain = [("parent_channel_id", "=", channel.id)]
|
||||
if before:
|
||||
domain.append(("id", "<", before))
|
||||
if search_term:
|
||||
domain.append(("name", "ilike", search_term))
|
||||
sub_channels = request.env["discuss.channel"].search(domain, order="id desc", limit=limit)
|
||||
return {
|
||||
"store_data": Store().add(sub_channels).add(sub_channels._get_last_messages()).get_result(),
|
||||
"sub_channel_ids": sub_channels.ids,
|
||||
}
|
||||
|
||||
@http.route("/discuss/channel/sub_channel/delete", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def discuss_delete_sub_channel(self, sub_channel_id):
|
||||
channel = request.env["discuss.channel"].search_fetch([("id", "=", sub_channel_id)])
|
||||
if not channel or not channel.parent_channel_id or channel.create_uid != request.env.user:
|
||||
raise NotFound()
|
||||
body = Markup('<div class="o_mail_notification" data-oe-type="thread_deletion">%s</div>') % channel.name
|
||||
channel.parent_channel_id.message_post(body=body, subtype_xmlid="mail.mt_comment")
|
||||
# sudo: discuss.channel - skipping ACL for users who created the thread
|
||||
channel.sudo().unlink()
|
||||
104
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/gif.py
Normal file
104
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/gif.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import urllib3
|
||||
import werkzeug.urls
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from odoo.http import request, route, Controller
|
||||
|
||||
TENOR_CONTENT_FILTER = "medium"
|
||||
TENOR_GIF_LIMIT = 8
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiscussGifController(Controller):
|
||||
def _request_gifs(self, endpoint):
|
||||
response = None
|
||||
try:
|
||||
response = requests.get(
|
||||
f"https://tenor.googleapis.com/v2/{endpoint}", timeout=3
|
||||
)
|
||||
response.raise_for_status()
|
||||
except (urllib3.exceptions.MaxRetryError, requests.exceptions.HTTPError):
|
||||
_logger.error("Exceeded the request's maximum size for a searching term.")
|
||||
|
||||
if not response:
|
||||
raise BadRequest()
|
||||
return response
|
||||
|
||||
@route("/discuss/gif/search", type="jsonrpc", auth="user")
|
||||
def search(self, search_term, locale="en", country="US", position=None, readonly=True):
|
||||
# sudo: ir.config_parameter - read keys are hard-coded and values are only used for server requests
|
||||
ir_config = request.env["ir.config_parameter"].sudo()
|
||||
query_string = werkzeug.urls.url_encode(
|
||||
{
|
||||
"q": search_term,
|
||||
"key": ir_config.get_param("discuss.tenor_api_key"),
|
||||
"client_key": request.env.cr.dbname,
|
||||
"limit": TENOR_GIF_LIMIT,
|
||||
"contentfilter": TENOR_CONTENT_FILTER,
|
||||
"locale": locale,
|
||||
"country": country,
|
||||
"media_filter": "tinygif",
|
||||
"pos": position,
|
||||
}
|
||||
)
|
||||
response = self._request_gifs(f"search?{query_string}")
|
||||
if response:
|
||||
return response.json()
|
||||
|
||||
@route("/discuss/gif/categories", type="jsonrpc", auth="user", readonly=True)
|
||||
def categories(self, locale="en", country="US"):
|
||||
# sudo: ir.config_parameter - read keys are hard-coded and values are only used for server requests
|
||||
ir_config = request.env["ir.config_parameter"].sudo()
|
||||
query_string = werkzeug.urls.url_encode(
|
||||
{
|
||||
"key": ir_config.get_param("discuss.tenor_api_key"),
|
||||
"client_key": request.env.cr.dbname,
|
||||
"limit": TENOR_GIF_LIMIT,
|
||||
"contentfilter": TENOR_CONTENT_FILTER,
|
||||
"locale": locale,
|
||||
"country": country,
|
||||
}
|
||||
)
|
||||
response = self._request_gifs(f"categories?{query_string}")
|
||||
if response:
|
||||
return response.json()
|
||||
|
||||
@route("/discuss/gif/add_favorite", type="jsonrpc", auth="user")
|
||||
def add_favorite(self, tenor_gif_id):
|
||||
request.env["discuss.gif.favorite"].create({"tenor_gif_id": tenor_gif_id})
|
||||
|
||||
def _gif_posts(self, ids):
|
||||
# sudo: ir.config_parameter - read keys are hard-coded and values are only used for server requests
|
||||
ir_config = request.env["ir.config_parameter"].sudo()
|
||||
query_string = werkzeug.urls.url_encode(
|
||||
{
|
||||
"ids": ",".join(ids),
|
||||
"key": ir_config.get_param("discuss.tenor_api_key"),
|
||||
"client_key": request.env.cr.dbname,
|
||||
"media_filter": "tinygif",
|
||||
}
|
||||
)
|
||||
response = self._request_gifs(f"posts?{query_string}")
|
||||
if response:
|
||||
return response.json()["results"]
|
||||
|
||||
@route("/discuss/gif/favorites", type="jsonrpc", auth="user", readonly=True)
|
||||
def get_favorites(self, offset=0):
|
||||
tenor_gif_ids = request.env["discuss.gif.favorite"].search(
|
||||
[("create_uid", "=", request.env.user.id)], limit=20, offset=offset
|
||||
)
|
||||
return (self._gif_posts(tenor_gif_ids.mapped("tenor_gif_id")) or [],)
|
||||
|
||||
@route("/discuss/gif/remove_favorite", type="jsonrpc", auth="user")
|
||||
def remove_favorite(self, tenor_gif_id):
|
||||
request.env["discuss.gif.favorite"].search(
|
||||
[
|
||||
("create_uid", "=", request.env.user.id),
|
||||
("tenor_gif_id", "=", tenor_gif_id),
|
||||
]
|
||||
).unlink()
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import psycopg2.errors
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
from odoo.tools import consteq, email_normalize, replace_exceptions
|
||||
from odoo.tools.misc import verify_hash_signed
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
|
||||
|
||||
class PublicPageController(http.Controller):
|
||||
@http.route(
|
||||
[
|
||||
"/chat/<string:create_token>",
|
||||
"/chat/<string:create_token>/<string:channel_name>",
|
||||
],
|
||||
methods=["GET"],
|
||||
type="http",
|
||||
auth="public",
|
||||
)
|
||||
@add_guest_to_context
|
||||
def discuss_channel_chat_from_token(self, create_token, channel_name=None):
|
||||
return self._response_discuss_channel_from_token(create_token=create_token, channel_name=channel_name)
|
||||
|
||||
@http.route(
|
||||
[
|
||||
"/meet/<string:create_token>",
|
||||
"/meet/<string:create_token>/<string:channel_name>",
|
||||
],
|
||||
methods=["GET"],
|
||||
type="http",
|
||||
auth="public",
|
||||
)
|
||||
@add_guest_to_context
|
||||
def discuss_channel_meet_from_token(self, create_token, channel_name=None):
|
||||
return self._response_discuss_channel_from_token(
|
||||
create_token=create_token, channel_name=channel_name, default_display_mode="video_full_screen"
|
||||
)
|
||||
|
||||
@http.route("/chat/<int:channel_id>/<string:invitation_token>", methods=["GET"], type="http", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel_invitation(self, channel_id, invitation_token, email_token=None):
|
||||
guest_email = email_token and verify_hash_signed(
|
||||
self.env(su=True), "mail.invite_email", email_token
|
||||
)
|
||||
guest_email = email_normalize(guest_email)
|
||||
channel = request.env["discuss.channel"].browse(channel_id).exists()
|
||||
# sudo: discuss.channel - channel access is validated with invitation_token
|
||||
if not channel or not channel.sudo().uuid or not consteq(channel.sudo().uuid, invitation_token):
|
||||
raise NotFound()
|
||||
store = Store().add_global_values(isChannelTokenSecret=True)
|
||||
return self._response_discuss_channel_invitation(store, channel, guest_email)
|
||||
|
||||
@http.route("/discuss/channel/<int:channel_id>", methods=["GET"], type="http", auth="public")
|
||||
@add_guest_to_context
|
||||
def discuss_channel(self, channel_id, *, highlight_message_id=None):
|
||||
# highlight_message_id is used JS side by parsing the query string
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
return self._response_discuss_public_template(Store(), channel)
|
||||
|
||||
def _response_discuss_channel_from_token(self, create_token, channel_name=None, default_display_mode=False):
|
||||
# sudo: ir.config_parameter - reading hard-coded key and using it in a simple condition
|
||||
if not request.env["ir.config_parameter"].sudo().get_param("mail.chat_from_token"):
|
||||
raise NotFound()
|
||||
# sudo: discuss.channel - channel access is validated with invitation_token
|
||||
channel_sudo = request.env["discuss.channel"].sudo().search([("uuid", "=", create_token)])
|
||||
if not channel_sudo:
|
||||
try:
|
||||
channel_sudo = channel_sudo.create(
|
||||
{
|
||||
"channel_type": "channel",
|
||||
"default_display_mode": default_display_mode,
|
||||
"group_public_id": None,
|
||||
"name": channel_name or create_token,
|
||||
"uuid": create_token,
|
||||
}
|
||||
)
|
||||
except psycopg2.errors.UniqueViolation:
|
||||
# concurrent insert attempt: another request created the channel.
|
||||
# commit the current transaction and get the channel.
|
||||
request.env.cr.commit()
|
||||
channel_sudo = channel_sudo.search([("uuid", "=", create_token)])
|
||||
store = Store().add_global_values(isChannelTokenSecret=False)
|
||||
return self._response_discuss_channel_invitation(store, channel_sudo.sudo(False))
|
||||
|
||||
def _response_discuss_channel_invitation(self, store, channel, guest_email=None):
|
||||
# group restriction takes precedence over token
|
||||
# sudo - res.groups: can access group public id of parent channel to determine if we
|
||||
# can access the channel.
|
||||
group_public_id = channel.group_public_id or channel.parent_channel_id.sudo().group_public_id
|
||||
if group_public_id and group_public_id not in request.env.user.all_group_ids:
|
||||
raise request.not_found()
|
||||
guest_already_known = channel.env["mail.guest"]._get_guest_from_context()
|
||||
with replace_exceptions(UserError, by=NotFound()):
|
||||
# sudo: mail.guest - creating a guest and its member inside a channel of which they have the token
|
||||
__, guest = channel.sudo()._find_or_create_persona_for_channel(
|
||||
guest_name=guest_email if guest_email else _("Guest"),
|
||||
country_code=request.geoip.country_code,
|
||||
timezone=request.env["mail.guest"]._get_timezone_from_request(request),
|
||||
)
|
||||
if guest_email and not guest.email:
|
||||
# sudo - mail.guest: writing email address of self guest is allowed
|
||||
guest.sudo().email = guest_email
|
||||
if guest and not guest_already_known:
|
||||
store.add_global_values(is_welcome_page_displayed=True)
|
||||
channel = channel.with_context(guest=guest)
|
||||
return self._response_discuss_public_template(store, channel)
|
||||
|
||||
def _response_discuss_public_template(self, store: Store, channel):
|
||||
store.add_global_values(
|
||||
companyName=request.env.company.name,
|
||||
inPublicPage=True,
|
||||
)
|
||||
store.add_singleton_values("DiscussApp", {"thread": store.One(channel)})
|
||||
return request.render(
|
||||
"mail.discuss_public_channel_template",
|
||||
{
|
||||
"data": store.get_result(),
|
||||
"session_info": channel.env["ir.http"].session_info(),
|
||||
},
|
||||
)
|
||||
148
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/rtc.py
Normal file
148
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/rtc.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.tools import file_open
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
|
||||
|
||||
class RtcController(http.Controller):
|
||||
@http.route("/mail/rtc/session/notify_call_members", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def session_call_notify(self, peer_notifications):
|
||||
"""Sends content to other session of the same channel, only works if the user is the user of that session.
|
||||
This is used to send peer to peer information between sessions.
|
||||
|
||||
:param peer_notifications: list of tuple with the following elements:
|
||||
- int sender_session_id: id of the session from which the content is sent
|
||||
- list target_session_ids: list of the ids of the sessions that should receive the content
|
||||
- string content: the content to send to the other sessions
|
||||
"""
|
||||
guest = request.env["mail.guest"]._get_guest_from_context()
|
||||
notifications_by_session = defaultdict(list)
|
||||
for sender_session_id, target_session_ids, content in peer_notifications:
|
||||
# sudo: discuss.channel.rtc.session - only keeping sessions matching the current user
|
||||
session_sudo = request.env["discuss.channel.rtc.session"].sudo().browse(int(sender_session_id)).exists()
|
||||
if (
|
||||
not session_sudo
|
||||
or (session_sudo.guest_id and session_sudo.guest_id != guest)
|
||||
or (session_sudo.partner_id and session_sudo.partner_id != request.env.user.partner_id)
|
||||
):
|
||||
continue
|
||||
notifications_by_session[session_sudo].append(([int(sid) for sid in target_session_ids], content))
|
||||
for session_sudo, notifications in notifications_by_session.items():
|
||||
session_sudo._notify_peers(notifications)
|
||||
|
||||
@http.route("/mail/rtc/session/update_and_broadcast", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def session_update_and_broadcast(self, session_id, values):
|
||||
"""Update a RTC session and broadcasts the changes to the members of its channel,
|
||||
only works of the user is the user of that session.
|
||||
:param int session_id: id of the session to update
|
||||
:param dict values: write dict for the fields to update
|
||||
"""
|
||||
if request.env.user._is_public():
|
||||
guest = request.env["mail.guest"]._get_guest_from_context()
|
||||
if guest:
|
||||
# sudo: discuss.channel.rtc.session - only keeping sessions matching the current user
|
||||
session = guest.env["discuss.channel.rtc.session"].sudo().browse(int(session_id)).exists()
|
||||
if session and session.guest_id == guest:
|
||||
session._update_and_broadcast(values)
|
||||
return
|
||||
return
|
||||
# sudo: discuss.channel.rtc.session - only keeping sessions matching the current user
|
||||
session = request.env["discuss.channel.rtc.session"].sudo().browse(int(session_id)).exists()
|
||||
if session and session.partner_id == request.env.user.partner_id:
|
||||
session._update_and_broadcast(values)
|
||||
|
||||
@http.route("/mail/rtc/channel/join_call", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def channel_call_join(self, channel_id, check_rtc_session_ids=None, camera=False):
|
||||
"""Joins the RTC call of a channel if the user is a member of that channel
|
||||
:param int channel_id: id of the channel to join
|
||||
"""
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise request.not_found()
|
||||
member = channel._find_or_create_member_for_self()
|
||||
if not member:
|
||||
raise NotFound()
|
||||
store = Store()
|
||||
# sudo: discuss.channel.rtc.session - member of current user can join call
|
||||
member.sudo()._rtc_join_call(store, check_rtc_session_ids=check_rtc_session_ids, camera=camera)
|
||||
return store.get_result()
|
||||
|
||||
@http.route("/mail/rtc/channel/leave_call", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def channel_call_leave(self, channel_id, session_id=None):
|
||||
"""Disconnects the current user from a rtc call and clears any invitation sent to that user on this channel
|
||||
:param int channel_id: id of the channel from which to disconnect
|
||||
:param int session_id: id of the leaving session
|
||||
"""
|
||||
member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)])
|
||||
if not member:
|
||||
raise NotFound()
|
||||
# sudo: discuss.channel.rtc.session - member of current user can leave call
|
||||
member.sudo()._rtc_leave_call(session_id)
|
||||
|
||||
@http.route("/mail/rtc/channel/upgrade_connection", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def channel_upgrade(self, channel_id):
|
||||
member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)])
|
||||
if not member:
|
||||
raise NotFound()
|
||||
member.sudo()._join_sfu(force=True)
|
||||
|
||||
@http.route("/mail/rtc/channel/cancel_call_invitation", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def channel_call_cancel_invitation(self, channel_id, member_ids=None):
|
||||
"""
|
||||
:param member_ids: members whose invitation is to cancel
|
||||
:type member_ids: list(int) or None
|
||||
"""
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise NotFound()
|
||||
# sudo: discuss.channel.rtc.session - can cancel invitations in accessible channel
|
||||
channel.sudo()._rtc_cancel_invitations(member_ids=member_ids)
|
||||
|
||||
@http.route("/mail/rtc/audio_worklet_processor_v2", methods=["GET"], type="http", auth="public", readonly=True)
|
||||
def audio_worklet_processor(self):
|
||||
"""Returns a JS file that declares a WorkletProcessor class in
|
||||
a WorkletGlobalScope, which means that it cannot be added to the
|
||||
bundles like other assets.
|
||||
"""
|
||||
with file_open("mail/static/src/worklets/audio_processor.js", "rb") as f:
|
||||
data = f.read()
|
||||
return request.make_response(
|
||||
data,
|
||||
headers=[
|
||||
("Content-Type", "application/javascript"),
|
||||
("Cache-Control", f"max-age={http.STATIC_CACHE}"),
|
||||
],
|
||||
)
|
||||
|
||||
@http.route("/discuss/channel/ping", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def channel_ping(self, channel_id, rtc_session_id=None, check_rtc_session_ids=None):
|
||||
member = request.env["discuss.channel.member"].search([("channel_id", "=", channel_id), ("is_self", "=", True)])
|
||||
if not member:
|
||||
raise NotFound()
|
||||
# sudo: discuss.channel.rtc.session - member of current user can access related sessions
|
||||
channel_member_sudo = member.sudo()
|
||||
if rtc_session_id:
|
||||
domain = [
|
||||
("id", "=", int(rtc_session_id)),
|
||||
("channel_member_id", "=", member.id),
|
||||
]
|
||||
channel_member_sudo.channel_id.rtc_session_ids.filtered_domain(domain).write({}) # update write_date
|
||||
current_rtc_sessions, outdated_rtc_sessions = channel_member_sudo._rtc_sync_sessions(check_rtc_session_ids)
|
||||
return Store().add(
|
||||
member.channel_id,
|
||||
[
|
||||
{"rtc_session_ids": Store.Many(current_rtc_sessions, mode="ADD")},
|
||||
{"rtc_session_ids": Store.Many(outdated_rtc_sessions, [], mode="DELETE")},
|
||||
],
|
||||
).get_result()
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.fields import Domain
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
|
||||
|
||||
class SearchController(http.Controller):
|
||||
@http.route("/discuss/search", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def search(self, term, category_id=None, limit=10):
|
||||
store = Store()
|
||||
self.get_search_store(store, search_term=term, limit=limit)
|
||||
return store.get_result()
|
||||
|
||||
def get_search_store(self, store: Store, search_term, limit):
|
||||
base_domain = Domain("name", "ilike", search_term) & Domain("channel_type", "!=", "chat")
|
||||
priority_conditions = [
|
||||
Domain("is_member", "=", True) & base_domain,
|
||||
base_domain,
|
||||
]
|
||||
channels = self.env["discuss.channel"]
|
||||
for domain in priority_conditions:
|
||||
remaining_limit = limit - len(channels)
|
||||
if remaining_limit <= 0:
|
||||
break
|
||||
# We are using _search to avoid the default order that is
|
||||
# automatically added by the search method. "Order by" makes the query
|
||||
# really slow.
|
||||
query = channels._search(Domain('id', 'not in', channels.ids) & domain, limit=remaining_limit)
|
||||
channels |= channels.browse(query)
|
||||
store.add(channels)
|
||||
request.env["res.partner"]._search_for_channel_invite(store, search_term=search_term, limit=limit)
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import fields
|
||||
from odoo.http import request, route, Controller
|
||||
|
||||
|
||||
class DiscussSettingsController(Controller):
|
||||
@route("/discuss/settings/mute", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def discuss_mute(self, minutes, channel_id):
|
||||
"""Mute notifications for the given number of minutes.
|
||||
:param minutes: (integer) number of minutes to mute notifications, -1 means mute until the user unmutes
|
||||
:param channel_id: (integer) id of the discuss.channel record
|
||||
"""
|
||||
channel = request.env["discuss.channel"].browse(channel_id)
|
||||
if not channel:
|
||||
raise request.not_found()
|
||||
member = channel._find_or_create_member_for_self()
|
||||
if not member:
|
||||
raise request.not_found()
|
||||
if minutes == -1:
|
||||
member.mute_until_dt = datetime.max
|
||||
elif minutes:
|
||||
member.mute_until_dt = fields.Datetime.now() + relativedelta(minutes=minutes)
|
||||
else:
|
||||
member.mute_until_dt = False
|
||||
member._notify_mute()
|
||||
|
||||
@route("/discuss/settings/custom_notifications", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def discuss_custom_notifications(self, custom_notifications, channel_id=None):
|
||||
"""Set custom notifications for the given channel or general user settings.
|
||||
:param custom_notifications: (false|all|mentions|no_notif) custom notifications to set
|
||||
:param channel_id: (integer) id of the discuss.channel record, if not set, set for res.users.settings
|
||||
"""
|
||||
if channel_id:
|
||||
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
raise request.not_found()
|
||||
member = channel._find_or_create_member_for_self()
|
||||
if not member:
|
||||
raise request.not_found()
|
||||
member.custom_notifications = custom_notifications
|
||||
else:
|
||||
user_settings = request.env["res.users.settings"]._find_or_create_for_user(request.env.user)
|
||||
if not user_settings:
|
||||
raise request.not_found()
|
||||
user_settings.set_res_users_settings({"channel_notifications": custom_notifications})
|
||||
19
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/voice.py
Normal file
19
odoo-bringout-oca-ocb-mail/mail/controllers/discuss/voice.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.tools import file_open
|
||||
|
||||
|
||||
class VoiceController(http.Controller):
|
||||
|
||||
@http.route("/discuss/voice/worklet_processor", methods=["GET"], type="http", auth="public", readonly=True)
|
||||
def voice_worklet_processor(self):
|
||||
with file_open("mail/static/src/discuss/voice_message/worklets/processor.js", "rb") as f:
|
||||
data = f.read()
|
||||
return request.make_response(
|
||||
data,
|
||||
headers=[
|
||||
("Content-Type", "application/javascript"),
|
||||
],
|
||||
)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import babel
|
||||
import requests
|
||||
|
||||
from odoo.http import request, route, Controller
|
||||
|
||||
|
||||
class GoogleTranslateController(Controller):
|
||||
@route("/mail/message/translate", type="jsonrpc", auth="user")
|
||||
def translate(self, message_id):
|
||||
message = request.env["mail.message"].search([("id", "=", message_id)])
|
||||
if not message:
|
||||
raise request.not_found()
|
||||
domain = [("message_id", "=", message.id), ("target_lang", "=", request.env.user.lang.split("_")[0])]
|
||||
# sudo: mail.message.translation - searching translations of a message that can be read with standard ACL
|
||||
translation = request.env["mail.message.translation"].sudo().search(domain)
|
||||
if not translation:
|
||||
try:
|
||||
source_lang = self._detect_source_lang(message)
|
||||
target_lang = request.env.user.lang.split("_")[0]
|
||||
# sudo: mail.message.translation - create translation of a message that can be read with standard ACL
|
||||
vals = {
|
||||
"body": self._get_translation(str(message.body), source_lang, target_lang),
|
||||
"message_id": message.id,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
}
|
||||
translation = request.env["mail.message.translation"].sudo().create(vals)
|
||||
except requests.exceptions.HTTPError as err:
|
||||
return {"error": err.response.json()["error"]["message"]}
|
||||
try:
|
||||
lang_name = babel.Locale(translation.source_lang).get_display_name(request.env.user.lang)
|
||||
except babel.UnknownLocaleError:
|
||||
lang_name = translation.source_lang
|
||||
return {
|
||||
"body": translation.body,
|
||||
"lang_name": lang_name,
|
||||
}
|
||||
|
||||
def _detect_source_lang(self, message):
|
||||
# sudo: mail.message.translation - searching translations of a message that can be read with standard ACL
|
||||
translation = request.env["mail.message.translation"].sudo().search([("message_id", "=", message.id)], limit=1)
|
||||
if translation:
|
||||
return translation.source_lang
|
||||
response = self._post(endpoint="detect", data={"q": str(message.body)})
|
||||
return response.json()["data"]["detections"][0][0]["language"]
|
||||
|
||||
def _get_translation(self, body, source_lang, target_lang):
|
||||
response = self._post(data={"q": body, "target": target_lang, "source": source_lang})
|
||||
return response.json()["data"]["translations"][0]["translatedText"]
|
||||
|
||||
def _post(self, endpoint="", data=None):
|
||||
# sudo: ir.config_parameter - reading google translate api key, using it to make the request
|
||||
api_key = request.env["ir.config_parameter"].sudo().get_param("mail.google_translate_api_key")
|
||||
url = f"https://translation.googleapis.com/language/translate/v2/{endpoint}?key={api_key}"
|
||||
response = requests.post(url, data=data, timeout=3)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
20
odoo-bringout-oca-ocb-mail/mail/controllers/guest.py
Normal file
20
odoo-bringout-oca-ocb-mail/mail/controllers/guest.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context
|
||||
|
||||
|
||||
class GuestController(http.Controller):
|
||||
@http.route("/mail/guest/update_name", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_guest_update_name(self, guest_id, name):
|
||||
guest = request.env["mail.guest"]._get_guest_from_context()
|
||||
guest_to_rename_sudo = guest.env["mail.guest"].browse(guest_id).sudo().exists()
|
||||
if not guest_to_rename_sudo:
|
||||
raise NotFound()
|
||||
if guest_to_rename_sudo != guest and not request.env.user._is_admin():
|
||||
raise NotFound()
|
||||
guest_to_rename_sudo._update_name(name)
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import ipaddress
|
||||
|
||||
from odoo import _, SUPERUSER_ID
|
||||
from odoo.http import request
|
||||
from odoo.addons.web.controllers.home import Home as WebHome
|
||||
|
||||
def _admin_password_warn(uid):
|
||||
""" Admin still has `admin` password, flash a message via chatter.
|
||||
|
||||
Uses a private mail.channel from the system (/ odoobot) to the user, as
|
||||
using a more generic mail.thread could send an email which is undesirable
|
||||
|
||||
Uses mail.channel directly because using mail.thread might send an email instead.
|
||||
"""
|
||||
if request.params['password'] != 'admin':
|
||||
return
|
||||
if ipaddress.ip_address(request.httprequest.remote_addr).is_private:
|
||||
return
|
||||
env = request.env(user=SUPERUSER_ID, su=True)
|
||||
admin = env.ref('base.partner_admin')
|
||||
if uid not in admin.user_ids.ids:
|
||||
return
|
||||
has_demo = bool(env['ir.module.module'].search_count([('demo', '=', True)]))
|
||||
if has_demo:
|
||||
return
|
||||
|
||||
user = request.env(user=uid)['res.users']
|
||||
MailChannel = env(context=user.context_get())['mail.channel']
|
||||
MailChannel.browse(MailChannel.channel_get([admin.id])['id'])\
|
||||
.message_post(
|
||||
body=_("Your password is the default (admin)! If this system is exposed to untrusted users it is important to change it immediately for security reasons. I will keep nagging you about it!"),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
|
||||
class Home(WebHome):
|
||||
def _login_redirect(self, uid, redirect=None):
|
||||
if request.params.get('login_success'):
|
||||
_admin_password_warn(uid)
|
||||
|
||||
return super()._login_redirect(uid, redirect)
|
||||
22
odoo-bringout-oca-ocb-mail/mail/controllers/im_status.py
Normal file
22
odoo-bringout-oca-ocb-mail/mail/controllers/im_status.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class ImStatusController(http.Controller):
|
||||
@http.route("/mail/set_manual_im_status", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def set_manual_im_status(self, status):
|
||||
if status not in ["online", "away", "busy", "offline"]:
|
||||
raise ValueError(_("Unexpected IM status %(status)s", status=status))
|
||||
user = request.env.user
|
||||
user.manual_im_status = False if status == "online" else status
|
||||
user._bus_send(
|
||||
"bus.bus/im_status_updated",
|
||||
{
|
||||
"debounce": False,
|
||||
"im_status": user.partner_id.im_status,
|
||||
"partner_id": user.partner_id.id,
|
||||
},
|
||||
subchannel="presence",
|
||||
)
|
||||
35
odoo-bringout-oca-ocb-mail/mail/controllers/link_preview.py
Normal file
35
odoo-bringout-oca-ocb-mail/mail/controllers/link_preview.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context
|
||||
|
||||
|
||||
class LinkPreviewController(http.Controller):
|
||||
@http.route("/mail/link_preview", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_link_preview(self, message_id):
|
||||
if not request.env["mail.link.preview"]._is_link_preview_enabled():
|
||||
return
|
||||
guest = request.env["mail.guest"]._get_guest_from_context()
|
||||
message = guest.env["mail.message"].search([("id", "=", int(message_id))])
|
||||
if not message:
|
||||
return
|
||||
if not message.is_current_user_or_guest_author and not guest.env.user._is_admin():
|
||||
return
|
||||
guest.env["mail.link.preview"].sudo()._create_from_message_and_notify(
|
||||
message, request_url=request.httprequest.url_root
|
||||
)
|
||||
|
||||
@http.route("/mail/link_preview/hide", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_link_preview_hide(self, message_link_preview_ids):
|
||||
guest = request.env["mail.guest"]._get_guest_from_context()
|
||||
# sudo: access check is done below using message_id
|
||||
link_preview_sudo = guest.env["mail.message.link.preview"].sudo().search([("id", "in", message_link_preview_ids)])
|
||||
if not guest.env.user._is_admin() and any(
|
||||
not link_preview.message_id.is_current_user_or_guest_author
|
||||
for link_preview in link_preview_sudo
|
||||
):
|
||||
return
|
||||
link_preview_sudo._hide_and_notify()
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
|
||||
from math import floor
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
from werkzeug.urls import url_encode
|
||||
from werkzeug.exceptions import NotFound
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse
|
||||
|
||||
from odoo import http
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import AccessError
|
||||
from odoo.http import request
|
||||
from odoo.http import request, Response
|
||||
from odoo.tools import consteq
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context
|
||||
from odoo.tools.misc import file_open
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -26,13 +31,13 @@ class MailController(http.Controller):
|
|||
|
||||
@classmethod
|
||||
def _redirect_to_messaging(cls):
|
||||
url = '/web#%s' % url_encode({'action': 'mail.action_discuss'})
|
||||
url = '/odoo/action-mail.action_discuss'
|
||||
return request.redirect(url)
|
||||
|
||||
@classmethod
|
||||
def _redirect_to_login_with_mail_view(cls, model, res_id, access_token=None, **kwargs):
|
||||
url_base = '/mail/view'
|
||||
url_params = request.env['mail.thread']._notify_get_action_link_params(
|
||||
url_params = request.env['mail.thread']._get_action_link_params(
|
||||
'view', **{
|
||||
'model': model,
|
||||
'res_id': res_id,
|
||||
|
|
@ -48,7 +53,7 @@ class MailController(http.Controller):
|
|||
base_link = request.httprequest.path
|
||||
params = dict(request.params)
|
||||
params.pop('token', '')
|
||||
valid_token = request.env['mail.thread']._notify_encode_link(base_link, params)
|
||||
valid_token = request.env['mail.thread']._encode_link(base_link, params)
|
||||
return consteq(valid_token, str(token))
|
||||
|
||||
@classmethod
|
||||
|
|
@ -89,10 +94,10 @@ class MailController(http.Controller):
|
|||
model, res_id, access_token=access_token, **kwargs,
|
||||
)
|
||||
|
||||
suggested_company = record_sudo._get_mail_redirect_suggested_company()
|
||||
suggested_company = record_sudo._get_redirect_suggested_company()
|
||||
# the record has a window redirection: check access rights
|
||||
if uid is not None:
|
||||
if not RecordModel.with_user(uid).check_access_rights('read', raise_exception=False):
|
||||
if not RecordModel.with_user(uid).has_access('read'):
|
||||
return cls._redirect_to_generic_fallback(
|
||||
model, res_id, access_token=access_token, **kwargs,
|
||||
)
|
||||
|
|
@ -100,24 +105,25 @@ class MailController(http.Controller):
|
|||
# We need here to extend the "allowed_company_ids" to allow a redirection
|
||||
# to any record that the user can access, regardless of currently visible
|
||||
# records based on the "currently allowed companies".
|
||||
cids_str = request.httprequest.cookies.get('cids', str(user.company_id.id))
|
||||
cids = [int(cid) for cid in cids_str.split(',')]
|
||||
cids_str = request.cookies.get('cids', str(user.company_id.id))
|
||||
cids = [int(cid) for cid in cids_str.split('-')]
|
||||
try:
|
||||
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('read')
|
||||
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access('read')
|
||||
except AccessError:
|
||||
# In case the allowed_company_ids from the cookies (i.e. the last user configuration
|
||||
# on their browser) is not sufficient to avoid an ir.rule access error, try to following
|
||||
# heuristic:
|
||||
# - Guess the supposed necessary company to access the record via the method
|
||||
# _get_mail_redirect_suggested_company
|
||||
# _get_redirect_suggested_company
|
||||
# - If no company, then redirect to the messaging
|
||||
# - Merge the suggested company with the companies on the cookie
|
||||
# - Make a new access test if it succeeds, redirect to the record. Otherwise,
|
||||
# - Make a new access test if it succeeds, redirect to the record. Otherwise,
|
||||
# redirect to the messaging.
|
||||
if not suggested_company:
|
||||
raise AccessError('')
|
||||
raise AccessError(_("There is no candidate company that has read access to the record."))
|
||||
cids = cids + [suggested_company.id]
|
||||
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('read')
|
||||
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access('read')
|
||||
request.future_response.set_cookie('cids', '-'.join([str(cid) for cid in cids]))
|
||||
except AccessError:
|
||||
return cls._redirect_to_generic_fallback(
|
||||
model, res_id, access_token=access_token, **kwargs,
|
||||
|
|
@ -135,9 +141,15 @@ class MailController(http.Controller):
|
|||
record_action.pop('target_type', None)
|
||||
# the record has an URL redirection: use it directly
|
||||
if record_action['type'] == 'ir.actions.act_url':
|
||||
return request.redirect(record_action['url'])
|
||||
url = record_action["url"]
|
||||
if highlight_message_id := kwargs.get("highlight_message_id"):
|
||||
parsed_url = urlparse(url)
|
||||
url = parsed_url._replace(query=urlencode(
|
||||
parse_qsl(parsed_url.query) + [("highlight_message_id", highlight_message_id)]
|
||||
)).geturl()
|
||||
return request.redirect(url)
|
||||
# anything else than an act_window is not supported
|
||||
elif not record_action['type'] == 'ir.actions.act_window':
|
||||
elif record_action['type'] != 'ir.actions.act_window':
|
||||
return cls._redirect_to_messaging()
|
||||
|
||||
# backend act_window: when not logged, unless really readable as public,
|
||||
|
|
@ -145,30 +157,29 @@ class MailController(http.Controller):
|
|||
# in that case. In case of readable record, we consider this might be
|
||||
# a customization and we do not change the behavior in stable
|
||||
if uid is None or request.env.user._is_public():
|
||||
has_access = record_sudo.with_user(request.env.user).check_access_rights('read', raise_exception=False)
|
||||
if has_access:
|
||||
try:
|
||||
record_sudo.with_user(request.env.user).check_access_rule('read')
|
||||
except AccessError:
|
||||
has_access = False
|
||||
has_access = record_sudo.with_user(request.env.user).has_access('read')
|
||||
if not has_access:
|
||||
return cls._redirect_to_login_with_mail_view(
|
||||
model, res_id, access_token=access_token, **kwargs,
|
||||
)
|
||||
|
||||
url_params = {
|
||||
'model': model,
|
||||
'id': res_id,
|
||||
'active_id': res_id,
|
||||
'action': record_action.get('id'),
|
||||
}
|
||||
url_params = {}
|
||||
menu_id = request.env['ir.ui.menu']._get_best_backend_root_menu_id_for_model(model)
|
||||
if menu_id:
|
||||
url_params['menu_id'] = menu_id
|
||||
view_id = record_sudo.get_formview_id()
|
||||
if view_id:
|
||||
url_params['view_id'] = view_id
|
||||
|
||||
if highlight_message_id := kwargs.get("highlight_message_id"):
|
||||
url_params["highlight_message_id"] = highlight_message_id
|
||||
if cids:
|
||||
url_params['cids'] = ','.join([str(cid) for cid in cids])
|
||||
url = '/web?#%s' % url_encode(url_params, sort=True)
|
||||
request.future_response.set_cookie('cids', '-'.join([str(cid) for cid in cids]))
|
||||
|
||||
# @see commit c63d14a0485a553b74a8457aee158384e9ae6d3f
|
||||
# @see router.js: heuristics to discrimate a model name from an action path
|
||||
# is the presence of dots, or the prefix m- for models
|
||||
model_in_url = model if "." in model else "m-" + model
|
||||
url = f'/odoo/{model_in_url}/{res_id}?{url_encode(url_params, sort=True)}'
|
||||
return request.redirect(url)
|
||||
|
||||
@http.route('/mail/view', type='http', auth='public')
|
||||
|
|
@ -204,3 +215,168 @@ class MailController(http.Controller):
|
|||
except ValueError:
|
||||
res_id = False
|
||||
return self._redirect_to_record(model, res_id, access_token, **kwargs)
|
||||
|
||||
# csrf is disabled here because it will be called by the MUA with unpredictable session at that time
|
||||
@http.route('/mail/unfollow', type='http', auth='public', csrf=False)
|
||||
def mail_action_unfollow(self, model, res_id, pid, token, **kwargs):
|
||||
comparison, record, __ = MailController._check_token_and_record_or_redirect(model, int(res_id), token)
|
||||
if not comparison or not record:
|
||||
raise AccessError(_('Non existing record or wrong token.'))
|
||||
|
||||
pid = int(pid)
|
||||
record_sudo = record.sudo()
|
||||
record_sudo.message_unsubscribe([pid])
|
||||
|
||||
display_link = True
|
||||
if request.session.uid:
|
||||
display_link = record.has_access('read')
|
||||
|
||||
return request.render('mail.message_document_unfollowed', {
|
||||
'name': record_sudo.display_name,
|
||||
'model_name': request.env['ir.model'].sudo()._get(model).display_name,
|
||||
'access_url': record._notify_get_action_link('view', model=model, res_id=res_id) if display_link else False,
|
||||
})
|
||||
|
||||
@http.route('/mail/message/<int:message_id>', type='http', auth='public')
|
||||
@add_guest_to_context
|
||||
def mail_thread_message_redirect(self, message_id, **kwargs):
|
||||
message = request.env['mail.message'].search([('id', '=', message_id)])
|
||||
if not message:
|
||||
if request.env.user._is_public():
|
||||
return request.redirect(f'/web/login?redirect=/mail/message/{message_id}')
|
||||
raise NotFound()
|
||||
|
||||
return self._redirect_to_record(message.model, message.res_id, highlight_message_id=message_id)
|
||||
|
||||
# web_editor routes need to be kept otherwise mail already sent won't be able to load icons anymore
|
||||
@http.route([
|
||||
'/web_editor/font_to_img/<icon>',
|
||||
'/web_editor/font_to_img/<icon>/<color>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<int:size>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<int:width>x<int:height>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<int:size>/<int:alpha>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<int:width>x<int:height>/<int:alpha>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<bg>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<bg>/<int:size>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>',
|
||||
'/web_editor/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>/<int:alpha>',
|
||||
'/mail/font_to_img/<icon>',
|
||||
'/mail/font_to_img/<icon>/<color>',
|
||||
'/mail/font_to_img/<icon>/<color>/<int:size>',
|
||||
'/mail/font_to_img/<icon>/<color>/<int:width>x<int:height>',
|
||||
'/mail/font_to_img/<icon>/<color>/<int:size>/<int:alpha>',
|
||||
'/mail/font_to_img/<icon>/<color>/<int:width>x<int:height>/<int:alpha>',
|
||||
'/mail/font_to_img/<icon>/<color>/<bg>',
|
||||
'/mail/font_to_img/<icon>/<color>/<bg>/<int:size>',
|
||||
'/mail/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>',
|
||||
'/mail/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>/<int:alpha>',
|
||||
], type='http', auth="none")
|
||||
def export_icon_to_png(self, icon, color='#000', bg=None, size=100, alpha=255, font='/web/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf', width=None, height=None):
|
||||
""" This method converts an unicode character to an image (using Font
|
||||
Awesome font by default) and is used only for mass mailing because
|
||||
custom fonts are not supported in mail.
|
||||
:param icon : decimal encoding of unicode character
|
||||
:param color : RGB code of the color
|
||||
:param bg : RGB code of the background color
|
||||
:param size : Pixels in integer
|
||||
:param alpha : transparency of the image from 0 to 255
|
||||
:param font : font path
|
||||
:param width : Pixels in integer
|
||||
:param height : Pixels in integer
|
||||
|
||||
:returns PNG image converted from given font
|
||||
"""
|
||||
# For custom icons, use the corresponding custom font
|
||||
if icon.isdigit():
|
||||
oi_font_char_codes = {
|
||||
# Replacement of existing Twitter icons by X icons (the route
|
||||
# here receives the old icon code always, but the replacement
|
||||
# one is also considered for consistency anyway).
|
||||
"61569": "59464", # F081 -> E848: fa-twitter-square
|
||||
"61593": "59418", # F099 -> E81A: fa-twitter
|
||||
|
||||
# Addition of new icons
|
||||
"59407": "59407", # E80F: fa-strava
|
||||
"59409": "59409", # E811: fa-discord
|
||||
"59416": "59416", # E818: fa-threads
|
||||
"59417": "59417", # E819: fa-kickstarter
|
||||
"59419": "59419", # E81B: fa-tiktok
|
||||
"59420": "59420", # E81C: fa-bluesky
|
||||
"59421": "59421", # E81D: fa-google-play
|
||||
}
|
||||
if icon in oi_font_char_codes:
|
||||
icon = oi_font_char_codes[icon]
|
||||
font = "/web/static/lib/odoo_ui_icons/fonts/odoo_ui_icons.woff"
|
||||
|
||||
size = max(width, height, 1) if width else size
|
||||
width = width or size
|
||||
height = height or size
|
||||
# Make sure we have at least size=1
|
||||
width = max(1, min(width, 512))
|
||||
height = max(1, min(height, 512))
|
||||
# Initialize font
|
||||
if font.startswith('/'):
|
||||
font = font[1:]
|
||||
font_obj = ImageFont.truetype(file_open(font, 'rb'), height)
|
||||
|
||||
# if received character is not a number, keep old behaviour (icon is character)
|
||||
icon = chr(int(icon)) if icon.isdigit() else icon
|
||||
|
||||
# Background standardization
|
||||
if bg is not None and bg.startswith('rgba'):
|
||||
bg = bg.replace('rgba', 'rgb')
|
||||
bg = ','.join(bg.split(',')[:-1]) + ')'
|
||||
|
||||
# Convert the opacity value compatible with PIL Image color (0 to 255)
|
||||
# when color specifier is 'rgba'
|
||||
if color is not None and color.startswith('rgba'):
|
||||
*rgb, a = color.strip(')').split(',')
|
||||
opacity = str(floor(float(a) * 255))
|
||||
color = ','.join([*rgb, opacity]) + ')'
|
||||
|
||||
# Determine the dimensions of the icon
|
||||
image = Image.new("RGBA", (width, height), color)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
if hasattr(draw, 'textbbox'):
|
||||
box = draw.textbbox((0, 0), icon, font=font_obj)
|
||||
left = box[0]
|
||||
top = box[1]
|
||||
boxw = box[2] - box[0]
|
||||
boxh = box[3] - box[1]
|
||||
else: # pillow < 8.00 (Focal)
|
||||
left, top, _right, _bottom = image.getbbox()
|
||||
boxw, boxh = draw.textsize(icon, font=font_obj)
|
||||
|
||||
draw.text((0, 0), icon, font=font_obj)
|
||||
|
||||
# Create an alpha mask
|
||||
imagemask = Image.new("L", (boxw, boxh), 0)
|
||||
drawmask = ImageDraw.Draw(imagemask)
|
||||
drawmask.text((-left, -top), icon, font=font_obj, fill=255)
|
||||
|
||||
# Create a solid color image and apply the mask
|
||||
if color.startswith('rgba'):
|
||||
color = color.replace('rgba', 'rgb')
|
||||
color = ','.join(color.split(',')[:-1]) + ')'
|
||||
iconimage = Image.new("RGBA", (boxw, boxh), color)
|
||||
iconimage.putalpha(imagemask)
|
||||
|
||||
# Create output image
|
||||
outimage = Image.new("RGBA", (boxw, height), bg or (0, 0, 0, 0))
|
||||
outimage.paste(iconimage, (left, top), iconimage)
|
||||
|
||||
# output image
|
||||
output = io.BytesIO()
|
||||
outimage.save(output, format="PNG")
|
||||
response = Response()
|
||||
response.mimetype = 'image/png'
|
||||
response.data = output.getvalue()
|
||||
response.headers['Cache-Control'] = 'public, max-age=604800'
|
||||
response.headers['Access-Control-Allow-Origin'] = '*'
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, POST'
|
||||
response.headers['Connection'] = 'close'
|
||||
response.headers['Date'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime())
|
||||
response.headers['Expires'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime(time.time() + 604800 * 60))
|
||||
|
||||
return response
|
||||
|
|
|
|||
52
odoo-bringout-oca-ocb-mail/mail/controllers/mailbox.py
Normal file
52
odoo-bringout-oca-ocb-mail/mail/controllers/mailbox.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class MailboxController(http.Controller):
|
||||
@http.route("/mail/inbox/messages", methods=["POST"], type="jsonrpc", auth="user", readonly=True)
|
||||
def discuss_inbox_messages(self, fetch_params=None):
|
||||
domain = [("needaction", "=", True)]
|
||||
res = request.env["mail.message"]._message_fetch(domain, **(fetch_params or {}))
|
||||
messages = res.pop("messages")
|
||||
# sudo: bus.bus: reading non-sensitive last id
|
||||
bus_last_id = request.env["bus.bus"].sudo()._bus_last_id()
|
||||
store = Store().add(
|
||||
messages,
|
||||
extra_fields=[
|
||||
Store.One("thread", [
|
||||
Store.Attr("message_needaction_counter"),
|
||||
Store.Attr("message_needaction_counter_bus_id", bus_last_id)
|
||||
], as_thread=True)
|
||||
],
|
||||
add_followers=True
|
||||
)
|
||||
return {
|
||||
**res,
|
||||
"data": store.get_result(),
|
||||
"messages": messages.ids,
|
||||
}
|
||||
|
||||
@http.route("/mail/history/messages", methods=["POST"], type="jsonrpc", auth="user", readonly=True)
|
||||
def discuss_history_messages(self, fetch_params=None):
|
||||
domain = [("needaction", "=", False)]
|
||||
res = request.env["mail.message"]._message_fetch(domain, **(fetch_params or {}))
|
||||
messages = res.pop("messages")
|
||||
return {
|
||||
**res,
|
||||
"data": Store().add(messages).get_result(),
|
||||
"messages": messages.ids,
|
||||
}
|
||||
|
||||
@http.route("/mail/starred/messages", methods=["POST"], type="jsonrpc", auth="user", readonly=True)
|
||||
def discuss_starred_messages(self, fetch_params=None):
|
||||
domain = [("starred_partner_ids", "in", [request.env.user.partner_id.id])]
|
||||
res = request.env["mail.message"]._message_fetch(domain, **(fetch_params or {}))
|
||||
messages = res.pop("messages")
|
||||
return {
|
||||
**res,
|
||||
"data": Store().add(messages).get_result(),
|
||||
"messages": messages.ids,
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.controllers.thread import ThreadController
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
|
||||
|
||||
class MessageReactionController(ThreadController):
|
||||
@http.route("/mail/message/reaction", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_message_reaction(self, message_id, content, action, **kwargs):
|
||||
message = self._get_message_with_access(int(message_id), mode="create", **kwargs)
|
||||
if not message:
|
||||
raise NotFound()
|
||||
partner, guest = self._get_reaction_author(message, **kwargs)
|
||||
if not partner and not guest:
|
||||
raise NotFound()
|
||||
store = Store()
|
||||
# sudo: mail.message - access mail.message.reaction through an accessible message is allowed
|
||||
message.sudo()._message_reaction(content, action, partner, guest, store)
|
||||
return store.get_result()
|
||||
|
||||
def _get_reaction_author(self, message, **kwargs):
|
||||
return request.env["res.partner"]._get_current_persona()
|
||||
271
odoo-bringout-oca-ocb-mail/mail/controllers/thread.py
Normal file
271
odoo-bringout-oca-ocb-mail/mail/controllers/thread.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
from markupsafe import Markup
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
from odoo.tools.misc import verify_limited_field_access_token
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
|
||||
|
||||
class ThreadController(http.Controller):
|
||||
|
||||
# access helpers
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _get_message_with_access(cls, message_id, mode="read", **kwargs):
|
||||
""" Simplified getter that filters access params only, making model methods
|
||||
using strong parameters. """
|
||||
message_su = request.env['mail.message'].sudo().browse(message_id).exists()
|
||||
if not message_su:
|
||||
return message_su
|
||||
return request.env['mail.message']._get_with_access(
|
||||
message_su.id,
|
||||
mode=mode,
|
||||
**{
|
||||
key: value for key, value in kwargs.items()
|
||||
if key in request.env[message_su.model or 'mail.thread']._get_allowed_access_params()
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_thread_with_access_for_post(cls, thread_model, thread_id, **kwargs):
|
||||
""" Helper allowing to fetch thread with access when requesting 'create'
|
||||
access on mail.message, aka rights to post on the document. Default
|
||||
behavior is to rely on _mail_post_access but it might be customized.
|
||||
See '_mail_get_operation_for_mail_message_operation'. """
|
||||
thread_su = request.env[thread_model].sudo().browse(int(thread_id))
|
||||
access_mode = thread_su._mail_get_operation_for_mail_message_operation('create')[thread_su]
|
||||
if not access_mode:
|
||||
return request.env[thread_model] # match _get_thread_with_access void result
|
||||
return cls._get_thread_with_access(thread_model, thread_id, mode=access_mode, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def _get_thread_with_access(cls, thread_model, thread_id, mode="read", **kwargs):
|
||||
""" Simplified getter that filters access params only, making model methods
|
||||
using strong parameters. """
|
||||
return request.env[thread_model]._get_thread_with_access(
|
||||
int(thread_id), mode=mode, **{
|
||||
key: value for key, value in kwargs.items()
|
||||
if key in request.env[thread_model]._get_allowed_access_params()
|
||||
},
|
||||
)
|
||||
|
||||
# main routes
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@http.route("/mail/thread/messages", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def mail_thread_messages(self, thread_model, thread_id, fetch_params=None):
|
||||
thread = self._get_thread_with_access(thread_model, thread_id, mode="read")
|
||||
res = request.env["mail.message"]._message_fetch(domain=None, thread=thread, **(fetch_params or {}))
|
||||
messages = res.pop("messages")
|
||||
if not request.env.user._is_public():
|
||||
messages.set_message_done()
|
||||
return {
|
||||
**res,
|
||||
"data": Store().add(messages).get_result(),
|
||||
"messages": messages.ids,
|
||||
}
|
||||
|
||||
@http.route("/mail/thread/recipients", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def mail_thread_recipients(self, thread_model, thread_id, message_id=None):
|
||||
""" Fetch discussion-based suggested recipients, creating partners on the fly """
|
||||
thread = self._get_thread_with_access(thread_model, thread_id, mode='read')
|
||||
if message_id:
|
||||
message = self._get_message_with_access(message_id, mode="read")
|
||||
suggested = thread._message_get_suggested_recipients(
|
||||
reply_message=message, no_create=False,
|
||||
)
|
||||
else:
|
||||
suggested = thread._message_get_suggested_recipients(
|
||||
reply_discussion=True, no_create=False,
|
||||
)
|
||||
return [
|
||||
{'id': info['partner_id'], 'email': info['email'], 'name': info['name']}
|
||||
for info in suggested if info['partner_id']
|
||||
]
|
||||
|
||||
@http.route("/mail/thread/recipients/fields", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def mail_thread_recipients_fields(self, thread_model):
|
||||
return {
|
||||
'partner_fields': request.env[thread_model]._mail_get_partner_fields(),
|
||||
'primary_email_field': [request.env[thread_model]._mail_get_primary_email_field()]
|
||||
}
|
||||
|
||||
@http.route("/mail/thread/recipients/get_suggested_recipients", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def mail_thread_recipients_get_suggested_recipients(self, thread_model, thread_id, partner_ids=None, main_email=False):
|
||||
"""This method returns the suggested recipients with updates coming from the frontend.
|
||||
:param thread_model: Model on which we are currently working on.
|
||||
:param thread_id: ID of the document we need to compute
|
||||
:param partner_ids: IDs of new customers that were edited on the frontend, usually only the customer but could be more.
|
||||
:param main_email: New email edited on the frontend linked to the @see _mail_get_primary_email_field
|
||||
"""
|
||||
thread = self._get_thread_with_access(thread_model, thread_id)
|
||||
partner_ids = request.env['res.partner'].search([('id', 'in', partner_ids)])
|
||||
recipients = thread._message_get_suggested_recipients(reply_discussion=True, additional_partners=partner_ids, primary_email=main_email)
|
||||
if partner_ids:
|
||||
old_customer_ids = set(thread._mail_get_partners()[thread.id].ids) - set(partner_ids.ids)
|
||||
recipients = list(filter(lambda rec: rec.get('partner_id') not in old_customer_ids, recipients))
|
||||
return [{key: recipient[key] for key in recipient if key in ['name', 'email', 'partner_id']} for recipient in recipients]
|
||||
|
||||
@http.route("/mail/partner/from_email", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def mail_thread_partner_from_email(self, thread_model, thread_id, emails):
|
||||
partners = [
|
||||
{"id": partner.id, "name": partner.name, "email": partner.email}
|
||||
for partner in request.env[thread_model].browse(thread_id)._partner_find_from_emails_single(
|
||||
emails, no_create=not request.env.user.has_group("base.group_partner_manager")
|
||||
)
|
||||
]
|
||||
return partners
|
||||
|
||||
@http.route("/mail/read_subscription_data", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def read_subscription_data(self, follower_id):
|
||||
"""Computes:
|
||||
- message_subtype_data: data about document subtypes: which are
|
||||
available, which are followed if any"""
|
||||
# limited to internal, who can read all followers
|
||||
follower = request.env["mail.followers"].browse(follower_id)
|
||||
follower.check_access("read")
|
||||
record = request.env[follower.res_model].browse(follower.res_id)
|
||||
record.check_access("read")
|
||||
# find current model subtypes, add them to a dictionary
|
||||
subtypes = record._mail_get_message_subtypes()
|
||||
store = Store().add(subtypes, ["name"]).add(follower, ["subtype_ids"])
|
||||
return {
|
||||
"store_data": store.get_result(),
|
||||
"subtype_ids": subtypes.sorted(
|
||||
key=lambda s: (
|
||||
s.parent_id.res_model or "",
|
||||
s.res_model or "",
|
||||
s.internal,
|
||||
s.sequence,
|
||||
),
|
||||
).ids,
|
||||
}
|
||||
|
||||
def _prepare_message_data(self, post_data, *, thread, **kwargs):
|
||||
res = {
|
||||
key: value
|
||||
for key, value in post_data.items()
|
||||
if key in thread._get_allowed_message_params()
|
||||
}
|
||||
if (attachment_ids := post_data.get("attachment_ids")) is not None:
|
||||
attachments = request.env["ir.attachment"].browse(map(int, attachment_ids))
|
||||
if not attachments._has_attachments_ownership(post_data.get("attachment_tokens")):
|
||||
msg = self.env._(
|
||||
"One or more attachments do not exist, or you do not have the rights to access them.",
|
||||
)
|
||||
raise UserError(msg)
|
||||
res["attachment_ids"] = attachments.ids
|
||||
if "body" in post_data:
|
||||
# User input is HTML string, so it needs to be in a Markup.
|
||||
# It will be sanitized by the field itself when writing on it.
|
||||
res["body"] = Markup(post_data["body"]) if post_data["body"] else post_data["body"]
|
||||
partner_ids = post_data.get("partner_ids")
|
||||
partner_emails = post_data.get("partner_emails")
|
||||
role_ids = post_data.get("role_ids")
|
||||
if partner_ids is not None or partner_emails is not None or role_ids is not None:
|
||||
partners = request.env["res.partner"].browse(map(int, partner_ids or []))
|
||||
if partner_emails:
|
||||
partners |= thread._partner_find_from_emails_single(
|
||||
partner_emails,
|
||||
no_create=not request.env.user.has_group("base.group_partner_manager"),
|
||||
)
|
||||
if role_ids:
|
||||
# sudo - res.users: getting partners linked to the role is allowed.
|
||||
partners |= (
|
||||
request.env["res.users"]
|
||||
.sudo()
|
||||
.search_fetch([("role_ids", "in", role_ids)], ["partner_id"])
|
||||
.partner_id
|
||||
)
|
||||
res["partner_ids"] = partners.filtered(
|
||||
lambda p: (not self.env.user.share and p.has_access("read"))
|
||||
or (
|
||||
verify_limited_field_access_token(
|
||||
p,
|
||||
"id",
|
||||
post_data.get("partner_ids_mention_token", {}).get(str(p.id), ""),
|
||||
scope="mail.message_mention",
|
||||
)
|
||||
),
|
||||
).ids
|
||||
res.setdefault("message_type", "comment")
|
||||
return res
|
||||
|
||||
@http.route("/mail/message/post", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_message_post(self, thread_model, thread_id, post_data, context=None, **kwargs):
|
||||
store = Store()
|
||||
request.update_context(message_post_store=store)
|
||||
if context:
|
||||
request.update_context(**context)
|
||||
canned_response_ids = tuple(cid for cid in kwargs.get('canned_response_ids', []) if isinstance(cid, int))
|
||||
if canned_response_ids:
|
||||
# Avoid serialization errors since last used update is not
|
||||
# essential and should not block message post.
|
||||
request.env.cr.execute("""
|
||||
UPDATE mail_canned_response SET last_used=%(last_used)s
|
||||
WHERE id IN (
|
||||
SELECT id from mail_canned_response WHERE id IN %(ids)s
|
||||
FOR NO KEY UPDATE SKIP LOCKED
|
||||
)
|
||||
""", {
|
||||
'last_used': datetime.now(),
|
||||
'ids': canned_response_ids,
|
||||
})
|
||||
thread = self._get_thread_with_access_for_post(thread_model, thread_id, **kwargs)
|
||||
if not thread:
|
||||
raise NotFound()
|
||||
if not self._get_thread_with_access(thread_model, thread_id, mode="write"):
|
||||
thread = thread.with_context(mail_post_autofollow_author_skip=True, mail_post_autofollow=False)
|
||||
# sudo: mail.thread - users can post on accessible threads
|
||||
message = thread.sudo().message_post(
|
||||
**self._prepare_message_data(post_data, thread=thread, from_create=True, **kwargs),
|
||||
)
|
||||
return {
|
||||
"store_data": store.add(message).get_result(),
|
||||
"message_id": message.id,
|
||||
}
|
||||
|
||||
@http.route("/mail/message/update_content", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_message_update_content(self, message_id, update_data, **kwargs):
|
||||
message = self._get_message_with_access(message_id, mode="create", **kwargs)
|
||||
if not message or not self._can_edit_message(message, **kwargs):
|
||||
raise NotFound()
|
||||
# sudo: mail.message - access is checked in _get_with_access and _can_edit_message
|
||||
message = message.sudo()
|
||||
thread = request.env[message.model].browse(message.res_id)
|
||||
thread._message_update_content(
|
||||
message,
|
||||
**self._prepare_message_data(update_data, thread=thread, from_create=False, **kwargs),
|
||||
)
|
||||
return Store().add(message).get_result()
|
||||
|
||||
# side check for access
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _can_edit_message(cls, message, **kwargs):
|
||||
return message.sudo().is_current_user_or_guest_author or request.env.user._is_admin()
|
||||
|
||||
@http.route("/mail/thread/unsubscribe", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def mail_thread_unsubscribe(self, res_model, res_id, partner_ids):
|
||||
thread = self.env[res_model].browse(res_id)
|
||||
thread.message_unsubscribe(partner_ids)
|
||||
return Store().add(
|
||||
thread, [], as_thread=True, request_list=["followers", "suggestedRecipients"]
|
||||
).get_result()
|
||||
|
||||
@http.route("/mail/thread/subscribe", methods=["POST"], type="jsonrpc", auth="user")
|
||||
def mail_thread_subscribe(self, res_model, res_id, partner_ids):
|
||||
thread = self.env[res_model].browse(res_id)
|
||||
thread.message_subscribe(partner_ids)
|
||||
return Store().add(
|
||||
thread, [], as_thread=True, request_list=["followers", "suggestedRecipients"]
|
||||
).get_result()
|
||||
133
odoo-bringout-oca-ocb-mail/mail/controllers/webclient.py
Normal file
133
odoo-bringout-oca-ocb-mail/mail/controllers/webclient.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.controllers.thread import ThreadController
|
||||
from odoo.addons.mail.tools.discuss import add_guest_to_context, Store
|
||||
|
||||
|
||||
class WebclientController(ThreadController):
|
||||
"""Routes for the web client."""
|
||||
|
||||
@http.route("/mail/action", methods=["POST"], type="jsonrpc", auth="public")
|
||||
@add_guest_to_context
|
||||
def mail_action(self, fetch_params, context=None):
|
||||
"""Execute actions and returns data depending on request parameters.
|
||||
This is similar to /mail/data except this method can have side effects.
|
||||
"""
|
||||
return self._process_request(fetch_params, context=context)
|
||||
|
||||
@http.route("/mail/data", methods=["POST"], type="jsonrpc", auth="public", readonly=True)
|
||||
@add_guest_to_context
|
||||
def mail_data(self, fetch_params, context=None):
|
||||
"""Returns data depending on request parameters.
|
||||
This is similar to /mail/action except this method should be read-only.
|
||||
"""
|
||||
return self._process_request(fetch_params, context=context)
|
||||
|
||||
@classmethod
|
||||
def _process_request(self, fetch_params, context):
|
||||
store = Store()
|
||||
if context:
|
||||
request.update_context(**context)
|
||||
self._process_request_loop(store, fetch_params)
|
||||
return store.get_result()
|
||||
|
||||
@classmethod
|
||||
def _process_request_loop(self, store: Store, fetch_params):
|
||||
for fetch_param in fetch_params:
|
||||
name, params, data_id = (
|
||||
(fetch_param, None, None)
|
||||
if isinstance(fetch_param, str)
|
||||
else (fetch_param + [None, None])[:3]
|
||||
)
|
||||
store.data_id = data_id
|
||||
self._process_request_for_all(store, name, params)
|
||||
if not request.env.user._is_public():
|
||||
self._process_request_for_logged_in_user(store, name, params)
|
||||
if request.env.user._is_internal():
|
||||
self._process_request_for_internal_user(store, name, params)
|
||||
store.data_id = None
|
||||
|
||||
@classmethod
|
||||
def _process_request_for_all(self, store: Store, name, params):
|
||||
if name == "init_messaging":
|
||||
if not request.env.user._is_public():
|
||||
user = request.env.user.sudo(False)
|
||||
user._init_messaging(store)
|
||||
if name == "mail.thread":
|
||||
thread = self._get_thread_with_access(
|
||||
params["thread_model"],
|
||||
params["thread_id"],
|
||||
mode="read",
|
||||
**params.get("access_params", {}),
|
||||
)
|
||||
if not thread:
|
||||
store.add(
|
||||
request.env[params["thread_model"]].browse(params["thread_id"]),
|
||||
{"hasReadAccess": False, "hasWriteAccess": False},
|
||||
as_thread=True,
|
||||
)
|
||||
else:
|
||||
store.add(thread, request_list=params["request_list"], as_thread=True)
|
||||
|
||||
@classmethod
|
||||
def _process_request_for_logged_in_user(self, store: Store, name, params):
|
||||
if name == "failures":
|
||||
domain = [
|
||||
("author_id", "=", request.env.user.partner_id.id),
|
||||
("notification_status", "in", ("bounce", "exception")),
|
||||
("mail_message_id.message_type", "!=", "user_notification"),
|
||||
("mail_message_id.model", "!=", False),
|
||||
("mail_message_id.res_id", "!=", 0),
|
||||
]
|
||||
# sudo as to not check ACL, which is far too costly
|
||||
# sudo: mail.notification - return only failures of current user as author
|
||||
notifications = request.env["mail.notification"].sudo().search(domain, limit=100)
|
||||
found = defaultdict(list)
|
||||
for message in notifications.mail_message_id:
|
||||
found[message.model].append(message.res_id)
|
||||
existing = {
|
||||
model: set(request.env[model].browse(ids).exists().ids)
|
||||
for model, ids in found.items()
|
||||
}
|
||||
valid = notifications.filtered(
|
||||
lambda n: n.mail_message_id.res_id in existing[n.mail_message_id.model]
|
||||
)
|
||||
lost = notifications - valid
|
||||
# might break readonly status of mail/data, but in really rare cases
|
||||
# and solves it by removing useless notifications
|
||||
if lost:
|
||||
lost.sudo().unlink() # no unlink right except admin, ok to remove as lost anyway
|
||||
valid.mail_message_id._message_notifications_to_store(store)
|
||||
|
||||
@classmethod
|
||||
def _process_request_for_internal_user(self, store: Store, name, params):
|
||||
if name == "systray_get_activities":
|
||||
# sudo: bus.bus: reading non-sensitive last id
|
||||
bus_last_id = request.env["bus.bus"].sudo()._bus_last_id()
|
||||
groups = request.env["res.users"]._get_activity_groups()
|
||||
store.add_global_values(
|
||||
activityCounter=sum(group.get("total_count", 0) for group in groups),
|
||||
activity_counter_bus_id=bus_last_id,
|
||||
activityGroups=groups,
|
||||
)
|
||||
if name == "mail.canned.response":
|
||||
domain = [
|
||||
"|",
|
||||
("create_uid", "=", request.env.user.id),
|
||||
("group_ids", "in", request.env.user.all_group_ids.ids),
|
||||
]
|
||||
store.add(request.env["mail.canned.response"].search(domain))
|
||||
if name == "avatar_card":
|
||||
record_id, model = params.get("id"), params.get("model")
|
||||
if not record_id or model not in ("res.users", "res.partner"):
|
||||
return
|
||||
context = {
|
||||
"active_test": False,
|
||||
"allowed_company_ids": request.env.user._get_company_ids(),
|
||||
}
|
||||
record = request.env[model].with_context(**context).search([("id", "=", record_id)])
|
||||
store.add(record, record._get_store_avatar_card_fields(store.target))
|
||||
19
odoo-bringout-oca-ocb-mail/mail/controllers/webmanifest.py
Normal file
19
odoo-bringout-oca-ocb-mail/mail/controllers/webmanifest.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.http import request
|
||||
from odoo.tools import file_open
|
||||
from odoo.addons.web.controllers import webmanifest
|
||||
|
||||
|
||||
class WebManifest(webmanifest.WebManifest):
|
||||
|
||||
def _get_service_worker_content(self):
|
||||
body = super()._get_service_worker_content()
|
||||
|
||||
# Add notification support to the service worker if user but no public
|
||||
if request.env.user._is_internal():
|
||||
with file_open('mail/static/src/service_worker.js') as f:
|
||||
body += f.read()
|
||||
|
||||
return body
|
||||
17
odoo-bringout-oca-ocb-mail/mail/controllers/websocket.py
Normal file
17
odoo-bringout-oca-ocb-mail/mail/controllers/websocket.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.bus.controllers.websocket import WebsocketController
|
||||
from odoo.http import request, route, SessionExpiredException
|
||||
|
||||
|
||||
class WebsocketControllerPresence(WebsocketController):
|
||||
"""Override of websocket controller to add mail features (presence in particular)."""
|
||||
|
||||
@route("/websocket/update_bus_presence", type="jsonrpc", auth="public", cors="*")
|
||||
def update_bus_presence(self, inactivity_period):
|
||||
"""Manually update presence of current user, useful when implementing custom websocket code.
|
||||
This is mainly used by Odoo.sh."""
|
||||
if "is_websocket_session" not in request.session:
|
||||
raise SessionExpiredException()
|
||||
request.env["ir.websocket"]._update_mail_presence(int(inactivity_period))
|
||||
return {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue