Public release from ruodoo-project: 19.0 - 2026-07-26 21:17:35 UTC

This commit is contained in:
CI Publish Bot
2026-07-26 21:17:45 +00:00
commit 4f7b594ec8
1335 changed files with 191620 additions and 0 deletions

View File

@ -0,0 +1,3 @@
from . import models
from . import wizard
from . import controllers

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

View File

@ -0,0 +1,2 @@
from . import tier_mailbox_controller
from . import send_notification

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

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

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

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

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

View 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

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

View 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

View 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='Канал связи, который будет использоваться по умолчанию при отправке сообщений.',
)

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

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

View 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']

View 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

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

View 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

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

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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_tier_channel_tag_user tier.channel.tag user model_tier_channel_tag base.group_user 1 0 0 0
3 access_tier_channel_tag_admin tier.channel.tag admin model_tier_channel_tag base.group_system 1 1 1 1
4 access_tier_channel_email_admin tier.channel.email admin model_tier_channel_email base.group_system 1 1 1 1
5 access_tier_mailbox_user tier.mailbox user model_tier_mailbox base.group_user 1 0 0 0
6 access_tier_mailbox_admin tier.mailbox admin model_tier_mailbox base.group_system 1 1 1 1
7 access_tier_mailbox_tag_user tier.mailbox.tag user model_tier_mailbox_tag base.group_user 1 1 1 1
8 access_tier_routing_channel_admin tier.routing.channel admin model_tier_routing_channel base.group_system 1 1 1 1
9 access_tier_routing_mailbox_admin tier.routing.mailbox admin model_tier_routing_mailbox base.group_system 1 1 1 1
10 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

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

View File

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

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

View File

@ -0,0 +1 @@
from . import tier_move_to_mailbox_wizard

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