19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -5,3 +5,6 @@ from . import models
from . import tools
from . import wizard
from . import controllers
def _mail_post_init(env):
env['mail.alias.domain']._migrate_icp_to_domain()

View file

@ -2,7 +2,7 @@
{
'name': 'Discuss',
'version': '1.10',
'version': '1.19',
'category': 'Productivity/Discuss',
'sequence': 145,
'summary': 'Chat, mail gateway and private channels',
@ -59,46 +59,58 @@ For more specific needs, you may also assign custom-defined actions
(technically: Server Actions) to be triggered for each incoming mail.
""",
'website': 'https://www.odoo.com/app/discuss',
'depends': ['base', 'base_setup', 'bus', 'web_tour'],
'depends': ['base', 'base_setup', 'bus', 'web_tour', 'html_editor'],
'data': [
'data/mail_groups.xml',
'wizard/mail_activity_schedule_views.xml',
'wizard/mail_blacklist_remove_views.xml',
'wizard/mail_compose_message_views.xml',
'wizard/mail_resend_message_views.xml',
'wizard/mail_template_preview_views.xml',
'wizard/mail_wizard_invite_views.xml',
'wizard/mail_followers_edit_views.xml',
'wizard/mail_template_reset_views.xml',
'views/fetchmail_views.xml',
'views/ir_cron_views.xml',
'views/ir_filters_views.xml',
'views/ir_mail_server_views.xml',
'views/mail_message_subtype_views.xml',
'views/mail_tracking_views.xml',
'views/mail_tracking_value_views.xml',
'views/mail_notification_views.xml',
'views/mail_message_views.xml',
'views/mail_message_schedule_views.xml',
'views/mail_mail_views.xml',
'views/mail_followers_views.xml',
'views/mail_ice_server_views.xml',
'views/mail_channel_member_views.xml',
'views/mail_channel_rtc_session_views.xml',
'views/discuss_channel_member_views.xml',
'views/discuss_channel_rtc_session_views.xml',
'views/mail_link_preview_views.xml',
'views/mail_channel_views.xml',
'views/mail_shortcode_views.xml',
'views/discuss/discuss_gif_favorite_views.xml',
'views/discuss_channel_views.xml',
'views/mail_canned_response_views.xml',
'views/res_role_views.xml',
'views/mail_activity_views.xml',
'views/mail_activity_plan_views.xml',
'views/mail_activity_plan_template_views.xml',
'views/res_config_settings_views.xml',
'data/ir_config_parameter_data.xml',
'data/res_partner_data.xml',
'data/mail_message_subtype_data.xml',
'data/mail_templates_chatter.xml',
'data/mail_templates_email_layouts.xml',
'data/mail_templates_mailgateway.xml',
'data/mail_channel_data.xml',
'data/mail_activity_data.xml',
'data/discuss_channel_data.xml',
'data/mail_activity_type_data.xml',
'data/security_notifications_templates.xml',
'data/ir_cron_data.xml',
'data/ir_actions_client.xml',
'security/mail_security.xml',
'security/ir.model.access.csv',
'views/discuss_public_templates.xml',
'views/mail_alias_domain_views.xml',
'views/mail_alias_views.xml',
'views/mail_gateway_allowed_views.xml',
'views/mail_guest_views.xml',
'views/mail_message_reaction_views.xml',
'views/mail_templates_public.xml',
'views/res_users_views.xml',
'views/res_users_settings_views.xml',
'views/mail_template_views.xml',
@ -107,132 +119,148 @@ For more specific needs, you may also assign custom-defined actions
'views/res_partner_views.xml',
'views/mail_blacklist_views.xml',
'views/mail_menus.xml',
'views/discuss/discuss_menus.xml',
'views/discuss/discuss_call_history_views.xml',
'views/res_company_views.xml',
"views/mail_scheduled_message_views.xml",
"data/mail_canned_response_data.xml",
'data/mail_templates_invite.xml',
'data/web_tour_tour.xml',
],
'demo': [
'data/mail_channel_demo.xml',
'demo/mail_activity_demo.xml',
'demo/discuss_channel_demo.xml',
'demo/discuss/public_channel_demo.xml',
"demo/mail_canned_response_demo.xml",
],
'installable': True,
'application': True,
'post_init_hook': '_mail_post_init',
'assets': {
'mail.assets_core_messaging': [
'mail/static/src/model/*.js',
'mail/static/src/core_models/*.js',
],
'mail.assets_messaging': [
('include', 'mail.assets_core_messaging'),
'mail/static/src/models/*.js',
'mail/static/lib/selfie_segmentation/selfie_segmentation.js',
],
'mail.assets_model_data': [
'mail/static/src/models_data/*.js',
],
# Custom bundle in case we want to remove things that are later added to web.assets_common
'mail.assets_common_discuss_public': [
('include', 'web.assets_common'),
],
'mail.assets_discuss_public': [
# SCSS dependencies (the order is important)
('include', 'web._assets_helpers'),
'web/static/src/scss/bootstrap_overridden.scss',
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'web/static/src/scss/import_bootstrap.scss',
'web/static/src/scss/utilities_custom.scss',
'web/static/lib/bootstrap/scss/utilities/_api.scss',
'web/static/src/scss/bootstrap_review.scss',
'web/static/src/webclient/webclient.scss',
'web/static/src/core/utils/*.scss',
# depends on BS variables, can't be loaded in assets_primary or assets_secondary
'mail/static/src/scss/variables/derived_variables.scss',
'mail/static/src/scss/composer.scss',
# Dependency of notification_group, notification_request, thread_needaction_preview and thread_preview
'mail/static/src/components/notification_list/notification_list_item.scss',
'mail/static/src/component_hooks/*.js',
'mail/static/src/components/*/*',
('remove', 'mail/static/src/**/*.dark.scss'),
# Unused by guests and depends on ViewDialogs, better to remove it instead of pulling the whole view dependency tree
('remove', 'mail/static/src/components/composer_suggested_recipient/*'),
('remove', 'mail/static/src/components/activity_menu_container/*'),
'mail/static/src/js/emojis.js',
'mail/static/src/js/utils.js',
('include', 'mail.assets_messaging'),
'mail/static/src/public/*',
'mail/static/src/services/*.js',
('remove', 'mail/static/src/services/systray_service.js'),
'mail/static/src/utils/*.js',
# Framework JS
'web/static/lib/luxon/luxon.js',
'web/static/src/core/**/*',
# FIXME: debug menu currently depends on webclient, once it doesn't we don't need to remove the contents of the debug folder
('remove', 'web/static/src/core/debug/**/*'),
'web/static/src/env.js',
'web/static/src/legacy/js/core/misc.js',
'web/static/src/legacy/js/env.js',
'web/static/src/legacy/js/fields/field_utils.js',
'web/static/src/legacy/js/owl_compatibility.js',
'web/static/src/legacy/js/services/data_manager.js',
'web/static/src/legacy/js/services/session.js',
'web/static/src/legacy/js/widgets/date_picker.js',
'web/static/src/legacy/legacy_load_views.js',
'web/static/src/legacy/legacy_promise_error_handler.js',
'web/static/src/legacy/legacy_rpc_error_handler.js',
'web/static/src/legacy/utils.js',
'web/static/src/legacy/xml/base.xml',
],
'web._assets_primary_variables': [
'mail/static/src/scss/variables/primary_variables.scss',
'mail/static/src/**/primary_variables.scss',
],
'web.assets_backend': [
# depends on BS variables, can't be loaded in assets_primary or assets_secondary
'mail/static/src/scss/variables/derived_variables.scss',
# defines mixins and variables used by multiple components
'mail/static/src/components/notification_list/notification_list_item.scss',
'mail/static/src/js/**/*.js',
'mail/static/src/utils/*.js',
'mail/static/src/scss/*.scss',
'mail/static/src/xml/*.xml',
'mail/static/src/component_hooks/*.js',
'mail/static/src/backend_components/*/*',
'mail/static/src/components/*/*.js',
'mail/static/src/components/*/*.scss',
'mail/static/src/components/*/*.xml',
'mail/static/src/views/*/*.xml',
('include', 'mail.assets_messaging'),
'mail/static/src/services/*.js',
'mail/static/src/views/**/*.js',
'mail/static/src/views/**/*.xml',
'mail/static/src/views/**/*.scss',
'mail/static/src/webclient/commands/*.js',
'mail/static/src/widgets/*/*.js',
'mail/static/src/widgets/*/*.scss',
# Don't include dark mode files in light mode
('remove', 'mail/static/src/components/*/*.dark.scss'),
'mail/static/lib/idb-keyval/idb-keyval.js',
'mail/static/lib/selfie_segmentation/selfie_segmentation.js',
'mail/static/src/js/**/*',
'mail/static/src/model/**/*',
'mail/static/src/core/common/**/*',
'mail/static/src/core/public_web/**/*',
'mail/static/src/core/web_portal/**/*',
'mail/static/src/core/web/**/*',
'mail/static/src/**/common/**/*',
'mail/static/src/**/public_web/**/*',
'mail/static/src/**/web_portal/**/*',
'mail/static/src/**/web/**/*',
('remove', 'mail/static/src/**/*.dark.scss'),
# discuss (loaded last to fix dependencies)
('remove', 'mail/static/src/discuss/**/*'),
'mail/static/src/discuss/core/common/**/*',
'mail/static/src/discuss/core/public_web/**/*',
'mail/static/src/discuss/core/web/**/*',
'mail/static/src/discuss/**/common/**/*',
'mail/static/src/discuss/**/public_web/**/*',
'mail/static/src/discuss/**/web/**/*',
('remove', 'mail/static/src/discuss/**/*.dark.scss'),
'mail/static/src/views/fields/**/*',
('remove', 'mail/static/src/views/web/activity/**'),
'mail/static/src/convert_inline/**/*',
],
"web.dark_mode_assets_backend": [
'mail/static/src/components/*/*.dark.scss',
'web.assets_backend_lazy': [
'mail/static/src/views/web/activity/**',
],
'web.assets_backend_prod_only': [
'mail/static/src/main.js',
"web.assets_web_dark": [
'mail/static/src/**/*.dark.scss',
],
"web.assets_frontend": [
"mail/static/src/utils/common/format.js",
],
'mail.assets_discuss_public_test_tours': [
'mail/static/tests/tours/discuss_public_tour.js',
'mail/static/tests/tours/mail_channel_as_guest_tour.js',
'web/static/lib/hoot-dom/**/*',
'web_tour/static/src/js/**/*',
'web_tour/static/src/tour_utils.js',
'web/static/tests/legacy/helpers/cleanup.js',
'web/static/tests/legacy/helpers/utils.js',
'web/static/tests/legacy/utils.js',
'mail/static/tests/tours/discuss_channel_public_tour.js',
'mail/static/tests/tours/discuss_channel_as_guest_tour.js',
'mail/static/tests/tours/discuss_channel_call_action.js',
'mail/static/tests/tours/discuss_channel_call_public_tour.js',
'mail/static/tests/tours/discuss_sidebar_in_public_page_tour.js',
'mail/static/tests/tours/discuss_channel_meeting_view_tour.js',
],
# Unit test files
'web.assets_unit_tests': [
'mail/static/tests/**/*',
('remove', 'mail/static/tests/legacy/helpers/mock_services.js'), # to remove when all legacy tests are ported
('remove', 'mail/static/tests/tours/**/*'),
],
'web.assets_tests': [
'mail/static/tests/tours/**/*',
],
'web.tests_assets': [
'mail/static/tests/helpers/**/*.js',
'mail/static/tests/models/*.js',
'mail/static/tests/legacy/helpers/mock_services.js',
],
'web.qunit_suite_tests': [
'mail/static/tests/qunit_suite_tests/**/*.js',
'mail.assets_odoo_sfu': [
'mail/static/lib/odoo_sfu/odoo_sfu.js',
],
'web.qunit_mobile_suite_tests': [
'mail/static/tests/qunit_mobile_suite_tests/**/*.js',
'mail.assets_lamejs': [
'mail/static/lib/lame/lame.js',
],
"mail.assets_message_email": [
"web/static/lib/odoo_ui_icons/style.css",
],
'mail.assets_public': [
'web/static/lib/jquery/jquery.js',
'web/static/lib/odoo_ui_icons/style.css',
('include', 'web._assets_helpers'),
('include', 'web._assets_backend_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
'web/static/lib/bootstrap/scss/_variables-dark.scss',
'web/static/lib/bootstrap/scss/_maps.scss',
('include', 'web._assets_bootstrap_backend'),
'web/static/src/scss/bootstrap_overridden.scss',
'web/static/src/libs/fontawesome/css/font-awesome.css',
'web/static/src/scss/animation.scss',
'web/static/src/webclient/webclient.scss',
'web/static/src/scss/mimetypes.scss',
'web/static/src/scss/ui.scss',
('include', 'web._assets_core'),
'web/static/src/views/fields/formatters.js',
'web/static/src/views/fields/file_handler.*',
'bus/static/src/*.js',
'bus/static/src/services/**/*.js',
'bus/static/src/workers/*.js',
('remove', 'bus/static/src/workers/bus_worker_script.js'),
("include", "html_editor.assets_editor"),
'mail/static/src/model/**/*',
'mail/static/src/core/common/**/*',
'mail/static/src/core/public_web/**/*',
'mail/static/src/**/common/**/*',
'mail/static/src/**/public_web/**/*',
'mail/static/src/**/public/**/*',
'mail/static/lib/selfie_segmentation/selfie_segmentation.js',
('remove', 'mail/static/src/**/*.dark.scss'),
# discuss (loaded last to fix dependencies)
('remove', 'mail/static/src/discuss/**/*'),
'mail/static/src/discuss/core/common/**/*',
'mail/static/src/discuss/core/public_web/**/*',
'mail/static/src/discuss/core/public/**/*',
'mail/static/src/discuss/**/common/**/*',
'mail/static/src/discuss/**/public_web/**/*',
'mail/static/src/discuss/**/public/**/*',
('remove', 'mail/static/src/discuss/**/*.dark.scss'),
('remove', 'web/static/src/**/*.dark.scss'),
]
},
'author': 'Odoo S.A.',
'license': 'LGPL-3',
}

View file

@ -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

View 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)

View file

@ -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)

View file

@ -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()

View file

@ -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

View 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()

View 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()

View file

@ -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(),
},
)

View 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()

View file

@ -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)

View file

@ -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})

View 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"),
],
)

View file

@ -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

View 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)

View file

@ -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)

View 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",
)

View 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()

View file

@ -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

View 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,
}

View file

@ -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()

View 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()

View 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))

View 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

View 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 {}

View file

@ -2,14 +2,21 @@
<odoo>
<data noupdate="1">
<record model="mail.channel" id="channel_all_employees">
<record model="discuss.channel" id="mail.channel_all_employees">
<field name="name">general</field>
<field name="description">General announcements for all employees.</field>
</record>
<record model="discuss.channel" id="mail.channel_admin">
<field name="name">Administrators</field>
<field name="description">General channel for administrators.</field>
<field name="group_public_id" ref="base.group_system"/>
<field name="group_ids" eval="[Command.link(ref('base.group_system'))]"/>
</record>
<!-- notify all employees of module installation -->
<record model="mail.message" id="module_install_notification">
<field name="model">mail.channel</field>
<record model="mail.message" id="mail.module_install_notification">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_all_employees"/>
<field name="message_type">email</field>
<field name="subtype_id" ref="mail.mt_comment"/>
@ -18,14 +25,14 @@
<p>This channel is accessible to all users to <b>easily share company information</b>.</p>]]></field>
</record>
<record model="mail.channel.member" id="channel_member_general_channel_for_admin">
<record model="discuss.channel.member" id="channel_member_general_channel_for_admin">
<field name="partner_id" ref="base.partner_admin"/>
<field name="channel_id" ref="mail.channel_all_employees"/>
<field name="fetched_message_id" ref="mail.module_install_notification"/>
<field name="seen_message_id" ref="mail.module_install_notification"/>
</record>
<record model="mail.channel" id="mail.channel_all_employees">
<record model="discuss.channel" id="mail.channel_all_employees">
<field name="group_ids" eval="[Command.link(ref('base.group_user'))]"/>
</record>
</data>

View file

@ -0,0 +1,17 @@
<?xml version="1.0"?>
<odoo>
<data>
<record id="mail.discuss_notification_settings_action" model="ir.actions.client">
<field name="name">Notifications</field>
<field name="tag">mail.discuss_notification_settings_action</field>
<field name="target">new</field>
<field name="context">{"dialog_size": "medium", "footer": false}</field>
</record>
<record id="mail.discuss_call_settings_action" model="ir.actions.client">
<field name="name">Voice &amp; Video Settings</field>
<field name="tag">mail.discuss_call_settings_action</field>
<field name="target">new</field>
<field name="context">{'dialog_size': 'medium', 'footer': false}</field>
</record>
</data>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="gc_delete_overdue_activities_year_threshold" model="ir.config_parameter">
<field name="key">mail.activity.gc.delete_overdue_years</field>
<field name="value">3</field>
</record>
<record id="restrict_template_rendering" model="ir.config_parameter">
<field name="key">mail.restrict.template.rendering</field>
<field name="value">1</field>
</record>
</data>
</odoo>

View file

@ -5,12 +5,11 @@
<field name="name">Mail: Email Queue Manager</field>
<field name="model_id" ref="model_mail_mail"/>
<field name="state">code</field>
<field name="code">model.process_email_queue()</field>
<field name="code">model.process_email_queue(batch_size=1000)</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall"/>
<field name="priority">6</field>
</record>
<record id="ir_cron_module_update_notification" model="ir.cron">
@ -21,9 +20,7 @@
<field name="user_id" ref="base.user_root" />
<field name="interval_number">1</field>
<field name="interval_type">weeks</field>
<field name="numbercall">-1</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')" />
<field eval="False" name="doall" />
<field name="priority">1000</field>
</record>
@ -32,11 +29,9 @@
</record>
<record id="ir_cron_delete_notification" model="ir.cron">
<field name="name">Notification: Delete Notifications older than 6 Month</field>
<field name="name">Notification: Delete Notifications older than 6 Months</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="interval_type">months</field>
<field name="model_id" ref="model_mail_notification"/>
<field name="code">model._gc_notifications(max_age_days=180)</field>
<field name="state">code</field>
@ -49,21 +44,45 @@
<field name="code">model._fetch_mails()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<!-- Active flag is set on fetchmail_server.create/write -->
<field name="active" eval="False"/>
</record>
<record id="ir_cron_post_scheduled_message" model="ir.cron">
<field name="name">Mail: Post scheduled messages</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="model_id" ref="model_mail_scheduled_message"/>
<field name="code">model._post_messages_cron()</field>
<field name="state">code</field>
</record>
<record id="ir_cron_send_scheduled_message" model="ir.cron">
<field name="name">Notification: Send scheduled message notifications</field>
<field name="name">Notification: Notify scheduled messages</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="model_id" ref="model_mail_message_schedule"/>
<field name="code">model._send_notifications_cron()</field>
<field name="state">code</field>
</record>
<record id="ir_cron_web_push_notification" model="ir.cron">
<field name="name">Mail: send web push notification</field>
<field name="model_id" ref="model_mail_push"/>
<field name="state">code</field>
<field name="code">model._push_notification_to_endpoint()</field>
<field name="active" eval="True"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
</record>
<record id="ir_cron_discuss_channel_member_unmute" model="ir.cron">
<field name="name">Discuss: channel member unmute</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="model_id" ref="model_discuss_channel_member"/>
<field name="code">model._cleanup_expired_mutes()</field>
<field name="state">code</field>
</record>
</data>
</odoo>

View file

@ -3,11 +3,13 @@
<data noupdate="1">
<record id="mail_activity_data_email" model="mail.activity.type">
<field name="name">Email</field>
<field name="summary">Email</field>
<field name="icon">fa-envelope</field>
<field name="sequence">3</field>
</record>
<record id="mail_activity_data_call" model="mail.activity.type">
<field name="name">Call</field>
<field name="summary">Call</field>
<field name="icon">fa-phone</field>
<field name="category">phonecall</field>
<field name="delay_count">2</field>
@ -15,17 +17,20 @@
</record>
<record id="mail_activity_data_meeting" model="mail.activity.type">
<field name="name">Meeting</field>
<field name="summary">Meeting</field>
<field name="icon">fa-users</field>
<field name="sequence">9</field>
</record>
<record id="mail_activity_data_todo" model="mail.activity.type">
<field name="name">To Do</field>
<field name="icon">fa-tasks</field>
<field name="name">To-Do</field>
<field name="summary">To-Do</field>
<field name="icon">fa-check</field>
<field name="delay_count">5</field>
<field name="sequence">12</field>
<field name="sequence">2</field>
</record>
<record id="mail_activity_data_upload_document" model="mail.activity.type">
<field name="name">Upload Document</field>
<field name="name">Document</field>
<field name="summary">Document</field>
<field name="icon">fa-upload</field>
<field name="delay_count">5</field>
<field name="sequence">25</field>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_canned_response_data_hello" model="mail.canned.response">
<field name="source">hello</field>
<field name="substitution">Hello, how may I help you?</field>
<field name="group_ids" eval="[(6, 0, [ref('base.group_user')])]"/>
</record>
</data>
</odoo>

View file

@ -1,18 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="res.groups.privilege" id="res_groups_privilege_canned_response">
<field name="name">Canned Responses</field>
<field name="sequence">100</field>
<field name="category_id" ref="base.module_category_marketing"/>
</record>
<record id="group_mail_canned_response_admin" model="res.groups">
<field name="name">Canned Response Administrator</field>
<field name="privilege_id" ref="res_groups_privilege_canned_response"/>
</record>
<record id="group_mail_template_editor" model="res.groups">
<field name="name">Mail Template Editor</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('mail.group_mail_template_editor'))]"/>
<field name="implied_ids" eval="[(4, ref('mail.group_mail_template_editor')), (4, ref('mail.group_mail_canned_response_admin'))]"/>
</record>
<!-- By default, allow all users to edit mail templates -->
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('mail.group_mail_template_editor'))]"/>
<!-- Group used for the notification_type field of res.users -->
<record id="group_mail_notification_type_inbox" model="res.groups">
<field name="name">Receive notifications in Odoo</field>
</record>
</data>
</odoo>

View file

@ -16,16 +16,17 @@
<t t-if="display_assignee"> (originally assigned to <span t-field="activity.user_id.name"/>)</t>
<span t-if="activity.summary">: </span><span t-if="activity.summary" t-field="activity.summary"/>
</p>
<t t-if="activity.note and activity.note != '&lt;p&gt;&lt;br&gt;&lt;/p&gt;'"><!-- <p></br></p> -->
<div class="o_mail_note_title fw-bold">Original note:</div>
<div t-field="activity.note"/>
</t>
<div t-if="feedback">
<div class="fw-bold">Feedback:</div>
<t t-foreach="feedback.split('\n')" t-as="feedback_line">
<t t-esc="feedback_line"/>
<br t-if="not feedback_line_last"/>
</t>
</div>
<t t-if="activity.note and activity.note != '&lt;p&gt;&lt;br&gt;&lt;/p&gt;'"><!-- <p></br></p> -->
<div class="o_mail_note_title"><strong>Original note:</strong></div>
<div t-field="activity.note"/>
</t>
</div>
</template>
@ -55,5 +56,22 @@
</t>
</p>
</template>
<template id="discuss_channel_invitation_template">
<div style="padding: 16px; background-color: #F1F1F1; font-family: Verdana, Arial, sans-serif; color: #454748; width: 100%; display: flex; justify-content: center;">
<div style="max-width: 590px; width: 100%; background-color: #ffffff; border-radius: 8px; padding: 20px; display: flex; flex-direction: column; align-items: center;">
<div style="width: 100%; font-size: 14px; line-height: 1.5; text-align: left;">
<t t-esc="mail_body"/>
<p style="text-align: center; margin: 24px 0;">
<a t-attf-href="{{base_url}}{{channel.invitation_url}}?email_token={{email_token}}"
t-attf-style="background-color: {{user.company_id.email_secondary_color or '#875A7B'}}; color: {{user.company_id.email_primary_color or '#FFFFFF'}}; text-decoration: none; padding: 8px 12px; border-radius: 4px; display: inline-block; font-weight: bold;"
>
Join Channel
</a>
</p>
</div>
</div>
</div>
</template>
</data>
</odoo>

View file

@ -8,42 +8,35 @@
</head>
<body style="font-family:Verdana, Arial,sans-serif; color: #454748;">
<t t-set="subtype_internal" t-value="subtype and subtype.internal"/>
<t t-set="show_header" t-value="email_notification_force_header or (
email_notification_allow_header and has_button_access)"/>
<t t-set="show_footer" t-value="email_notification_force_footer or (
email_notification_allow_footer and show_header and author_user and author_user._is_internal())"/>
<!-- HEADER -->
<t t-call="mail.notification_preview"/>
<div style="max-width: 900px; width: 100%;">
<div t-if="has_button_access" itemscope="itemscope" itemtype="http://schema.org/EmailMessage">
<div t-if="show_header and has_button_access" itemscope="itemscope" itemtype="http://schema.org/EmailMessage">
<div itemprop="potentialAction" itemscope="itemscope" itemtype="http://schema.org/ViewAction">
<link itemprop="target" t-att-href="button_access['url']"/>
<link itemprop="url" t-att-href="button_access['url']"/>
<meta itemprop="name" t-att-content="button_access['title']"/>
</div>
</div>
<div t-if="subtitles or has_button_access or actions or not is_discussion"
<div t-if="show_header and (subtitles or has_button_access or not is_discussion)"
summary="o_mail_notification" style="padding: 0px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width: 100%; margin-top: 5px;">
<tbody>
<tr>
<td valign="center">
<img t-att-src="'/logo.png?company=%s' % (company.id or 0)" style="padding: 0px; margin: 0px; height: auto; max-width: 200px; max-height: 36px;" t-att-alt="'%s' % company.name"/>
</td>
</tr>
<tr>
<td valign="center">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 10px 0px;"/>
</td>
</tr>
<tr>
<td valign="center" style="white-space:nowrap;">
<table cellspacing="0" cellpadding="0" border="0">
<tbody>
<tr>
<td t-if="has_button_access" t-att-style="'border-radius: 3px; text-align: center; background: ' + (company.secondary_color if company.secondary_color else '#875A7B')">
<a t-att-href="button_access['url']" style="font-size: 12px; color: #FFFFFF; display: block; padding: 8px 12px 11px; text-decoration: none !important; font-weight: 400;">
<td t-if="has_button_access" t-att-style="'border-radius: 3px; text-align: center; background: ' + (company.email_secondary_color or '#875A7B') + ';'">
<a t-att-href="button_access['url']" t-att-style="'font-size: 12px; color: ' + (company.email_primary_color or '#FFFFFF') + '; display: block; padding: 8px 12px 11px; text-decoration: none !important; font-weight: bold;'">
<t t-out="button_access['title']"/>
</a>
</td>
<td t-if="has_button_access">&amp;nbsp;&amp;nbsp;</td>
<td t-if="subtitles" style="font-size: 12px;">
<t t-foreach="subtitles" t-as="subtitle">
<span t-attf-style="{{ 'font-weight:bold;' if subtitle_first else '' }}"
@ -51,17 +44,8 @@
<br t-if="not subtitle_last"/>
</t>
</td>
<td t-else=""><span style="font-weight:bold;" t-out="record_name"/><br/></td>
<td>&amp;nbsp;&amp;nbsp;</td>
<td t-else=""><span style="font-weight:bold;" t-out="record_name or (message.record_name and message.record_name.replace('/','-')) or ''"/><br/></td>
<td t-if="actions">
<t t-foreach="actions" t-as="action">
|
<a t-att-href="action['url']" style="font-size: 12px; color: #875A7B; text-decoration:none !important;">
<t t-out="action['title']"/>
</a>
</t>
</td>
</tr>
</tbody>
</table>
@ -71,9 +55,6 @@
<td valign="center">
<hr width="100%"
style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0;margin: 10px 0px;"/>
<p t-if="subtype_internal" style="background-color: #f2dede; padding: 5px; margin-bottom: 16px; font-size: 13px;">
<strong>Internal communication</strong>: Replying will post an internal note. Followers won't receive any email notification.
</p>
</td>
</tr>
</tbody>
@ -83,14 +64,14 @@
<div t-out="message.body" style="font-size: 13px;"/>
<ul t-if="tracking_values">
<t t-foreach="tracking_values" t-as="tracking">
<li><t t-out="tracking[0]"/>: <t t-out="tracking[1]"/> &#8594; <t t-out="tracking[2]"/></li>
<li><t t-out="tracking[0]"/>: <t t-if="tracking[1]" t-out="tracking[1]"/><em t-else="">None</em> &#8594; <t t-if="tracking[2]" t-out="tracking[2]"/><em t-else="">None</em></li>
</t>
</ul>
<t class="o_signature">
<div t-if="email_add_signature and not is_html_empty(signature)" t-out="signature" style="font-size: 13px;"/>
<div t-if="email_add_signature and not is_html_empty(signature)" t-out="signature" class="o_signature" style="font-size: 13px;"/>
</t>
<!-- FOOTER -->
<div style="margin-top:32px;">
<div t-if="show_footer" style="margin-top:16px;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 4px 0px;"/>
<b t-out="company.name" style="font-size:11px;"/><br/>
<p style="color: #999999; margin-top:2px; font-size:11px;">
@ -101,9 +82,13 @@
<a t-if="company.website" t-att-href="'%s' % company.website" style="text-decoration:none; color: #999999;" t-out="company.website"/>
</p>
</div>
<p style="color: #555555; font-size:11px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email" style="color: #875A7B;">Odoo</a>
</p>
<div t-if="show_footer" style="color: #555555; font-size:11px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email"
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Odoo</a>
<span t-if="show_unfollow" id="mail_unfollow">
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
</span>
</div>
</div>
</body></html>
</template>
@ -129,17 +114,17 @@
<t t-if="has_button_access">
<a t-att-href="button_access['url']">
<span style="font-size: 20px; font-weight: bold;">
<t t-out="message.record_name and message.record_name.replace('/','-') or ''"/>
<t t-out="(record_name or message.record_name or '').replace('/','-')"/>
</span>
</a>
</t>
<t t-else="">
<span style="font-size: 20px; font-weight: bold;">
<t t-out="message.record_name and message.record_name.replace('/','-') or ''"/>
<t t-out="(record_name or message.record_name or '').replace('/','-')"/>
</span>
</t>
</td><td valign="middle" align="right">
<img t-att-src="'/logo.png?company=%s' % (company.id or 0)" style="padding: 0px; margin: 0px; height: 48px;" t-att-alt="'%s' % company.name"/>
</td><td valign="middle" align="right" t-if="company and not company.uses_default_logo">
<img t-att-src="'/logo.png?company=%s' % company.id" style="padding: 0px; margin: 0px; height: 48px;" t-att-alt="'%s' % company.name"/>
</td></tr>
<tr><td colspan="2" style="text-align:center;">
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin:4px 0px 32px 0px;"/>
@ -172,7 +157,11 @@
</td></tr>
<!-- POWERED BY -->
<tr><td align="center" style="min-width: 590px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email" style="color: #875A7B;">Odoo</a>
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email"
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Odoo</a>
<span t-if="show_unfollow" id="mail_unfollow">
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
</span>
</td></tr>
</table>
</body>
@ -197,8 +186,8 @@
inherit_id="mail.mail_notification_layout" primary="True">
<xpath expr="//t[hasclass('o_signature')]" position="replace">
<t class="o_signature">
<div t-if="email_add_signature and 'user_id' in record and record.user_id and not record.env.user._is_superuser() and not is_html_empty(record.user_id.sudo().signature)"
t-out="record.user_id.sudo().signature" style="font-size: 13px;"/>
<div t-if="email_add_signature and record and 'user_id' in record and record.user_id and not record.env.user._is_superuser() and not is_html_empty(record.user_id.sudo().signature)"
t-out="record.user_id.sudo().signature" class="o_signature" style="font-size: 13px;"/>
</t>
</xpath>
</template>

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="mail_notification_invite" inherit_id="mail.mail_notification_layout" primary="True">
<xpath expr="//td[@t-if='subtitles']" position="before">
<t t-if="not has_button_access">
<t t-set="subtitles" t-value="False" />
</t>
</xpath>
<xpath expr="//div[@t-out='message.body']" position="replace">
<div style="font-size:13px;">
<div class="o-mail-invite-openingMessage">
<t t-out='message.author_id.name'/> (<t t-out='message.author_id.email'/>) added you as a follower of this <t t-out="model_description"/>.
</div>
<br t-if="len(message.body) > 0"/>
<div style="color:grey;">
<t t-out="message.body"/>
</div>
</div>
</xpath>
<xpath expr="//span[@id='mail_unfollow']" position="replace"/>
<xpath expr="//div[@style='margin-top:16px;']/hr" position="before">
<span t-if="show_unfollow" id="mail_unfollow" style="font-size: 13px;">
Not interested by this? <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
</span>
</xpath>
</template>
<template id="mail_notification_multi_invite" inherit_id="mail.mail_notification_invite" primary="True">
<xpath expr="//div[hasclass('o-mail-invite-openingMessage')]" position="replace">
<div>
<t t-out='message.author_id.name'/> (<t t-out='message.author_id.email'/>) added you as a follower of <t t-out="model_description"/> listed below:
</div>
</xpath>
</template>
</data>
</odoo>

View file

@ -12,6 +12,16 @@
<p>Kind Regards</p>
</template>
<!-- Out-Of-Office content layout -->
<template id="message_notification_out_of_office">
<div t-out="out_of_office_message"/>
<div class="o_mail_reply_container">
<div class="o_mail_reply_content">
<blockquote t-out="replied_body"/>
</div>
</div>
</template>
<template id="mail_bounce_catchall">
<div>
<p>Hello <t t-esc="message['email_from']"/>,</p>
@ -29,5 +39,17 @@
<div><t t-out="body"/></div>
<blockquote><t t-out="message['body']"/></blockquote>
</template>
<template id="mail_attachment_links" name="Attachment links">
<div style="max-width: 900px; width: 100%;">
<hr style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 10px 0px;"/>
<div t-foreach="attachments" t-as="attachment">
<a t-attf-href="{{attachment.get_base_url()}}/web/content/{{attachment.id}}?download=1&amp;access_token={{attachment.access_token}}"
style="font-size: 12px; color: #875A7B; text-decoration:none !important; text-decoration:none; font-weight: 400;">
&amp;#128229; <t t-out="attachment.name"/>
</a>
</div>
</div>
</template>
</data>
</odoo>

View file

@ -4,3 +4,12 @@ UPDATE mail_template
-- deactivate fetchmail server
UPDATE fetchmail_server
SET active = false;
-- reset WEB Push Notification:
-- * delete VAPID/JWT keys
DELETE FROM ir_config_parameter
WHERE key IN ('mail.web_push_vapid_private_key', 'mail.web_push_vapid_public_key', 'mail.sfu_server_key');
-- * delete delayed messages (CRON)
TRUNCATE mail_push;
-- * delete Devices for each partners
DELETE FROM mail_push_device;

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Template for security update notification (password/login/mail changed, ...) -->
<template id="account_security_alert" name="Alert Security Update">
<div>
Dear <t t-out="user.name or ''">Marc Demo</t>,<br/><br/>
<t t-out="content"/> <br/><br/>
Here are some details about the connection:<br/>
<ul>
<li><span style="font-weight: bold">
Date:</span> <t t-out="format_datetime(event_datetime, dt_format='long')">day, month dd, yyyy - hh:mm:ss (GMT)</t></li>
<li t-if="location_address"><span style="font-weight: bold">
Location:</span> <t t-out="location_address">City, Region, Country</t></li>
<li t-if="useros"><span style="font-weight: bold">
Platform:</span> <t t-out="useros">OS</t></li>
<li t-if="browser"><span style="font-weight: bold">
Browser:</span> <t t-out="browser">Browser</t></li>
<li><span style="font-weight: bold">
IP Address:</span> <t t-out="ip_address">111.222.333.444</t></li>
</ul>
<div t-if="suggest_password_reset" class="o_mail_account_security_suggestions">
If you don't recognize it, you should change your password immediately via this link:<br/>
<div style="margin: 16px 0px 16px 0px">
<a t-attf-href="{{ user.get_base_url() }}/web/reset_password"
style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px">
Reset Password
</a>
</div>
Otherwise, you can safely ignore this email.
</div>
</div>
</template>
</data>
</odoo>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="discuss_channel_tour" model="web_tour.tour">
<field name="name">discuss_channel_tour</field>
<field name="sequence">2000</field>
<field name="url">/odoo</field>
</record>
</odoo>

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="mail.guest" id="mail.guest_alex_demo">
<field name="name">Alex</field>
</record>
<record model="discuss.channel" id="mail.channel_public_community_demo">
<field name="name">Public Community</field>
<field name="description">A space for engaging discussions among employees, partners, and the public.</field>
<field name="group_public_id" eval="False"/>
</record>
<record model="mail.message" id="mail.channel_public_community_message_0_demo">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_public_community_demo"/>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_admin"/>
<field name="date" eval="(DateTime.today() - timedelta(minutes=30)).strftime('%Y-%m-%d %H:%M')"/>
<field name="body">
We're excited to announce our product launch this September! 🎉
We'll be hosting a live video conference on this channel to showcase all the details.
If you're interested, feel free to share the link with your customers so they can tune
in and watch it live!
</field>
</record>
<record model="mail.message" id="mail.channel_public_community_message_1_demo">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_public_community_demo"/>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id"/>
<field name="author_guest_id" ref="mail.guest_alex_demo"/>
<field name="parent_id" ref="mail.channel_public_community_message_0_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(minutes=20)).strftime('%Y-%m-%d %H:%M')"/>
<field name="body">
That sounds amazing! Can't wait to see what's new.
Will there be a QA session during the conference?
</field>
</record>
<record model="mail.message" id="mail.channel_public_community_message_2_demo">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_public_community_demo"/>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="parent_id" ref="mail.channel_public_community_message_1_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(minutes=10)).strftime('%Y-%m-%d %H:%M')"/>
<field name="body">
Thanks, Alex! 😊 Yes, there will be a live QA session at
the end of the conference where attendees can ask questions and get insights
directly from our team. Stay tuned for more details!
</field>
</record>
<record model="mail.message.reaction" id="mail.channel_public_community_message_1_reaction_0_demo">
<field name="content">❤️</field>
<field name="message_id" ref="mail.channel_public_community_message_1_demo"/>
<field name="partner_id" ref="base.partner_demo"/>
</record>
<record model="mail.message.reaction" id="mail.channel_public_community_message_2_reaction_1_demo">
<field name="content">👍</field>
<field name="message_id" ref="mail.channel_public_community_message_2_demo"/>
<field name="guest_id" ref="mail.guest_alex_demo"/>
</record>
<record model="discuss.channel.member" id="mail.channel_public_community_member_partner_admin_demo">
<field name="partner_id" ref="base.partner_admin"/>
<field name="channel_id" ref="mail.channel_public_community_demo"/>
<field name="fetched_message_id" ref="mail.channel_public_community_message_0_demo"/>
<field name="seen_message_id" ref="mail.channel_public_community_message_0_demo"/>
<field name="new_message_separator" eval="ref('mail.channel_public_community_message_0_demo') + 1"/>
</record>
<record model="discuss.channel.member" id="mail.channel_public_community_member_partner_demo_demo">
<field name="partner_id" ref="base.partner_demo"/>
<field name="channel_id" ref="mail.channel_public_community_demo"/>
<field name="fetched_message_id" ref="mail.channel_public_community_message_2_demo"/>
<field name="seen_message_id" ref="mail.channel_public_community_message_2_demo"/>
<field name="new_message_separator" eval="ref('mail.channel_public_community_message_2_demo') + 1"/>
</record>
<record model="discuss.channel.member" id="mail.channel_public_community_member_guest_alex_demo">
<field name="guest_id" ref="mail.guest_alex_demo"/>
<field name="channel_id" ref="mail.channel_public_community_demo"/>
<field name="fetched_message_id" ref="mail.channel_public_community_message_1_demo"/>
<field name="seen_message_id" ref="mail.channel_public_community_message_1_demo"/>
<field name="new_message_separator" eval="ref('mail.channel_public_community_message_1_demo') + 1"/>
</record>
</data>
</odoo>

View file

@ -3,52 +3,52 @@
<data noupdate="1">
<!-- Discussion groups, done in 2 steps to remove creator from followers -->
<record model="mail.channel" id="channel_1">
<record model="discuss.channel" id="mail.channel_1">
<field name="name">sales</field>
<field name="description">Discussion about best sales practices and deals.</field>
</record>
<record model="mail.channel" id="channel_2">
<record model="discuss.channel" id="mail.channel_2">
<field name="name">board-meetings</field>
<field name="description">Board meetings, budgets, strategic plans</field>
</record>
<record model="mail.channel" id="channel_3">
<record model="discuss.channel" id="mail.channel_3">
<field name="name">rd</field>
<field name="description">Research and development discussion group</field>
</record>
<!-- Best sales practices messages -->
<record id="mail_message_channel_1_1" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_1"/>
<field name="body"><![CDATA[<p>Selling a training session and selling the products after the training session is more efficient than directly selling a pack with the training session and the products.</p>]]></field>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mt_comment"/>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(days=5)).strftime('%Y-%m-%d %H:%M')"/>
</record>
<record id="mail_message_channel_1_2" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_1"/>
<field name="body"><![CDATA[<p>I noted I can not manage efficiently my pipeline when I have more than 50 opportunities in the qualification stage.</p><p>Any advice on this? How do you organize your activities with more than 50 opportunities?</p>]]></field>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mt_comment"/>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_root"/>
<field name="date" eval="(DateTime.today() - timedelta(days=4)).strftime('%Y-%m-%d %H:%M')"/>
</record>
<record id="mail_message_channel_1_2_1" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_1"/>
<field name="body"><![CDATA[<p>When I have too much opportunities in the pipe, I start communicating with prospects more by email than phonecalls.</p><p>I send an email to create a sense of emergency, like <i>"can I call you this week about our quote?"</i> and I call only those that answer this email.</p><p>You can use the email template feature of Odoo to automate email composition.</p>]]></field>
<field name="message_type">comment</field>
<field name="parent_id" ref="mail_message_channel_1_2"/>
<field name="subtype_id" ref="mt_comment"/>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(days=3)).strftime('%Y-%m-%d %H:%M')"/>
</record>
<!-- Pushed to all employees -->
<record id="mail_message_channel_whole_1" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_all_employees"/>
<field name="body"><![CDATA[
<p>
@ -64,7 +64,7 @@
<field name="subtype_id" ref="mail.mt_comment"/>
</record>
<record id="mail_message_channel_whole_2" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_all_employees"/>
<field name="body"><![CDATA[<p>Your monthly meal vouchers arrived. You can get them at the HR's office.</p>
<p>This month you also get 250 EUR of eco-vouchers if you have been in the company for more than a year.</p>]]></field>
@ -74,7 +74,7 @@
<field name="subtype_id" ref="mail.mt_comment"/>
</record>
<record id="mail_message_channel_whole_2_1" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="channel_all_employees"/>
<field name="body"><![CDATA[<p>Thanks! Could you please remind me where is Christine's office, if I may ask? I'm new here!</p>]]></field>
<field name="parent_id" ref="mail_message_channel_whole_2"/>
@ -84,7 +84,7 @@
<field name="subtype_id" ref="mail.mt_comment"/>
</record>
<record id="mail_message_channel_whole_2_2" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="channel_all_employees"/>
<field name="body"><![CDATA[<p>Building B3, second floor to the right :-).</p>]]></field>
<field name="parent_id" ref="mail_message_channel_whole_2_1"/>
@ -96,7 +96,7 @@
<!-- Board messages -->
<record id="mail_message_channel_2_1" model="mail.message">
<field name="model">mail.channel</field>
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_2"/>
<field name="body"><![CDATA[
<p>
@ -150,10 +150,88 @@
</p>
]]></field>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mt_comment"/>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(days=3)).strftime('%Y-%m-%d %H:%M')"/>
</record>
<!-- sub-threads setup -->
<record model="discuss.channel" id="mail.from_notification_message">
<field name="parent_channel_id" ref="mail.channel_all_employees"/>
<field name="from_message_id" ref="mail.module_install_notification"/>
<field name="name">Welcome to the #general channel</field>
</record>
<record model="discuss.channel.member" id="mail.admin_member_from_notification">
<field name="partner_id" ref="base.partner_admin"/>
<field name="channel_id" ref="mail.from_notification_message"/>
</record>
<record model="discuss.channel" id="mail.idea_suggestions_sub">
<field name="parent_channel_id" ref="mail.channel_all_employees"/>
<field name="name">Ideas &amp; Suggestions</field>
</record>
<record model="discuss.channel.member" id="mail.admin_member_idea_suggestions">
<field name="partner_id" ref="base.partner_admin"/>
<field name="channel_id" ref="mail.idea_suggestions_sub"/>
</record>
<record model="discuss.channel.member" id="mail.demo_member_idea_suggestions">
<field name="partner_id" ref="base.partner_demo"/>
<field name="channel_id" ref="mail.idea_suggestions_sub"/>
</record>
<record model="mail.message" id="mail.idea_suggestions_message_0">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.idea_suggestions_sub"/>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="body"><![CDATA[<p>Hey team, I was thinking it might be
helpful to set up a weekly brainstorming session where we can
discuss new strategies for improving customer engagement. What do
you all think?</p>]]></field>
</record>
<record model="discuss.channel" id="mail.troubleshooting_sub">
<field name="parent_channel_id" ref="mail.channel_all_employees"/>
<field name="name">Troubleshooting</field>
</record>
<record model="mail.message" id="mail.troubleshooting_message_0">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.troubleshooting_sub"/>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="body">I can't access the team calendar.
It keeps saying 'access denied'.
Has anyone else had this issue?</field>
</record>
<record model="mail.message" id="mail.troubleshooting_message_1">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.troubleshooting_sub"/>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_admin"/>
<field name="body">Have you tried refreshing the page?
Perhaps it's not up to date?</field>
</record>
<record model="mail.message" id="mail.troubleshooting_message_2">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.troubleshooting_sub"/>
<field name="message_type">comment</field>
<field name="subtype_id" ref="mail.mt_comment"/>
<field name="author_id" ref="base.partner_demo"/>
<field name="body">Haha, thanks! You just saved the day!</field>
</record>
<record model="discuss.channel.member" id="mail.admin_member_troubleshooting">
<field name="partner_id" ref="base.partner_admin"/>
<field name="channel_id" ref="mail.troubleshooting_sub"/>
<field name="fetched_message_id" ref="mail.troubleshooting_message_2"/>
<field name="seen_message_id" ref="mail.troubleshooting_message_2"/>
<field name="new_message_separator" eval="ref('mail.troubleshooting_message_2') + 1"/>
</record>
<record model="discuss.channel.member" id="mail.demo_member_troubleshooting">
<field name="partner_id" ref="base.partner_demo"/>
<field name="channel_id" ref="mail.troubleshooting_sub"/>
<field name="fetched_message_id" ref="mail.troubleshooting_message_2"/>
<field name="seen_message_id" ref="mail.troubleshooting_message_2"/>
<field name="new_message_separator" eval="ref('mail.troubleshooting_message_2') + 1"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="mail_activity_demo_admin_email" model="mail.activity">
<field name="activity_type_id" ref="mail.mail_activity_data_email"/>
<field name="note">Much email, nice ChatGPT</field>
<field name="summary">Send Email to Alfred</field>
<field name="user_id" ref="base.user_admin"/>
</record>
<record id="mail_activity_demo_admin_todo" model="mail.activity">
<field name="activity_type_id" ref="mail.mail_activity_data_todo"/>
<field name="note">Nomnom yummy yummy</field>
<field name="summary">Eat cookies</field>
<field name="user_id" ref="base.user_admin"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_canned_response_bye" model="mail.canned.response">
<field name="source">bye</field>
<field name="substitution">Thanks for your feedback. Goodbye!</field>
<field name="group_ids" eval="[(6, 0, [ref('base.group_user')])]"/>
</record>
</data>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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