initial commit

This commit is contained in:
Данил Воробьев 2024-05-03 09:40:35 +00:00
commit 6e6f15d803
973 changed files with 1607730 additions and 0 deletions

54
README.md Normal file
View File

@ -0,0 +1,54 @@
Odoo Enterprise Social Network
------------------------------
Connect with experts, follow what interests you, share documents and promote
best practices with Odoo <a href="https://www.odoo.com/app/discuss">Enterprise Social Network</a>. Get work done with
effective collaboration across departments, geographies, documents and business
applications. All of this while decreasing email overload.
Connect with experts
--------------------
Next time you have a question for the marketing, sales, R&D or any other
department, don't send an email blast-post the question to Odoo and get answers
from the right persons.
Follow what interests you
-------------------------
Want to get informed about new product features, hot deals, bottlenecks in
projects or any other event? Just follow what interests you to get the
information you need what you need; no more, no less.
Get Things Done
---------------
You can process (not only read) the inbox and easily mark messages for future
actions. Start feeling the pleasure of having an empty inbox every day; no more
overload of information.
Promote best practices
----------------------
Cut back on meetings and email chains by working together in groups of
interests. Create a group to let people share files, discuss ideas, and vote to
promote best practices.
Improve Access to Information and Expertise
-------------------------------------------
Break down information silos. Search across your existing systems to find the
answers and expertise you need to complete projects quickly.
Collaborate securely
--------------------
Set the right security policy; public, private or on invitation only --
according to the information sensitivity.
A Twitter-like Network For My Company
---------------------------------------
Make every employee feel more connected and engaged with twitter-like features
for your own company. Follow people, share best practices, 'like' top ideas,
etc.

11
__init__.py Normal file
View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import tools
from . import wizard
from . import controllers
from . import populate
def _mail_post_init(env):
env['mail.alias.domain']._migrate_icp_to_domain()

229
__manifest__.py Normal file
View File

