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