lizerk cc24a073f4 refactor(documents): реструктуризация notifications в модульную архитектуру
Проблема:
- Монолитный notification_signals.py (~520 строк) смешивал разные concerns
- Сложная логика с множественными сигналами и обработчиками в одном файле
- Логирование и бизнес-логика переплетены с signal receivers

Решение:
Разделен на модульную структуру documents/notifications/:
- config.py - NotificationVerbs, MessageTemplates, ActionURLs, get_uploader_name(),
  get_bulk_threshold()
- handlers.py - notify_all_employees(), notify_specific_users(),
  notify_department_employees(), notify_all_acknowledged(), notify_document_comment(),
  notify_comment_reply()
- signals.py - Django signal receivers (@receiver) для Document, DocumentAcknowledgement,
  DocumentComment
- __init__.py - публичный API модуля

Изменения:
- Создан: documents/notifications/ модуль с 4 файлами
- Переименован: notification_signals.py → notification_signals.py.old
- Изменен: apps.py - обновлен импорт на documents.notifications

Логика уведомлений:
1. Новый документ → все сотрудники/отделы/конкретные получатели
2. Все ознакомились → уведомление загрузившему
3. Комментарий → автор документа
4. Ответ на комментарий → автор родительского комментария

Преимущества:
- ✅ Единообразие с requests_app, procurement, feed, communications
- ✅ Четкое разделение ответственности (SRP)
- ✅ Централизованное логирование с контекстом
- ✅ Поддержка bulk режима для массовых рассылок
- ✅ Легче тестировать компоненты
- ✅ Публичный API для переиспользования

Верификация:
- python manage.py check - успешно
2026-03-10 13:36:04 +03:00