@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
{
'name': 'Discuss',
'version': '1.15',
'category': 'Productivity/Discuss',
'sequence': 145,
'summary': 'Chat, mail gateway and private channels',
'description': """
Chat, mail gateway and private channel.
=======================================
Communicate with your colleagues/customers/guest within Odoo.
Discuss/Chat
------------
User-friendly "Discuss" features that allows one 2 one or group communication
(text chat/voice call/video call), invite guests and share documents with
them, all real-time.
Mail gateway
------------
Sending information and documents made simplified. You can send emails
from Odoo itself, and that too with great possibilities. For example,
design a beautiful email template for the invoices, and use the same
for all your customers, no need to do the same exercise every time.
Chatter
-------
Do all the contextual conversation on a document. For example on an
applicant, directly post an update to send email to the applicant,
schedule the next interview call, attach the contract, add HR officer
to the follower list to notify them for important events(with help of
subtypes),...
Retrieve incoming email on POP/IMAP servers.
============================================
Enter the parameters of your POP/IMAP account(s), and any incoming emails on
these accounts will be automatically downloaded into your Odoo system. All
POP3/IMAP-compatible servers are supported, included those that require an
encrypted SSL/TLS connection.
This can be used to easily create email-based workflows for many email-enabled Odoo documents, such as:
----------------------------------------------------------------------------------------------------------
* CRM Leads/Opportunities
* CRM Claims
* Project Issues
* Project Tasks
* Human Resource Recruitment (Applicants)
Just install the relevant application, and you can assign any of these document
types (Leads, Project Issues) to your incoming email accounts. New emails will
automatically spawn new documents of the chosen type, so it's a snap to create a
mailbox-to-Odoo integration. Even better: these documents directly act as mini
conversations synchronized by email. You can reply from within Odoo, and the
answers will automatically be collected when they come back, and attached to the
same *conversation* document.
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'],
'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_resend_partner_views.xml',
'wizard/mail_template_preview_views.xml',
'wizard/mail_wizard_invite_views.xml',
'wizard/mail_template_reset_views.xml',
'views/fetchmail_views.xml',
'views/mail_message_subtype_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/discuss_channel_member_views.xml',
'views/discuss_channel_rtc_session_views.xml',
'views/mail_link_preview_views.xml',
'views/discuss/discuss_gif_favorite_views.xml',
'views/discuss_channel_views.xml',
'views/mail_shortcode_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/discuss_channel_data.xml',
'data/mail_activity_data.xml',
'data/security_notifications_templates.xml',
'data/ir_cron_data.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',
'views/ir_actions_server_views.xml',
'views/ir_model_views.xml',
'views/res_partner_views.xml',
'views/mail_blacklist_views.xml',
'views/mail_menus.xml',
'views/res_company_views.xml',
],
'demo': [
'data/discuss_channel_demo.xml',
],
'installable': True,
'application': True,
'post_init_hook': '_mail_post_init',
'assets': {
'web._assets_primary_variables': [
'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',
'mail/static/src/scss/*.scss',
'mail/static/lib/**/*',
('remove', 'mail/static/lib/odoo_sfu/odoo_sfu.js'),
('remove', 'mail/static/lib/lame/lame.js'),
'mail/static/src/js/**/*',
'mail/static/src/core/common/**/*',
'mail/static/src/core/web_portal/**/*',
'mail/static/src/core/web/**/*',
'mail/static/src/**/common/**/*',
'mail/static/src/**/web/**/*',
('remove', 'mail/static/src/core/web/wysiwyg.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/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/**/*',
],
'web_editor.backend_assets_wysiwyg': [
'mail/static/src/core/web/wysiwyg.js',
],
"web.assets_web_dark": [
'mail/static/src/**/*.dark.scss',
],
'mail.assets_discuss_public_test_tours': [
'web_tour/static/src/tour_pointer/**/*',
# scss not needed in tests and depends on scss variables that are not in this bundle
('remove', 'web_tour/static/src/tour_pointer/**/*.scss'),
'web_tour/static/src/tour_service/**/*',
'web/static/tests/helpers/cleanup.js',
'web/static/tests/helpers/utils.js',
'web/static/tests/utils.js',
'mail/static/tests/tours/discuss_channel_public_tour.js',
'mail/static/tests/tours/discuss_channel_as_guest_tour.js',
],
'web.assets_tests': [
'mail/static/tests/tours/**/*',
],
'web.tests_assets': [
'mail/static/tests/helpers/**/*',
],
'web.qunit_suite_tests': [
'mail/static/tests/**/*',
('remove', 'mail/static/tests/tours/**/*'),
('remove', 'mail/static/tests/helpers/**/*'),
],
'mail.assets_odoo_sfu': [
'mail/static/lib/odoo_sfu/odoo_sfu.js',
],
'mail.assets_lamejs': [
'mail/static/lib/lame/lame.js',
],
'mail.assets_public': [
'web/static/lib/jquery/jquery.js',
'web/static/lib/odoo_ui_icons/style.css',
'web/static/src/libs/fontawesome/css/font-awesome.css',
('include', 'web._assets_helpers'),
('include', 'web._assets_backend_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
('include', 'web._assets_bootstrap_backend'),
'web/static/src/scss/bootstrap_overridden.scss',
'web/static/src/webclient/webclient.scss',
'web/static/src/scss/mimetypes.scss',
('include', 'web._assets_core'),
'web/static/src/libs/pdfjs.js',
'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/websocket_worker.js',
'bus/static/src/workers/websocket_worker_utils.js',
'mail/static/src/core/common/**/*',
'mail/static/src/**/common/**/*',
'mail/static/src/**/public/**/*',
('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/**/*',
'mail/static/src/discuss/**/public_web/**/*',
('remove', 'mail/static/src/discuss/**/*.dark.scss'),
]
},
'license': 'LGPL-3',
}

Binary file not shown.

Binary file not shown.

15
controllers/__init__.py Normal file
View File

@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import attachment
from . import google_translate
from . import guest
from . import link_preview
from . import mail
from . import mailbox
from . import message_reaction
from . import thread
from . import webclient
from . import webmanifest
# after mail specifically as discuss module depends on mail
from . import discuss

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

124
controllers/attachment.py Normal file
View File

@ -0,0 +1,124 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import io
import logging
import zipfile
from werkzeug.exceptions import NotFound
from odoo import _, http
from odoo.exceptions import AccessError
from odoo.http import request, content_disposition
from odoo.tools import consteq
from ..models.discuss.mail_guest import add_guest_to_context
logger = logging.getLogger(__name__)
class AttachmentController(http.Controller):
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 = request.env[thread_model].with_context(active_test=False).search([("id", "=", thread_id)])
if not thread:
raise NotFound()
if thread_model == "discuss.channel" and not thread.allow_public_upload and not request.env.user._is_internal():
raise AccessError(_("You are not allowed to upload attachments on this channel."))
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 request.env.user.share:
# Only generate the access token if absolutely necessary (= not for internal user).
vals["access_token"] = request.env["ir.attachment"]._generate_access_token()
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)
attachmentData = attachment._attachment_format()[0]
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")
@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:
target = request.env.user.partner_id
request.env["bus.bus"]._sendone(target, "ir.attachment/delete", {"id": attachment_id})
return
message = request.env["mail.message"].search([("attachment_ids", "in", attachment.ids)], limit=1)
if not request.env.user.share:
# Check through standard access rights/rules for internal users.
attachment._delete_and_notify(message)
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).
# sudo: ir.attachment: access is validated below with membership of message or access token
attachment_sudo = attachment.sudo()
if message:
if not message.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(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)

View File

@ -0,0 +1,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import binary
from . import channel
from . import gif
from . import public_page
from . import rtc
from . import voice

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,94 @@
# 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.models.discuss.mail_guest import add_guest_to_context
from odoo.addons.web.controllers.binary import Binary
class BinaryController(Binary):
@http.route(
"/discuss/channel/<int:channel_id>/partner/<int:partner_id>/avatar_128",
methods=["GET"],
type="http",
auth="public",
)
@add_guest_to_context
def discuss_channel_partner_avatar_128(self, channel_id, partner_id, unique=False):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
partner = request.env["res.partner"].browse(partner_id).exists()
domain = [("channel_id", "=", channel_id), ("partner_id", "=", partner_id)]
if channel and partner and request.env["discuss.channel.member"].search(domain):
# sudo: res.partner - the partner is in the same channel as the current user, so they can see their avatar
return (
request.env["ir.binary"]._get_image_stream_from(partner.sudo(), field_name="avatar_128").get_response(immutable=True if unique else False)
)
return self.content_image(model="res.partner", id=partner_id, field="avatar_128", unique=unique)
@http.route(
"/discuss/channel/<int:channel_id>/guest/<int:guest_id>/avatar_128", methods=["GET"], type="http", auth="public"
)
@add_guest_to_context
def discuss_channel_guest_avatar_128(self, channel_id, guest_id, unique=False):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
guest = request.env["mail.guest"].browse(guest_id).exists()
domain = [("channel_id", "=", channel_id), ("guest_id", "=", guest_id)]
if channel and guest and request.env["discuss.channel.member"].search(domain):
# sudo: mail.guest - the guest is in the same channel as the current user, so they can see their avatar
return request.env["ir.binary"]._get_image_stream_from(guest.sudo(), field_name="avatar_128").get_response(immutable=True if unique else False)
return self.content_image(model="mail.guest", id=guest_id, field="avatar_128", unique=unique)
@http.route(
"/discuss/channel/<int:channel_id>/attachment/<int:attachment_id>", methods=["GET"], type="http", auth="public"
)
@add_guest_to_context
def discuss_channel_attachment(self, channel_id, attachment_id, download=None, **kwargs):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
if not channel:
raise NotFound()
domain = [
("id", "=", int(attachment_id)),
("res_id", "=", int(channel_id)),
("res_model", "=", "discuss.channel"),
]
# sudo: ir.attachment - searching for an attachment on a specific channel that the current user can access
attachment_sudo = request.env["ir.attachment"].sudo().search(domain)
if not attachment_sudo:
raise NotFound()
return request.env["ir.binary"]._get_stream_from(attachment_sudo).get_response(as_attachment=download)
@http.route("/discuss/channel/<int:channel_id>/avatar_128", methods=["GET"], type="http", auth="public")
@add_guest_to_context
def discuss_channel_avatar_128(self, channel_id, unique=False):
return self.content_image(model="discuss.channel", id=channel_id, field="avatar_128", unique=unique)
@http.route(
[
"/discuss/channel/<int:channel_id>/image/<int:attachment_id>",
"/discuss/channel/<int:channel_id>/image/<int:attachment_id>/<int:width>x<int:height>",
],
methods=["GET"],
type="http",
auth="public",
)
@add_guest_to_context
def fetch_image(self, channel_id, attachment_id, width=0, height=0, **kwargs):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
if not channel:
raise NotFound()
domain = [
("id", "=", attachment_id),
("res_id", "=", channel_id),
("res_model", "=", "discuss.channel"),
]
# sudo: ir.attachment - searching for an attachment on a specific channel that the current user can access
attachment_sudo = request.env["ir.attachment"].sudo().search(domain, 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"))
)

View File

@ -0,0 +1,141 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from dateutil.relativedelta import relativedelta
from werkzeug.exceptions import NotFound
from odoo import fields, http
from odoo.http import request
from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context
class ChannelController(http.Controller):
@http.route("/discuss/channel/members", methods=["POST"], type="json", auth="public")
@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()
return channel.load_more_members(known_member_ids)
@http.route("/discuss/channel/update_avatar", methods=["POST"], type="json")
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/info", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def discuss_channel_info(self, channel_id):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
if not channel:
return
return channel._channel_info()[0]
@http.route("/discuss/channel/messages", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def discuss_channel_messages(self, channel_id, search_term=None, before=None, after=None, limit=30, around=None):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
if not channel:
raise NotFound()
domain = [
("res_id", "=", channel_id),
("model", "=", "discuss.channel"),
("message_type", "!=", "user_notification"),
]
res = request.env["mail.message"]._message_fetch(
domain, search_term=search_term, before=before, after=after, around=around, limit=limit
)
if not request.env.user._is_public() and not around:
res["messages"].set_message_done()
return {**res, "messages": res["messages"].message_format()}
@http.route("/discuss/channel/pinned_messages", methods=["POST"], type="json", auth="public")
@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()
return channel.pinned_message_ids.sorted(key="pinned_at", reverse=True).message_format()
@http.route("/discuss/channel/mute", methods=["POST"], type="json", auth="user")
def discuss_channel_mute(self, channel_id, minutes):
"""Mute notifications for the given number of minutes.
:param minutes: (integer) number of minutes to mute notifications, -1 means mute until the user unmutes
"""
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()
if minutes == -1:
member.mute_until_dt = datetime.max
elif minutes:
member.mute_until_dt = fields.Datetime.now() + relativedelta(minutes=minutes)
request.env.ref("mail.ir_cron_discuss_channel_member_unmute")._trigger(member.mute_until_dt)
else:
member.mute_until_dt = False
channel_data = {
"id": member.channel_id.id,
"model": "discuss.channel",
"mute_until_dt": member.mute_until_dt,
}
request.env["bus.bus"]._sendone(member.partner_id, "mail.record/insert", {"Thread": channel_data})
@http.route("/discuss/channel/update_custom_notifications", methods=["POST"], type="json", auth="user")
def discuss_channel_update_custom_notifications(self, channel_id, custom_notifications):
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
channel_data = {
"custom_notifications": member.custom_notifications,
"id": member.channel_id.id,
"model": "discuss.channel",
}
request.env["bus.bus"]._sendone(member.partner_id, "mail.record/insert", {"Thread": channel_data})
@http.route("/discuss/channel/set_last_seen_message", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def discuss_channel_mark_as_seen(self, channel_id, last_message_id, allow_older=False):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
if not channel:
raise NotFound()
return channel._channel_seen(last_message_id, allow_older=allow_older)
@http.route("/discuss/channel/notify_typing", methods=["POST"], type="json", 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()
member = channel._find_or_create_member_for_self()
if not member:
raise NotFound()
member._notify_typing(is_typing)
@http.route("/discuss/channel/attachments", methods=["POST"], type="json", auth="public")
@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
return request.env["ir.attachment"].sudo().search(domain, limit=limit, order="id DESC")._attachment_format()

View File

@ -0,0 +1,89 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import requests
import werkzeug.urls
from odoo.http import request, route, Controller
class DiscussGifController(Controller):
def _request_gifs(self, endpoint):
response = requests.get(
f"https://tenor.googleapis.com/v2/{endpoint}", timeout=3
)
response.raise_for_status()
return response
@route("/discuss/gif/search", type="json", auth="user")
def search(self, search_term, locale="en", country="US", position=None):
# 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": ir_config.get_param("discuss.tenor_gif_limit"),
"contentfilter": ir_config.get_param("discuss.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="json", auth="user")
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": ir_config.get_param("discuss.tenor_gif_limit"),
"contentfilter": ir_config.get_param("discuss.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="json", 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="json", auth="user")
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="json", auth="user")
def remove_favorite(self, tenor_gif_id):
request.env["discuss.gif.favorite"].search(
[
("create_uid", "=", request.env.user.id),
("tenor_gif_id", "=", tenor_gif_id),
]
).unlink()

View File

@ -0,0 +1,126 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from psycopg2 import IntegrityError
from psycopg2.errorcodes import UNIQUE_VIOLATION
from werkzeug.exceptions import NotFound
from odoo import _, http
from odoo.exceptions import UserError
from odoo.http import request
from odoo.tools import consteq, replace_exceptions
from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context
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):
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()
return self._response_discuss_channel_invitation(channel)
@http.route("/discuss/channel/<int:channel_id>", methods=["GET"], type="http", auth="public")
@add_guest_to_context
def discuss_channel(self, channel_id):
channel = request.env["discuss.channel"].search([("id", "=", channel_id)])
if not channel:
raise NotFound()
return self._response_discuss_public_template(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 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.sudo(False), is_channel_token_secret=False)
def _response_discuss_channel_invitation(self, channel, is_channel_token_secret=True):
# group restriction takes precedence over token
if channel.group_public_id and channel.group_public_id not in request.env.user.groups_id:
raise request.not_found()
discuss_public_view_data = {
"isChannelTokenSecret": is_channel_token_secret,
}
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"),
country_code=request.geoip.country_code,
timezone=request.env["mail.guest"]._get_timezone_from_request(request),
)
if guest and not guest_already_known:
discuss_public_view_data.update(
{
"shouldDisplayWelcomeViewInitially": True,
}
)
channel = channel.with_context(guest=guest)
return self._response_discuss_public_template(channel, discuss_public_view_data=discuss_public_view_data)
def _response_discuss_public_template(self, channel, 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._channel_info()[0],
"discussPublicViewData": dict(
{
"shouldDisplayWelcomeViewInitially": channel.default_display_mode == "video_full_screen",
},
**discuss_public_view_data,
),
},
"session_info": channel.env["ir.http"].session_info(),
},
)

138
controllers/discuss/rtc.py Normal file
View File

@ -0,0 +1,138 @@
# 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.models.discuss.mail_guest import add_guest_to_context
class RtcController(http.Controller):
@http.route("/mail/rtc/session/notify_call_members", methods=["POST"], type="json", 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="json", 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="json", auth="public")
@add_guest_to_context
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 = 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()
# sudo: discuss.channel.rtc.session - member of current user can join call
return 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")
@add_guest_to_context
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
"""
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
return member.sudo()._rtc_leave_call()
@http.route("/mail/rtc/channel/cancel_call_invitation", methods=["POST"], type="json", 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
return channel.sudo()._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", f"max-age={http.STATIC_CACHE}"),
],
)
@http.route("/discuss/channel/ping", methods=["POST"], type="json", 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 {
"rtcSessions": [
("ADD", [rtc_session_sudo._mail_rtc_session_format() for rtc_session_sudo in current_rtc_sessions]),
(
"DELETE",
[{"id": missing_rtc_session_sudo.id} for missing_rtc_session_sudo in outdated_rtc_sessions],
),
]
}

View File

@ -0,0 +1,17 @@
# 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")
def voice_worklet_processor(self):
return request.make_response(
file_open("mail/static/src/discuss/voice_message/worklets/processor.js", "rb").read(),
headers=[
("Content-Type", "application/javascript"),
],
)

View File

@ -0,0 +1,55 @@
# 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="json", 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"]}
return {
"body": translation.body,
"lang_name": babel.Locale(translation.source_lang).get_display_name(request.env.user.lang),
}
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
controllers/guest.py Normal file
View File

@ -0,0 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.exceptions import NotFound
from odoo import http
from odoo.http import request
from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context
class GuestController(http.Controller):
@http.route("/mail/guest/update_name", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def mail_guest_update_name(self, guest_id, name):
guest = request.env["mail.guest"]._get_guest_from_context()
guest_to_rename_sudo = guest.env["mail.guest"].browse(guest_id).sudo().exists()
if not guest_to_rename_sudo:
raise NotFound()
if guest_to_rename_sudo != guest and not request.env.user._is_admin():
raise NotFound()
guest_to_rename_sudo._update_name(name)

View File

@ -0,0 +1,33 @@
# 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.models.discuss.mail_guest import add_guest_to_context
class LinkPreviewController(http.Controller):
@http.route("/mail/link_preview", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def mail_link_preview(self, message_id, clear=None):
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
if clear:
message.sudo().link_preview_ids._unlink_and_notify()
guest.env["mail.link.preview"].sudo()._create_from_message_and_notify(message)
@http.route("/mail/link_preview/delete", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def mail_link_preview_delete(self, link_preview_ids):
guest = request.env["mail.guest"]._get_guest_from_context()
link_preview_sudo = guest.env["mail.link.preview"].sudo().search([("id", "in", link_preview_ids)])
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._unlink_and_notify()

195
controllers/mail.py Normal file
View File

@ -0,0 +1,195 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from werkzeug.urls import url_encode
from odoo import _, http
from odoo.exceptions import AccessError
from odoo.http import request
from odoo.tools import consteq
_logger = logging.getLogger(__name__)
class MailController(http.Controller):
_cp_path = '/mail'
@classmethod
def _redirect_to_messaging(cls):
url = '/web#%s' % url_encode({'action': 'mail.action_discuss'})
return request.redirect(url)
@classmethod
def _check_token(cls, token):
base_link = request.httprequest.path
params = dict(request.params)
params.pop('token', '')
valid_token = request.env['mail.thread']._encode_link(base_link, params)
return consteq(valid_token, str(token))
@classmethod
def _check_token_and_record_or_redirect(cls, model, res_id, token):
comparison = cls._check_token(token)
if not comparison:
_logger.warning('Invalid token in route %s', request.httprequest.url)
return comparison, None, cls._redirect_to_messaging()
try:
record = request.env[model].browse(res_id).exists()
except Exception:
record = None
redirect = cls._redirect_to_messaging()
else:
redirect = cls._redirect_to_record(model, res_id)
return comparison, record, redirect
@classmethod
def _redirect_to_record(cls, model, res_id, access_token=None, **kwargs):
# access_token and kwargs are used in the portal controller override for the Send by email or Share Link
# to give access to the record to a recipient that has normally no access.
uid = request.session.uid
user = request.env['res.users'].sudo().browse(uid)
cids = []
# no model / res_id, meaning no possible record -> redirect to login
if not model or not res_id or model not in request.env:
return cls._redirect_to_messaging()
# find the access action using sudo to have the details about the access link
RecordModel = request.env[model]
record_sudo = RecordModel.sudo().browse(res_id).exists()
if not record_sudo:
# record does not seem to exist -> redirect to login
return cls._redirect_to_messaging()
suggested_company = record_sudo._get_mail_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):
return cls._redirect_to_messaging()
try:
# 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(',')]
try:
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('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
# - 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,
# redirect to the messaging.
if not suggested_company:
raise AccessError('')
cids = cids + [suggested_company.id]
record_sudo.with_user(uid).with_context(allowed_company_ids=cids).check_access_rule('read')
except AccessError:
return cls._redirect_to_messaging()
else:
record_action = record_sudo._get_access_action(access_uid=uid)
else:
record_action = record_sudo._get_access_action()
if suggested_company:
cids = [suggested_company.id]
if record_action['type'] == 'ir.actions.act_url' and record_action.get('target_type') != 'public':
url_params = {
'model': model,
'id': res_id,
'active_id': res_id,
'action': record_action.get('id'),
}
if cids:
url_params['cids'] = cids[0]
view_id = record_sudo.get_formview_id()
if view_id:
url_params['view_id'] = view_id
url = '/web/login?redirect=#%s' % url_encode(url_params)
return request.redirect(url)
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'])
# other choice: act_window (no support of anything else currently)
elif not record_action['type'] == 'ir.actions.act_window':
return cls._redirect_to_messaging()
url_params = {
'model': model,
'id': res_id,
'active_id': res_id,
'action': record_action.get('id'),
}
view_id = record_sudo.get_formview_id()
if view_id:
url_params['view_id'] = view_id
if cids:
url_params['cids'] = ','.join([str(cid) for cid in cids])
url = '/web?#%s' % url_encode(url_params)
return request.redirect(url)
@http.route('/mail/view', type='http', auth='public')
def mail_action_view(self, model=None, res_id=None, access_token=None, **kwargs):
""" Generic access point from notification emails. The heuristic to
choose where to redirect the user is the following :
- find a public URL
- if none found
- users with a read access are redirected to the document
- users without read access are redirected to the Messaging
- not logged users are redirected to the login page
models that have an access_token may apply variations on this.
"""
# ==============================================================================================
# This block of code disappeared on saas-11.3 to be reintroduced by TBE.
# This is needed because after a migration from an older version to saas-11.3, the link
# received by mail with a message_id no longer work.
# So this block of code is needed to guarantee the backward compatibility of those links.
if kwargs.get('message_id'):
try:
message = request.env['mail.message'].sudo().browse(int(kwargs['message_id'])).exists()
except:
message = request.env['mail.message']
if message:
model, res_id = message.model, message.res_id
# ==============================================================================================
if res_id and isinstance(res_id, str):
try:
res_id = int(res_id)
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:
try:
record.check_access_rights('read')
record.check_access_rule('read')
except AccessError:
display_link = False
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,
})

25
controllers/mailbox.py Normal file
View File

@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class MailboxController(http.Controller):
@http.route("/mail/inbox/messages", methods=["POST"], type="json", auth="user")
def discuss_inbox_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
partner_id = request.env.user.partner_id.id
domain = [("needaction", "=", True)]
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
return {**res, "messages": res["messages"]._message_format_personalize(partner_id)}
@http.route("/mail/history/messages", methods=["POST"], type="json", auth="user")
def discuss_history_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
domain = [("needaction", "=", False)]
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
return {**res, "messages": res["messages"].message_format()}
@http.route("/mail/starred/messages", methods=["POST"], type="json", auth="user")
def discuss_starred_messages(self, search_term=None, before=None, after=None, limit=30, around=None):
domain = [("starred_partner_ids", "in", [request.env.user.partner_id.id])]
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
return {**res, "messages": res["messages"].message_format()}

View File

@ -0,0 +1,17 @@
# 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.models.discuss.mail_guest import add_guest_to_context
class MessageReactionController(http.Controller):
@http.route("/mail/message/reaction", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def mail_message_add_reaction(self, message_id, content, action):
message = request.env["mail.message"].browse(int(message_id)).exists()
if not message._validate_access_for_current_persona("write"):
raise NotFound()
message.sudo()._message_reaction(content, action)

129
controllers/thread.py Normal file
View File

@ -0,0 +1,129 @@
# 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.http import request
from odoo.addons.mail.models.discuss.mail_guest import add_guest_to_context
class ThreadController(http.Controller):
@http.route("/mail/thread/data", methods=["POST"], type="json", auth="user")
def mail_thread_data(self, thread_model, thread_id, request_list):
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, search_term=None, before=None, after=None, around=None, limit=30):
domain = [
("res_id", "=", int(thread_id)),
("model", "=", thread_model),
("message_type", "!=", "user_notification"),
]
res = request.env["mail.message"]._message_fetch(domain, search_term=search_term, before=before, after=after, around=around, limit=limit)
if not request.env.user._is_public():
res["messages"].set_message_done()
return {**res, "messages": res["messages"].message_format()}
@http.route("/mail/partner/from_email", methods=["POST"], type="json", auth="user")
def mail_thread_partner_from_email(self, emails, additional_values=None):
partners = [
{"id": partner.id, "name": partner.name, "email": partner.email}
for partner in request.env["res.partner"]._find_or_create_from_emails(emails, additional_values)
]
return partners
@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"]),
)
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")
@add_guest_to_context
def mail_message_post(self, thread_model, thread_id, post_data, context=None):
guest = request.env["mail.guest"]._get_guest_from_context()
guest.env["ir.attachment"].browse(post_data.get("attachment_ids", []))._check_attachments_access(
post_data.get("attachment_tokens")
)
if context:
request.update_context(**context)
canned_response_ids = tuple(cid for cid in post_data.pop('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_shortcode SET last_used=%(last_used)s
WHERE id IN (
SELECT id from mail_shortcode WHERE id IN %(ids)s
FOR NO KEY UPDATE SKIP LOCKED
)
""", {
'last_used': datetime.now(),
'ids': canned_response_ids,
})
thread = request.env[thread_model].with_context(active_test=False).search([("id", "=", thread_id)])
thread = thread.with_context(active_test=True)
if not thread:
raise NotFound()
if "body" in post_data:
post_data["body"] = Markup(post_data["body"]) # contains HTML such as @mentions
new_partners = []
if "partner_emails" in post_data:
new_partners = [record.id for record in request.env["res.partner"]._find_or_create_from_emails(
post_data["partner_emails"], post_data.get("partner_additional_values", {})
)]
post_data["partner_ids"] = list(set((post_data.get("partner_ids", [])) + new_partners))
message_data = thread.message_post(
**{key: value for key, value in post_data.items() if key in self._get_allowed_message_post_params()}
).message_format()[0]
if "temporary_id" in request.context:
message_data["temporary_id"] = request.context["temporary_id"]
return message_data
@http.route("/mail/message/update_content", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def mail_message_update_content(self, message_id, body, attachment_ids, attachment_tokens=None, partner_ids=None):
guest = request.env["mail.guest"]._get_guest_from_context()
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()
body = Markup(body) if body else body # may contain HTML such as @mentions
guest.env[message_sudo.model].browse([message_sudo.res_id])._message_update_content(
message_sudo, body, attachment_ids=attachment_ids, partner_ids=partner_ids
)
return message_sudo.message_format()[0]

25
controllers/webclient.py Normal file
View File

@ -0,0 +1,25 @@
# 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.models.discuss.mail_guest import add_guest_to_context
class WebclientController(http.Controller):
@http.route("/mail/init_messaging", methods=["POST"], type="json", auth="public")
@add_guest_to_context
def mail_init_messaging(self):
if not request.env.user._is_public():
return request.env.user.sudo(False)._init_messaging()
guest = request.env["mail.guest"]._get_guest_from_context()
if guest:
return guest._init_messaging()
raise NotFound()
@http.route("/mail/load_message_failures", methods=["POST"], type="json", auth="user")
def mail_load_message_failures(self):
# sudo as to not check ACL, which is far too costly
# sudo: res.users - return only failures of current user as author
return request.env.user.partner_id._message_fetch_failed()

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.http import request
from odoo.tools import file_open
from odoo.addons.web.controllers.webmanifest import WebManifest as WebWebManifest
class WebManifest(WebWebManifest):
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.has_group('base.group_user'):
with file_open('mail/static/src/service_worker.js') as f:
body += f.read()
return body

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record model="discuss.channel" id="mail.channel_all_employees">
<field name="name">general</field>
<field name="description">General announcements for all employees.</field>
</record>
<!-- notify all employees of module installation -->
<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"/>
<field name="subject">Welcome to Odoo!</field>
<field name="body"><![CDATA[<p>Welcome to the #general channel.</p>
<p>This channel is accessible to all users to <b>easily share company information</b>.</p>]]></field>
</record>
<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="discuss.channel" id="mail.channel_all_employees">
<field name="group_ids" eval="[Command.link(ref('base.group_user'))]"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,159 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Discussion groups, done in 2 steps to remove creator from followers -->
<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="discuss.channel" id="mail.channel_2">
<field name="name">board-meetings</field>
<field name="description">Board meetings, budgets, strategic plans</field>
</record>
<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">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="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">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="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">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="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">discuss.channel</field>
<field name="res_id" ref="mail.channel_all_employees"/>
<field name="body"><![CDATA[
<p>
Great news!<br/>
Our company has received the Deloitte Fast 50 award. We are the fastest
growing company of the country, with a growth of 1549% over the past 5
years. You can get more information <a href="http://www.openerp.com/node/1244/2012/10">on our blog</a>.
</p>
]]></field>
<field name="message_type">comment</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(minutes=22)).strftime('%Y-%m-%d %H:%M')"/>
<field name="subtype_id" ref="mail.mt_comment"/>
</record>
<record id="mail_message_channel_whole_2" model="mail.message">
<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>
<field name="message_type">comment</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(hours=1)).strftime('%Y-%m-%d %H:%M')"/>
<field name="subtype_id" ref="mail.mt_comment"/>
</record>
<record id="mail_message_channel_whole_2_1" model="mail.message">
<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"/>
<field name="message_type">comment</field>
<field name="author_id" ref="base.partner_root"/>
<field name="date" eval="(DateTime.today() - timedelta(minutes=34)).strftime('%Y-%m-%d %H:%M')"/>
<field name="subtype_id" ref="mail.mt_comment"/>
</record>
<record id="mail_message_channel_whole_2_2" model="mail.message">
<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"/>
<field name="message_type">comment</field>
<field name="author_id" ref="base.partner_demo"/>
<field name="date" eval="(DateTime.today() - timedelta(minutes=22)).strftime('%Y-%m-%d %H:%M')"/>
<field name="subtype_id" ref="mail.mt_comment"/>
</record>
<!-- Board messages -->
<record id="mail_message_channel_2_1" model="mail.message">
<field name="model">discuss.channel</field>
<field name="res_id" ref="mail.channel_2"/>
<field name="body"><![CDATA[
<p>
Dear Board Members,
</p>
<p>
The main events of the month of October are:
</p>
<p>
<b>Sales:</b>
</p>
<ul>
<li>Invoicing is respectively of 442k€ for our European company (66% of the budget) and $404k for the U.S. office (75% of the budget). Despite these numbers that are far below our initial expectations, the growth of the month of October is 51% compared to last year.</li>
<li>The month of September having been better than our initial forecasts, the consolidated yearly revenue is only of $20k below our forecast made during the board of September.</li>
<li>The consolidated forecast for the end of the year is $6.749k, which is a growth of 76% compared to last year and an achievement of 87% of the budget.</li>
<li>The recruitment of new resellers has been very good, especially in Europe, where we signed 30 new resellers this month.</li>
</ul>
<p>
<b>Finance :</b>
</p>
<ul>
<li>The profit and loss has been negatively impacted this month by revenues that are far beyond the budget and charges that are 15% above the budget. The main extra we had in our charges this month is due to the provisioning of the salaries for the holidays period, $50k.</li>
<li>We also got the payment of our long awaited subsidies, the cash level has increased of 300K€ which gives a current balance of 963 K€ without including the straight loan of 350 K€.</li>
<li>The aged customer balance has been similar to the one of the last month with a small decrease of the DSO. We have recruited a new accountant assistant for the credit collection. She is mostly doing phone calls for all invoices that are due since 30 days, so we should get improvements of the DSO in November. The sum of the invoicing on which we have a risk in the aged customer balance is 100K€.</li>
</ul>
<p>
<b>Resellers and Customers:</b>
</p>
<ul>
<li>The total number of resellers is 429, across 87 countries.</li>
<li>The total number of installations of our software increased to 37K, against 33K for the month of September but we still did not reached the highest level we reached during this year (44K in march and may)</li>
<li>We have passed the 10000th customer in production with 10271 customers at the end of October. The paying customer ratio is 6,6%.</li>
</ul>
<p>
<b>Launch of the new release:</b>
</p>
<p>
We are working actively on the new release which is scheduled for the end of November.
</p>
<ul>
<li>We will publish the release note this week</li>
<li>The whole Sales Team will be trained on the new version this Friday</li>
<li>We will do a public announce to our resellers the 21th of November. We plan to show them: a description of the new features, the new distribution strategy, the new pricing and the communication plan.</li>
</ul>
<br/>
<p>
Nicolas, can you book a meeting room for our meeting of Friday 2pm?
</p>
<p>
Regards.
</p>
]]></field>
<field name="message_type">comment</field>
<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>
</data>
</odoo>

View File

@ -0,0 +1,9 @@
<?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>
</data>
</odoo>

92
data/ir_cron_data.xml Normal file
View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record forcecreate="True" id="ir_cron_mail_scheduler_action" model="ir.cron">
<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="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"/>
</record>
<record id="ir_cron_module_update_notification" model="ir.cron">
<field name="name">Publisher: Update Notification</field>
<field name="model_id" ref="model_publisher_warranty_contract"/>
<field name="state">code</field>
<field name="code">model.update_notification(None)</field>
<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>
<record id="base.ir_cron_act" model="ir.actions.act_window">
<field name="domain" eval="[('id','!=', ref('mail.ir_cron_module_update_notification'))]"/>
</record>
<record id="ir_cron_delete_notification" model="ir.cron">
<field name="name">Notification: Delete Notifications older than 6 Month</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="model_id" ref="model_mail_notification"/>
<field name="code">model._gc_notifications(max_age_days=180)</field>
<field name="state">code</field>
</record>
<record id="ir_cron_mail_gateway_action" model="ir.cron">
<field name="name">Mail: Fetchmail Service</field>
<field name="model_id" ref="model_fetchmail_server"/>
<field name="state">code</field>
<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_send_scheduled_message" model="ir.cron">
<field name="name">Notification: Send scheduled message notifications</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_notification_web_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>
<field name="numbercall">-1</field>
<field name="doall" eval="True"/>
</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="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="model_id" ref="model_discuss_channel_member"/>
<field name="code">model._unmute()</field>
<field name="state">code</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="mail_activity_data_email" model="mail.activity.type">
<field name="name">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="icon">fa-phone</field>
<field name="category">phonecall</field>
<field name="delay_count">2</field>
<field name="sequence">6</field>
</record>
<record id="mail_activity_data_meeting" model="mail.activity.type">
<field name="name">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="delay_count">5</field>
<field name="sequence">12</field>
</record>
<record id="mail_activity_data_upload_document" model="mail.activity.type">
<field name="name">Upload Document</field>
<field name="icon">fa-upload</field>
<field name="delay_count">5</field>
<field name="sequence">25</field>
<field name="category">upload_file</field>
</record>
<record id="mail_activity_data_warning" model="mail.activity.type">
<field name="name">Exception</field>
<field name="icon">fa-warning</field>
<field name="delay_count">0</field>
<field name="sequence">99</field>
<field name="decoration_type">warning</field>
<field name="active">False</field>
</record>
</data>
</odoo>

25
data/mail_groups.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<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'))]"/>
</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'))]"/>
</record>
<!-- 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>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
</data>
</odoo>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<!-- Discussion subtype for messaging / Chatter -->
<record id="mt_comment" model="mail.message.subtype">
<field name="name">Discussions</field>
<field name="sequence" eval="0"/>
<field name="track_recipients" eval="True"/>
</record>
<record id="mt_note" model="mail.message.subtype">
<field name="name">Note</field>
<field name="default" eval="False"/>
<field name="internal" eval="True"/>
<field name="sequence" eval="100"/>
<field name="track_recipients" eval="True"/>
</record>
<record id="mt_activities" model="mail.message.subtype">
<field name="name">Activities</field>
<field name="default" eval="False"/>
<field name="internal" eval="True"/>
<field name="sequence" eval="90"/>
</record>
</data></odoo>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Discuss utility templates for notifications -->
<template id="message_user_assigned">
<span>Dear <t t-esc="object.user_id.sudo().name"/>,</span>
<br/><br/>
<span style="margin-top: 8px;">You have been assigned to the <t t-esc="model_description or 'document'"/> <t t-esc="object.display_name"/>.</span>
<br/>
</template>
<template id="message_activity_done">
<div>
<p>
<span t-attf-class="fa #{activity.activity_type_id.icon} fa-fw"/><span t-field="activity.activity_type_id.name"/> done
<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>
<div t-if="feedback">
<t t-foreach="feedback.split('\n')" t-as="feedback_line">
<t t-esc="feedback_line"/>
<br t-if="not feedback_line_last"/>
</t>
</div>
<t t-if="activity.note and activity.note != '&lt;p&gt;&lt;br&gt;&lt;/p&gt;'"><!-- <p></br></p> -->
<div class="o_mail_note_title"><strong>Original note:</strong></div>
<div t-field="activity.note"/>
</t>
</div>
</template>
<template id="message_activity_assigned">
<div style="margin: 0px; padding: 0px; font-size: 13px;">
Dear <span t-field="activity.user_id.name"/>,
<br/><br/>
<p>
<span t-field="activity.create_uid.name"/> has just assigned you the following activity:
<ul>
<li>Document: "<t t-esc="activity.res_name"/>"
<t t-if="model_description"> (<t t-esc="model_description"/>)</t>
</li>
<li t-if="activity.summary">Summary: <span t-field="activity.summary"/></li>
<li>Deadline: <span t-field="activity.date_deadline"/></li>
</ul>
</p>
</div>
</template>
<template id="message_origin_link">
<p>
<t t-if="edit">This <t t-esc="self.env['ir.model']._get(self._name).name.lower()"/> has been modified from:</t>
<t t-else="">This <t t-esc="self.env['ir.model']._get(self._name).name.lower()"/> has been created from:</t>
<t t-foreach="origin" t-as="o">
<a href="#" t-att-data-oe-model="o._name" t-att-data-oe-id="o.id"> <t t-esc="o.display_name"/></a><span t-if="origin.ids[-1:] != o.ids">, </span>
</t>
</p>
</template>
</data>
</odoo>

View File

@ -0,0 +1,213 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="mail_notification_layout" name="Mail: mail notification layout template">
<html t-att-lang="lang">
<head>
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
</head>
<body style="font-family:Verdana, Arial,sans-serif; color: #454748;">
<t t-set="subtype_internal" t-value="subtype and subtype.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 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"
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" 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: 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.email_secondary_color or '#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;">
<t t-out="button_access['title']"/>
</a>
</td>
<td t-if="has_button_access">&amp;nbsp;&amp;nbsp;</td>
<td t-if="actions">
<t t-foreach="actions" t-as="action">
<a t-att-href="action['url']" t-att-style="'font-size: 12px; color: ' + (company.email_secondary_color or '#875A7B')+ '; text-decoration:none !important;'">
<t t-out="action['title']"/>
</a>
&amp;nbsp;&amp;nbsp;
</t>
</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 '' }}"
t-out="subtitle"/>
<br t-if="not subtitle_last"/>
</t>
</td>
<td t-else=""><span style="font-weight:bold;" t-out="record_name"/><br/></td>
</tr>
</tbody>
</table>
</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;"/>
<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>
</table>
</div>
<!-- CONTENT -->
<div t-out="message.body" style="font-size: 13px;"/>
<ul t-if="tracking_values">
<t t-foreach="tracking_values" t-as="tracking">
<li><t t-out="tracking[0]"/>: <t t-out="tracking[1]"/> &#8594; <t t-out="tracking[2]"/></li>
</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;"/>
</t>
<!-- FOOTER -->
<div style="margin-top:32px;">
<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;">
<t t-out="company.phone"/>
<t t-if="company.email and company.phone"> |</t>
<a t-if="company.email" t-att-href="'mailto:%s' % company.email" style="text-decoration:none; color: #999999;" t-out="company.email"/>
<t t-if="company.website and (company.phone or company.email)"> |</t>
<a t-if="company.website" t-att-href="'%s' % company.website" style="text-decoration:none; color: #999999;" t-out="company.website"/>
</p>
</div>
<div style="color: #555555; font-size:11px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email"
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Odoo</a>
<span id="mail_unfollow">
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
</span>
</div>
</div>
</body></html>
</template>
<template id="mail_notification_light">
<html t-att-lang="lang">
<head>
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
</head>
<body>
<t t-set="subtype_internal" t-value="False"/>
<t t-call="mail.notification_preview"/>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 24px; background-color: white; color: #454748; border-collapse:separate;">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: white; padding: 0; border-collapse:separate;">
<tr><td valign="middle">
<span style="font-size: 10px;">Your <t t-out="model_description or 'document'"/></span>
<br/>
<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 ''"/>
</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 ''"/>
</span>
</t>
</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;"/>
</td></tr>
</table>
</td>
</tr>
<!-- CONTENT -->
<tr>
<td style="min-width: 590px;">
<t t-out="message.body"/>
</td>
</tr>
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px; padding: 0 8px 0 8px; font-size:11px;">
<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"/><br/>
<div style="color: #999999;">
<t t-out="company.phone"/>
<t t-if="company.email and company.phone"> |</t>
<a t-if="company.email" t-att-href="'mailto:%s' % company.email" style="text-decoration:none; color: #999999;" t-out="company.email"/>
<t t-if="company.website and (company.phone or company.email)"> |</t>
<a t-if="company.website" t-att-href="'%s' % company.website" style="text-decoration:none; color: #999999;" t-out="company.website"/>
</div>
</td>
</tr>
</tbody>
</table>
</td></tr>
<!-- POWERED BY -->
<tr><td align="center" style="min-width: 590px;">
Powered by <a target="_blank" href="https://www.odoo.com?utm_source=db&amp;utm_medium=email"
t-att-style="'color: ' + (company.email_secondary_color or '#875A7B') + ';'">Odoo</a>
<span id="mail_unfollow">
| <a href="/mail/unfollow" style="text-decoration:none; color:#555555;">Unfollow</a>
</span>
</td></tr>
</table>
</body>
</html>
</template>
<template id="notification_preview">
<div style="display: none; max-height: 0px; overflow: hidden; color:#fff; font-size:0px; line-height:0px">
<t t-if="tracking_values">
<t t-out="tracking_values[0][0]"/>: <t t-out="tracking_values[0][1]"/> &#8594; <t t-out="tracking_values[0][2]"/>
<t t-if="len(tracking_values) > 1"> |...</t>
<t t-if="message.preview"> | </t>
</t>
<t t-if="subtype_internal">Internal communication: </t><t t-out="message.preview"/>
<!--Trailing whitespace to push back email content so that it doesn't appear in preview. Specific characters to use may change over time -->
<t t-out="'&#847; &#8203; ' * 140"/>
</div>
</template>
<template id="mail_notification_layout_with_responsible_signature"
name="Mail: mail notification layout with responsible signature (user_id of the record)"
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;"/>
</t>
</xpath>
</template>
</data>
</odoo>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="message_notification_limit_email">
<p>Dear Sender,</p>
<p>
The message below could not be accepted by the address <t t-esc="email"/> because you have
contacted it too many times in the last few minutes.
<br/>
Please try again later.
</p>
<p>Kind Regards</p>
</template>
<template id="mail_bounce_catchall">
<div>
<p>Hello <t t-esc="message['email_from']"/>,</p>
<p>The email sent to <t t-esc="message['to']"/> cannot be processed. This address
is used to collect replies and should not be used to directly contact <t t-esc="res_company.name"/>.</p>
<p>Please contact us instead using <a t-att-href="'mailto:%s' % res_company.email"><t t-esc="res_company.email"/></a></p>
<p>Regards,</p>
<p>The <t t-esc="res_company.name"/> team.</p>
</div>
<blockquote><t t-esc="message['body']"/></blockquote>
</template>
<!-- Mail bounce alias mail template -->
<template id="mail_bounce_alias_security">
<div><t t-out="body"/></div>
<blockquote><t t-out="message['body']"/></blockquote>
</template>
</data>
</odoo>

15
data/neutralize.sql Normal file
View File

@ -0,0 +1,15 @@
-- deactivate mail template
UPDATE mail_template
SET mail_server_id = NULL;
-- 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_notification_web_push;
-- * delete Devices for each partners
TRUNCATE mail_partner_device CASCADE;

14
data/res_partner_data.xml Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo><data noupdate="1">
<record id="base.partner_root" model="res.partner">
<field name="name">OdooBot</field>
<field name="image_1920" type="base64" file="mail/static/src/img/odoobot.png"/>
</record>
<!-- user root should not receive emails at creation -->
<record id="base.user_root" model="res.users">
<field name="notification_type">inbox</field>
</record>
</data></odoo>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Template for security update notification (password/login/mail changed, ...) -->
<template id="account_security_setting_update">
<p>
<span>Dear <t t-out="user.name"/>,</span>
</p>
<p>
<span t-out="security_update_text"/>
<span>(<t
t-out="update_datetime"
t-options='{"widget": "datetime", "hide_seconds": True}'/>).</span>
</p>
<p>
<span>If this was done by you:</span><br/>
<ul>
<li>You can safely ignore this message</li>
</ul>
</p>
<p>
<span>If this was not done by you:</span>
</p>
<ul class="o_mail_account_security_suggestions">
<li t-if="suggest_password_reset">
<span>We suggest you start by</span>
<a t-att-href="password_reset_url">
Resetting Your Password
</a>
</li>
<li>
Contact your administrator
</li>
</ul>
</template>
</data>
</odoo>

32038
i18n/af.po Normal file

File diff suppressed because it is too large Load Diff

32034
i18n/am.po Normal file

File diff suppressed because it is too large Load Diff

11258
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

32047
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

11004
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

32050
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

11181
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

11064
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

11061
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

11408
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

32063
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/en_GB.po Normal file

File diff suppressed because it is too large Load Diff

11381
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

11382
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_BO.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_CL.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_CO.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_CR.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_DO.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_EC.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_PE.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_PY.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/es_VE.po Normal file

File diff suppressed because it is too large Load Diff

11027
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/eu.po Normal file

File diff suppressed because it is too large Load Diff

10951
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

11326
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/fo.po Normal file

File diff suppressed because it is too large Load Diff

11377
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/fr_CA.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/gl.po Normal file

File diff suppressed because it is too large Load Diff

32050
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

10978
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

32077
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

11006
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

10950
i18n/hy.po Normal file

File diff suppressed because it is too large Load Diff

11329
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

10877
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

11351
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

11047
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/ka.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/kab.po Normal file

File diff suppressed because it is too large Load Diff

32042
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

11056
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

32034
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

32037
i18n/lo.po Normal file

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