From fc9b9de50ac80f1688b9f2135ad35524b7d94b0c Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Mar 2026 14:20:05 +0300 Subject: [PATCH] =?UTF-8?q?refactor(calendar=5Fapp):=20=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20notifications=20=D0=B2=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=B0=D1=80=D1=85?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - Монолитный 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 - успешно --- backend/calendar_app/apps.py | 2 +- ...signals.py => notification_signals.py.old} | 0 .../calendar_app/notifications/__init__.py | 37 ++++ backend/calendar_app/notifications/config.py | 126 +++++++++++++ .../calendar_app/notifications/handlers.py | 174 ++++++++++++++++++ backend/calendar_app/notifications/signals.py | 75 ++++++++ 6 files changed, 413 insertions(+), 1 deletion(-) rename backend/calendar_app/{notification_signals.py => notification_signals.py.old} (100%) create mode 100644 backend/calendar_app/notifications/__init__.py create mode 100644 backend/calendar_app/notifications/config.py create mode 100644 backend/calendar_app/notifications/handlers.py create mode 100644 backend/calendar_app/notifications/signals.py diff --git a/backend/calendar_app/apps.py b/backend/calendar_app/apps.py index 62ed3041..8ae429be 100644 --- a/backend/calendar_app/apps.py +++ b/backend/calendar_app/apps.py @@ -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: регистрация предикатов и правил доступа diff --git a/backend/calendar_app/notification_signals.py b/backend/calendar_app/notification_signals.py.old similarity index 100% rename from backend/calendar_app/notification_signals.py rename to backend/calendar_app/notification_signals.py.old diff --git a/backend/calendar_app/notifications/__init__.py b/backend/calendar_app/notifications/__init__.py new file mode 100644 index 00000000..935b4690 --- /dev/null +++ b/backend/calendar_app/notifications/__init__.py @@ -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', +] diff --git a/backend/calendar_app/notifications/config.py b/backend/calendar_app/notifications/config.py new file mode 100644 index 00000000..42c673fa --- /dev/null +++ b/backend/calendar_app/notifications/config.py @@ -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 + ]) diff --git a/backend/calendar_app/notifications/handlers.py b/backend/calendar_app/notifications/handlers.py new file mode 100644 index 00000000..a529f1ff --- /dev/null +++ b/backend/calendar_app/notifications/handlers.py @@ -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, + } + ) diff --git a/backend/calendar_app/notifications/signals.py b/backend/calendar_app/notifications/signals.py new file mode 100644 index 00000000..743abe9b --- /dev/null +++ b/backend/calendar_app/notifications/signals.py @@ -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)