refactor(calendar_app): реструктуризация notifications в модульную архитектуру

Проблема:
- Монолитный notification_signals.py (~220 строк) смешивал разные concerns
- Логика отслеживания изменений полей переплетена с signal receivers
- Вспомогательные функции без четкой структуры

Решение:
Разделен на модульную структуру calendar_app/notifications/:
- config.py - NotificationVerbs, MessageTemplates, ActionURLs, константы полей,
  вспомогательные функции (format_date, format_changes, FIELD_NAMES, IMPORTANT_FIELDS)
- handlers.py - get_event_recipients(), notify_event_created(), notify_event_changed(),
  notify_event_cancelled()
- signals.py - Django signal receivers (@receiver) для CalendarEvent
- __init__.py - публичный API модуля

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

Логика уведомлений:
1. Новое событие → получатели по типу (компания/отдел/личное)
2. Изменение события → участники события (если изменены важные поля)
3. Отмена события → все участники

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

Верификация:
- python manage.py check - успешно
This commit is contained in:
lizerk 2026-03-10 14:20:05 +03:00
parent d9a8e71e3a
commit fc9b9de50a
6 changed files with 413 additions and 1 deletions

View File

@ -17,7 +17,7 @@ class CalendarAppConfig(AppConfig):
Raises:
LookupError: Если приложение 'employees' или модель 'Employee' не найдены.
"""
import calendar_app.notification_signals # noqa: F401
import calendar_app.notifications # Модуль уведомлений (signals регистрируются автоматически)
from calendar_app import signals # noqa: F401 - старая система
import calendar_app.rules # django-rules: регистрация предикатов и правил доступа

View File

@ -0,0 +1,37 @@
"""
Модуль уведомлений для Calendar приложения.
Публичный API:
- NotificationVerbs - константы глаголов уведомлений
- MessageTemplates - шаблоны сообщений
- ActionURLs - генераторы URL-адресов
- get_event_recipients - получение списка получателей для события
Использование:
from calendar_app.notifications import get_event_recipients
# В коде:
recipients = get_event_recipients(event)
"""
from .config import (
NotificationVerbs,
MessageTemplates,
ActionURLs,
format_date,
format_changes,
)
from .handlers import get_event_recipients
# Импортируем handlers и signals для регистрации
from . import handlers # noqa: F401
from . import signals # noqa: F401
__all__ = [
'NotificationVerbs',
'MessageTemplates',
'ActionURLs',
'get_event_recipients',
'format_date',
'format_changes',
]

View File

@ -0,0 +1,126 @@
"""
Конфигурация уведомлений для модуля Calendar.
Содержит:
- NotificationVerbs - глаголы для уведомлений
- MessageTemplates - шаблоны сообщений
- ActionURLs - URL-адреса действий
- Вспомогательные функции
"""
class NotificationVerbs:
"""Глаголы (verbs) для уведомлений calendar."""
EVENT_CREATED = 'event_created'
EVENT_CHANGED = 'event_changed'
EVENT_CANCELLED = 'event_cancelled'
EVENT_REMINDER = 'event_reminder' # Для будущей реализации через Celery
class MessageTemplates:
"""Шаблоны сообщений для уведомлений calendar."""
@staticmethod
def event_created_company(event_title: str, event_date: str) -> str:
"""Шаблон для нового события компании."""
return f'Добавлено событие компании: "{event_title}" ({event_date})'
@staticmethod
def event_created_department(
department_name: str,
event_title: str,
event_date: str
) -> str:
"""Шаблон для нового события отдела."""
return (
f'Добавлено событие отдела {department_name}: '
f'"{event_title}" ({event_date})'
)
@staticmethod
def event_created_title() -> str:
"""Заголовок для нового события."""
return 'Новое событие в календаре'
@staticmethod
def event_changed(event_title: str, changes_text: str) -> str:
"""Шаблон для изменения события."""
return f'Событие "{event_title}" изменено. Изменения: {changes_text}'
@staticmethod
def event_changed_title() -> str:
"""Заголовок для изменения события."""
return 'Событие изменено'
@staticmethod
def event_cancelled(event_title: str, event_date: str) -> str:
"""Шаблон для отмены события."""
return f'Событие "{event_title}" ({event_date}) отменено'
@staticmethod
def event_cancelled_title() -> str:
"""Заголовок для отмены события."""
return 'Событие отменено'
class ActionURLs:
"""URL-адреса для действий с календарем."""
CALENDAR = '/calendar'
@staticmethod
def event_detail(event_id: int) -> str:
"""
Возвращает URL-адрес события.
TODO: реализовать прямые ссылки на конкретные события
"""
return f'/calendar?event={event_id}'
# ===== Константы для полей =====
FIELD_NAMES = {
'title': 'название',
'start_date': 'дата начала',
'end_date': 'дата окончания',
'start_time': 'время начала',
'end_time': 'время окончания',
'location': 'место проведения',
}
IMPORTANT_FIELDS = [
'title', 'start_date', 'end_date',
'start_time', 'end_time', 'location'
]
def format_date(date) -> str:
"""
Форматирует дату в строку.
Args:
date: Объект date или datetime
Returns:
str: Отформатированная дата (например, "10.03.2026")
"""
if not date:
return ''
return date.strftime('%d.%m.%Y')
def format_changes(changed_fields: list[str]) -> str:
"""
Форматирует список изменённых полей в текст.
Args:
changed_fields: Список имён полей
Returns:
str: Текстовое описание изменений через запятую
"""
return ', '.join([
FIELD_NAMES.get(f, f) for f in changed_fields
])

View File

@ -0,0 +1,174 @@
"""
Бизнес-логика отправки уведомлений для модуля Calendar.
Функции:
- get_event_recipients - определение получателей уведомлений
- notify_event_created - уведомление о новом событии
- notify_event_changed - уведомление об изменении события
- notify_event_cancelled - уведомление об отмене события
"""
from django.contrib.auth import get_user_model
from notifications.signals import notify
from .config import (
NotificationVerbs,
MessageTemplates,
ActionURLs,
format_date,
format_changes,
)
Employee = get_user_model()
def get_event_recipients(event) -> list:
"""
Возвращает список получателей уведомлений для события.
Args:
event: Объект CalendarEvent
Returns:
list: Список пользователей (Employee):
- Для личного события - только владелец
- Для события компании - все активные сотрудники
- Для события отдела - сотрудники этого отдела
"""
if event.is_personal:
# Личное событие - только владелец
return [event.employee] if event.employee else []
elif event.is_company:
# Событие компании - все активные сотрудники
return list(Employee.objects.filter(is_active=True))
elif event.department:
# Событие отдела - сотрудники через EmployeeDepartment
from employees.models import EmployeeDepartment
employee_ids = EmployeeDepartment.objects.filter(
department=event.department,
is_active=True
).values_list('employee_id', flat=True)
return list(Employee.objects.filter(id__in=employee_ids, is_active=True))
return []
def notify_event_created(event):
"""
Отправляет уведомления о новом событии всем участникам.
Args:
event: Объект CalendarEvent
"""
recipients = get_event_recipients(event)
# Исключаем создателя события
if event.created_by:
recipients = [r for r in recipients if r.id != event.created_by.id]
# Определяем тип события для сообщения
event_date = format_date(event.start_date)
if event.is_company:
description = MessageTemplates.event_created_company(event.title, event_date)
elif event.department:
description = MessageTemplates.event_created_department(
event.department.name,
event.title,
event_date
)
else:
# Личное событие - не должно попадать сюда (нет других получателей)
return
# Отправляем уведомления
for recipient in recipients:
notify.send(
sender=event.created_by,
recipient=recipient,
verb=NotificationVerbs.EVENT_CREATED,
action_object=event,
description=description,
action_url=ActionURLs.CALENDAR,
data={
'title': MessageTemplates.event_created_title(),
'event_id': event.id,
'event_title': event.title,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
'is_company_event': event.is_company,
'created_by_id': (
event.created_by.id if event.created_by else None
),
}
)
def notify_event_changed(event, changed_fields: list[str]):
"""
Отправляет уведомления об изменении события.
Args:
event: Объект CalendarEvent
changed_fields: Список изменённых полей
"""
if not changed_fields:
return
recipients = get_event_recipients(event)
# Формируем сообщение об изменениях
changes_text = format_changes(changed_fields)
for recipient in recipients:
notify.send(
sender=None,
recipient=recipient,
verb=NotificationVerbs.EVENT_CHANGED,
action_object=event,
description=MessageTemplates.event_changed(event.title, changes_text),
action_url=ActionURLs.CALENDAR,
data={
'title': MessageTemplates.event_changed_title(),
'event_id': event.id,
'event_title': event.title,
'changed_fields': changed_fields,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
}
)
def notify_event_cancelled(event):
"""
Отправляет уведомления об отмене (удалении) события.
Args:
event: Объект CalendarEvent
"""
recipients = get_event_recipients(event)
event_date = format_date(event.start_date)
for recipient in recipients:
notify.send(
sender=None,
recipient=recipient,
verb=NotificationVerbs.EVENT_CANCELLED,
description=MessageTemplates.event_cancelled(event.title, event_date),
action_url=ActionURLs.CALENDAR,
data={
'title': MessageTemplates.event_cancelled_title(),
'event_title': event.title,
'event_date': event.start_date.isoformat(),
'department_id': (
event.department.id if event.department else None
),
'is_company_event': event.is_company,
}
)

View File

@ -0,0 +1,75 @@
"""
Django signals для автоматической генерации уведомлений в модуле Calendar.
Обрабатывает события:
- post_save для CalendarEvent - создание или изменение события
- pre_save для CalendarEvent - отслеживание изменений полей
- pre_delete для CalendarEvent - отмена (удаление) события
"""
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from ..models import CalendarEvent
from .config import IMPORTANT_FIELDS
from .handlers import (
notify_event_created,
notify_event_changed,
notify_event_cancelled,
)
@receiver(post_save, sender=CalendarEvent)
def create_event_notifications(sender, instance, created, **kwargs):
"""
Создает уведомления при создании или изменении события.
Уведомления отправляются:
- При создании - всем сотрудникам компании или отдела
- При изменении - участникам события (если изменились важные поля)
Уведомления отправляются через универсальную систему
channels.py → Celery → WebSocket/Email/Push
"""
if created:
notify_event_created(instance)
else:
changed_fields = getattr(instance, '_changed_fields', [])
if changed_fields:
notify_event_changed(instance, changed_fields)
@receiver(pre_save, sender=CalendarEvent)
def track_event_changes(sender, instance, **kwargs):
"""
Отслеживает изменения важных полей события для уведомлений.
Сохраняет список изменённых полей в атрибут _changed_fields,
который затем используется в post_save signal.
"""
if instance.pk:
try:
old_event = CalendarEvent.objects.get(pk=instance.pk)
changed_fields = []
# Проверяем изменения важных полей
for field in IMPORTANT_FIELDS:
old_value = getattr(old_event, field)
new_value = getattr(instance, field)
if old_value != new_value:
changed_fields.append(field)
instance._changed_fields = changed_fields
except CalendarEvent.DoesNotExist:
instance._changed_fields = []
@receiver(pre_delete, sender=CalendarEvent)
def notify_event_cancelled_signal(sender, instance, **kwargs):
"""
Создает уведомления при удалении (отмене) события.
Уведомления отправляются через универсальную систему
channels.py → Celery → WebSocket/Email/Push
"""
notify_event_cancelled(instance)