405 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Бизнес-логика отправки уведомлений для модуля Documents.
Функции:
- notify_document_ready - уведомление о новом документе
- notify_all_employees - уведомление всем сотрудникам
- notify_specific_users - уведомление конкретным пользователям
- notify_department_employees - уведомление сотрудникам отделов
- notify_all_acknowledged - уведомление о завершении ознакомления
- notify_document_comment - уведомление о комментарии к документу
"""
import logging
from django.contrib.auth import get_user_model
from notifications.signals import notify
from .config import (
NotificationVerbs,
MessageTemplates,
ActionURLs,
get_bulk_threshold,
get_uploader_name,
)
logger = logging.getLogger(__name__)
Employee = get_user_model()
def notify_document_ready(document, recipient):
"""
Отправляет уведомление конкретному получателю о новом документе.
Args:
document: Объект Document
recipient: Объект Employee
"""
logger.info(
f"[handlers] create_document_ready_notification "
f"doc={document.id} recipient={recipient.id}"
)
uploader_name = get_uploader_name(document.uploaded_by)
notify.send(
sender=document.uploaded_by,
recipient=recipient,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': document.uploaded_by.id if document.uploaded_by else None,
'sent_to_all': document.sent_to_all,
},
)
def notify_all_employees(document):
"""
Отправляет уведомление о новом документе всем активным сотрудникам.
Args:
document: Объект Document
"""
logger.info(
f"[handlers] notify_all_employees doc={document.id}"
)
active_employees = Employee.objects.filter(is_active=True)
# Исключаем загрузившего документ
if document.uploaded_by:
active_employees = active_employees.exclude(id=document.uploaded_by.id)
count = active_employees.count()
logger.info(
f"[handlers] Creating {count} notifications "
f"(web immediately, email/telegram deferred)"
)
# Создаём уведомления
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
for employee in active_employees:
try:
notify.send(
sender=document.uploaded_by,
recipient=employee,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Exception as e:
logger.error(
f"[handlers] Error creating notification for user {employee.id}: {e}"
)
logger.info(
f"[handlers] Created {created_count}/{count} notifications "
f"for doc={document.id}. Web sent immediately, email/telegram pending."
)
# Запускаем асинхронную отправку email/telegram
try:
from django.core.management import call_command
import threading
def send_pending():
try:
call_command('send_pending_notifications', '--batch-size=100')
except Exception as e:
logger.error(f"[handlers] Error in background send: {e}")
# Запускаем в отдельном потоке
thread = threading.Thread(target=send_pending, daemon=True)
thread.start()
logger.info("[handlers] Started background email/telegram send")
except Exception as e:
logger.error(f"[handlers] Error starting background send: {e}")
def notify_specific_users(document, user_ids):
"""
Уведомляет конкретных получателей о документе.
Args:
document: Объект Document
user_ids: Набор ID пользователей
"""
recipient_count = len(user_ids or [])
logger.info(
f"[handlers] Processing {recipient_count} specific recipients"
)
# Определяем, это массовая рассылка или нет
is_bulk = recipient_count >= get_bulk_threshold()
if is_bulk:
logger.info(
f"[handlers] Bulk mode activated "
f"({recipient_count} >= {get_bulk_threshold()})"
)
# Уведомляем получателей
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
for user_id in user_ids:
try:
user = Employee.objects.get(id=user_id)
logger.debug(
f"[handlers] Creating notification for user={user_id}"
)
notify.send(
sender=document.uploaded_by,
recipient=user,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Employee.DoesNotExist:
logger.warning(f"[handlers] User {user_id} not found, skipping")
except Exception as e:
logger.error(f"[handlers] Error creating notification for user {user_id}: {e}")
logger.info(f"[handlers] Created {created_count} notifications")
def notify_department_employees(document, department_ids):
"""
Уведомляет всех сотрудников выбранных отделов о документе.
Args:
document: Объект Document
department_ids: Набор ID отделов
"""
logger.info(
f"[handlers] Processing {len(department_ids)} departments"
)
if not department_ids:
logger.warning("[handlers] No departments provided")
return
# Получаем всех активных сотрудников выбранных отделов
from employees.models import Department
all_employees = set()
for dept_id in department_ids:
try:
department = Department.objects.get(id=dept_id)
dept_employees = department.active_employees
logger.info(
f"[handlers] Department '{department.name}' "
f"has {len(dept_employees)} active employees: "
f"{[e.id for e in dept_employees]}"
)
all_employees.update(dept_employees)
logger.info(
f"[handlers] After update, all_employees has: "
f"{[e.id for e in all_employees]}"
)
except Department.DoesNotExist:
logger.warning(
f"[handlers] Department {dept_id} not found"
)
continue
# Исключаем загрузившего
if document.uploaded_by and document.uploaded_by in all_employees:
all_employees.remove(document.uploaded_by)
recipient_count = len(all_employees)
logger.info(
f"[handlers] Processing {recipient_count} employees "
f"from {len(department_ids)} departments"
)
# Определяем режим рассылки
is_bulk = recipient_count >= get_bulk_threshold()
if is_bulk:
logger.info(
f"[handlers] Bulk mode activated "
f"({recipient_count} >= {get_bulk_threshold()})"
)
# Создаём уведомления
created_count = 0
uploader_name = get_uploader_name(document.uploaded_by)
for employee in all_employees:
try:
notify.send(
sender=document.uploaded_by,
recipient=employee,
verb=NotificationVerbs.DOCUMENT_READY,
action_object=document,
description=MessageTemplates.document_ready(uploader_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.document_ready_title(),
'document_id': document.id,
'uploaded_by_id': (
document.uploaded_by.id if document.uploaded_by else None
),
'sent_to_all': document.sent_to_all,
},
)
created_count += 1
except Exception as e:
logger.error(f"[handlers] Error creating notification for employee {employee.id}: {e}")
logger.info(f"[handlers] Created {created_count} notifications")
def notify_all_acknowledged(document, total_recipients, acknowledged_count):
"""
Уведомляет загрузившего о том, что все ознакомились с документом.
Args:
document: Объект Document
total_recipients: Общее количество получателей
acknowledged_count: Количество ознакомившихся
"""
if not document.uploaded_by:
logger.info(
"[handlers] Document has no uploader, skipping acknowledgement notification"
)
return
logger.info(
f"[handlers] Notifying uploader about full acknowledgement "
f"({acknowledged_count}/{total_recipients})"
)
notify.send(
sender=None,
recipient=document.uploaded_by,
verb=NotificationVerbs.DOCUMENT_SIGNED_ALL,
action_object=document,
description=MessageTemplates.all_acknowledged(document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.all_acknowledged_title(),
'document_id': document.id,
'total_recipients': total_recipients,
'acknowledged_count': acknowledged_count,
}
)
def notify_document_comment(document, comment):
"""
Уведомляет автора документа о новом комментарии.
Args:
document: Объект Document
comment: Объект DocumentComment
"""
author = comment.author
if not document.uploaded_by:
logger.info(
"[handlers] Document has no author, skipping"
)
return
doc_author = document.uploaded_by
# Не уведомляем самого себя
if doc_author.id == author.id:
logger.info(
"[handlers] Skipping self-comment notification"
)
return
logger.info(
f"[handlers] Notifying document author={doc_author.pk} "
f"about new comment"
)
author_name = author.get_full_name() or author.username
notify.send(
sender=author,
recipient=doc_author,
verb=NotificationVerbs.DOCUMENT_COMMENT,
action_object=comment,
description=MessageTemplates.comment(author_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.comment_title(),
'document_id': document.id,
'comment_id': comment.id,
'author_id': author.id,
},
)
def notify_comment_reply(parent_comment, reply_comment):
"""
Уведомляет автора комментария об ответе на него.
Args:
parent_comment: Объект DocumentComment (родительский)
reply_comment: Объект DocumentComment (ответ)
"""
author = reply_comment.author
parent_author = parent_comment.author
document = reply_comment.document
# Не уведомляем самого себя
if parent_author.id == author.id:
logger.info(
"[handlers] Skipping self-reply notification"
)
return
logger.info(
f"[handlers] Notifying parent author={parent_author.pk} about reply"
)
author_name = author.get_full_name() or author.username
notify.send(
sender=author,
recipient=parent_author,
verb=NotificationVerbs.DOCUMENT_COMMENT_REPLY,
action_object=reply_comment,
description=MessageTemplates.comment_reply(author_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.comment_reply_title(),
'document_id': document.id,
'comment_id': reply_comment.id,
'parent_comment_id': parent_comment.id,
'author_id': author.id,
},
)