mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 04:52:00 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
21
odoo-bringout-oca-ocb-mail/mail/models/discuss/__init__.py
Normal file
21
odoo-bringout-oca-ocb-mail/mail/models/discuss/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
# mail
|
||||
from . import mail_message
|
||||
|
||||
# discuss
|
||||
from . import discuss_call_history
|
||||
from . import discuss_channel_member
|
||||
from . import discuss_channel_rtc_session
|
||||
from . import discuss_channel
|
||||
from . import discuss_gif_favorite
|
||||
from . import discuss_voice_metadata
|
||||
from . import mail_guest
|
||||
|
||||
# odoo models
|
||||
from . import bus_listener_mixin
|
||||
from . import ir_attachment
|
||||
from . import ir_websocket
|
||||
from . import res_groups
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class BusListenerMixin(models.AbstractModel):
|
||||
_inherit = "bus.listener.mixin"
|
||||
|
||||
def _bus_send_transient_message(self, channel, content):
|
||||
"""Posts a fake message in the given ``channel``, only visible for ``self`` listeners."""
|
||||
self._bus_send(
|
||||
"discuss.channel/transient_message",
|
||||
{
|
||||
"body": Markup("<span class='o_mail_notification'>%s</span>") % content,
|
||||
"channel_id": channel.id,
|
||||
},
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
class DiscussCallHistory(models.Model):
|
||||
_name = "discuss.call.history"
|
||||
_order = "start_dt DESC, id DESC"
|
||||
_description = "Keep the call history"
|
||||
|
||||
channel_id = fields.Many2one("discuss.channel", index=True, required=True, ondelete="cascade")
|
||||
duration_hour = fields.Float(compute="_compute_duration_hour")
|
||||
start_dt = fields.Datetime(index=True, required=True)
|
||||
end_dt = fields.Datetime()
|
||||
start_call_message_id = fields.Many2one("mail.message", index=True)
|
||||
|
||||
_channel_id_not_null_constraint = models.Constraint(
|
||||
"CHECK (channel_id IS NOT NULL)", "Call history must have a channel"
|
||||
)
|
||||
_start_dt_is_not_null_constraint = models.Constraint(
|
||||
"CHECK (start_dt IS NOT NULL)", "Call history must have a start date"
|
||||
)
|
||||
_message_id_unique_constraint = models.Constraint(
|
||||
"UNIQUE (start_call_message_id)", "Messages can only be linked to one call history"
|
||||
)
|
||||
_channel_id_end_dt_idx = models.Index("(channel_id, end_dt) WHERE end_dt IS NULL")
|
||||
|
||||
@api.depends("start_dt", "end_dt")
|
||||
def _compute_duration_hour(self):
|
||||
for record in self:
|
||||
end_dt = record.end_dt or fields.Datetime.now()
|
||||
record.duration_hour = (end_dt - record.start_dt).total_seconds() / 3600
|
||||
1690
odoo-bringout-oca-ocb-mail/mail/models/discuss/discuss_channel.py
Normal file
1690
odoo-bringout-oca-ocb-mail/mail/models/discuss/discuss_channel.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,691 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
from odoo.addons.mail.tools.web_push import PUSH_NOTIFICATION_ACTION, PUSH_NOTIFICATION_TYPE
|
||||
from odoo.exceptions import AccessError, UserError, ValidationError
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import SQL
|
||||
|
||||
from ...tools import jwt, discuss
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
SFU_MODE_THRESHOLD = 3
|
||||
|
||||
|
||||
class DiscussChannelMember(models.Model):
|
||||
_name = 'discuss.channel.member'
|
||||
_inherit = ["bus.listener.mixin"]
|
||||
_description = "Channel Member"
|
||||
_rec_names_search = ["channel_id", "partner_id", "guest_id"]
|
||||
_bypass_create_check = {}
|
||||
|
||||
# identity
|
||||
partner_id = fields.Many2one("res.partner", "Partner", ondelete="cascade", index=True)
|
||||
guest_id = fields.Many2one("mail.guest", "Guest", ondelete="cascade", index=True)
|
||||
is_self = fields.Boolean(compute="_compute_is_self", search="_search_is_self")
|
||||
# channel
|
||||
channel_id = fields.Many2one("discuss.channel", "Channel", ondelete="cascade", required=True, bypass_search_access=True)
|
||||
# state
|
||||
custom_channel_name = fields.Char('Custom channel name')
|
||||
fetched_message_id = fields.Many2one('mail.message', string='Last Fetched', index="btree_not_null")
|
||||
seen_message_id = fields.Many2one('mail.message', string='Last Seen', index="btree_not_null")
|
||||
new_message_separator = fields.Integer(help="Message id before which the separator should be displayed", default=0, required=True)
|
||||
message_unread_counter = fields.Integer('Unread Messages Counter', compute='_compute_message_unread', compute_sudo=True)
|
||||
custom_notifications = fields.Selection(
|
||||
[("all", "All Messages"), ("mentions", "Mentions Only"), ("no_notif", "Nothing")],
|
||||
"Customized Notifications",
|
||||
help="Use default from user settings if not specified. This setting will only be applied to channels.",
|
||||
)
|
||||
mute_until_dt = fields.Datetime("Mute notifications until", help="If set, the member will not receive notifications from the channel until this date.")
|
||||
is_pinned = fields.Boolean("Is pinned on the interface", compute="_compute_is_pinned", search="_search_is_pinned")
|
||||
unpin_dt = fields.Datetime("Unpin date", index=True, help="Contains the date and time when the channel was unpinned by the user.")
|
||||
last_interest_dt = fields.Datetime(
|
||||
"Last Interest",
|
||||
default=lambda self: fields.Datetime.now() - timedelta(seconds=1),
|
||||
index=True,
|
||||
help="Contains the date and time of the last interesting event that happened in this channel for this user. This includes: creating, joining, pinning",
|
||||
)
|
||||
last_seen_dt = fields.Datetime("Last seen date")
|
||||
# RTC
|
||||
rtc_session_ids = fields.One2many(string="RTC Sessions", comodel_name='discuss.channel.rtc.session', inverse_name='channel_member_id')
|
||||
rtc_inviting_session_id = fields.Many2one('discuss.channel.rtc.session', string='Ringing session')
|
||||
|
||||
_seen_message_id_idx = models.Index("(channel_id, partner_id, seen_message_id)")
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_unpin_outdated_sub_channels(self):
|
||||
outdated_dt = fields.Datetime.now() - timedelta(days=2)
|
||||
self.env["discuss.channel"].flush_model()
|
||||
self.env["discuss.channel.member"].flush_model()
|
||||
self.env["mail.message"].flush_model()
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
SELECT member.id
|
||||
FROM discuss_channel_member member
|
||||
JOIN discuss_channel channel
|
||||
ON channel.id = member.channel_id
|
||||
AND channel.parent_channel_id IS NOT NULL
|
||||
WHERE (
|
||||
member.unpin_dt IS NULL
|
||||
OR member.last_interest_dt >= member.unpin_dt
|
||||
OR channel.last_interest_dt >= member.unpin_dt
|
||||
)
|
||||
AND COALESCE(member.last_interest_dt, member.create_date) < %(outdated_dt)s
|
||||
AND COALESCE(channel.last_interest_dt, channel.create_date) < %(outdated_dt)s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM mail_message
|
||||
WHERE mail_message.res_id = channel.id
|
||||
AND mail_message.model = 'discuss.channel'
|
||||
AND mail_message.id >= member.new_message_separator
|
||||
AND mail_message.message_type NOT IN ('notification', 'user_notification')
|
||||
)
|
||||
""",
|
||||
{"outdated_dt": outdated_dt},
|
||||
)
|
||||
members = self.env["discuss.channel.member"].search(
|
||||
[("id", "in", [row[0] for row in self.env.cr.fetchall()])],
|
||||
)
|
||||
members.unpin_dt = fields.Datetime.now()
|
||||
for member in members:
|
||||
Store(bus_channel=member._bus_channel()).add(
|
||||
member.channel_id, {"close_chat_window": True}
|
||||
).bus_send()
|
||||
|
||||
@api.constrains('partner_id')
|
||||
def _contrains_no_public_member(self):
|
||||
for member in self:
|
||||
if any(user._is_public() for user in member.partner_id.user_ids):
|
||||
raise ValidationError(_("Channel members cannot include public users."))
|
||||
|
||||
@api.depends_context("uid", "guest")
|
||||
def _compute_is_self(self):
|
||||
if not self:
|
||||
return
|
||||
current_partner, current_guest = self.env["res.partner"]._get_current_persona()
|
||||
self.is_self = False
|
||||
for member in self:
|
||||
if current_partner and member.partner_id == current_partner:
|
||||
member.is_self = True
|
||||
if current_guest and member.guest_id == current_guest:
|
||||
member.is_self = True
|
||||
|
||||
def _search_is_self(self, operator, operand):
|
||||
if operator != 'in':
|
||||
return NotImplemented
|
||||
current_partner, current_guest = self.env["res.partner"]._get_current_persona()
|
||||
domain_partner = Domain("partner_id", "=", current_partner.id) if current_partner else Domain.FALSE
|
||||
domain_guest = Domain("guest_id", "=", current_guest.id) if current_guest else Domain.FALSE
|
||||
return domain_partner | domain_guest
|
||||
|
||||
def _search_is_pinned(self, operator, operand):
|
||||
if operator != 'in':
|
||||
return NotImplemented
|
||||
|
||||
def custom_pinned(model: models.BaseModel, alias, query):
|
||||
channel_model = model.browse().channel_id
|
||||
channel_alias = query.make_alias(alias, 'channel_id')
|
||||
query.add_join("LEFT JOIN", channel_alias, channel_model._table, SQL(
|
||||
"%s = %s",
|
||||
model._field_to_sql(alias, 'channel_id'),
|
||||
channel_model._field_to_sql(channel_alias, 'id'),
|
||||
))
|
||||
return SQL(
|
||||
"""(%(unpin)s IS NULL
|
||||
OR %(last_interest)s >= %(unpin)s
|
||||
OR %(channel_last_interest)s >= %(unpin)s
|
||||
)""",
|
||||
unpin=model._field_to_sql(alias, "unpin_dt", query),
|
||||
last_interest=model._field_to_sql(alias, "last_interest_dt", query),
|
||||
channel_last_interest=channel_model._field_to_sql(channel_alias, "last_interest_dt", query),
|
||||
)
|
||||
|
||||
return Domain.custom(to_sql=custom_pinned)
|
||||
|
||||
@api.depends("channel_id.message_ids", "new_message_separator")
|
||||
def _compute_message_unread(self):
|
||||
if self.ids:
|
||||
self.env['mail.message'].flush_model()
|
||||
self.flush_recordset(['channel_id', 'new_message_separator'])
|
||||
self.env.cr.execute("""
|
||||
SELECT count(mail_message.id) AS count,
|
||||
discuss_channel_member.id
|
||||
FROM mail_message
|
||||
INNER JOIN discuss_channel_member
|
||||
ON discuss_channel_member.channel_id = mail_message.res_id
|
||||
WHERE mail_message.model = 'discuss.channel'
|
||||
AND mail_message.message_type NOT IN ('notification', 'user_notification')
|
||||
AND mail_message.id >= discuss_channel_member.new_message_separator
|
||||
AND discuss_channel_member.id IN %(ids)s
|
||||
GROUP BY discuss_channel_member.id
|
||||
""", {'ids': tuple(self.ids)})
|
||||
unread_counter_by_member = {res['id']: res['count'] for res in self.env.cr.dictfetchall()}
|
||||
for member in self:
|
||||
member.message_unread_counter = unread_counter_by_member.get(member.id)
|
||||
else:
|
||||
self.message_unread_counter = 0
|
||||
|
||||
@api.depends("partner_id.name", "guest_id.name", "channel_id.display_name")
|
||||
def _compute_display_name(self):
|
||||
for member in self:
|
||||
member.display_name = _(
|
||||
"“%(member_name)s” in “%(channel_name)s”",
|
||||
member_name=member.partner_id.name or member.guest_id.name,
|
||||
channel_name=member.channel_id.display_name,
|
||||
)
|
||||
|
||||
@api.depends("last_interest_dt", "unpin_dt", "channel_id.last_interest_dt")
|
||||
def _compute_is_pinned(self):
|
||||
for member in self:
|
||||
member.is_pinned = (
|
||||
not member.unpin_dt
|
||||
or (
|
||||
member.last_interest_dt
|
||||
and member.last_interest_dt >= member.unpin_dt
|
||||
)
|
||||
or (
|
||||
member.channel_id.last_interest_dt
|
||||
and member.channel_id.last_interest_dt >= member.unpin_dt
|
||||
)
|
||||
)
|
||||
|
||||
_partner_unique = models.UniqueIndex("(channel_id, partner_id) WHERE partner_id IS NOT NULL")
|
||||
_guest_unique = models.UniqueIndex("(channel_id, guest_id) WHERE guest_id IS NOT NULL")
|
||||
_partner_or_guest_exists = models.Constraint(
|
||||
'CHECK((partner_id IS NOT NULL AND guest_id IS NULL) OR (partner_id IS NULL AND guest_id IS NOT NULL))',
|
||||
'A channel member must be a partner or a guest.',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
if self.env.context.get("mail_create_bypass_create_check") is self._bypass_create_check:
|
||||
self = self.sudo()
|
||||
for vals in vals_list:
|
||||
if "channel_id" not in vals:
|
||||
raise UserError(
|
||||
_(
|
||||
"It appears you're trying to create a channel member, but it seems like you forgot to specify the related channel. "
|
||||
"To move forward, please make sure to provide the necessary channel information."
|
||||
)
|
||||
)
|
||||
channel = self.env["discuss.channel"].browse(vals["channel_id"])
|
||||
if channel.channel_type == "chat" and len(channel.channel_member_ids) > 0:
|
||||
raise UserError(
|
||||
_("Adding more members to this chat isn't possible; it's designed for just two people.")
|
||||
)
|
||||
name_members_by_channel = {
|
||||
channel: channel.channel_name_member_ids
|
||||
for channel in self.env["discuss.channel"].browse(
|
||||
{vals["channel_id"] for vals in vals_list}
|
||||
)
|
||||
}
|
||||
res = super().create(vals_list)
|
||||
# help the ORM to detect changes
|
||||
res.partner_id.invalidate_recordset(["channel_ids"])
|
||||
res.guest_id.invalidate_recordset(["channel_ids"])
|
||||
# Always link members to parent channels as well. Member list should be
|
||||
# kept in sync.
|
||||
for member in res:
|
||||
if parent := member.channel_id.parent_channel_id:
|
||||
parent._add_members(partners=member.partner_id, guests=member.guest_id)
|
||||
for channel, members in name_members_by_channel.items():
|
||||
if channel.channel_name_member_ids != members:
|
||||
Store(bus_channel=channel).add(
|
||||
channel,
|
||||
Store.Many("channel_name_member_ids", sort="id"),
|
||||
).bus_send()
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
for channel_member in self:
|
||||
for field_name in ['channel_id', 'partner_id', 'guest_id']:
|
||||
if field_name in vals and vals[field_name] != channel_member[field_name].id:
|
||||
raise AccessError(_('You can not write on %(field_name)s.', field_name=field_name))
|
||||
|
||||
def get_field_name(field_description):
|
||||
if isinstance(field_description, Store.Attr):
|
||||
return field_description.field_name
|
||||
return field_description
|
||||
|
||||
def get_vals(member):
|
||||
return {
|
||||
get_field_name(field_description): (
|
||||
member[get_field_name(field_description)],
|
||||
field_description,
|
||||
)
|
||||
for field_description in self._sync_field_names()
|
||||
}
|
||||
|
||||
old_vals_by_member = {member: get_vals(member) for member in self}
|
||||
result = super().write(vals)
|
||||
for member in self:
|
||||
new_values = get_vals(member)
|
||||
diff = []
|
||||
for field_name, (new_value, field_description) in new_values.items():
|
||||
old_value = old_vals_by_member[member][field_name][0]
|
||||
if new_value != old_value:
|
||||
diff.append(field_description)
|
||||
if diff:
|
||||
diff.extend(
|
||||
[
|
||||
Store.One("channel_id", [], as_thread=True),
|
||||
*self.env["discuss.channel.member"]._to_store_persona([]),
|
||||
]
|
||||
)
|
||||
if "message_unread_counter" in diff:
|
||||
# sudo: bus.bus: reading non-sensitive last id
|
||||
bus_last_id = self.env["bus.bus"].sudo()._bus_last_id()
|
||||
diff.append({"message_unread_counter_bus_id": bus_last_id})
|
||||
Store(bus_channel=member._bus_channel()).add(member, diff).bus_send()
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _sync_field_names(self):
|
||||
return [
|
||||
"custom_channel_name",
|
||||
"custom_notifications",
|
||||
"last_interest_dt",
|
||||
"message_unread_counter",
|
||||
"mute_until_dt",
|
||||
"new_message_separator",
|
||||
# sudo: discuss.channel.rtc.session - each member can see who is inviting them
|
||||
Store.One(
|
||||
"rtc_inviting_session_id",
|
||||
extra_fields=self.rtc_inviting_session_id._get_store_extra_fields(),
|
||||
sudo=True,
|
||||
),
|
||||
"unpin_dt",
|
||||
]
|
||||
|
||||
def unlink(self):
|
||||
# sudo: discuss.channel.rtc.session - cascade unlink of sessions for self member
|
||||
self.sudo().rtc_session_ids.unlink() # ensure unlink overrides are applied
|
||||
# always unlink members of sub-channels as well
|
||||
domains = [
|
||||
[
|
||||
("id", "not in", self.ids),
|
||||
("partner_id", "=", member.partner_id.id),
|
||||
("guest_id", "=", member.guest_id.id),
|
||||
("channel_id", "in", member.channel_id.sub_channel_ids.ids),
|
||||
]
|
||||
for member in self
|
||||
]
|
||||
for member in self.env["discuss.channel.member"].search(Domain.OR(domains)):
|
||||
member.channel_id._action_unfollow(partner=member.partner_id, guest=member.guest_id)
|
||||
# sudo - discuss.channel: allowed to access channels to update member-based naming
|
||||
name_members_by_channel = {
|
||||
channel: channel.channel_name_member_ids for channel in self.channel_id
|
||||
}
|
||||
res = super().unlink()
|
||||
for channel, members in name_members_by_channel.items():
|
||||
# sudo - discuss.channel: updating channel names according to members is allowed,
|
||||
# even after the member left the channel.
|
||||
channel_sudo = channel.sudo()
|
||||
if channel_sudo.channel_name_member_ids != members:
|
||||
Store(bus_channel=channel).add(
|
||||
channel_sudo,
|
||||
Store.Many("channel_name_member_ids", sort="id"),
|
||||
).bus_send()
|
||||
return res
|
||||
|
||||
def _bus_channel(self):
|
||||
return self.partner_id.main_user_id or self.guest_id
|
||||
|
||||
def _notify_typing(self, is_typing):
|
||||
""" Broadcast the typing notification to channel members
|
||||
:param is_typing: (boolean) tells whether the members are typing or not
|
||||
"""
|
||||
for member in self:
|
||||
Store(bus_channel=member.channel_id).add(
|
||||
member,
|
||||
extra_fields={"isTyping": is_typing, "is_typing_dt": fields.Datetime.now()},
|
||||
).bus_send()
|
||||
|
||||
def _notify_mute(self):
|
||||
for member in self:
|
||||
if member.mute_until_dt and member.mute_until_dt != -1:
|
||||
self.env.ref("mail.ir_cron_discuss_channel_member_unmute")._trigger(member.mute_until_dt)
|
||||
|
||||
@api.model
|
||||
def _cleanup_expired_mutes(self):
|
||||
"""
|
||||
Cron job for cleanup expired unmute by resetting mute_until_dt and sending bus notifications.
|
||||
"""
|
||||
members = self.search([("mute_until_dt", "<=", fields.Datetime.now())])
|
||||
members.write({"mute_until_dt": False})
|
||||
members._notify_mute()
|
||||
|
||||
def _to_store_persona(self, fields=None):
|
||||
if fields == "avatar_card":
|
||||
fields = ["avatar_128", "im_status", "name"]
|
||||
return [
|
||||
# sudo: res.partner - reading partner related to a member is considered acceptable
|
||||
Store.Attr(
|
||||
"partner_id",
|
||||
lambda m: Store.One(
|
||||
m.partner_id.sudo(),
|
||||
(p_fields := m._get_store_partner_fields(fields)),
|
||||
extra_fields=self.env["res.partner"]._get_store_mention_fields()
|
||||
if p_fields or p_fields is None
|
||||
else None,
|
||||
),
|
||||
predicate=lambda m: m.partner_id,
|
||||
),
|
||||
# sudo: mail.guest - reading guest related to a member is considered acceptable
|
||||
Store.Attr(
|
||||
"guest_id",
|
||||
lambda m: Store.One(m.guest_id.sudo(), m._get_store_guest_fields(fields)),
|
||||
predicate=lambda m: m.guest_id,
|
||||
),
|
||||
]
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
return [
|
||||
Store.One("channel_id", [], as_thread=True),
|
||||
"create_date",
|
||||
"fetched_message_id",
|
||||
"last_seen_dt",
|
||||
"seen_message_id",
|
||||
*self.env["discuss.channel.member"]._to_store_persona(),
|
||||
]
|
||||
|
||||
def _get_store_partner_fields(self, fields):
|
||||
self.ensure_one()
|
||||
return fields
|
||||
|
||||
def _get_store_guest_fields(self, fields):
|
||||
self.ensure_one()
|
||||
return fields
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# RTC (voice/video)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _rtc_join_call(self, store: Store = None, check_rtc_session_ids=None, camera=False):
|
||||
self.ensure_one()
|
||||
session_domain = []
|
||||
if self.partner_id:
|
||||
session_domain = [("partner_id", "=", self.partner_id.id)]
|
||||
elif self.guest_id:
|
||||
session_domain = [("guest_id", "=", self.guest_id.id)]
|
||||
user_sessions = self.search(session_domain).rtc_session_ids
|
||||
check_rtc_session_ids = (check_rtc_session_ids or []) + user_sessions.ids
|
||||
self.channel_id._rtc_cancel_invitations(member_ids=self.ids)
|
||||
user_sessions.unlink()
|
||||
rtc_session = self.env['discuss.channel.rtc.session'].create({'channel_member_id': self.id, 'is_camera_on': camera})
|
||||
current_rtc_sessions, outdated_rtc_sessions = self._rtc_sync_sessions(check_rtc_session_ids=check_rtc_session_ids)
|
||||
ice_servers = self.env["mail.ice.server"]._get_ice_servers()
|
||||
self._join_sfu(ice_servers)
|
||||
if store:
|
||||
store.add(
|
||||
self.channel_id, {"rtc_session_ids": Store.Many(current_rtc_sessions, mode="ADD")}
|
||||
)
|
||||
store.add(
|
||||
self.channel_id,
|
||||
{"rtc_session_ids": Store.Many(outdated_rtc_sessions, [], mode="DELETE")},
|
||||
)
|
||||
store.add_singleton_values(
|
||||
"Rtc",
|
||||
{
|
||||
"iceServers": ice_servers or False,
|
||||
"localSession": Store.One(rtc_session),
|
||||
"serverInfo": self._get_rtc_server_info(rtc_session, ice_servers),
|
||||
},
|
||||
)
|
||||
if self.channel_id._should_invite_members_to_join_call():
|
||||
self._rtc_invite_members()
|
||||
|
||||
def _join_sfu(self, ice_servers=None, force=False):
|
||||
if len(self.channel_id.rtc_session_ids) < SFU_MODE_THRESHOLD and not force:
|
||||
if self.channel_id.sfu_channel_uuid:
|
||||
self.channel_id.sfu_channel_uuid = None
|
||||
self.channel_id.sfu_server_url = None
|
||||
return
|
||||
elif self.channel_id.sfu_channel_uuid and self.channel_id.sfu_server_url:
|
||||
return
|
||||
sfu_server_url = discuss.get_sfu_url(self.env)
|
||||
if not sfu_server_url:
|
||||
return
|
||||
sfu_local_key = self.env["ir.config_parameter"].sudo().get_param("mail.sfu_local_key")
|
||||
if not sfu_local_key:
|
||||
sfu_local_key = str(uuid.uuid4())
|
||||
self.env["ir.config_parameter"].sudo().set_param("mail.sfu_local_key", sfu_local_key)
|
||||
json_web_token = jwt.sign(
|
||||
{"iss": f"{self.get_base_url()}:channel:{self.channel_id.id}", "key": sfu_local_key},
|
||||
key=discuss.get_sfu_key(self.env),
|
||||
ttl=30,
|
||||
algorithm=jwt.Algorithm.HS256,
|
||||
)
|
||||
try:
|
||||
response = requests.get(
|
||||
sfu_server_url + "/v1/channel",
|
||||
headers={"Authorization": "jwt " + json_web_token},
|
||||
timeout=3,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
_logger.warning("Failed to obtain a channel from the SFU server, user will stay in p2p: %s", error)
|
||||
return
|
||||
response_dict = response.json()
|
||||
self.channel_id.sfu_channel_uuid = response_dict["uuid"]
|
||||
self.channel_id.sfu_server_url = response_dict["url"]
|
||||
for session in self.channel_id.rtc_session_ids:
|
||||
session._bus_send(
|
||||
"discuss.channel.rtc.session/sfu_hot_swap",
|
||||
{"serverInfo": self._get_rtc_server_info(session, ice_servers, key=sfu_local_key)},
|
||||
)
|
||||
|
||||
def _get_rtc_server_info(self, rtc_session, ice_servers=None, key=None):
|
||||
sfu_channel_uuid = self.channel_id.sfu_channel_uuid
|
||||
sfu_server_url = self.channel_id.sfu_server_url
|
||||
if not sfu_channel_uuid or not sfu_server_url:
|
||||
return None
|
||||
if not key:
|
||||
key = self.env["ir.config_parameter"].sudo().get_param("mail.sfu_local_key")
|
||||
claims = {
|
||||
"session_id": rtc_session.id,
|
||||
"ice_servers": ice_servers,
|
||||
}
|
||||
json_web_token = jwt.sign(claims, key=key, ttl=60 * 60 * 8, algorithm=jwt.Algorithm.HS256) # 8 hours
|
||||
return {"url": sfu_server_url, "channelUUID": sfu_channel_uuid, "jsonWebToken": json_web_token}
|
||||
|
||||
def _rtc_leave_call(self, session_id=None):
|
||||
self.ensure_one()
|
||||
if self.rtc_session_ids:
|
||||
if session_id:
|
||||
self.rtc_session_ids.filtered(lambda rec: rec.id == session_id).unlink()
|
||||
return
|
||||
self.rtc_session_ids.unlink()
|
||||
else:
|
||||
self.channel_id._rtc_cancel_invitations(member_ids=self.ids)
|
||||
|
||||
def _rtc_sync_sessions(self, check_rtc_session_ids=None):
|
||||
"""Synchronize the RTC sessions for self channel member.
|
||||
- Inactive sessions of the channel are deleted.
|
||||
- Current sessions are returned.
|
||||
- Sessions given in check_rtc_session_ids that no longer exists
|
||||
are returned as non-existing.
|
||||
|
||||
:param list check_rtc_session_ids: list of the ids of the sessions to check
|
||||
:returns: (current_rtc_sessions, outdated_rtc_sessions)
|
||||
:rtype: tuple
|
||||
"""
|
||||
self.ensure_one()
|
||||
self.channel_id.rtc_session_ids._delete_inactive_rtc_sessions()
|
||||
check_rtc_sessions = self.env['discuss.channel.rtc.session'].browse([int(check_rtc_session_id) for check_rtc_session_id in (check_rtc_session_ids or [])])
|
||||
return self.channel_id.rtc_session_ids, check_rtc_sessions - self.channel_id.rtc_session_ids
|
||||
|
||||
def _get_rtc_invite_members_domain(self, member_ids=None):
|
||||
""" Get the domain used to get the members to invite to and RTC call on
|
||||
the member's channel.
|
||||
|
||||
:param list member_ids: List of the partner ids to invite.
|
||||
"""
|
||||
self.ensure_one()
|
||||
domain = Domain.AND([
|
||||
[('channel_id', '=', self.channel_id.id)],
|
||||
[('rtc_inviting_session_id', '=', False)],
|
||||
[('rtc_session_ids', '=', False)],
|
||||
Domain.OR([
|
||||
[("partner_id", "=", False)],
|
||||
[("partner_id.user_ids.manual_im_status", "!=", "busy")],
|
||||
]),
|
||||
Domain("guest_id", "=", False) | Domain("guest_id.presence_ids.last_poll", ">", "-12H"),
|
||||
])
|
||||
if member_ids:
|
||||
domain &= Domain('id', 'in', member_ids)
|
||||
return domain
|
||||
|
||||
def _rtc_invite_members(self, member_ids=None):
|
||||
""" Sends invitations to join the RTC call to all connected members of the thread who are not already invited,
|
||||
if member_ids is set, only the specified ids will be invited.
|
||||
|
||||
:param list member_ids: list of the partner ids to invite
|
||||
"""
|
||||
self.ensure_one()
|
||||
members = self.env["discuss.channel.member"].search(
|
||||
self._get_rtc_invite_members_domain(member_ids)
|
||||
)
|
||||
if members:
|
||||
members.rtc_inviting_session_id = self.rtc_session_ids.id
|
||||
Store(bus_channel=self.channel_id).add(
|
||||
self.channel_id,
|
||||
{
|
||||
"invited_member_ids": Store.Many(
|
||||
members,
|
||||
[
|
||||
Store.One("channel_id", [], as_thread=True),
|
||||
*self.env["discuss.channel.member"]._to_store_persona("avatar_card"),
|
||||
],
|
||||
mode="ADD",
|
||||
),
|
||||
},
|
||||
).bus_send()
|
||||
devices, private_key, public_key = self.channel_id._web_push_get_partners_parameters(members.partner_id.ids)
|
||||
if devices:
|
||||
if self.channel_id.channel_type != 'chat':
|
||||
icon = f"/web/image/discuss.channel/{self.channel_id.id}/avatar_128"
|
||||
elif guest := self.env["mail.guest"]._get_guest_from_context():
|
||||
icon = f"/web/image/mail.guest/{guest.id}/avatar_128"
|
||||
elif partner := self.env.user.partner_id:
|
||||
icon = f"/web/image/res.partner/{partner.id}/avatar_128"
|
||||
languages = [partner.lang for partner in devices.partner_id]
|
||||
payload_by_lang = {}
|
||||
for lang in languages:
|
||||
env_lang = self.with_context(lang=lang).env
|
||||
payload_by_lang[lang] = {
|
||||
"title": env_lang._("Incoming call"),
|
||||
"options": {
|
||||
"body": env_lang._("Conference: %s", self.channel_id.display_name),
|
||||
"icon": icon,
|
||||
"vibrate": [100, 50, 100],
|
||||
"requireInteraction": True,
|
||||
"tag": self.channel_id._get_call_notification_tag(),
|
||||
"data": {
|
||||
"type": PUSH_NOTIFICATION_TYPE.CALL,
|
||||
"model": "discuss.channel",
|
||||
"action": "mail.action_discuss",
|
||||
"res_id": self.channel_id.id,
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"action": PUSH_NOTIFICATION_ACTION.DECLINE,
|
||||
"type": "button",
|
||||
"title": env_lang._("Decline"),
|
||||
},
|
||||
{
|
||||
"action": PUSH_NOTIFICATION_ACTION.ACCEPT,
|
||||
"type": "button",
|
||||
"title": env_lang._("Accept"),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
self.channel_id._web_push_send_notification(devices, private_key, public_key, payload_by_lang=payload_by_lang)
|
||||
return members
|
||||
|
||||
def _mark_as_read(self, last_message_id):
|
||||
"""
|
||||
Mark channel as read by updating the seen message id of the current
|
||||
member as well as its new message separator.
|
||||
|
||||
:param last_message_id: the id of the message to be marked as read.
|
||||
"""
|
||||
self.ensure_one()
|
||||
domain = [
|
||||
("model", "=", "discuss.channel"),
|
||||
("res_id", "=", self.channel_id.id),
|
||||
("id", "<=", last_message_id),
|
||||
]
|
||||
last_message = self.env['mail.message'].search(domain, order="id DESC", limit=1)
|
||||
if not last_message:
|
||||
return
|
||||
self._set_last_seen_message(last_message)
|
||||
self._set_new_message_separator(last_message.id + 1)
|
||||
|
||||
def _set_last_seen_message(self, message, notify=True):
|
||||
"""
|
||||
Set the last seen message of the current member.
|
||||
|
||||
:param message: the message to set as last seen message.
|
||||
:param notify: whether to send a bus notification relative to the new
|
||||
last seen message.
|
||||
"""
|
||||
self.ensure_one()
|
||||
bus_channel = self._bus_channel()
|
||||
if self.seen_message_id.id < message.id:
|
||||
self.write({
|
||||
"fetched_message_id": max(self.fetched_message_id.id, message.id),
|
||||
"seen_message_id": message.id,
|
||||
"last_seen_dt": fields.Datetime.now(),
|
||||
})
|
||||
if self.channel_id.channel_type in self.channel_id._types_allowing_seen_infos():
|
||||
bus_channel = self.channel_id
|
||||
if not notify:
|
||||
return
|
||||
Store(bus_channel=bus_channel).add(
|
||||
self,
|
||||
[
|
||||
Store.One("channel_id", [], as_thread=True),
|
||||
*self.env["discuss.channel.member"]._to_store_persona("avatar_card"),
|
||||
"seen_message_id",
|
||||
],
|
||||
).bus_send()
|
||||
|
||||
def _set_new_message_separator(self, message_id):
|
||||
"""
|
||||
:param message_id: id of the message above which the new message
|
||||
separator should be displayed.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if message_id == self.new_message_separator:
|
||||
bus_last_id = self.env["bus.bus"].sudo()._bus_last_id()
|
||||
Store(bus_channel=self._bus_channel()).add(
|
||||
self,
|
||||
[
|
||||
Store.One("channel_id", [], as_thread=True),
|
||||
"message_unread_counter",
|
||||
{"message_unread_counter_bus_id": bus_last_id},
|
||||
"new_message_separator",
|
||||
*self.env["discuss.channel.member"]._to_store_persona([]),
|
||||
],
|
||||
).bus_send()
|
||||
return
|
||||
self.new_message_separator = message_id
|
||||
|
||||
def _get_html_link_title(self):
|
||||
return self.partner_id.name if self.partner_id else self.guest_id.name
|
||||
|
||||
def _get_html_link(self, *args, for_persona=False, **kwargs):
|
||||
if not for_persona:
|
||||
return self._get_html_link(*args, **kwargs)
|
||||
if self.partner_id:
|
||||
return self.partner_id._get_html_link(title=f"@{self._get_html_link_title()}")
|
||||
return Markup("<strong>%s</strong>") % self.guest_id.name
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.mail.tools import discuss, jwt
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DiscussChannelRtcSession(models.Model):
|
||||
_name = 'discuss.channel.rtc.session'
|
||||
_inherit = ["bus.listener.mixin"]
|
||||
_description = 'Mail RTC session'
|
||||
_rec_name = 'channel_member_id'
|
||||
|
||||
channel_member_id = fields.Many2one('discuss.channel.member', required=True, ondelete='cascade')
|
||||
channel_id = fields.Many2one('discuss.channel', related='channel_member_id.channel_id', store=True, readonly=True, index='btree_not_null')
|
||||
partner_id = fields.Many2one('res.partner', related='channel_member_id.partner_id', string="Partner", store=True, index=True)
|
||||
guest_id = fields.Many2one('mail.guest', related='channel_member_id.guest_id')
|
||||
|
||||
write_date = fields.Datetime("Last Updated On", index=True)
|
||||
|
||||
is_screen_sharing_on = fields.Boolean(string="Is sharing the screen")
|
||||
is_camera_on = fields.Boolean(string="Is sending user video")
|
||||
is_muted = fields.Boolean(string="Is microphone muted")
|
||||
is_deaf = fields.Boolean(string="Has disabled incoming sound")
|
||||
|
||||
_channel_member_unique = models.Constraint(
|
||||
'UNIQUE(channel_member_id)',
|
||||
'There can only be one rtc session per channel member',
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
rtc_sessions = super().create(vals_list)
|
||||
rtc_sessions_by_channel = defaultdict(lambda: self.env["discuss.channel.rtc.session"])
|
||||
for rtc_session in rtc_sessions:
|
||||
rtc_sessions_by_channel[rtc_session.channel_id] += rtc_session
|
||||
for channel, rtc_sessions in rtc_sessions_by_channel.items():
|
||||
Store(bus_channel=channel).add(
|
||||
channel,
|
||||
{"rtc_session_ids": Store.Many(rtc_sessions, mode="ADD")},
|
||||
).bus_send()
|
||||
for channel in rtc_sessions.channel_id.filtered(lambda c: len(c.rtc_session_ids) == 1):
|
||||
body = Markup('<div data-oe-type="call" class="o_mail_notification"></div>')
|
||||
message = channel.message_post(body=body, message_type="notification")
|
||||
# sudo - discuss.call.history: can create call history when call is created.
|
||||
self.env["discuss.call.history"].sudo().create(
|
||||
{
|
||||
"channel_id": channel.id,
|
||||
"start_dt": fields.Datetime.now(),
|
||||
"start_call_message_id": message.id,
|
||||
},
|
||||
)
|
||||
Store(bus_channel=channel).add(message, [Store.Many("call_history_ids", [])]).bus_send()
|
||||
return rtc_sessions
|
||||
|
||||
def unlink(self):
|
||||
call_ended_channels = self.channel_id.filtered(lambda c: not (c.rtc_session_ids - self))
|
||||
for channel in call_ended_channels:
|
||||
# If there is no member left in the RTC call, all invitations are cancelled.
|
||||
# Note: invitation depends on field `rtc_inviting_session_id` so the cancel must be
|
||||
# done before the delete to be able to know who was invited.
|
||||
channel._rtc_cancel_invitations()
|
||||
# If there is no member left in the RTC call, we remove the SFU channel uuid as the SFU
|
||||
# server will timeout the channel. It is better to obtain a new channel from the SFU server
|
||||
# than to attempt recycling a possibly stale channel uuid.
|
||||
channel.sfu_channel_uuid = False
|
||||
channel.sfu_server_url = False
|
||||
rtc_sessions_by_channel = defaultdict(lambda: self.env["discuss.channel.rtc.session"])
|
||||
for rtc_session in self:
|
||||
rtc_sessions_by_channel[rtc_session.channel_id] += rtc_session
|
||||
for channel, rtc_sessions in rtc_sessions_by_channel.items():
|
||||
Store(bus_channel=channel).add(
|
||||
channel,
|
||||
{"rtc_session_ids": Store.Many(rtc_sessions, [], mode="DELETE")},
|
||||
).bus_send()
|
||||
for rtc_session in self:
|
||||
rtc_session._bus_send(
|
||||
"discuss.channel.rtc.session/ended", {"sessionId": rtc_session.id}
|
||||
)
|
||||
# sudo - dicuss.rtc.call.history: setting the end date of the call
|
||||
# after it ends is allowed.
|
||||
for history in (
|
||||
self.env["discuss.call.history"]
|
||||
.sudo()
|
||||
.search([("channel_id", "in", call_ended_channels.ids), ("end_dt", "=", False)])
|
||||
):
|
||||
history.end_dt = fields.Datetime.now()
|
||||
Store(bus_channel=history.channel_id).add(
|
||||
history,
|
||||
["duration_hour", "end_dt"],
|
||||
).bus_send()
|
||||
return super().unlink()
|
||||
|
||||
def _bus_channel(self):
|
||||
return self.channel_member_id._bus_channel()
|
||||
|
||||
def _update_and_broadcast(self, values):
|
||||
""" Updates the session and notifies all members of the channel
|
||||
of the change.
|
||||
"""
|
||||
valid_values = {'is_screen_sharing_on', 'is_camera_on', 'is_muted', 'is_deaf'}
|
||||
self.write({key: values[key] for key in valid_values if key in values})
|
||||
store = Store().add(self, extra_fields=self._get_store_extra_fields())
|
||||
self.channel_id._bus_send(
|
||||
"discuss.channel.rtc.session/update_and_broadcast",
|
||||
{"data": store.get_result(), "channelId": self.channel_id.id},
|
||||
)
|
||||
|
||||
@api.autovacuum
|
||||
def _gc_inactive_sessions(self):
|
||||
""" Garbage collect sessions that aren't active anymore,
|
||||
this can happen when the server or the user's browser crash
|
||||
or when the user's odoo session ends.
|
||||
"""
|
||||
self.search(self._inactive_rtc_session_domain()).unlink()
|
||||
|
||||
def action_disconnect(self):
|
||||
session_ids_by_channel_by_url = defaultdict(lambda: defaultdict(list))
|
||||
for rtc_session in self:
|
||||
sfu_channel_uuid = rtc_session.channel_id.sfu_channel_uuid
|
||||
url = rtc_session.channel_id.sfu_server_url
|
||||
if sfu_channel_uuid and url:
|
||||
session_ids_by_channel_by_url[url][sfu_channel_uuid].append(rtc_session.id)
|
||||
key = discuss.get_sfu_key(self.env)
|
||||
if key:
|
||||
with requests.Session() as requests_session:
|
||||
for url, session_ids_by_channel in session_ids_by_channel_by_url.items():
|
||||
try:
|
||||
requests_session.post(
|
||||
url + '/v1/disconnect',
|
||||
data=jwt.sign({'sessionIdsByChannel': session_ids_by_channel}, key=key, ttl=20, algorithm=jwt.Algorithm.HS256),
|
||||
timeout=3
|
||||
).raise_for_status()
|
||||
except requests.exceptions.RequestException as error:
|
||||
_logger.warning("Could not disconnect sessions at sfu server %s: %s", url, error)
|
||||
self.unlink()
|
||||
|
||||
def _delete_inactive_rtc_sessions(self):
|
||||
"""Deletes the inactive sessions from self."""
|
||||
self.filtered_domain(self._inactive_rtc_session_domain()).unlink()
|
||||
|
||||
def _notify_peers(self, notifications):
|
||||
""" Used for peer-to-peer communication,
|
||||
guarantees that the sender is the current guest or partner.
|
||||
|
||||
:param notifications: list of tuple with the following elements:
|
||||
- target_session_ids: a list of discuss.channel.rtc.session ids
|
||||
- content: a string with the content to be sent to the targets
|
||||
"""
|
||||
self.ensure_one()
|
||||
payload_by_target = defaultdict(lambda: {'sender': self.id, 'notifications': []})
|
||||
for target_session_ids, content in notifications:
|
||||
for target_session in self.env['discuss.channel.rtc.session'].browse(target_session_ids).exists():
|
||||
payload_by_target[target_session]['notifications'].append(content)
|
||||
for target, payload in payload_by_target.items():
|
||||
target._bus_send("discuss.channel.rtc.session/peer_notification", payload)
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
return Store.One(
|
||||
"channel_member_id",
|
||||
[
|
||||
Store.One("channel_id", [], as_thread=True),
|
||||
*self.env["discuss.channel.member"]._to_store_persona("avatar_card"),
|
||||
],
|
||||
)
|
||||
|
||||
def _get_store_extra_fields(self):
|
||||
return ["is_camera_on", "is_deaf", "is_muted", "is_screen_sharing_on"]
|
||||
|
||||
@api.model
|
||||
def _inactive_rtc_session_domain(self):
|
||||
return [('write_date', '<', fields.Datetime.now() - relativedelta(minutes=1, seconds=15))]
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class DiscussGifFavorite(models.Model):
|
||||
_name = 'discuss.gif.favorite'
|
||||
_description = "Save favorite GIF from Tenor API"
|
||||
|
||||
tenor_gif_id = fields.Char("GIF id from Tenor", required=True)
|
||||
|
||||
_user_gif_favorite = models.Constraint(
|
||||
'unique(create_uid,tenor_gif_id)',
|
||||
'User should not have duplicated favorite GIF',
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class DiscussVoiceMetadata(models.Model):
|
||||
_name = 'discuss.voice.metadata'
|
||||
_description = "Metadata for voice attachments"
|
||||
|
||||
attachment_id = fields.Many2one(
|
||||
"ir.attachment", ondelete="cascade", bypass_search_access=True, copy=False, index=True
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = "ir.attachment"
|
||||
|
||||
voice_ids = fields.One2many("discuss.voice.metadata", "attachment_id")
|
||||
|
||||
def _bus_channel(self):
|
||||
self.ensure_one()
|
||||
if self.res_model == "discuss.channel" and self.res_id:
|
||||
return self.env["discuss.channel"].browse(self.res_id)
|
||||
guest = self.env["mail.guest"]._get_guest_from_context()
|
||||
if self.env.user._is_public() and guest:
|
||||
return guest
|
||||
return super()._bus_channel()
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
# sudo: discuss.voice.metadata - checking the existence of voice metadata for accessible
|
||||
# attachments is fine
|
||||
return super()._to_store_defaults(target) + [Store.Many("voice_ids", [], sudo=True)]
|
||||
|
||||
def _post_add_create(self, **kwargs):
|
||||
super()._post_add_create(**kwargs)
|
||||
if kwargs.get('voice'):
|
||||
self._set_voice_metadata()
|
||||
|
||||
def _set_voice_metadata(self):
|
||||
self.env["discuss.voice.metadata"].create([{"attachment_id": att.id} for att in self])
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import re
|
||||
|
||||
from odoo import models
|
||||
from odoo.fields import Domain
|
||||
|
||||
|
||||
class IrWebsocket(models.AbstractModel):
|
||||
"""Override to handle discuss specific features (channel in particular)."""
|
||||
|
||||
_inherit = "ir.websocket"
|
||||
|
||||
def _build_bus_channel_list(self, channels):
|
||||
channels = list(channels) # do not alter original list
|
||||
discuss_channel_ids = list()
|
||||
for channel in list(channels):
|
||||
if isinstance(channel, str) and channel.startswith("mail.guest_"):
|
||||
channels.remove(channel)
|
||||
guest = self.env["mail.guest"]._get_guest_from_token(channel.split("_")[1])
|
||||
if guest:
|
||||
self = self.with_context(guest=guest)
|
||||
if isinstance(channel, str):
|
||||
match = re.findall(r'discuss\.channel_(\d+)', channel)
|
||||
if match:
|
||||
channels.remove(channel)
|
||||
discuss_channel_ids.append(int(match[0]))
|
||||
guest = self.env["mail.guest"]._get_guest_from_context()
|
||||
if guest:
|
||||
channels.append(guest)
|
||||
domain = ["|", ("is_member", "=", True), ("id", "in", discuss_channel_ids)]
|
||||
all_user_channels = self.env["discuss.channel"].search(domain)
|
||||
internal_specific_channels = [
|
||||
(c, "internal_users")
|
||||
for c in all_user_channels
|
||||
if not self.env.user.share
|
||||
]
|
||||
channels.extend([*all_user_channels, *internal_specific_channels])
|
||||
return super()._build_bus_channel_list(channels)
|
||||
157
odoo-bringout-oca-ocb-mail/mail/models/discuss/mail_guest.py
Normal file
157
odoo-bringout-oca-ocb-mail/mail/models/discuss/mail_guest.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import pytz
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo.tools import consteq, get_lang
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.http import request
|
||||
from odoo.addons.base.models.res_partner import _tz_get
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import limited_field_access_token
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class MailGuest(models.Model):
|
||||
_name = 'mail.guest'
|
||||
_description = "Guest"
|
||||
_inherit = ["avatar.mixin", "bus.listener.mixin"]
|
||||
_avatar_name_field = "name"
|
||||
_cookie_name = 'dgid'
|
||||
_cookie_separator = '|'
|
||||
|
||||
@api.model
|
||||
def _lang_get(self):
|
||||
return self.env['res.lang'].get_installed()
|
||||
|
||||
name = fields.Char(string="Name", required=True)
|
||||
access_token = fields.Char(string="Access Token", default=lambda self: str(uuid.uuid4()), groups='base.group_system', required=True, readonly=True, copy=False)
|
||||
country_id = fields.Many2one(string="Country", comodel_name='res.country')
|
||||
email = fields.Char()
|
||||
lang = fields.Selection(string="Language", selection=_lang_get)
|
||||
timezone = fields.Selection(string="Timezone", selection=_tz_get)
|
||||
channel_ids = fields.Many2many(string="Channels", comodel_name='discuss.channel', relation='discuss_channel_member', column1='guest_id', column2='channel_id', copy=False)
|
||||
presence_ids = fields.One2many("mail.presence", "guest_id", groups="base.group_system")
|
||||
# sudo: mail.guest - can access presence of accessible guest
|
||||
im_status = fields.Char("IM Status", compute="_compute_im_status", compute_sudo=True)
|
||||
offline_since = fields.Datetime("Offline since", compute="_compute_im_status", compute_sudo=True)
|
||||
|
||||
@api.depends("presence_ids.status")
|
||||
def _compute_im_status(self):
|
||||
for guest in self:
|
||||
guest.im_status = guest.presence_ids.status or "offline"
|
||||
guest.offline_since = (
|
||||
guest.presence_ids.last_poll
|
||||
if guest.im_status == "offline"
|
||||
else None
|
||||
)
|
||||
|
||||
def _get_guest_from_token(self, token=""):
|
||||
"""Returns the guest record for the given token, if applicable."""
|
||||
guest = self.env["mail.guest"]
|
||||
parts = token.split(self._cookie_separator)
|
||||
if len(parts) == 2:
|
||||
guest_id, guest_access_token = parts
|
||||
# sudo: mail.guest: guests need sudo to read their access_token
|
||||
guest = self.browse(int(guest_id)).sudo().exists()
|
||||
if not guest or not guest.access_token or not consteq(guest.access_token, guest_access_token):
|
||||
guest = self.env["mail.guest"]
|
||||
return guest.sudo(False)
|
||||
|
||||
def _get_guest_from_context(self):
|
||||
"""Returns the current guest record from the context, if applicable."""
|
||||
guest = self.env.context.get('guest')
|
||||
if isinstance(guest, self.pool['mail.guest']):
|
||||
assert len(guest) <= 1, "Context guest should be empty or a single record."
|
||||
return guest.sudo(False).with_context(guest=guest)
|
||||
return self.env['mail.guest']
|
||||
|
||||
def _get_or_create_guest(self, *, guest_name, country_code, timezone):
|
||||
if not (guest := self._get_guest_from_context()):
|
||||
guest = self.create(
|
||||
{
|
||||
"country_id": self.env["res.country"].search([("code", "=", country_code)]).id,
|
||||
"lang": get_lang(self.env).code,
|
||||
"name": guest_name,
|
||||
"timezone": timezone,
|
||||
}
|
||||
)
|
||||
guest._set_auth_cookie()
|
||||
return guest.sudo(False)
|
||||
|
||||
def _get_timezone_from_request(self, request):
|
||||
timezone = request.cookies.get('tz')
|
||||
return timezone if timezone in pytz.all_timezones else False
|
||||
|
||||
def _update_name(self, name):
|
||||
self.ensure_one()
|
||||
name = name.strip()
|
||||
if len(name) < 1:
|
||||
raise UserError(_("Guest's name cannot be empty."))
|
||||
if len(name) > 512:
|
||||
raise UserError(_("Guest's name is too long."))
|
||||
self.name = name
|
||||
for channel in self.channel_ids:
|
||||
Store(bus_channel=channel).add(self, ["avatar_128", "name"]).bus_send()
|
||||
Store(bus_channel=self).add(self, ["avatar_128", "name"]).bus_send()
|
||||
|
||||
def _update_timezone(self, timezone):
|
||||
query = """
|
||||
UPDATE mail_guest
|
||||
SET timezone = %s
|
||||
WHERE id IN (
|
||||
SELECT id FROM mail_guest WHERE id = %s
|
||||
FOR NO KEY UPDATE SKIP LOCKED
|
||||
)
|
||||
"""
|
||||
self.env.cr.execute(query, (timezone, self.id))
|
||||
|
||||
def _get_im_status_access_token(self):
|
||||
"""Return a scoped access token for the `im_status` field. The token is used in
|
||||
`ir_websocket._prepare_subscribe_data` to grant access to presence channels.
|
||||
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
return limited_field_access_token(self, "im_status", scope="mail.presence")
|
||||
|
||||
def _field_store_repr(self, field_name):
|
||||
if field_name == "avatar_128":
|
||||
return [
|
||||
Store.Attr("avatar_128_access_token", lambda g: g._get_avatar_128_access_token()),
|
||||
"write_date",
|
||||
]
|
||||
if field_name == "im_status":
|
||||
return [
|
||||
"im_status",
|
||||
Store.Attr("im_status_access_token", lambda g: g._get_im_status_access_token()),
|
||||
]
|
||||
return [field_name]
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
return ["avatar_128", "im_status", "name"]
|
||||
|
||||
def _set_auth_cookie(self):
|
||||
"""Add a cookie to the response to identify the guest. Every route
|
||||
that expects a guest will make use of it to authenticate the guest
|
||||
through `add_guest_to_context`.
|
||||
"""
|
||||
self.ensure_one()
|
||||
expiration_date = datetime.now() + timedelta(days=365)
|
||||
request.future_response.set_cookie(
|
||||
self._cookie_name,
|
||||
self._format_auth_cookie(),
|
||||
httponly=True,
|
||||
expires=expiration_date,
|
||||
)
|
||||
request.update_context(guest=self.sudo(False))
|
||||
|
||||
def _format_auth_cookie(self):
|
||||
"""Format the cookie value for the given guest.
|
||||
|
||||
:return: formatted cookie value
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
return f"{self.id}{self._cookie_separator}{self.access_token}"
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, fields
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class MailMessage(models.Model):
|
||||
_inherit = "mail.message"
|
||||
|
||||
call_history_ids = fields.One2many("discuss.call.history", "start_call_message_id")
|
||||
channel_id = fields.Many2one("discuss.channel", compute="_compute_channel_id")
|
||||
|
||||
@api.depends("model", "res_id")
|
||||
def _compute_channel_id(self):
|
||||
for message in self:
|
||||
if message.model == "discuss.channel" and message.res_id:
|
||||
message.channel_id = self.env["discuss.channel"].browse(message.res_id)
|
||||
else:
|
||||
message.channel_id = False
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
return super()._to_store_defaults(target) + [
|
||||
Store.Many(
|
||||
"call_history_ids",
|
||||
["duration_hour", "end_dt"],
|
||||
predicate=lambda m: m.body and 'data-oe-type="call"' in m.body,
|
||||
),
|
||||
]
|
||||
|
||||
def _extras_to_store(self, store: Store, format_reply):
|
||||
super()._extras_to_store(store, format_reply=format_reply)
|
||||
if format_reply:
|
||||
# sudo: mail.message: access to parent is allowed
|
||||
store.add(
|
||||
self.sudo().filtered(lambda message: message.channel_id),
|
||||
Store.One("parent_id", format_reply=False),
|
||||
)
|
||||
|
||||
def _bus_channel(self):
|
||||
self.ensure_one()
|
||||
if self.channel_id:
|
||||
return self.channel_id
|
||||
guest = self.env["mail.guest"]._get_guest_from_context()
|
||||
if self.env.user._is_public() and guest:
|
||||
return guest
|
||||
return super()._bus_channel()
|
||||
13
odoo-bringout-oca-ocb-mail/mail/models/discuss/res_groups.py
Normal file
13
odoo-bringout-oca-ocb-mail/mail/models/discuss/res_groups.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class ResGroups(models.Model):
|
||||
_inherit = "res.groups"
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if vals.get("user_ids"):
|
||||
self.env["discuss.channel"].search([("group_ids", "in", self.all_implied_ids._ids)])._subscribe_users_automatically()
|
||||
return res
|
||||
158
odoo-bringout-oca-ocb-mail/mail/models/discuss/res_partner.py
Normal file
158
odoo-bringout-oca-ocb-mail/mail/models/discuss/res_partner.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import email_normalize, single_email_re, SQL
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
channel_ids = fields.Many2many(
|
||||
"discuss.channel",
|
||||
"discuss_channel_member",
|
||||
"partner_id",
|
||||
"channel_id",
|
||||
string="Channels",
|
||||
copy=False,
|
||||
)
|
||||
channel_member_ids = fields.One2many("discuss.channel.member", "partner_id")
|
||||
is_in_call = fields.Boolean(compute="_compute_is_in_call", groups="base.group_system")
|
||||
rtc_session_ids = fields.One2many("discuss.channel.rtc.session", "partner_id")
|
||||
|
||||
@api.depends("rtc_session_ids")
|
||||
def _compute_is_in_call(self):
|
||||
for partner in self:
|
||||
partner.is_in_call = bool(partner.rtc_session_ids)
|
||||
|
||||
@api.readonly
|
||||
@api.model
|
||||
def search_for_channel_invite(self, search_term, channel_id=None, limit=30):
|
||||
"""Returns partners matching search_term that can be invited to a channel.
|
||||
|
||||
- If `channel_id` is specified, only partners that can actually be invited to the channel
|
||||
are returned (not already members, and in accordance to the channel configuration).
|
||||
|
||||
- If no matching partners are found and the search term is a valid email address,
|
||||
then the method may return `selectable_email` as a fallback direct email invite, provided that
|
||||
the channel allows invites by email.
|
||||
|
||||
"""
|
||||
store = Store()
|
||||
channel_invites = self._search_for_channel_invite(store, search_term, channel_id, limit)
|
||||
selectable_email = None
|
||||
email_already_sent = None
|
||||
if channel_invites["count"] == 0 and single_email_re.match(search_term):
|
||||
email = email_normalize(search_term)
|
||||
channel = self.env["discuss.channel"].search_fetch([("id", "=", int(channel_id))])
|
||||
member_domain = Domain("channel_id", "=", channel.id)
|
||||
member_domain &= Domain("guest_id.email", "=", email) | Domain(
|
||||
"partner_id.email", "=", email
|
||||
)
|
||||
if channel._allow_invite_by_email() and not self.env[
|
||||
"discuss.channel.member"
|
||||
].search_count(member_domain):
|
||||
selectable_email = email
|
||||
# sudo - mail.mail: checking mail records to determine if an email was already sent is acceptable.
|
||||
email_already_sent = (
|
||||
self.env["mail.mail"]
|
||||
.sudo()
|
||||
.search_count(
|
||||
[
|
||||
("email_to", "=", email),
|
||||
("model", "=", "discuss.channel"),
|
||||
("res_id", "=", channel.id),
|
||||
]
|
||||
)
|
||||
> 0
|
||||
)
|
||||
|
||||
return {
|
||||
**channel_invites,
|
||||
"email_already_sent": email_already_sent,
|
||||
"selectable_email": selectable_email,
|
||||
"store_data": store.get_result(),
|
||||
}
|
||||
|
||||
@api.readonly
|
||||
@api.model
|
||||
def _search_for_channel_invite(self, store: Store, search_term, channel_id=None, limit=30):
|
||||
domain = Domain.AND(
|
||||
[
|
||||
Domain("name", "ilike", search_term) | Domain("email", "ilike", search_term),
|
||||
[('id', '!=', self.env.user.partner_id.id)],
|
||||
[("active", "=", True)],
|
||||
[("user_ids", "!=", False)],
|
||||
[("user_ids.active", "=", True)],
|
||||
[("user_ids.share", "=", False)],
|
||||
]
|
||||
)
|
||||
channel = self.env["discuss.channel"]
|
||||
if channel_id:
|
||||
channel = self.env["discuss.channel"].search([("id", "=", int(channel_id))])
|
||||
domain &= Domain("channel_ids", "not in", channel.id)
|
||||
if channel.group_public_id:
|
||||
domain &= Domain("user_ids.all_group_ids", "in", channel.group_public_id.id)
|
||||
query = self._search(domain, limit=limit)
|
||||
# bypass lack of support for case insensitive order in search()
|
||||
query.order = SQL('LOWER(%s), "res_partner"."id"', self._field_to_sql(self._table, "name"))
|
||||
selectable_partners = self.env["res.partner"].browse(query)
|
||||
selectable_partners._search_for_channel_invite_to_store(store, channel)
|
||||
return {
|
||||
"count": self.env["res.partner"].search_count(domain),
|
||||
"partner_ids": selectable_partners.ids,
|
||||
}
|
||||
|
||||
def _search_for_channel_invite_to_store(self, store: Store, channel):
|
||||
store.add(self)
|
||||
|
||||
@api.readonly
|
||||
@api.model
|
||||
def get_mention_suggestions_from_channel(self, channel_id, search, limit=8):
|
||||
"""Return 'limit'-first partners' such that the name or email matches a 'search' string.
|
||||
Prioritize partners that are also (internal) users, and then extend the research to all partners.
|
||||
Only members of the given channel are returned.
|
||||
The return format is a list of partner data (as per returned by `_to_store()`).
|
||||
"""
|
||||
channel = self.env["discuss.channel"].search([("id", "=", channel_id)])
|
||||
if not channel:
|
||||
return []
|
||||
domain = Domain([
|
||||
self._get_mention_suggestions_domain(search),
|
||||
("channel_ids", "in", (channel.parent_channel_id | channel).ids)
|
||||
])
|
||||
extra_domain = Domain([
|
||||
('user_ids', '!=', False),
|
||||
('user_ids.active', '=', True),
|
||||
('partner_share', '=', False),
|
||||
])
|
||||
allowed_group = (channel.parent_channel_id or channel).group_public_id
|
||||
if allowed_group:
|
||||
extra_domain &= Domain("user_ids.all_group_ids", "in", allowed_group.id)
|
||||
partners = self._search_mention_suggestions(domain, limit, extra_domain)
|
||||
members_domain = [
|
||||
("channel_id", "in", (channel.parent_channel_id | channel).ids),
|
||||
("partner_id", "in", partners.ids)
|
||||
]
|
||||
members = self.env["discuss.channel.member"].search(members_domain)
|
||||
member_fields = [
|
||||
Store.One("channel_id", [], as_thread=True),
|
||||
*self.env["discuss.channel.member"]._to_store_persona([]),
|
||||
]
|
||||
store = (
|
||||
Store()
|
||||
.add(members, member_fields)
|
||||
.add(partners, extra_fields=partners._get_store_mention_fields())
|
||||
)
|
||||
store.add(channel, "group_public_id")
|
||||
if allowed_group:
|
||||
for p in partners:
|
||||
store.add(p, {"group_ids": [("ADD", (allowed_group & p.user_ids.all_group_ids).ids)]})
|
||||
try:
|
||||
roles = self.env["res.role"].search([("name", "ilike", search)], limit=8)
|
||||
store.add(roles, "name")
|
||||
except AccessError:
|
||||
pass
|
||||
return store.get_result()
|
||||
72
odoo-bringout-oca-ocb-mail/mail/models/discuss/res_users.py
Normal file
72
odoo-bringout-oca-ocb-mail/mail/models/discuss/res_users.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
is_in_call = fields.Boolean("Is in call", related="partner_id.is_in_call")
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
users = super().create(vals_list)
|
||||
self.env["discuss.channel"].search([("group_ids", "in", users.all_group_ids.ids)])._subscribe_users_automatically()
|
||||
return users
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if "active" in vals and not vals["active"]:
|
||||
self._unsubscribe_from_non_public_channels()
|
||||
if vals.get("group_ids"):
|
||||
# form: {'group_ids': [(3, 10), (3, 3), (4, 10), (4, 3)]} or {'group_ids': [(6, 0, [ids]}
|
||||
user_group_ids = [command[1] for command in vals["group_ids"] if command[0] == 4]
|
||||
user_group_ids += [id for command in vals["group_ids"] if command[0] == 6 for id in command[2]]
|
||||
user_group_ids += self.env['res.groups'].browse(user_group_ids).all_implied_ids._ids
|
||||
self.env["discuss.channel"].search([("group_ids", "in", user_group_ids)])._subscribe_users_automatically()
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
self._unsubscribe_from_non_public_channels()
|
||||
return super().unlink()
|
||||
|
||||
def _unsubscribe_from_non_public_channels(self):
|
||||
"""This method un-subscribes users from group restricted channels. Main purpose
|
||||
of this method is to prevent sending internal communication to archived / deleted users.
|
||||
"""
|
||||
domain = [("partner_id", "in", self.partner_id.ids)]
|
||||
# sudo: discuss.channel.member - removing member of other users based on channel restrictions
|
||||
current_cm = self.env["discuss.channel.member"].sudo().search(domain)
|
||||
current_cm.filtered(
|
||||
lambda cm: (cm.channel_id.channel_type == "channel" and cm.channel_id.group_public_id)
|
||||
).unlink()
|
||||
|
||||
def _init_messaging(self, store: Store):
|
||||
self = self.with_user(self)
|
||||
channels = self.env["discuss.channel"]._get_channels_as_member()
|
||||
domain = [("channel_id", "in", channels.ids), ("is_self", "=", True)]
|
||||
members = self.env["discuss.channel.member"].search(domain)
|
||||
members_with_unread = members.filtered(lambda member: member.message_unread_counter)
|
||||
# fetch channels data before calling super to benefit from prefetching (channel info might
|
||||
# prefetch a lot of data that super could use, about the current user in particular)
|
||||
super()._init_messaging(store)
|
||||
store.add_global_values(initChannelsUnreadCounter=len(members_with_unread))
|
||||
|
||||
def _init_store_data(self, store: Store):
|
||||
super()._init_store_data(store)
|
||||
# sudo: ir.config_parameter - reading hard-coded keys to check their existence, safe to
|
||||
# return whether the features are enabled
|
||||
get_param = self.env["ir.config_parameter"].sudo().get_param
|
||||
store.add_global_values(
|
||||
hasGifPickerFeature=bool(get_param("discuss.tenor_api_key")),
|
||||
hasMessageTranslationFeature=bool(get_param("mail.google_translate_api_key")),
|
||||
hasCannedResponses=bool(self.env["mail.canned.response"].sudo().search([
|
||||
"|",
|
||||
("create_uid", "=", self.env.user.id),
|
||||
("group_ids", "in", self.env.user.all_group_ids.ids),
|
||||
], limit=1)) if self.env.user else False,
|
||||
channel_types_with_seen_infos=sorted(
|
||||
self.env["discuss.channel"]._types_allowing_seen_infos()
|
||||
),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue