Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC
This commit is contained in:
3
tier_communications/__init__.py
Normal file
3
tier_communications/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import wizard
|
||||
from . import controllers
|
||||
44
tier_communications/__manifest__.py
Normal file
44
tier_communications/__manifest__.py
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
'name': 'Tier Communications',
|
||||
'version': '19.0.1.0.0',
|
||||
'category': 'Discuss',
|
||||
'summary': 'Extended communication channels, mailboxes and routing for Odoo',
|
||||
'description': """
|
||||
Extends Odoo mail with:
|
||||
- Abstract delivery channel mixin
|
||||
- Email channel implementation
|
||||
- Named mailboxes (lead inbox, ticket inbox)
|
||||
- Channel and mailbox routing rules
|
||||
- Extended mail.message with channel/mailbox tags
|
||||
""",
|
||||
'author': 'MKLAB',
|
||||
'depends': ['mail'],
|
||||
'data': [
|
||||
'security/tier_communications_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/tier_channel_tag_data.xml',
|
||||
'data/tier_mailbox_data.xml',
|
||||
'data/tier_mailbox_demo.xml',
|
||||
'views/tier_channel_tag_views.xml',
|
||||
'views/tier_mailbox_views.xml',
|
||||
'views/res_users_views.xml',
|
||||
'views/tier_move_to_mailbox_wizard_views.xml',
|
||||
'views/mail_message_views.xml',
|
||||
'views/tier_communications_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'tier_communications/static/src/components/tier_mailbox_store_patch.js',
|
||||
'tier_communications/static/src/components/discuss_sidebar_mailboxes.js',
|
||||
'tier_communications/static/src/xml/discuss_sidebar_mailboxes.xml',
|
||||
'tier_communications/static/src/components/message_composer_channels.js',
|
||||
'tier_communications/static/src/xml/message_composer_channels.xml',
|
||||
'tier_communications/static/src/components/message_channel_tag.js',
|
||||
'tier_communications/static/src/xml/message_channel_tag.xml',
|
||||
'tier_communications/static/src/components/message_move_to_mailbox.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
2
tier_communications/controllers/__init__.py
Normal file
2
tier_communications/controllers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import tier_mailbox_controller
|
||||
from . import send_notification
|
||||
156
tier_communications/controllers/send_notification.py
Normal file
156
tier_communications/controllers/send_notification.py
Normal file
@ -0,0 +1,156 @@
|
||||
import re
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class TierSendNotificationController(http.Controller):
|
||||
"""RPC endpoint for sending a message through an external channel.
|
||||
|
||||
Called from JS after message_post succeeds.
|
||||
Returns a status dict that JS uses to show a toast notification.
|
||||
"""
|
||||
|
||||
@http.route(
|
||||
'/tier_communications/send_via_channel',
|
||||
methods=['POST'],
|
||||
type='jsonrpc',
|
||||
auth='user',
|
||||
)
|
||||
def send_via_channel(self, message_id, channel_code, thread_model=None, thread_id=None):
|
||||
"""Send an already-created mail.message through the specified external channel.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'status': 'success' | 'error' | 'no_data',
|
||||
'message': str, # human-readable status for the toast
|
||||
}
|
||||
"""
|
||||
message = request.env['mail.message'].browse(message_id)
|
||||
if not message.exists():
|
||||
return {'status': 'error', 'message': 'Сообщение не найдено'}
|
||||
|
||||
if channel_code == 'email':
|
||||
return _send_email(request.env, message, thread_model, thread_id)
|
||||
elif channel_code == 'matrix':
|
||||
return _send_matrix(request.env, message)
|
||||
else:
|
||||
return {'status': 'error', 'message': f'Неизвестный канал: {channel_code}'}
|
||||
|
||||
|
||||
def _send_email(env, message, thread_model, thread_id):
|
||||
"""Send message via email. Returns status dict."""
|
||||
recipient_email = None
|
||||
|
||||
# 1. Thread partner_id
|
||||
if thread_model and thread_id and thread_model not in ('mail.thread', 'discuss.channel'):
|
||||
thread_record = env[thread_model].browse(thread_id)
|
||||
if thread_record.exists() and hasattr(thread_record, 'partner_id'):
|
||||
partner = thread_record.partner_id
|
||||
if partner and partner.email:
|
||||
recipient_email = partner.email
|
||||
|
||||
# 2. Message partner_ids
|
||||
if not recipient_email and message.partner_ids:
|
||||
for partner in message.partner_ids:
|
||||
if partner.email:
|
||||
recipient_email = partner.email
|
||||
break
|
||||
|
||||
# 3. Last incoming message email_from
|
||||
if not recipient_email and message.model and message.res_id:
|
||||
last_incoming = env['mail.message'].search([
|
||||
('model', '=', message.model),
|
||||
('res_id', '=', message.res_id),
|
||||
('message_type', 'in', ['email', 'comment']),
|
||||
('email_from', '!=', False),
|
||||
('author_id', '!=', env.user.partner_id.id),
|
||||
], order='id desc', limit=1)
|
||||
if last_incoming and last_incoming.email_from:
|
||||
email_match = re.search(r'<([^>]+)>', last_incoming.email_from)
|
||||
recipient_email = email_match.group(1) if email_match else last_incoming.email_from
|
||||
|
||||
# 4. discuss.channel other member
|
||||
if not recipient_email and thread_model == 'discuss.channel' and thread_id:
|
||||
channel = env['discuss.channel'].browse(thread_id)
|
||||
if channel.exists():
|
||||
other_members = channel.channel_member_ids.filtered(
|
||||
lambda m: m.partner_id and m.partner_id != env.user.partner_id
|
||||
)
|
||||
for member in other_members:
|
||||
if member.partner_id.email:
|
||||
recipient_email = member.partner_id.email
|
||||
break
|
||||
|
||||
if not recipient_email:
|
||||
return {
|
||||
'status': 'no_data',
|
||||
'message': 'Не заполнен email получателя',
|
||||
}
|
||||
|
||||
try:
|
||||
mail_values = {
|
||||
'subject': message.subject or 'Сообщение из Odoo',
|
||||
'body_html': message.body or '',
|
||||
'email_to': recipient_email,
|
||||
'email_from': env.company.email or env.user.email or '',
|
||||
'auto_delete': True,
|
||||
}
|
||||
outgoing_mail = env['mail.mail'].sudo().create(mail_values)
|
||||
outgoing_mail.send()
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': f'Сообщение отправлено на {recipient_email}',
|
||||
}
|
||||
except Exception as send_error:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'Ошибка отправки email: {send_error}',
|
||||
}
|
||||
|
||||
|
||||
def _send_matrix(env, message):
|
||||
"""Send message via Matrix. Returns status dict."""
|
||||
current_user = env.user
|
||||
|
||||
if not hasattr(current_user, 'tier_matrix_homeserver'):
|
||||
return {
|
||||
'status': 'no_data',
|
||||
'message': 'Модуль Matrix не установлен',
|
||||
}
|
||||
|
||||
homeserver_url = current_user.tier_matrix_homeserver
|
||||
access_token = current_user.tier_matrix_access_token
|
||||
room_id = current_user.tier_matrix_room_id
|
||||
|
||||
missing_fields = []
|
||||
if not homeserver_url:
|
||||
missing_fields.append('Homeserver URL')
|
||||
if not access_token:
|
||||
missing_fields.append('Access Token')
|
||||
if not room_id:
|
||||
missing_fields.append('Room ID')
|
||||
|
||||
if missing_fields:
|
||||
return {
|
||||
'status': 'no_data',
|
||||
'message': f'Не заполнены данные Matrix: {", ".join(missing_fields)}',
|
||||
}
|
||||
|
||||
if 'tier.channel.matrix.helper' not in env:
|
||||
return {
|
||||
'status': 'no_data',
|
||||
'message': 'Модуль Matrix не установлен',
|
||||
}
|
||||
|
||||
try:
|
||||
env['tier.channel.matrix.helper'].channel_send_message(message, user=current_user)
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': f'Сообщение отправлено в Matrix (room: {room_id})',
|
||||
}
|
||||
except Exception as send_error:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': f'Ошибка отправки Matrix: {send_error}',
|
||||
}
|
||||
41
tier_communications/controllers/tier_mailbox_controller.py
Normal file
41
tier_communications/controllers/tier_mailbox_controller.py
Normal file
@ -0,0 +1,41 @@
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class TierMailboxController(http.Controller):
|
||||
"""HTTP controller for tier mailbox message feeds.
|
||||
|
||||
Follows the same pattern as /mail/inbox/messages, /mail/starred/messages.
|
||||
"""
|
||||
|
||||
@http.route(
|
||||
"/mail/tier_mailbox/<int:mailbox_id>/messages",
|
||||
methods=["POST"],
|
||||
type="jsonrpc",
|
||||
auth="user",
|
||||
readonly=True,
|
||||
)
|
||||
def tier_mailbox_messages(self, mailbox_id, fetch_params=None):
|
||||
"""Return messages assigned to the given tier.mailbox."""
|
||||
# Verify the user has access to this mailbox
|
||||
mailbox = request.env["tier.mailbox"].browse(mailbox_id)
|
||||
if not mailbox.exists():
|
||||
return {"messages": [], "data": {}}
|
||||
|
||||
# Check access: user must be in user_ids or user_ids is empty
|
||||
uid = request.env.uid
|
||||
if mailbox.user_ids and uid not in mailbox.user_ids.ids:
|
||||
return {"messages": [], "data": {}}
|
||||
|
||||
# Find messages that have a mailbox tag pointing to this mailbox
|
||||
domain = [
|
||||
("mailbox_tag_ids.mailbox_id", "=", mailbox_id),
|
||||
]
|
||||
res = request.env["mail.message"]._message_fetch(domain, **(fetch_params or {}))
|
||||
messages = res.pop("messages")
|
||||
return {
|
||||
**res,
|
||||
"data": Store().add(messages, add_followers=True).get_result(),
|
||||
"messages": messages.ids,
|
||||
}
|
||||
15
tier_communications/data/tier_channel_tag_data.xml
Normal file
15
tier_communications/data/tier_channel_tag_data.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="channel_tag_email" model="tier.channel.tag">
|
||||
<field name="name">Email</field>
|
||||
<field name="code">email</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
<record id="channel_tag_matrix" model="tier.channel.tag">
|
||||
<field name="name">Matrix</field>
|
||||
<field name="code">matrix</field>
|
||||
<field name="active" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
15
tier_communications/data/tier_mailbox_data.xml
Normal file
15
tier_communications/data/tier_mailbox_data.xml
Normal file
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mailbox_lead_inbox" model="tier.mailbox">
|
||||
<field name="name">Входящие лиды</field>
|
||||
<field name="code">lead_inbox</field>
|
||||
<field name="is_lead_inbox" eval="True"/>
|
||||
</record>
|
||||
<record id="mailbox_ticket_inbox" model="tier.mailbox">
|
||||
<field name="name">Входящие тикеты</field>
|
||||
<field name="code">ticket_inbox</field>
|
||||
<field name="is_ticket_inbox" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
50
tier_communications/data/tier_mailbox_demo.xml
Normal file
50
tier_communications/data/tier_mailbox_demo.xml
Normal file
@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Demo messages for Lead Inbox -->
|
||||
<record id="demo_msg_lead_1" model="mail.message">
|
||||
<field name="body">Добрый день! Интересует ваш продукт, хотел бы узнать подробнее о ценах.</field>
|
||||
<field name="message_type">email</field>
|
||||
<field name="email_from">lead.client@example.com</field>
|
||||
<field name="subject">Запрос о продукте</field>
|
||||
</record>
|
||||
<record id="demo_mailbox_tag_lead_1" model="tier.mailbox.tag">
|
||||
<field name="mailbox_id" ref="mailbox_lead_inbox"/>
|
||||
<field name="message_id" ref="demo_msg_lead_1"/>
|
||||
</record>
|
||||
|
||||
<record id="demo_msg_lead_2" model="mail.message">
|
||||
<field name="body">Здравствуйте! Мы ищем партнёра для долгосрочного сотрудничества. Можем ли мы обсудить условия?</field>
|
||||
<field name="message_type">email</field>
|
||||
<field name="email_from">partner@business.com</field>
|
||||
<field name="subject">Предложение о партнёрстве</field>
|
||||
</record>
|
||||
<record id="demo_mailbox_tag_lead_2" model="tier.mailbox.tag">
|
||||
<field name="mailbox_id" ref="mailbox_lead_inbox"/>
|
||||
<field name="message_id" ref="demo_msg_lead_2"/>
|
||||
</record>
|
||||
|
||||
<!-- Demo messages for Ticket Inbox -->
|
||||
<record id="demo_msg_ticket_1" model="mail.message">
|
||||
<field name="body">У меня не работает функция экспорта в PDF. Ошибка появляется при нажатии кнопки "Скачать".</field>
|
||||
<field name="message_type">email</field>
|
||||
<field name="email_from">user.support@example.com</field>
|
||||
<field name="subject">Проблема с экспортом PDF</field>
|
||||
</record>
|
||||
<record id="demo_mailbox_tag_ticket_1" model="tier.mailbox.tag">
|
||||
<field name="mailbox_id" ref="mailbox_ticket_inbox"/>
|
||||
<field name="message_id" ref="demo_msg_ticket_1"/>
|
||||
</record>
|
||||
|
||||
<record id="demo_msg_ticket_2" model="mail.message">
|
||||
<field name="body">Добрый день! После последнего обновления система работает очень медленно. Страницы загружаются по 10-15 секунд.</field>
|
||||
<field name="message_type">email</field>
|
||||
<field name="email_from">slow.user@company.org</field>
|
||||
<field name="subject">Медленная работа системы</field>
|
||||
</record>
|
||||
<record id="demo_mailbox_tag_ticket_2" model="tier.mailbox.tag">
|
||||
<field name="mailbox_id" ref="mailbox_ticket_inbox"/>
|
||||
<field name="message_id" ref="demo_msg_ticket_2"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
11
tier_communications/models/__init__.py
Normal file
11
tier_communications/models/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
from . import tier_channel_mixin
|
||||
from . import tier_channel_email
|
||||
from . import tier_channel_tag
|
||||
from . import tier_mailbox
|
||||
from . import tier_mailbox_tag
|
||||
from . import tier_routing_channel
|
||||
from . import tier_routing_mailbox
|
||||
from . import tier_router
|
||||
from . import mail_message
|
||||
from . import mail_thread
|
||||
from . import res_users
|
||||
61
tier_communications/models/mail_message.py
Normal file
61
tier_communications/models/mail_message.py
Normal file
@ -0,0 +1,61 @@
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class MailMessage(models.Model):
|
||||
_inherit = 'mail.message'
|
||||
|
||||
channel_tag_ids = fields.Many2many(
|
||||
'tier.channel.tag',
|
||||
string='Delivery Channels',
|
||||
)
|
||||
mailbox_tag_ids = fields.One2many(
|
||||
'tier.mailbox.tag',
|
||||
'message_id',
|
||||
string='Mailbox Tags',
|
||||
)
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
"""Include channel_tag_ids as plain list of dicts for the frontend."""
|
||||
defaults = super()._to_store_defaults(target)
|
||||
defaults.append(
|
||||
Store.Attr(
|
||||
'channel_tag_ids',
|
||||
value=lambda m: [
|
||||
{'id': tag.id, 'name': tag.name, 'code': tag.code}
|
||||
for tag in m.channel_tag_ids
|
||||
]
|
||||
)
|
||||
)
|
||||
return defaults
|
||||
|
||||
@api.model
|
||||
def get_last_channel_code(self, thread_model, thread_id):
|
||||
"""Return the channel code of the last message in the thread.
|
||||
|
||||
Used by the composer to auto-select the channel based on the last
|
||||
incoming message. Returns None if no channel tag found.
|
||||
"""
|
||||
last_msg = self.search([
|
||||
('model', '=', thread_model),
|
||||
('res_id', '=', thread_id),
|
||||
('channel_tag_ids', '!=', False),
|
||||
], order='id desc', limit=1)
|
||||
if last_msg and last_msg.channel_tag_ids:
|
||||
return last_msg.channel_tag_ids[0].code
|
||||
return None
|
||||
|
||||
def action_move_to_mailbox(self):
|
||||
"""Open wizard to manually move this message to a mailbox."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Переместить в ящик'),
|
||||
'res_model': 'tier.move.to.mailbox.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_message_id': self.id,
|
||||
'default_current_mailbox_ids': self.mailbox_tag_ids.mapped('mailbox_id').ids,
|
||||
},
|
||||
}
|
||||
253
tier_communications/models/mail_thread.py
Normal file
253
tier_communications/models/mail_thread.py
Normal file
@ -0,0 +1,253 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MailThread(models.AbstractModel):
|
||||
_inherit = 'mail.thread'
|
||||
|
||||
def _get_allowed_message_params(self):
|
||||
"""Extend allowed params so channel_tag_ids passes through the controller."""
|
||||
return super()._get_allowed_message_params() | {'channel_tag_ids'}
|
||||
|
||||
def _get_message_create_valid_field_names(self):
|
||||
"""Add channel_tag_ids to the whitelist for _message_create."""
|
||||
return super()._get_message_create_valid_field_names() | {'channel_tag_ids'}
|
||||
|
||||
def assign_mailbox(self, mailbox_id):
|
||||
"""Assign all messages of this thread to the given tier.mailbox.
|
||||
|
||||
Called from the chatter UI when user clicks "Входящие лиды" / "Входящие тикеты".
|
||||
Removes existing tags for this mailbox first to avoid duplicates.
|
||||
"""
|
||||
self.ensure_one()
|
||||
mailbox = self.env['tier.mailbox'].browse(mailbox_id)
|
||||
if not mailbox.exists():
|
||||
return False
|
||||
|
||||
# Get all messages of this thread
|
||||
thread_messages = self.env['mail.message'].search([
|
||||
('model', '=', self._name),
|
||||
('res_id', '=', self.id),
|
||||
])
|
||||
|
||||
for message in thread_messages:
|
||||
# Remove existing tag for this mailbox if any
|
||||
existing_tag = message.mailbox_tag_ids.filtered(
|
||||
lambda tag: tag.mailbox_id.id == mailbox_id
|
||||
)
|
||||
if existing_tag:
|
||||
existing_tag.unlink()
|
||||
# Create new tag
|
||||
self.env['tier.mailbox.tag'].create({
|
||||
'mailbox_id': mailbox_id,
|
||||
'message_id': message.id,
|
||||
})
|
||||
|
||||
print(
|
||||
f"[tier_communications] assign_mailbox: "
|
||||
f"thread {self._name}#{self.id} → mailbox '{mailbox.name}' "
|
||||
f"({len(thread_messages)} messages tagged)"
|
||||
)
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# ОТПРАВКА ИСХОДЯЩИХ СООБЩЕНИЙ
|
||||
# =========================================================================
|
||||
|
||||
def message_post(self, *, body='', **kwargs):
|
||||
"""Override: создаём сообщение в Odoo. Внешняя отправка через канал
|
||||
выполняется отдельным RPC-вызовом из JS (/tier_communications/send_via_channel).
|
||||
"""
|
||||
message = super().message_post(body=body, **kwargs)
|
||||
if not message:
|
||||
return message
|
||||
|
||||
if not message.channel_tag_ids:
|
||||
print(
|
||||
f"[tier_communications] message_post: msg_id={message.id} — "
|
||||
f"канал не выбран, сообщение только в Odoo"
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
# =========================================================================
|
||||
# ! ОТПРАВКА СООБЩЕНИЯ НА EMAIL
|
||||
# =========================================================================
|
||||
|
||||
def _tier_send_email(self, message):
|
||||
"""Отправить сообщение на email партнёра треда через стандартный mail.mail."""
|
||||
recipient_email = None
|
||||
|
||||
# 1. Thread has partner_id (business models: sale.order, crm.lead, etc.)
|
||||
if self._name not in ('mail.thread', 'discuss.channel') and hasattr(self, 'partner_id'):
|
||||
partner = self.partner_id
|
||||
if partner and partner.email:
|
||||
recipient_email = partner.email
|
||||
|
||||
# 2. Message has explicit partner_ids
|
||||
if not recipient_email and message.partner_ids:
|
||||
for partner in message.partner_ids:
|
||||
partner_email = partner.email if hasattr(partner, 'email') else None
|
||||
if partner_email and isinstance(partner_email, str) and '@' in partner_email:
|
||||
recipient_email = partner_email
|
||||
break
|
||||
|
||||
# 3. Last incoming message in this thread — use its email_from
|
||||
if not recipient_email and message.model and message.res_id:
|
||||
last_incoming = self.env['mail.message'].search([
|
||||
('model', '=', message.model),
|
||||
('res_id', '=', message.res_id),
|
||||
('message_type', 'in', ['email', 'comment']),
|
||||
('email_from', '!=', False),
|
||||
('author_id', '!=', self.env.user.partner_id.id),
|
||||
], order='id desc', limit=1)
|
||||
if last_incoming and last_incoming.email_from:
|
||||
email_match = re.search(r'<([^>]+)>', last_incoming.email_from)
|
||||
recipient_email = email_match.group(1) if email_match else last_incoming.email_from
|
||||
|
||||
# 4. For discuss.channel — find the other member's email
|
||||
if not recipient_email and self._name == 'discuss.channel':
|
||||
other_members = self.channel_member_ids.filtered(
|
||||
lambda m: m.partner_id and m.partner_id != self.env.user.partner_id
|
||||
)
|
||||
for member in other_members:
|
||||
if member.partner_id.email:
|
||||
recipient_email = member.partner_id.email
|
||||
break
|
||||
|
||||
if not recipient_email:
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА EMAIL: "
|
||||
f"msg_id={message.id} — У пользователя нет email адреса получателя"
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА EMAIL: "
|
||||
f"msg_id={message.id}, email_to={recipient_email}"
|
||||
)
|
||||
|
||||
try:
|
||||
mail_values = {
|
||||
'subject': message.subject or 'Сообщение из Odoo',
|
||||
'body_html': message.body or '',
|
||||
'email_to': recipient_email,
|
||||
'email_from': self.env.company.email or self.env.user.email or '',
|
||||
'auto_delete': True,
|
||||
}
|
||||
outgoing_mail = self.env['mail.mail'].sudo().create(mail_values)
|
||||
outgoing_mail.send()
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА EMAIL: "
|
||||
f"msg_id={message.id} — SUCCESS, mail.mail id={outgoing_mail.id}"
|
||||
)
|
||||
except Exception as send_error:
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА EMAIL: "
|
||||
f"msg_id={message.id} — ОШИБКА: {send_error}"
|
||||
)
|
||||
_logger.exception(
|
||||
'tier_communications: ошибка отправки email, msg_id=%s', message.id
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX
|
||||
# =========================================================================
|
||||
|
||||
def _tier_send_matrix(self, message):
|
||||
"""Отправить сообщение в Matrix комнату текущего пользователя."""
|
||||
current_user = self.env.user
|
||||
|
||||
# Matrix fields are defined in tier_communications_matrix module
|
||||
# If not installed — fields don't exist
|
||||
if not hasattr(current_user, 'tier_matrix_homeserver'):
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX: "
|
||||
f"msg_id={message.id} — модуль tier_communications_matrix не установлен"
|
||||
)
|
||||
return
|
||||
|
||||
homeserver_url = current_user.tier_matrix_homeserver
|
||||
access_token = current_user.tier_matrix_access_token
|
||||
room_id = current_user.tier_matrix_room_id
|
||||
|
||||
if not homeserver_url:
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX: "
|
||||
f"msg_id={message.id} — У пользователя нет homeserver URL (tier_matrix_homeserver)"
|
||||
)
|
||||
return
|
||||
if not access_token:
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX: "
|
||||
f"msg_id={message.id} — У пользователя нет access token (tier_matrix_access_token)"
|
||||
)
|
||||
return
|
||||
if not room_id:
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX: "
|
||||
f"msg_id={message.id} — У пользователя нет room ID (tier_matrix_room_id)"
|
||||
)
|
||||
return
|
||||
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX: "
|
||||
f"msg_id={message.id}, room={room_id}, homeserver={homeserver_url}"
|
||||
)
|
||||
|
||||
if 'tier.channel.matrix.helper' in self.env:
|
||||
try:
|
||||
self.env['tier.channel.matrix.helper'].channel_send_message(
|
||||
message, user=current_user
|
||||
)
|
||||
except Exception as send_error:
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX: "
|
||||
f"msg_id={message.id} — ОШИБКА: {send_error}"
|
||||
)
|
||||
_logger.exception(
|
||||
'tier_communications: ошибка отправки matrix, msg_id=%s', message.id
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[tier_communications] ! ОТПРАВКА СООБЩЕНИЯ НА MATRIX: "
|
||||
f"msg_id={message.id} — модуль tier_communications_matrix не установлен"
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# ПРИЁМ ВХОДЯЩИХ СООБЩЕНИЙ (fetchmail / стандартный механизм Odoo)
|
||||
# =========================================================================
|
||||
|
||||
def message_process(self, model, message, custom_values=None, save_original=False,
|
||||
strip_attachments=False, thread_id=None):
|
||||
"""Override: после стандартной обработки входящего письма — помечаем тегом email."""
|
||||
result = super().message_process(
|
||||
model, message,
|
||||
custom_values=custom_values,
|
||||
save_original=save_original,
|
||||
strip_attachments=strip_attachments,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
if result:
|
||||
incoming_message = self.env['mail.message'].search(
|
||||
[('model', '=', model), ('res_id', '=', result)],
|
||||
order='id desc',
|
||||
limit=1,
|
||||
)
|
||||
if incoming_message:
|
||||
email_tag = self.env['tier.channel.tag'].search(
|
||||
[('code', '=', 'email')], limit=1
|
||||
)
|
||||
if email_tag and not incoming_message.channel_tag_ids:
|
||||
incoming_message.channel_tag_ids = [(4, email_tag.id)]
|
||||
print(
|
||||
f"[tier_communications] message_process: "
|
||||
f"входящее письмо msg_id={incoming_message.id} помечено тегом 'email'"
|
||||
)
|
||||
from .tier_router import TierRouter
|
||||
TierRouter(self.env).route_incoming(incoming_message)
|
||||
return result
|
||||
12
tier_communications/models/res_users.py
Normal file
12
tier_communications/models/res_users.py
Normal file
@ -0,0 +1,12 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
tier_preferred_channel_id = fields.Many2one(
|
||||
'tier.channel.tag',
|
||||
string='Предпочтительный канал связи',
|
||||
domain=[('active', '=', True)],
|
||||
help='Канал связи, который будет использоваться по умолчанию при отправке сообщений.',
|
||||
)
|
||||
51
tier_communications/models/tier_channel_email.py
Normal file
51
tier_communications/models/tier_channel_email.py
Normal file
@ -0,0 +1,51 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class TierChannelEmail(models.Model):
|
||||
"""Concrete Email channel implementation.
|
||||
|
||||
Thin wrapper around Odoo's standard mail.mail mechanism.
|
||||
Requirements: 3.1, 3.2
|
||||
"""
|
||||
_name = 'tier.channel.email'
|
||||
_inherit = ['tier.channel.mixin']
|
||||
_description = 'Email Channel'
|
||||
|
||||
def channel_authenticate(self):
|
||||
"""Email requires no external authentication — always returns True."""
|
||||
return True
|
||||
|
||||
def channel_send_message(self, message):
|
||||
"""Send a mail.message via Odoo's standard mail.mail mechanism."""
|
||||
email_to = message.email_from
|
||||
if not email_to and message.author_id:
|
||||
email_to = message.author_id.email
|
||||
|
||||
mail_mail = self.env['mail.mail'].create({
|
||||
'subject': message.subject or '(no subject)',
|
||||
'body_html': message.body,
|
||||
'email_to': email_to,
|
||||
'email_from': self.env.company.email,
|
||||
})
|
||||
mail_mail.send()
|
||||
|
||||
def channel_receive_message(self, payload):
|
||||
"""Create a mail.message from an incoming payload dict.
|
||||
|
||||
Expected payload keys:
|
||||
body (str): message body HTML
|
||||
subject (str, optional): message subject
|
||||
email_from (str, optional): sender email address
|
||||
author_id (int, optional): res.partner id of the author
|
||||
"""
|
||||
vals = {
|
||||
'body': payload.get('body', ''),
|
||||
'subject': payload.get('subject', False),
|
||||
'email_from': payload.get('email_from', False),
|
||||
'message_type': 'email',
|
||||
}
|
||||
author_id = payload.get('author_id')
|
||||
if author_id:
|
||||
vals['author_id'] = author_id
|
||||
|
||||
return self.env['mail.message'].create(vals)
|
||||
44
tier_communications/models/tier_channel_mixin.py
Normal file
44
tier_communications/models/tier_channel_mixin.py
Normal file
@ -0,0 +1,44 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class TierChannelMixin(models.AbstractModel):
|
||||
_name = 'tier.channel.mixin'
|
||||
_description = 'Tier Channel Mixin'
|
||||
_abstract = True
|
||||
|
||||
credential_scope = fields.Selection(
|
||||
selection=[('company', 'Company'), ('user', 'User')],
|
||||
string='Credential Scope',
|
||||
default='company',
|
||||
required=True,
|
||||
)
|
||||
|
||||
def channel_authenticate(self):
|
||||
"""Authenticate with the channel. Returns True by default (e.g. Email needs no auth)."""
|
||||
return True
|
||||
|
||||
def channel_send_message(self, message):
|
||||
"""Send a message through this channel. Must be overridden by subclasses."""
|
||||
raise NotImplementedError(
|
||||
f"channel_send_message() is not implemented for {self._name}"
|
||||
)
|
||||
|
||||
def channel_receive_message(self, payload):
|
||||
"""Receive a message from this channel. Must be overridden by subclasses."""
|
||||
raise NotImplementedError(
|
||||
f"channel_receive_message() is not implemented for {self._name}"
|
||||
)
|
||||
|
||||
def _get_credentials(self):
|
||||
"""Return credentials based on credential_scope."""
|
||||
if self.credential_scope == 'user':
|
||||
return self._get_user_credentials(self.env.user)
|
||||
return self._get_company_credentials(self.env.company)
|
||||
|
||||
def _get_user_credentials(self, user):
|
||||
"""Return user-level credentials. Override in subclasses."""
|
||||
return {}
|
||||
|
||||
def _get_company_credentials(self, company):
|
||||
"""Return company-level credentials. Override in subclasses."""
|
||||
return {}
|
||||
27
tier_communications/models/tier_channel_tag.py
Normal file
27
tier_communications/models/tier_channel_tag.py
Normal file
@ -0,0 +1,27 @@
|
||||
from odoo import fields, models
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
|
||||
class TierChannelTag(models.Model):
|
||||
_name = 'tier.channel.tag'
|
||||
_description = 'Tier Channel Tag'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(string='Code', required=True)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
description = fields.Text(string='Description')
|
||||
is_company_reply = fields.Boolean(
|
||||
string='Company Reply',
|
||||
default=False,
|
||||
help='If enabled, this channel is only available to users in the '
|
||||
'"Отправитель от имени компании" group.',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('code_unique', 'UNIQUE(code)', 'The channel tag code must be unique.'),
|
||||
]
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
"""Expose id and name to the JS store."""
|
||||
return ['id', 'name', 'code', 'is_company_reply']
|
||||
98
tier_communications/models/tier_mailbox.py
Normal file
98
tier_communications/models/tier_mailbox.py
Normal file
@ -0,0 +1,98 @@
|
||||
import re
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class TierMailbox(models.Model):
|
||||
_name = 'tier.mailbox'
|
||||
_description = 'Tier Mailbox'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(string='Code', required=True)
|
||||
description = fields.Text(string='Description')
|
||||
user_ids = fields.Many2many(
|
||||
'res.users',
|
||||
string='Allowed Users',
|
||||
help='Users who have access to this mailbox. If empty, all users can access it.',
|
||||
)
|
||||
is_lead_inbox = fields.Boolean(string='Lead Inbox', default=False)
|
||||
is_ticket_inbox = fields.Boolean(string='Ticket Inbox', default=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('code_unique', 'UNIQUE(code)', 'The mailbox code must be unique.'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_accessible_mailboxes(self):
|
||||
"""Return mailboxes accessible to the current user.
|
||||
|
||||
A mailbox is accessible if the current user is in user_ids,
|
||||
or if user_ids is empty (open to all).
|
||||
"""
|
||||
uid = self.env.uid
|
||||
domain = [
|
||||
'|',
|
||||
('user_ids', '=', False),
|
||||
('user_ids', 'in', [uid]),
|
||||
]
|
||||
mailboxes = self.search(domain)
|
||||
return mailboxes.read(['id', 'name', 'code', 'is_lead_inbox', 'is_ticket_inbox'])
|
||||
|
||||
@api.model
|
||||
def get_mailbox_conversations(self, mailbox_id, limit=50):
|
||||
"""Return messages in a mailbox grouped by author (partner).
|
||||
|
||||
Returns a list of conversations:
|
||||
[{
|
||||
'partner_id': int,
|
||||
'partner_name': str,
|
||||
'partner_avatar_url': str,
|
||||
'last_message_preview': str, # first 80 chars of body text
|
||||
'last_message_date': str,
|
||||
'channel_code': str|None, # email, matrix, or None
|
||||
'message_count': int,
|
||||
}]
|
||||
"""
|
||||
mailbox_tags = self.env['tier.mailbox.tag'].search([
|
||||
('mailbox_id', '=', mailbox_id),
|
||||
], order='message_id desc', limit=500)
|
||||
|
||||
if not mailbox_tags:
|
||||
return []
|
||||
|
||||
message_ids = mailbox_tags.mapped('message_id')
|
||||
|
||||
# Group by author — show last message in conversation (ours or theirs)
|
||||
conversations_by_partner = {}
|
||||
for message in message_ids.sorted(key=lambda m: m.id, reverse=True):
|
||||
author = message.author_id
|
||||
partner_id = author.id if author else 0
|
||||
|
||||
if partner_id not in conversations_by_partner:
|
||||
# Strip HTML for preview
|
||||
body_text = re.sub(r'<[^>]+>', '', message.body or '')
|
||||
preview = body_text[:80].strip() if body_text else (message.subject or '')
|
||||
|
||||
# If preview is still empty, use subject
|
||||
if not preview and message.subject:
|
||||
preview = message.subject[:80]
|
||||
|
||||
# Get channel code
|
||||
channel_code = None
|
||||
if message.channel_tag_ids:
|
||||
channel_code = message.channel_tag_ids[0].code
|
||||
|
||||
conversations_by_partner[partner_id] = {
|
||||
'partner_id': partner_id,
|
||||
'partner_name': author.name if author else (message.email_from or 'Неизвестный'),
|
||||
'last_message_preview': preview or 'Нет текста',
|
||||
'last_message_date': str(message.date or message.create_date or ''),
|
||||
'channel_code': channel_code,
|
||||
'message_count': 0,
|
||||
}
|
||||
|
||||
conversations_by_partner[partner_id]['message_count'] += 1
|
||||
|
||||
result = list(conversations_by_partner.values())[:limit]
|
||||
return result
|
||||
20
tier_communications/models/tier_mailbox_tag.py
Normal file
20
tier_communications/models/tier_mailbox_tag.py
Normal file
@ -0,0 +1,20 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class TierMailboxTag(models.Model):
|
||||
_name = 'tier.mailbox.tag'
|
||||
_description = 'Mailbox Tag (Message-Mailbox link)'
|
||||
_rec_name = 'mailbox_id'
|
||||
|
||||
mailbox_id = fields.Many2one(
|
||||
'tier.mailbox',
|
||||
string='Mailbox',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
message_id = fields.Many2one(
|
||||
'mail.message',
|
||||
string='Message',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
73
tier_communications/models/tier_router.py
Normal file
73
tier_communications/models/tier_router.py
Normal file
@ -0,0 +1,73 @@
|
||||
class TierRouter:
|
||||
"""Plain Python router — not an Odoo model.
|
||||
|
||||
Handles channel routing for outgoing messages and inbox fallback for incoming.
|
||||
Mailbox assignment is now manual-only (via wizard).
|
||||
"""
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def route_outgoing(self, message, partner, source_model=None):
|
||||
"""Assign channel tag to an outgoing message based on routing rules.
|
||||
|
||||
Mailboxes are no longer auto-assigned — user does it manually.
|
||||
"""
|
||||
channel = self._resolve_channel(partner)
|
||||
if channel:
|
||||
message.channel_tag_ids = [(4, channel.id)]
|
||||
|
||||
def route_incoming(self, message):
|
||||
"""Route an incoming message.
|
||||
|
||||
If it's a reply — inherit mailboxes from parent.
|
||||
Otherwise — place in standard Odoo inbox.
|
||||
"""
|
||||
parent = message.parent_id
|
||||
if parent and parent.mailbox_tag_ids:
|
||||
for tag in parent.mailbox_tag_ids:
|
||||
self.env['tier.mailbox.tag'].create({
|
||||
'mailbox_id': tag.mailbox_id.id,
|
||||
'message_id': message.id,
|
||||
})
|
||||
else:
|
||||
self._fallback_inbox(message)
|
||||
|
||||
def _fallback_inbox(self, message):
|
||||
"""Place message in the standard Odoo inbox via mail.notification."""
|
||||
partner = self.env.user.partner_id
|
||||
if not partner:
|
||||
return
|
||||
existing = self.env['mail.notification'].sudo().search([
|
||||
('mail_message_id', '=', message.id),
|
||||
('res_partner_id', '=', partner.id),
|
||||
], limit=1)
|
||||
if not existing:
|
||||
self.env['mail.notification'].sudo().create({
|
||||
'mail_message_id': message.id,
|
||||
'res_partner_id': partner.id,
|
||||
'notification_type': 'inbox',
|
||||
'is_read': False,
|
||||
})
|
||||
|
||||
def _resolve_channel(self, partner):
|
||||
"""Return the best channel tag for partner from routing rules."""
|
||||
if not partner:
|
||||
return None
|
||||
|
||||
rule = self.env['tier.routing.channel'].search(
|
||||
[('partner_id', '=', partner.id)],
|
||||
order='priority desc',
|
||||
limit=1,
|
||||
)
|
||||
if not rule:
|
||||
return None
|
||||
|
||||
preferred = rule.preferred_channel_tag_id
|
||||
if preferred and preferred.active:
|
||||
return preferred
|
||||
|
||||
for tag in rule.channel_tag_ids.filtered('active'):
|
||||
return tag
|
||||
|
||||
return None
|
||||
24
tier_communications/models/tier_routing_channel.py
Normal file
24
tier_communications/models/tier_routing_channel.py
Normal file
@ -0,0 +1,24 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class TierRoutingChannel(models.Model):
|
||||
_name = 'tier.routing.channel'
|
||||
_description = 'Channel Routing Rule'
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Partner',
|
||||
required=True,
|
||||
)
|
||||
channel_tag_ids = fields.Many2many(
|
||||
'tier.channel.tag',
|
||||
string='Allowed Channels',
|
||||
)
|
||||
preferred_channel_tag_id = fields.Many2one(
|
||||
'tier.channel.tag',
|
||||
string='Preferred Channel',
|
||||
)
|
||||
priority = fields.Integer(
|
||||
string='Priority',
|
||||
default=10,
|
||||
)
|
||||
21
tier_communications/models/tier_routing_mailbox.py
Normal file
21
tier_communications/models/tier_routing_mailbox.py
Normal file
@ -0,0 +1,21 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class TierRoutingMailbox(models.Model):
|
||||
_name = 'tier.routing.mailbox'
|
||||
_description = 'Mailbox Routing Rule'
|
||||
|
||||
model_id = fields.Many2one(
|
||||
'ir.model',
|
||||
string='Model',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
mailbox_ids = fields.Many2many(
|
||||
'tier.mailbox',
|
||||
string='Target Mailboxes',
|
||||
)
|
||||
priority = fields.Integer(
|
||||
string='Priority',
|
||||
default=10,
|
||||
)
|
||||
10
tier_communications/security/ir.model.access.csv
Normal file
10
tier_communications/security/ir.model.access.csv
Normal file
@ -0,0 +1,10 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_tier_channel_tag_user,tier.channel.tag user,model_tier_channel_tag,base.group_user,1,0,0,0
|
||||
access_tier_channel_tag_admin,tier.channel.tag admin,model_tier_channel_tag,base.group_system,1,1,1,1
|
||||
access_tier_channel_email_admin,tier.channel.email admin,model_tier_channel_email,base.group_system,1,1,1,1
|
||||
access_tier_mailbox_user,tier.mailbox user,model_tier_mailbox,base.group_user,1,0,0,0
|
||||
access_tier_mailbox_admin,tier.mailbox admin,model_tier_mailbox,base.group_system,1,1,1,1
|
||||
access_tier_mailbox_tag_user,tier.mailbox.tag user,model_tier_mailbox_tag,base.group_user,1,1,1,1
|
||||
access_tier_routing_channel_admin,tier.routing.channel admin,model_tier_routing_channel,base.group_system,1,1,1,1
|
||||
access_tier_routing_mailbox_admin,tier.routing.mailbox admin,model_tier_routing_mailbox,base.group_system,1,1,1,1
|
||||
access_tier_move_to_mailbox_wizard_user,tier.move.to.mailbox.wizard user,model_tier_move_to_mailbox_wizard,base.group_user,1,1,1,1
|
||||
|
14
tier_communications/security/tier_communications_groups.xml
Normal file
14
tier_communications/security/tier_communications_groups.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="res_groups_privilege_tier_communications" model="res.groups.privilege">
|
||||
<field name="name">Tier Communications</field>
|
||||
<field name="sequence">200</field>
|
||||
</record>
|
||||
|
||||
<record id="group_company_sender" model="res.groups">
|
||||
<field name="name">Отправитель от имени компании</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_tier_communications"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@ -0,0 +1,82 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { discussSidebarItemsRegistry } from "@mail/core/public_web/discuss_sidebar";
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { markEventHandled } from "@web/core/utils/misc";
|
||||
|
||||
/**
|
||||
* Renders each tier.mailbox as a collapsible section with conversation list.
|
||||
*
|
||||
* Under each mailbox header — list of authors (grouped conversations).
|
||||
* Clicking an author opens a DM (discuss.channel chat) with that partner.
|
||||
*/
|
||||
export class TierDiscussSidebarMailboxes extends Component {
|
||||
static template = "tier_communications.DiscussSidebarMailboxes";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.store = useService("mail.store");
|
||||
this.orm = useService("orm");
|
||||
this.state = useState({
|
||||
openIds: {},
|
||||
conversationsByMailbox: {}, // { mailbox_id: [{partner_id, partner_name, ...}] }
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this._loadConversations();
|
||||
});
|
||||
}
|
||||
|
||||
get mailboxThreads() {
|
||||
return this.store.tierMailboxThreads || [];
|
||||
}
|
||||
|
||||
isOpen(threadId) {
|
||||
return this.state.openIds[threadId] !== false;
|
||||
}
|
||||
|
||||
toggle(threadId) {
|
||||
this.state.openIds[threadId] = !this.isOpen(threadId);
|
||||
}
|
||||
|
||||
getConversations(mailboxId) {
|
||||
return this.state.conversationsByMailbox[mailboxId] || [];
|
||||
}
|
||||
|
||||
async _loadConversations() {
|
||||
try {
|
||||
const mailboxes = await this.orm.call(
|
||||
"tier.mailbox", "get_accessible_mailboxes", [], {}
|
||||
);
|
||||
for (const mailbox of (mailboxes || [])) {
|
||||
const conversations = await this.orm.call(
|
||||
"tier.mailbox", "get_mailbox_conversations", [mailbox.id], {}
|
||||
);
|
||||
this.state.conversationsByMailbox[mailbox.id] = conversations || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[tier_communications] _loadConversations error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a DM (personal chat) with the given partner.
|
||||
* Uses store.getChat() which finds or creates the DM channel.
|
||||
*/
|
||||
async openConversation(ev, partnerId) {
|
||||
markEventHandled(ev, "sidebar.openThread");
|
||||
if (!partnerId) {
|
||||
return;
|
||||
}
|
||||
const chat = await this.store.getChat({ partnerId });
|
||||
if (chat) {
|
||||
chat.setAsDiscussThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
discussSidebarItemsRegistry.add(
|
||||
"tier_mailboxes",
|
||||
TierDiscussSidebarMailboxes,
|
||||
{ sequence: 40 }
|
||||
);
|
||||
@ -0,0 +1,40 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Message } from "@mail/core/common/message_model";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/**
|
||||
* Patch Message to display channel tags (Email, Matrix, etc.) next to the message.
|
||||
*
|
||||
* channel_tag_ids arrives from the backend as a plain JSON array:
|
||||
* [{id: 1, name: "Email", code: "email"}, ...]
|
||||
* via Store.Attr in _to_store_defaults.
|
||||
*
|
||||
* We intercept it in update() and store it as _tierChannelTags.
|
||||
*/
|
||||
patch(Message.prototype, {
|
||||
/** @type {Array<{id: number, name: string, code: string}>|undefined} */
|
||||
_tierChannelTags: undefined,
|
||||
|
||||
update(data) {
|
||||
super.update(data);
|
||||
if (data && Array.isArray(data.channel_tag_ids)) {
|
||||
this._tierChannelTags = data.channel_tag_ids.filter(
|
||||
(tag) => tag && typeof tag === 'object' && tag.name
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns display names of all channel tags on this message.
|
||||
* Used by message_channel_tag.xml to render badges.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
get tierChannelTagNames() {
|
||||
const tags = this._tierChannelTags;
|
||||
if (!tags || tags.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return tags.map((tag) => tag.name).filter(Boolean);
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,235 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Composer } from "@mail/core/common/composer";
|
||||
import { Store } from "@mail/core/common/store_service";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
import { useState, onWillStart } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { user } from "@web/core/user";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
/**
|
||||
* Patch Store.getMessagePostParams to inject channel_tag_ids into post_data.
|
||||
* This is the correct way to add custom fields — they end up in post_data
|
||||
* which is filtered by _get_allowed_message_params on the Python side.
|
||||
*/
|
||||
patch(Store.prototype, {
|
||||
async getMessagePostParams({ body, postData, thread }) {
|
||||
const params = await super.getMessagePostParams({ body, postData, thread });
|
||||
if (postData.tierChannelTagIds !== undefined) {
|
||||
if (postData.tierChannelTagIds) {
|
||||
params.post_data.channel_tag_ids = postData.tierChannelTagIds;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
},
|
||||
|
||||
async doMessagePost(params, tmpMessage) {
|
||||
const result = await super.doMessagePost(params, tmpMessage);
|
||||
// Store the last posted message_id so Composer can use it for channel notification
|
||||
if (result?.message_id) {
|
||||
this._lastPostedMessageId = result.message_id;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Patch Composer to add channel/mailbox selector UI.
|
||||
*
|
||||
* Логика выбора канала:
|
||||
* - По умолчанию канал не выбран (только Odoo)
|
||||
* - Если последнее сообщение в треде пришло через email → автовыбор email
|
||||
* - Если через matrix → автовыбор matrix
|
||||
* - Если без канала → ничего не выбираем
|
||||
* - Пользователь может вручную переключить или снять выбор
|
||||
*/
|
||||
patch(Composer.prototype, {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
this.store = useService("mail.store");
|
||||
this.tierState = useState({
|
||||
selectedChannelId: null,
|
||||
selectedMailboxIds: [],
|
||||
availableChannels: [],
|
||||
availableMailboxes: [],
|
||||
});
|
||||
onWillStart(async () => {
|
||||
await this._loadTierData();
|
||||
});
|
||||
},
|
||||
|
||||
async _loadTierData() {
|
||||
try {
|
||||
const [isCompanySender, channelTags, mailboxes] = await Promise.all([
|
||||
user.hasGroup("tier_communications.group_company_sender"),
|
||||
this.orm.searchRead(
|
||||
"tier.channel.tag",
|
||||
[["active", "=", true]],
|
||||
["id", "name", "code", "is_company_reply"]
|
||||
),
|
||||
this.orm.call("tier.mailbox", "get_accessible_mailboxes", [], {}),
|
||||
]);
|
||||
|
||||
this.tierState.availableChannels = (channelTags || []).filter(
|
||||
(channelTag) => !channelTag.is_company_reply || isCompanySender
|
||||
);
|
||||
this.tierState.availableMailboxes = mailboxes || [];
|
||||
|
||||
await this._autoSelectChannel();
|
||||
} catch (error) {
|
||||
console.warn("[tier_communications] _loadTierData error:", error);
|
||||
this.tierState.availableChannels = [];
|
||||
this.tierState.availableMailboxes = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-select channel based on the last message in the thread.
|
||||
* email → select email, matrix → select matrix, none → no selection.
|
||||
*/
|
||||
async _autoSelectChannel() {
|
||||
const composerThread = this.props?.composer?.thread;
|
||||
if (!composerThread || composerThread.model === "mail.box" || !composerThread.id) {
|
||||
this.tierState.selectedChannelId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastChannelCode = await this.orm.call(
|
||||
"mail.message",
|
||||
"get_last_channel_code",
|
||||
[composerThread.model, composerThread.id],
|
||||
{}
|
||||
);
|
||||
|
||||
if (!lastChannelCode) {
|
||||
this.tierState.selectedChannelId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedChannel = this.tierState.availableChannels.find(
|
||||
(channelTag) => channelTag.code === lastChannelCode
|
||||
);
|
||||
this.tierState.selectedChannelId = matchedChannel ? matchedChannel.id : null;
|
||||
} catch {
|
||||
this.tierState.selectedChannelId = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Toggle channel: click selected → deselect, click other → select */
|
||||
selectChannel(channelId) {
|
||||
this.tierState.selectedChannelId =
|
||||
this.tierState.selectedChannelId === channelId ? null : channelId;
|
||||
},
|
||||
|
||||
toggleMailbox(mailboxId) {
|
||||
const existingIndex = this.tierState.selectedMailboxIds.indexOf(mailboxId);
|
||||
if (existingIndex >= 0) {
|
||||
this.tierState.selectedMailboxIds.splice(existingIndex, 1);
|
||||
} else {
|
||||
this.tierState.selectedMailboxIds.push(mailboxId);
|
||||
}
|
||||
},
|
||||
|
||||
get postData() {
|
||||
const basePostData = super.postData;
|
||||
// Inject tierChannelTagIds into postData so Store.getMessagePostParams can pick it up
|
||||
if (this.tierState.selectedChannelId) {
|
||||
basePostData.tierChannelTagIds = [[6, 0, [this.tierState.selectedChannelId]]];
|
||||
} else {
|
||||
basePostData.tierChannelTagIds = null;
|
||||
}
|
||||
return basePostData;
|
||||
},
|
||||
|
||||
async _sendMessage(value, postData, extraData) {
|
||||
const sentMessage = await super._sendMessage(value, postData, extraData);
|
||||
|
||||
if (!this.tierState.selectedChannelId) {
|
||||
return sentMessage;
|
||||
}
|
||||
|
||||
const selectedChannel = this.tierState.availableChannels.find(
|
||||
(channelTag) => channelTag.id === this.tierState.selectedChannelId
|
||||
);
|
||||
if (!selectedChannel) {
|
||||
return sentMessage;
|
||||
}
|
||||
|
||||
// For discuss.channel, sentMessage may be undefined (optimistic send).
|
||||
// Fall back to the message_id captured by doMessagePost patch.
|
||||
const messageId = sentMessage?.id ?? this.store._lastPostedMessageId;
|
||||
if (!messageId) {
|
||||
return sentMessage;
|
||||
}
|
||||
|
||||
const composerThread = this.props?.composer?.thread;
|
||||
// Fire-and-forget: show toast after send
|
||||
this._tierNotifyChannelSend(
|
||||
messageId,
|
||||
selectedChannel.code,
|
||||
composerThread?.model,
|
||||
composerThread?.id
|
||||
);
|
||||
|
||||
return sentMessage;
|
||||
},
|
||||
|
||||
/**
|
||||
* Call the backend to send via external channel and show a toast notification.
|
||||
* Runs async — does not block message posting.
|
||||
*/
|
||||
async _tierNotifyChannelSend(messageId, channelCode, threadModel, threadId) {
|
||||
try {
|
||||
const result = await rpc('/tier_communications/send_via_channel', {
|
||||
message_id: messageId,
|
||||
channel_code: channelCode,
|
||||
thread_model: threadModel || null,
|
||||
thread_id: threadId || null,
|
||||
});
|
||||
|
||||
const notificationType = result.status === 'success' ? 'success'
|
||||
: result.status === 'no_data' ? 'warning'
|
||||
: 'danger';
|
||||
|
||||
this.notification.add(result.message, { type: notificationType });
|
||||
} catch (error) {
|
||||
this.notification.add(
|
||||
`Ошибка при отправке через ${channelCode}: ${error.message || error}`,
|
||||
{ type: 'danger' }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Assign the current thread's conversation to a mailbox.
|
||||
* Called from the chatter toolbar buttons "Входящие лиды" / "Входящие тикеты".
|
||||
*/
|
||||
async assignToMailbox(mailboxId, mailboxName) {
|
||||
const composerThread = this.props?.composer?.thread;
|
||||
if (!composerThread || composerThread.model === "mail.box" || !composerThread.id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.orm.call(
|
||||
composerThread.model,
|
||||
"assign_mailbox",
|
||||
[[composerThread.id], mailboxId],
|
||||
{}
|
||||
);
|
||||
this.notification.add(
|
||||
`Переписка перенесена в "${mailboxName}"`,
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[tier_communications] assignToMailbox error:", error);
|
||||
this.notification.add(
|
||||
`Ошибка при переносе в "${mailboxName}"`,
|
||||
{ type: "danger" }
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,32 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registerMessageAction } from "@mail/core/common/message_actions";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
/**
|
||||
* Register "Move to Mailbox" action in the message action menu.
|
||||
* Pункт 2: пользователь может вручную перенести сообщение в ящик.
|
||||
*/
|
||||
registerMessageAction("tier-move-to-mailbox", {
|
||||
condition: ({ message }) => Boolean(message?.id && Number.isInteger(message.id)),
|
||||
icon: "fa fa-inbox",
|
||||
name: _t("Переместить в ящик"),
|
||||
setup: ({ owner }) => {
|
||||
owner.tierActionService = useService("action");
|
||||
},
|
||||
onSelected: async ({ message, owner }) => {
|
||||
await owner.tierActionService.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
name: _t("Переместить в ящик"),
|
||||
res_model: "tier.move.to.mailbox.wizard",
|
||||
view_mode: "form",
|
||||
views: [[false, "form"]],
|
||||
target: "new",
|
||||
context: {
|
||||
default_message_id: message.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
sequence: 90,
|
||||
});
|
||||
@ -0,0 +1,77 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Store } from "@mail/core/common/store_service";
|
||||
import { Thread } from "@mail/core/common/thread_model";
|
||||
import { patch } from "@web/core/utils/patch";
|
||||
|
||||
/**
|
||||
* Patch Thread to support tier mailbox fetch routes.
|
||||
* Must be patched before Store so Thread.insert works correctly.
|
||||
*/
|
||||
patch(Thread.prototype, {
|
||||
/** @type {number|undefined} */
|
||||
tier_mailbox_id: undefined,
|
||||
|
||||
getFetchRoute() {
|
||||
if (
|
||||
this.model === "mail.box" &&
|
||||
typeof this.id === "string" &&
|
||||
this.id.startsWith("tier_mailbox_") &&
|
||||
this.tier_mailbox_id
|
||||
) {
|
||||
return `/mail/tier_mailbox/${this.tier_mailbox_id}/messages`;
|
||||
}
|
||||
return super.getFetchRoute(...arguments);
|
||||
},
|
||||
|
||||
getFetchParams() {
|
||||
if (
|
||||
this.model === "mail.box" &&
|
||||
typeof this.id === "string" &&
|
||||
this.id.startsWith("tier_mailbox_")
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return super.getFetchParams(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Patch Store to load tier mailboxes as Thread objects on startup.
|
||||
* Follows the same pattern as inbox/starred/history in store_service_patch.js.
|
||||
*/
|
||||
patch(Store.prototype, {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.tierMailboxThreads = [];
|
||||
},
|
||||
|
||||
onStarted() {
|
||||
super.onStarted(...arguments);
|
||||
// Load tier mailboxes asynchronously after store is ready
|
||||
this._loadTierMailboxes().catch((error) => {
|
||||
console.warn("[tier_communications] Failed to load tier mailboxes:", error);
|
||||
});
|
||||
},
|
||||
|
||||
async _loadTierMailboxes() {
|
||||
const mailboxes = await this.env.services.orm.call(
|
||||
"tier.mailbox",
|
||||
"get_accessible_mailboxes",
|
||||
[],
|
||||
{}
|
||||
);
|
||||
|
||||
this.tierMailboxThreads = (mailboxes || []).map((mailboxData) => {
|
||||
const threadRecord = this.Thread.insert({
|
||||
id: `tier_mailbox_${mailboxData.id}`,
|
||||
model: "mail.box",
|
||||
display_name: mailboxData.name,
|
||||
counter: 0,
|
||||
});
|
||||
// Store the real DB id for the fetch route
|
||||
threadRecord.tier_mailbox_id = mailboxData.id;
|
||||
return threadRecord;
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="tier_communications.DiscussSidebarMailboxes">
|
||||
<t t-foreach="mailboxThreads" t-as="mailboxThread" t-key="mailboxThread.id">
|
||||
|
||||
<!-- Section header with chevron -->
|
||||
<div class="o-mail-DiscussSidebarCategory position-relative d-flex align-items-center rounded opacity-trigger-hover mt-1 ms-4 me-2 pe-1">
|
||||
<button
|
||||
class="o-mail-DiscussSidebarCategory-toggler btn btn-link text-reset flex-grow-1 d-flex p-0 text-start align-items-baseline o-gap-0_5"
|
||||
t-on-click="() => this.toggle(mailboxThread.id)"
|
||||
>
|
||||
<i class="o-mail-DiscussSidebarCategory-icon o-xsmaller fa-fw position-absolute ms-n3 align-self-center"
|
||||
t-att-class="isOpen(mailboxThread.id) ? 'oi oi-chevron-down' : 'oi oi-chevron-right'"/>
|
||||
<span class="o-mail-DiscussSidebarCategory-title text-break smaller"
|
||||
t-att-class="{ 'fw-bold': mailboxThread.counter > 0 }"
|
||||
t-esc="mailboxThread.display_name"/>
|
||||
</button>
|
||||
<t t-if="mailboxThread.counter > 0">
|
||||
<span class="o-mail-DiscussSidebar-badge shadow-sm badge rounded-pill o-discuss-badge fw-bold me-1"
|
||||
t-esc="mailboxThread.counter"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Conversation list (grouped by author) when expanded -->
|
||||
<t t-if="isOpen(mailboxThread.id)">
|
||||
<t t-set="conversations" t-value="getConversations(mailboxThread.tier_mailbox_id)"/>
|
||||
<t t-if="conversations.length > 0">
|
||||
<t t-foreach="conversations" t-as="conversation" t-key="conversation.partner_id">
|
||||
<div class="o-mail-DiscussSidebarChannel-container d-flex flex-column mx-2 bg-inherit">
|
||||
<button
|
||||
class="o-mail-DiscussSidebarChannel btn d-flex align-items-center px-0 mx-2 border-0 rounded fw-normal text-reset"
|
||||
style="line-height:1.5; padding:2px 0;"
|
||||
t-on-click="(ev) => this.openConversation(ev, conversation.partner_id)"
|
||||
>
|
||||
<div class="d-flex flex-column flex-grow-1 overflow-hidden ms-1">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="text-truncate smaller fw-bold" style="max-width:120px;"
|
||||
t-esc="conversation.partner_name"/>
|
||||
<t t-if="conversation.channel_code">
|
||||
<span class="badge text-bg-secondary fw-normal"
|
||||
style="font-size:0.6rem; padding:1px 4px;"
|
||||
t-esc="conversation.channel_code"/>
|
||||
</t>
|
||||
<t t-if="conversation.message_count > 1">
|
||||
<span class="badge rounded-pill text-bg-primary"
|
||||
style="font-size:0.6rem; padding:1px 4px;"
|
||||
t-esc="conversation.message_count"/>
|
||||
</t>
|
||||
</div>
|
||||
<span class="text-truncate text-muted"
|
||||
style="font-size:0.7rem; max-width:180px;"
|
||||
t-esc="conversation.last_message_preview"/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="mx-4 my-1">
|
||||
<span class="text-muted" style="font-size:0.72rem;">Нет сообщений</span>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
19
tier_communications/static/src/xml/message_channel_tag.xml
Normal file
19
tier_communications/static/src/xml/message_channel_tag.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<!--
|
||||
Extends the mail.Message template to display channel tags
|
||||
in the message header next to the date.
|
||||
Requirements: 15.1, 15.2, 15.3, 15.4, 15.5
|
||||
-->
|
||||
<t t-name="mail.Message" t-inherit="mail.Message" t-inherit-mode="extension">
|
||||
<xpath expr="//div[@name='header']" position="inside">
|
||||
<t t-if="message.tierChannelTagNames and message.tierChannelTagNames.length > 0">
|
||||
<span class="o-tier-Message-channelTags ms-1 d-inline-flex gap-1 align-items-baseline">
|
||||
<t t-foreach="message.tierChannelTagNames" t-as="tagName" t-key="tagName_index">
|
||||
<span class="badge text-bg-secondary fw-normal small" t-esc="tagName"/>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="mail.Composer" t-inherit="mail.Composer" t-inherit-mode="extension">
|
||||
<xpath expr="//div[hasclass('o-mail-Composer-footer')]" position="before">
|
||||
<t t-if="tierState.availableChannels.length > 0 or tierState.availableMailboxes.length > 0">
|
||||
<div style="display:flex; flex-direction:column; gap:4px; padding:4px 12px 2px; grid-column:1/-1;">
|
||||
|
||||
<!-- Channel row: horizontal pill buttons (radio) -->
|
||||
<t t-if="tierState.availableChannels.length > 0">
|
||||
<div style="display:flex; flex-direction:row; flex-wrap:wrap; align-items:center; gap:6px;">
|
||||
<span style="font-size:0.78rem; color:#6c757d; white-space:nowrap; flex-shrink:0;">Канал:</span>
|
||||
<t t-foreach="tierState.availableChannels" t-as="channel" t-key="channel.id">
|
||||
<button
|
||||
t-on-click="() => this.selectChannel(channel.id)"
|
||||
t-att-style="tierState.selectedChannelId === channel.id
|
||||
? 'display:inline-block; font-size:0.78rem; line-height:1.4; padding:1px 10px; border-radius:20px; border:1px solid #0d6efd; background:#0d6efd; color:#fff; cursor:pointer; white-space:nowrap;'
|
||||
: 'display:inline-block; font-size:0.78rem; line-height:1.4; padding:1px 10px; border-radius:20px; border:1px solid #6c757d; background:transparent; color:#6c757d; cursor:pointer; white-space:nowrap;'"
|
||||
><t t-esc="channel.name"/></button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Assign conversation to mailbox: green arrow buttons -->
|
||||
<t t-if="tierState.availableMailboxes.length > 0">
|
||||
<div style="display:flex; flex-direction:row; flex-wrap:wrap; align-items:center; gap:6px;">
|
||||
<span style="font-size:0.78rem; color:#6c757d; white-space:nowrap; flex-shrink:0;">Перенести в:</span>
|
||||
<t t-foreach="tierState.availableMailboxes" t-as="mailbox" t-key="mailbox.id">
|
||||
<button
|
||||
t-on-click="() => this.assignToMailbox(mailbox.id, mailbox.name)"
|
||||
style="display:inline-flex; align-items:center; gap:4px; font-size:0.78rem; line-height:1.4; padding:1px 10px; border-radius:20px; border:1px solid #198754; background:transparent; color:#198754; cursor:pointer; white-space:nowrap;"
|
||||
>
|
||||
<i class="fa fa-arrow-right" style="font-size:0.7rem;"/>
|
||||
<t t-esc="mailbox.name"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
14
tier_communications/views/mail_message_views.xml
Normal file
14
tier_communications/views/mail_message_views.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!-- Server action: "Переместить в ящик" — appears in Action menu on mail.message list/form -->
|
||||
<record id="action_move_message_to_mailbox" model="ir.actions.server">
|
||||
<field name="name">Переместить в ящик</field>
|
||||
<field name="model_id" ref="mail.model_mail_message"/>
|
||||
<field name="binding_model_id" ref="mail.model_mail_message"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records[0].action_move_to_mailbox()</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
34
tier_communications/views/res_users_views.xml
Normal file
34
tier_communications/views/res_users_views.xml
Normal file
@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<!-- Preferences form (My Profile) -->
|
||||
<record id="view_users_form_tier_preferences" model="ir.ui.view">
|
||||
<field name="name">res.users.form.tier.preferences</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='other_preferences']" position="inside">
|
||||
<field name="tier_preferred_channel_id"
|
||||
string="Предпочтительный канал связи"
|
||||
options="{'no_create': True}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Full user form (Settings > Users) -->
|
||||
<record id="view_users_form_tier_full" model="ir.ui.view">
|
||||
<field name="name">res.users.form.tier.full</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='other_preferences']" position="inside">
|
||||
<field name="tier_preferred_channel_id"
|
||||
string="Предпочтительный канал связи"
|
||||
options="{'no_create': True}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
51
tier_communications/views/tier_channel_tag_views.xml
Normal file
51
tier_communications/views/tier_channel_tag_views.xml
Normal file
@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="tier_channel_tag_view_list" model="ir.ui.view">
|
||||
<field name="name">tier.channel.tag.list</field>
|
||||
<field name="model">tier.channel.tag</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Каналы связи">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="active"/>
|
||||
<field name="is_company_reply"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="tier_channel_tag_view_form" model="ir.ui.view">
|
||||
<field name="name">tier.channel.tag.form</field>
|
||||
<field name="model">tier.channel.tag</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Канал связи">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Email"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="active"/>
|
||||
<field name="is_company_reply"
|
||||
help="Если включено, канал доступен только пользователям группы 'Отправитель от имени компании'."/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_tier_channel_tag" model="ir.actions.act_window">
|
||||
<field name="name">Каналы связи</field>
|
||||
<field name="res_model">tier.channel.tag</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
26
tier_communications/views/tier_communications_menus.xml
Normal file
26
tier_communications/views/tier_communications_menus.xml
Normal file
@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<menuitem id="menu_tier_communications_config"
|
||||
name="Tier Communications"
|
||||
parent="mail.menu_configuration"
|
||||
sequence="50"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<menuitem id="menu_tier_channel_tag"
|
||||
name="Каналы"
|
||||
parent="menu_tier_communications_config"
|
||||
action="action_tier_channel_tag"
|
||||
sequence="10"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<menuitem id="menu_tier_mailbox"
|
||||
name="Ящики"
|
||||
parent="menu_tier_communications_config"
|
||||
action="action_tier_mailbox"
|
||||
sequence="20"
|
||||
groups="base.group_system"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
55
tier_communications/views/tier_mailbox_views.xml
Normal file
55
tier_communications/views/tier_mailbox_views.xml
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="tier_mailbox_view_list" model="ir.ui.view">
|
||||
<field name="name">tier.mailbox.list</field>
|
||||
<field name="model">tier.mailbox</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Ящики">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="is_lead_inbox"/>
|
||||
<field name="is_ticket_inbox"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="tier_mailbox_view_form" model="ir.ui.view">
|
||||
<field name="name">tier.mailbox.form</field>
|
||||
<field name="model">tier.mailbox</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ящик">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Входящие лиды"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="is_lead_inbox"/>
|
||||
<field name="is_ticket_inbox"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Пользователи">
|
||||
<field name="user_ids" widget="many2many_tags"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_tier_mailbox" model="ir.actions.act_window">
|
||||
<field name="name">Ящики</field>
|
||||
<field name="res_model">tier.mailbox</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="tier_move_to_mailbox_wizard_view_form" model="ir.ui.view">
|
||||
<field name="name">tier.move.to.mailbox.wizard.form</field>
|
||||
<field name="model">tier.move.to.mailbox.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Переместить в ящик">
|
||||
<group>
|
||||
<field name="mailbox_ids" widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
string="Выберите ящики"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_confirm" string="Переместить" type="object" class="btn-primary"/>
|
||||
<button string="Отмена" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
44
tier_communications/views/tier_routing_views.xml
Normal file
44
tier_communications/views/tier_routing_views.xml
Normal file
@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<!-- Channel routing rules: partner → preferred channel -->
|
||||
<record id="tier_routing_channel_view_list" model="ir.ui.view">
|
||||
<field name="name">tier.routing.channel.list</field>
|
||||
<field name="model">tier.routing.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Правила каналов">
|
||||
<field name="partner_id"/>
|
||||
<field name="preferred_channel_tag_id"/>
|
||||
<field name="priority"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="tier_routing_channel_view_form" model="ir.ui.view">
|
||||
<field name="name">tier.routing.channel.form</field>
|
||||
<field name="model">tier.routing.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Правило канала">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="preferred_channel_tag_id"/>
|
||||
<field name="priority"/>
|
||||
</group>
|
||||
<group string="Допустимые каналы">
|
||||
<field name="channel_tag_ids" widget="many2many_tags" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_tier_routing_channel" model="ir.actions.act_window">
|
||||
<field name="name">Правила каналов</field>
|
||||
<field name="res_model">tier.routing.channel</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
1
tier_communications/wizard/__init__.py
Normal file
1
tier_communications/wizard/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import tier_move_to_mailbox_wizard
|
||||
27
tier_communications/wizard/tier_move_to_mailbox_wizard.py
Normal file
27
tier_communications/wizard/tier_move_to_mailbox_wizard.py
Normal file
@ -0,0 +1,27 @@
|
||||
from odoo import _, fields, models
|
||||
|
||||
|
||||
class TierMoveToMailboxWizard(models.TransientModel):
|
||||
_name = 'tier.move.to.mailbox.wizard'
|
||||
_description = 'Переместить сообщение в ящик'
|
||||
|
||||
message_id = fields.Many2one('mail.message', string='Сообщение', required=True)
|
||||
mailbox_ids = fields.Many2many(
|
||||
'tier.mailbox',
|
||||
string='Ящики',
|
||||
help='Выберите ящики, в которые нужно поместить это сообщение.',
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
"""Apply mailbox assignment to the message."""
|
||||
self.ensure_one()
|
||||
message = self.message_id
|
||||
# Remove existing mailbox tags
|
||||
message.mailbox_tag_ids.unlink()
|
||||
# Create new ones
|
||||
for mailbox in self.mailbox_ids:
|
||||
self.env['tier.mailbox.tag'].create({
|
||||
'mailbox_id': mailbox.id,
|
||||
'message_id': message.id,
|
||||
})
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
Reference in New Issue
Block a user