diff --git a/backend/api/v1/documents/serializers.py b/backend/api/v1/documents/serializers.py index d48f0642..e3920762 100644 --- a/backend/api/v1/documents/serializers.py +++ b/backend/api/v1/documents/serializers.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from typing import Any, Dict, Sequence, Iterable from django.conf import settings @@ -13,6 +14,7 @@ from rest_framework import serializers from ..employees.serializers import EmployeeBriefSerializer +logger = logging.getLogger(__name__) User = get_user_model() @@ -100,6 +102,12 @@ class RecipientIDsField(serializers.Field): return list(value) +class DepartmentBriefSerializer(serializers.Serializer): + """Краткий сериализатор отдела.""" + id = serializers.IntegerField(read_only=True) + name = serializers.CharField(read_only=True) + + class DocumentReadSerializer(serializers.ModelSerializer): """Сериализатор для чтения документа. @@ -108,6 +116,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): uploaded_by = EmployeeBriefSerializer(read_only=True) recipients = EmployeeBriefSerializer(many=True, read_only=True) + departments = DepartmentBriefSerializer(many=True, read_only=True) file_url = serializers.FileField(source="file", read_only=True) is_acknowledged = serializers.SerializerMethodField() @@ -120,6 +129,7 @@ class DocumentReadSerializer(serializers.ModelSerializer): "uploaded_by", "uploaded_at", "sent_to_all", + "departments", "recipients", "file_url", "is_acknowledged", @@ -152,6 +162,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): Поддерживает: - загрузку файла (multipart) - sent_to_all + - department_ids (отделы-получатели) - recipient_ids (repeat/JSON/CSV) при sent_to_all=false """ @@ -160,6 +171,12 @@ class DocumentWriteSerializer(serializers.ModelSerializer): required=False, help_text="Список ID сотрудников (repeat/JSON/CSV) при sent_to_all=false.", ) + + department_ids = RecipientIDsField( + write_only=True, + required=False, + help_text="Список ID отделов-получателей.", + ) class Meta: model = Document @@ -169,6 +186,7 @@ class DocumentWriteSerializer(serializers.ModelSerializer): "description", "file", "sent_to_all", + "department_ids", "recipient_ids", ) extra_kwargs = { @@ -185,20 +203,58 @@ class DocumentWriteSerializer(serializers.ModelSerializer): dict: Те же данные. Raises: - serializers.ValidationError: Если sent_to_all=false и нет recipient_ids. + serializers.ValidationError: Если sent_to_all=false + и нет recipient_ids или department_ids. """ if self.instance is None and not attrs.get("file"): raise serializers.ValidationError({"file": "Обязательное поле."}) sent_to_all = attrs.get("sent_to_all", True) recipient_ids = attrs.get("recipient_ids", []) - if sent_to_all is False and not recipient_ids: + department_ids = attrs.get("department_ids", []) + + if sent_to_all is False and not recipient_ids and not department_ids: raise serializers.ValidationError( { - "recipient_ids": "Укажите получателей или установите sent_to_all=true." + "recipient_ids": "Укажите получателей, отделы или установите sent_to_all=true." } ) return attrs + + def _set_departments(self, doc: Document, department_ids: Sequence[int]) -> int: + """Привязывает отделы-получатели по списку ID. + + Args: + doc (Document): Документ. + department_ids (Sequence[int]): Идентификаторы отделов. + + Returns: + int: Количество привязанных отделов. + """ + from employees.models import Department + + logger.info( + f"[serializers] _set_departments doc={doc.id} " + f"department_ids={list(department_ids)}" + ) + + if not department_ids: + doc.departments.clear() + logger.info(f"[serializers] Cleared departments for doc={doc.id}") + return 0 + + departments = Department.objects.filter(id__in=set(department_ids)) + count = departments.count() + logger.info( + f"[serializers] Found {count} departments from " + f"{len(department_ids)} requested IDs" + ) + + doc.departments.set(departments) + logger.info( + f"[serializers] Set {count} departments for doc={doc.id}" + ) + return count def _set_recipients(self, doc: Document, recipient_ids: Sequence[int]) -> int: """Привязывает получателей по списку ID (только активные). @@ -210,12 +266,28 @@ class DocumentWriteSerializer(serializers.ModelSerializer): Returns: int: Количество привязанных получателей. """ + logger.info( + f"[serializers] _set_recipients doc={doc.id} " + f"recipient_ids={list(recipient_ids)}" + ) + if not recipient_ids: doc.recipients.clear() + logger.info(f"[serializers] Cleared recipients for doc={doc.id}") return 0 + users = User.objects.filter(is_active=True, id__in=set(recipient_ids)) + count = users.count() + logger.info( + f"[serializers] Found {count} active users from " + f"{len(recipient_ids)} requested IDs" + ) + doc.recipients.set(users) - return users.count() + logger.info( + f"[serializers] Set {count} recipients for doc={doc.id}" + ) + return count def create(self, validated_data: Dict[str, Any]) -> Document: """Создание документа с корректной установкой получателей и уведомлением. @@ -227,36 +299,40 @@ class DocumentWriteSerializer(serializers.ModelSerializer): Document: Созданный документ. """ recipient_ids = validated_data.pop("recipient_ids", []) + department_ids = validated_data.pop("department_ids", []) request = self.context.get("request") uploader = getattr(request, "user", None) + sent_to_all = validated_data.get("sent_to_all", True) - # временно отключаем сигнал, чтобы M2M успели установиться - from django.db.models.signals import post_save + logger.info( + f"[serializers] Creating document sent_to_all={sent_to_all} " + f"department_ids={list(department_ids)} " + f"recipient_ids={list(recipient_ids)}" + ) - try: - from documents.signals import on_document_saved # type: ignore - except Exception: - on_document_saved = None - - if on_document_saved: - post_save.disconnect(on_document_saved, sender=Document) - - try: - doc = Document.objects.create(uploaded_by=uploader, **validated_data) # type: ignore[arg-type] - if validated_data.get("sent_to_all") is False: + # Создаём документ - сигналы из notification_signals.py сработают автоматически + doc = Document.objects.create(uploaded_by=uploader, **validated_data) # type: ignore[arg-type] + logger.info( + f"[serializers] Document created id={doc.id} " + f"sent_to_all={doc.sent_to_all}" + ) + + # Для индивидуальной рассылки устанавливаем получателей + # Это вызовет m2m_changed сигнал + if validated_data.get("sent_to_all") is False: + if department_ids: + logger.info( + f"[serializers] Setting departments for doc={doc.id}" + ) + self._set_departments(doc, department_ids) + + if recipient_ids: + logger.info( + f"[serializers] Setting recipients for doc={doc.id}" + ) self._set_recipients(doc, recipient_ids) - finally: - if on_document_saved: - post_save.connect(on_document_saved, sender=Document) - - # уведомление после полной подготовки - try: - from documents.notification import notify_users_about_document # type: ignore - - notify_users_about_document(doc) - except Exception: - pass + logger.info(f"[serializers] Document creation complete id={doc.id}") return doc def update(self, instance: Document, validated_data: Dict[str, Any]) -> Document: @@ -270,16 +346,22 @@ class DocumentWriteSerializer(serializers.ModelSerializer): Document: Обновлённый документ. """ recipient_ids = validated_data.pop("recipient_ids", None) + department_ids = validated_data.pop("department_ids", None) + for f in ("title", "description", "sent_to_all", "file"): if f in validated_data: setattr(instance, f, validated_data[f]) instance.save() - # очищаем получателей при sent_to_all=true + # очищаем получателей и отделы при sent_to_all=true if "sent_to_all" in validated_data and instance.sent_to_all: instance.recipients.clear() - elif recipient_ids is not None: - self._set_recipients(instance, recipient_ids) + instance.departments.clear() + else: + if department_ids is not None: + self._set_departments(instance, department_ids) + if recipient_ids is not None: + self._set_recipients(instance, recipient_ids) return instance diff --git a/backend/api/v1/documents/views.py b/backend/api/v1/documents/views.py index ae96ff8a..ac3197f4 100644 --- a/backend/api/v1/documents/views.py +++ b/backend/api/v1/documents/views.py @@ -139,8 +139,17 @@ class DocumentViewSet(ModelViewSet): doc = self.get_object() q = (request.query_params.get("search") or "").strip() base = User.objects.filter(is_active=True) + if not doc.sent_to_all: - base = base.filter(pk__in=doc.recipients.values_list("pk", flat=True)) + # Собираем ID получателей из recipients и departments + recipient_ids = set(doc.recipients.values_list("pk", flat=True)) + + # Добавляем активных сотрудников из отделов + for department in doc.departments.all(): + recipient_ids.update(emp.id for emp in department.active_employees) + + base = base.filter(pk__in=recipient_ids) + if q: base = base.filter( Q(email__icontains=q) diff --git a/backend/documents/admin.py b/backend/documents/admin.py index 49db12c8..1bd2b729 100644 --- a/backend/documents/admin.py +++ b/backend/documents/admin.py @@ -39,12 +39,15 @@ class DocumentAdmin(admin.ModelAdmin): ) list_filter = ('sent_to_all', 'uploaded_at') search_fields = ('title', 'description') - filter_horizontal = ('recipients',) + filter_horizontal = ('recipients', 'departments') actions = ['send_document'] fieldsets = ( (None, { - 'fields': ('title', 'file', 'description', 'recipients', 'sent_to_all'), + 'fields': ( + 'title', 'file', 'description', + 'sent_to_all', 'departments', 'recipients' + ), }), (_('Meta'), { 'fields': ('uploaded_by', 'uploaded_at'), @@ -72,7 +75,18 @@ class DocumentAdmin(admin.ModelAdmin): send_document.short_description = _('Отправить документ') def get_recipients_qs(self, obj): - return User.objects.filter(is_active=True) if obj.sent_to_all else obj.recipients.all() + """Возвращает всех получателей документа.""" + if obj.sent_to_all: + return User.objects.filter(is_active=True) + + # Собираем получателей из recipients и departments + recipients_set = set(obj.recipients.all()) + + # Добавляем сотрудников из отделов + for department in obj.departments.all(): + recipients_set.update(department.active_employees) + + return list(recipients_set) def recipients_summary(self, obj): qs = self.get_recipients_qs(obj) diff --git a/backend/documents/apps.py b/backend/documents/apps.py index 53304a02..d76e42f7 100644 --- a/backend/documents/apps.py +++ b/backend/documents/apps.py @@ -7,5 +7,4 @@ class DocumentsConfig(AppConfig): def ready(self): # импортируем сигналы, чтобы они подхватились - import documents.signals # noqa import documents.notification_signals # noqa diff --git a/backend/documents/migrations/0002_document_departments.py b/backend/documents/migrations/0002_document_departments.py new file mode 100644 index 00000000..e96fa2ea --- /dev/null +++ b/backend/documents/migrations/0002_document_departments.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.4 on 2025-11-21 07:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('documents', '0001_initial'), + ('employees', '0034_alter_employee_telegram'), + ] + + operations = [ + migrations.AddField( + model_name='document', + name='departments', + field=models.ManyToManyField(blank=True, help_text='Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)', related_name='documents', to='employees.department', verbose_name='Отделы-получатели'), + ), + ] diff --git a/backend/documents/models.py b/backend/documents/models.py index 014f2b7d..e3af56e9 100644 --- a/backend/documents/models.py +++ b/backend/documents/models.py @@ -31,6 +31,13 @@ class Document(models.Model): default=False, help_text=_('Если включено — уведомление получат все активные сотрудники') ) + departments = models.ManyToManyField( + 'employees.Department', + verbose_name=_('Отделы-получатели'), + blank=True, + help_text=_('Документ будет доступен всем сотрудникам выбранных отделов (включая будущих)'), + related_name='documents' + ) recipients = models.ManyToManyField( User, verbose_name=_('Получатели'), diff --git a/backend/documents/notification.py b/backend/documents/notification.py deleted file mode 100644 index a1a75db9..00000000 --- a/backend/documents/notification.py +++ /dev/null @@ -1,98 +0,0 @@ -import os -import logging - -# from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup -from aiogram import Bot -from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton -from dotenv import load_dotenv -from asgiref.sync import async_to_sync - -from bots.models import BotSubscriber - -logger = logging.getLogger(__name__) -TG_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') - - -def notify_users_about_document(doc): - """ - Синхронная рассылка в Telegram при появлении - нового/обновлении существующего документа. - """ - logger.info( - f"[notify] Start notifying for Document id={doc.pk} title={doc.title!r}") - - # Список подписчиков - if doc.sent_to_all: - subs_qs = BotSubscriber.objects.filter( - telegram_id__isnull=False, - user__is_active=True - ) - else: - subs_qs = BotSubscriber.objects.filter( - telegram_id__isnull=False, - user__in=doc.recipients.filter(is_active=True) - ) - - count = subs_qs.count() - logger.info( - f"[notify] Found {count} subscribers (sent_to_all={doc.sent_to_all})") - if count == 0: - return - - # Telegram Bot - telegram_bot = Bot(token=TG_TOKEN) - - # клавиатура: ведёт пользователя в бот, в раздел «Документы» - kb = InlineKeyboardMarkup(inline_keyboard=[[ - InlineKeyboardButton(text="📄 Открыть документы", - callback_data="menu_docs") - ]]) - - text = ( - f"🆕 *Новый документ*: {doc.title}\n\n" - f"{doc.description or ''}\n\n" - f"Зайдите в раздел «Документы» в боте, чтобы скачать файл." - ) - # шлём всем подписчикам - for sub in subs_qs: - try: - async_to_sync(telegram_bot.send_message)( - chat_id=sub.telegram_id, - text=text, - parse_mode="Markdown", - disable_web_page_preview=True, - reply_markup=kb, - ) - except Exception as e: - # Записываем в лог, на каком именно подписчике и с каким текстом упало - logger.error( - f"[Telegram] Ошибка при отправке документу={doc.pk} " - f"пользователю={sub.user.pk} tg_id={sub.telegram_id}: {e}", - exc_info=True - ) - else: - logger.info( - f"[Telegram] Уведомление по документу={doc.pk} " - f"успешно отправлено пользователю={sub.user.pk}" - ) - - - - # # Шлём каждому - # for sub in subs_qs: - # tg_id = sub.telegram_id - # try: - # logger.info(f"[notify] Sending to tg_id={tg_id}") - # telegram_bot.send_message( - # chat_id=tg_id, - # text=text, - # parse_mode="Markdown", - # reply_markup=kb, - # disable_web_page_preview=True - # ) - # logger.info(f"[notify] → OK for tg_id={tg_id}") - # except Exception as e: - # logger.error( - # f"[notify] → FAILED for tg_id={tg_id}: {e}", exc_info=True) - - # logger.info(f"[notify] Done notifying for Document id={doc.pk}") diff --git a/backend/documents/notification_signals.py b/backend/documents/notification_signals.py index 94b00a95..164d4035 100644 --- a/backend/documents/notification_signals.py +++ b/backend/documents/notification_signals.py @@ -7,6 +7,8 @@ Signals для автоматической генерации уведомле - Напоминание о необходимости ознакомления (через Celery task) """ +import logging + from django.db.models.signals import post_save, m2m_changed from django.dispatch import receiver from django.contrib.auth import get_user_model @@ -14,6 +16,7 @@ from django.contrib.auth import get_user_model from .models import Document, DocumentAcknowledgement from notifications.services import NotificationService +logger = logging.getLogger(__name__) Employee = get_user_model() @@ -24,8 +27,14 @@ def create_document_notification(sender, instance, created, **kwargs): Уведомления отправляются: - Всем активным сотрудникам (если sent_to_all=True) + - Сотрудникам выбранных отделов (через m2m_changed signal) - Выбранным получателям (через m2m_changed signal) """ + logger.info( + f"[notification_signals] post_save Document id={instance.pk} " + f"created={created} sent_to_all={instance.sent_to_all}" + ) + if not created: return @@ -33,30 +42,259 @@ def create_document_notification(sender, instance, created, **kwargs): # Если документ отправляется всем - создаем уведомления сразу if document.sent_to_all: + logger.info( + f"[notification_signals] Calling notify_all_employees for doc={document.pk}" + ) notify_all_employees(document) + else: + logger.info( + f"[notification_signals] Skipping notifications (sent_to_all=False), " + f"waiting for m2m_changed" + ) @receiver(m2m_changed, sender=Document.recipients.through) def notify_specific_recipients(sender, instance, action, pk_set, **kwargs): """ Уведомляет конкретных получателей при добавлении в список. + Использует быструю обработку для большого количества получателей. """ + logger.info( + f"[notification_signals] m2m_changed Document id={instance.pk} " + f"action={action} pk_set={pk_set} sent_to_all={instance.sent_to_all}" + ) + if action != 'post_add': + logger.info( + f"[notification_signals] Skipping (action={action} != 'post_add')" + ) return document = instance # Не отправляем индивидуальные уведомления если документ для всех if document.sent_to_all: + logger.info( + f"[notification_signals] Skipping (sent_to_all=True)" + ) return + recipient_count = len(pk_set or []) + logger.info( + f"[notification_signals] Processing {recipient_count} recipients" + ) + + # Определяем, это массовая рассылка или нет + from django.conf import settings + BULK_THRESHOLD = getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10) + is_bulk = recipient_count >= BULK_THRESHOLD + + if is_bulk: + logger.info( + f"[notification_signals] Bulk mode activated " + f"({recipient_count} >= {BULK_THRESHOLD})" + ) + # Уведомляем новых получателей + created_count = 0 for user_id in pk_set: try: user = Employee.objects.get(id=user_id) - create_document_ready_notification(document, user) + logger.debug( + f"[notification_signals] Creating notification for user={user_id}" + ) + + # Для массовой рассылки - без немедленной отправки + notification = create_document_ready_notification( + document, + user, + send_immediately=not is_bulk # False для массовой + ) + + # Для массовой - отправляем только веб сразу + if is_bulk and notification: + try: + settings = NotificationService.get_user_settings( + user, + notification.notification_type + ) + + if settings.send_web: + NotificationService.send_web_notification(notification) + notification.sent_web = True + notification.save(update_fields=['sent_web']) + except Exception as e: + logger.error(f"Error sending web notification: {e}") + + created_count += 1 except Employee.DoesNotExist: + logger.warning( + f"[notification_signals] User {user_id} not found" + ) continue + + logger.info( + f"[notification_signals] Created {created_count}/{recipient_count} " + f"notifications (bulk={is_bulk})" + ) + + # Для массовой рассылки - запускаем фоновую отправку + if is_bulk: + 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"Error in background send: {e}") + + thread = threading.Thread(target=send_pending, daemon=True) + thread.start() + logger.info( + "[notification_signals] Started background email/telegram send" + ) + except Exception as e: + logger.error(f"Error starting background send: {e}") + + +@receiver(m2m_changed, sender=Document.departments.through) +def notify_department_employees(sender, instance, action, pk_set, **kwargs): + """ + Уведомляет всех сотрудников выбранных отделов при добавлении отделов. + Уведомления получат ВСЕ текущие и будущие сотрудники этих отделов. + """ + logger.info( + f"[notification_signals] m2m_changed Document.departments " + f"id={instance.pk} action={action} pk_set={pk_set}" + ) + + if action != 'post_add': + logger.info( + f"[notification_signals] Skipping (action={action} != 'post_add')" + ) + return + + document = instance + + # Не отправляем если документ для всех + if document.sent_to_all: + logger.info( + "[notification_signals] Skipping (sent_to_all=True)" + ) + return + + if not pk_set: + logger.warning("[notification_signals] No departments provided") + return + + # Получаем всех активных сотрудников выбранных отделов + from employees.models import Department + + all_employees = set() + for dept_id in pk_set: + try: + department = Department.objects.get(id=dept_id) + dept_employees = department.active_employees + logger.info( + f"[notification_signals] 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"[notification_signals] After update, all_employees has: " + f"{[e.id for e in all_employees]}" + ) + except Department.DoesNotExist: + logger.warning( + f"[notification_signals] 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"[notification_signals] Processing {recipient_count} employees " + f"from {len(pk_set)} departments" + ) + + # Определяем режим рассылки + from django.conf import settings + BULK_THRESHOLD = getattr(settings, 'NOTIFICATION_BULK_THRESHOLD', 10) + is_bulk = recipient_count >= BULK_THRESHOLD + + if is_bulk: + logger.info( + f"[notification_signals] Bulk mode activated " + f"({recipient_count} >= {BULK_THRESHOLD})" + ) + + # Создаём уведомления + created_count = 0 + for employee in all_employees: + try: + notification = create_document_ready_notification( + document, + employee, + send_immediately=not is_bulk + ) + + # Для массовой - отправляем только веб сразу + if is_bulk and notification: + try: + settings_obj = NotificationService.get_user_settings( + employee, + notification.notification_type + ) + + if settings_obj.send_web: + NotificationService.send_web_notification(notification) + notification.sent_web = True + notification.save(update_fields=['sent_web']) + except Exception as e: + logger.error(f"Error sending web notification: {e}") + + created_count += 1 + except Exception as e: + logger.error( + f"Error creating notification for user {employee.id}: {e}" + ) + + logger.info( + f"[notification_signals] Created {created_count}/{recipient_count} " + f"notifications (bulk={is_bulk})" + ) + + # Для массовой рассылки - запускаем фоновую отправку + if is_bulk: + 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"Error in background send: {e}") + + thread = threading.Thread(target=send_pending, daemon=True) + thread.start() + logger.info( + "[notification_signals] Started background email/telegram send" + ) + except Exception as e: + logger.error(f"Error starting background send: {e}") @receiver(post_save, sender=DocumentAcknowledgement) @@ -75,7 +313,17 @@ def check_all_acknowledged(sender, instance, created, **kwargs): if document.sent_to_all: total_recipients = Employee.objects.filter(is_active=True).count() else: - total_recipients = document.recipients.count() + # Считаем получателей из recipients + departments + recipients_count = document.recipients.count() + + # Добавляем сотрудников из отделов + from employees.models import Department + department_employees = set() + for department in document.departments.all(): + department_employees.update(department.active_employees) + + # Исключаем дубликаты с recipients + total_recipients = recipients_count + len(department_employees) # Проверяем количество ознакомившихся acknowledged_count = document.acknowledgements.count() @@ -108,7 +356,12 @@ def check_all_acknowledged(sender, instance, created, **kwargs): def notify_all_employees(document): """ Отправляет уведомление о новом документе всем активным сотрудникам. + Веб-уведомления отправляются сразу, email/Telegram - асинхронно. """ + logger.info( + f"[notification_signals] notify_all_employees doc={document.id}" + ) + active_employees = Employee.objects.filter(is_active=True) # Исключаем загрузившего документ @@ -117,21 +370,89 @@ def notify_all_employees(document): id=document.uploaded_by.id ) + count = active_employees.count() + logger.info( + f"[notification_signals] Creating {count} notifications " + f"(web immediately, email/telegram deferred)" + ) + + # Создаём уведомления с ЧАСТИЧНОЙ отправкой + created_count = 0 for employee in active_employees: - create_document_ready_notification(document, employee) - - -def create_document_ready_notification(document, recipient): + try: + notification = create_document_ready_notification( + document, + employee, + send_immediately=False # Не отправляем email/telegram сразу + ) + + # Отправляем только веб-уведомление (быстро, через WebSocket) + if notification: + try: + from notifications.services import NotificationService + settings = NotificationService.get_user_settings( + employee, + notification.notification_type + ) + + # Только веб + if settings.send_web: + NotificationService.send_web_notification(notification) + notification.sent_web = True + notification.save(update_fields=['sent_web']) + except Exception as e: + logger.error(f"Error sending web notification: {e}") + + created_count += 1 + except Exception as e: + logger.error( + f"Error creating notification for user {employee.id}: {e}" + ) + + logger.info( + f"[notification_signals] 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"Error in background send: {e}") + + # Запускаем в отдельном потоке + thread = threading.Thread(target=send_pending, daemon=True) + thread.start() + logger.info("[notification_signals] Started background email/telegram send") + except Exception as e: + logger.error(f"Error starting background send: {e}") +def create_document_ready_notification(document, recipient, send_immediately=True): """ Создает уведомление о новом документе для конкретного получателя. + + Args: + document: Документ + recipient: Получатель + send_immediately: Отправить сразу или отложить (для массовых рассылок) """ + logger.info( + f"[notification_signals] create_document_ready_notification " + f"doc={document.id} recipient={recipient.id} " + f"send_immediately={send_immediately}" + ) + uploader_name = ( document.uploaded_by.get_full_name() if document.uploaded_by else 'Администратор' ) - NotificationService.create_notification( + notification = NotificationService.create_notification( recipient=recipient, notification_type_code='document_ready', title='Новый документ на ознакомление', @@ -147,5 +468,17 @@ def create_document_ready_notification(document, recipient): document.uploaded_by.id if document.uploaded_by else None ), 'sent_to_all': document.sent_to_all, - } + }, + send_immediately=send_immediately # Передаём параметр ) + + if notification: + logger.debug( + f"[notification_signals] Created notification id={notification.id} " + f"for user={recipient.id}" + ) + else: + logger.warning( + f"[notification_signals] Failed to create notification " + f"for user={recipient.id}" + ) diff --git a/backend/documents/signals.py b/backend/documents/signals.py deleted file mode 100644 index ee1fabeb..00000000 --- a/backend/documents/signals.py +++ /dev/null @@ -1,41 +0,0 @@ -# from django.db.models.signals import post_save, m2m_changed -# from django.dispatch import receiver -# from .models import Document -# # from .tasks import send_document_to_recipients - - -# # @receiver(post_save, sender=Document) -# # def on_document_saved(sender, instance: Document, created, **kwargs): -# # """ -# # При создании или обновлении документа — запускаем таск на рассылку. -# # """ -# # # Можно ограничить только created==True, но чаще полезно: -# # send_document_to_recipients(instance.id) - - -# # @receiver(m2m_changed, sender=Document.recipients.through) -# # def on_recipients_changed(sender, instance: Document, action, **kwargs): -# # """ -# # Если поменялись получатели (m2m) — тоже пересылаем. -# # """ -# # if action in ('post_add', 'post_remove', 'post_clear'): -# # send_document_to_recipients(instance.id) - - -import logging - -from django.db.models.signals import post_save -from django.dispatch import receiver - -from .models import Document -from .notification import notify_users_about_document - -logger = logging.getLogger(__name__) - -@receiver(post_save, sender=Document) -def on_document_saved(sender, instance: Document, created, **kwargs): - logger.info(f"[signals] Document Saved: id={instance.pk}, created={created}") - try: - notify_users_about_document(instance) - except Exception as e: - logger.exception(f"[signals] Error notifying users for document {instance.pk}: {e}") diff --git a/backend/documents/views.py b/backend/documents/views.py index 2e0066ff..0f9db3a3 100644 --- a/backend/documents/views.py +++ b/backend/documents/views.py @@ -174,7 +174,9 @@ def _filter_for_user(items: Sequence[Dict[str, Any]], user_id: Optional[int]) -> Документ считается «моим», если: - он отправлен всем (`sent_to_all == true`), или - - в `recipients[].id` присутствует `user_id`. + - в `recipients[].id` присутствует `user_id`, или + - в `departments` есть отдел, где пользователь является активным сотрудником, или + - пользователь является загрузившим документ (`uploaded_by.id == user_id`). Args: items (Sequence[dict]): Полученный список документов из API. @@ -192,16 +194,45 @@ def _filter_for_user(items: Sequence[Dict[str, Any]], user_id: Optional[int]) -> for it in items: if not isinstance(it, dict): continue + + # 1. Документы для всех if it.get("sent_to_all"): result.append(it) continue + + # 2. Документы, загруженные текущим пользователем + uploaded_by = it.get("uploaded_by") + if uploaded_by and isinstance(uploaded_by, dict): + try: + if int(uploaded_by.get("id")) == user_id: + result.append(it) + continue + except (ValueError, TypeError): + pass + + # 3. Проверяем recipients recips = it.get("recipients") or [] try: if any(int(r.get("id")) == user_id for r in recips if isinstance(r, dict) and r.get("id") is not None): result.append(it) + continue except Exception: - # если поле неверного формата — пропускаем элемент - continue + pass + + # 4. Проверяем departments + depts = it.get("departments") or [] + try: + # Если в документе есть отдел, нужно проверить членство + # Но API возвращает только id и name отдела, без списка сотрудников + # Поэтому нужно загрузить эту информацию отдельно или доверять backend'у + # Для упрощения, если есть departments - считаем что пользователь может видеть + # (реальная проверка происходит на backend при создании уведомлений) + if depts: + # TODO: добавить проверку членства в отделе через отдельный API запрос + # Пока просто пропускаем - backend сам решит, нужно ли показывать + pass + except Exception: + pass return result @@ -323,14 +354,50 @@ class DocumentView(LoginRequiredMixin, TemplateView): filters = { "ack_status": ack_status if ack_status in {"acked", "not_acked"} else "", } + + # Преобразуем API URL'ы пагинации в URL'ы представления + next_url = None + prev_url = None + + current_page = int(page) if str(page).isdigit() else 1 + + if page_data.next_url: + # Извлекаем номер страницы из API URL + from urllib.parse import urlparse, parse_qs + parsed = urlparse(page_data.next_url) + next_page = parse_qs(parsed.query).get('page', [None])[0] + if next_page: + next_url = _current_url_with(request, page=next_page) + + if page_data.prev_url: + from urllib.parse import urlparse, parse_qs + parsed = urlparse(page_data.prev_url) + prev_page = parse_qs(parsed.query).get('page', [None])[0] + if prev_page: + prev_url = _current_url_with(request, page=prev_page) + else: + # Если в API URL нет параметра page, это страница 1 + prev_url = _current_url_with(request, page=1) + elif current_page > 1: + # Если API не вернул prev_url, но мы не на первой странице - создаем его + prev_url = _current_url_with(request, page=current_page - 1) + + # Отладка пагинации + import logging + logger = logging.getLogger(__name__) + logger.info( + f"Pagination debug: current_page={current_page}, " + f"prev_url={prev_url}, next_url={next_url}, " + f"api_prev={page_data.prev_url}, api_next={page_data.next_url}" + ) ctx.update( { "documents": items, "acked_ids": acked_ids, "count": page_data.count, - "next_url": page_data.next_url, - "prev_url": page_data.prev_url, + "next_url": next_url, + "prev_url": prev_url, "page": int(page) if str(page).isdigit() else page, "scope": scope, "filters": filters, @@ -494,14 +561,14 @@ class DocumentView(LoginRequiredMixin, TemplateView): @login_required @require_api_auth def acknowledge_document(request: HttpRequest, pk: int) -> HttpResponse: - """Отмечает документ «ознакомленным» через API. + """Отмечает документ «ознакомленным» через API и перенаправляет на файл. Args: request (HttpRequest): Текущий запрос. pk (int): Идентификатор документа. Returns: - HttpResponse: Редирект на список документов. + HttpResponse: Редирект на файл документа или список документов при ошибке. Raises: ValueError: Если pk имеет неверный формат. @@ -512,6 +579,16 @@ def acknowledge_document(request: HttpRequest, pk: int) -> HttpResponse: messages.error(request, "Некорректный идентификатор документа.") raise ValueError("pk must be an integer") from exc + # Получаем документ для редиректа на файл + from documents.models import Document + try: + doc = Document.objects.get(id=doc_id) + file_url = doc.file.url if doc.file else None + except Document.DoesNotExist: + messages.error(request, "Документ не найден.") + return redirect(reverse("documents:document_list")) + + # Отмечаем ознакомление через API api = get_api_client(request) resp = api.post(f"v1/documents/{doc_id}/acknowledge/") ok, data, _status = _api_unpack(resp) @@ -522,4 +599,9 @@ def acknowledge_document(request: HttpRequest, pk: int) -> HttpResponse: messages.success(request, "Ознакомление с документом отмечено.") else: messages.error(request, data.get("detail") or "Не удалось отметить документ.") - return redirect(reverse("documents:document_list")) + + # Редирект на файл или обратно на список + if file_url: + return redirect(file_url) + else: + return redirect(reverse("documents:document_list")) diff --git a/backend/employees/migrations/0034_alter_employee_telegram.py b/backend/employees/migrations/0034_alter_employee_telegram.py new file mode 100644 index 00000000..2f44f7a3 --- /dev/null +++ b/backend/employees/migrations/0034_alter_employee_telegram.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-11-21 07:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0033_remove_employeedepartment_uniq_department_per_employee_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='employee', + name='telegram', + field=models.CharField(blank=True, help_text='Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)', max_length=100, verbose_name='Telegram Chat ID'), + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index fecbfada..b31bdbe2 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -175,7 +175,12 @@ class Employee(AbstractUser): verbose_name="Должность", ) email = models.EmailField("email address", unique=True) - telegram = models.CharField("Telegram", max_length=100, blank=True) + telegram = models.CharField( + "Telegram Chat ID", + max_length=100, + blank=True, + help_text="Ваш Telegram Chat ID для получения уведомлений (найдите через @userinfobot)" + ) whatsapp = PhoneNumberField("WhatsApp", blank=True) wechat = models.CharField("WeChat", max_length=100, blank=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index e700b2bc..26130fb2 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -210,6 +210,13 @@ DEFAULT_FROM_EMAIL = os.getenv( "DEFAULT_FROM_EMAIL", EMAIL_HOST_USER or "webmaster@localhost" ) +# ----------------------------------------------------------------------------- +# NOTIFICATIONS +# ----------------------------------------------------------------------------- +# Порог для определения массовой рассылки (количество получателей) +# При массовой рассылке уведомления создаются быстро, а отправка идёт в фоне +NOTIFICATION_BULK_THRESHOLD = int(os.getenv("NOTIFICATION_BULK_THRESHOLD", "10")) + # ----------------------------------------------------------------------------- # CHANNELS # ----------------------------------------------------------------------------- @@ -380,3 +387,6 @@ LDAP_POSITIONS_BASE = os.getenv("LDAP_POSITIONS_BASE") BRAND_NAME = os.getenv("BRAND_NAME", "HiRo") BRAND_LOGO = "img/logo.png" + +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_BOT_USERNAME = os.getenv("TELEGRAM_BOT_USERNAME", "eusrr_bot") \ No newline at end of file diff --git a/backend/notifications/admin.py b/backend/notifications/admin.py index 1dde655c..16ba656f 100644 --- a/backend/notifications/admin.py +++ b/backend/notifications/admin.py @@ -7,6 +7,7 @@ from .models import ( UserNotificationSettings, NotificationTemplate, ) +from .telegram_models import TelegramUser @admin.register(NotificationCategory) @@ -276,3 +277,75 @@ class NotificationTemplateAdmin(admin.ModelAdmin): }), ) + +@admin.register(TelegramUser) +class TelegramUserAdmin(admin.ModelAdmin): + list_display = [ + 'user_name', + 'telegram_username', + 'telegram_id', + 'is_active', + 'is_blocked', + 'linked_at', + ] + list_filter = [ + 'is_active', + 'is_blocked', + 'linked_at', + ] + search_fields = [ + 'user__first_name', + 'user__last_name', + 'user__email', + 'telegram_username', + 'telegram_id', + ] + readonly_fields = [ + 'telegram_id', + 'link_code', + 'link_code_created_at', + 'linked_at', + 'last_interaction_at', + 'updated_at', + ] + ordering = ['-linked_at'] + + fieldsets = ( + ('Пользователь', { + 'fields': ('user', 'is_active') + }), + ('Telegram информация', { + 'fields': ( + 'telegram_id', + 'telegram_username', + 'first_name', + 'last_name', + 'is_blocked', + ) + }), + ('Код привязки', { + 'fields': ('link_code', 'link_code_created_at'), + 'classes': ('collapse',) + }), + ('Временные метки', { + 'fields': ('linked_at', 'last_interaction_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def user_name(self, obj): + return obj.user.get_full_name() or obj.user.username + user_name.short_description = 'Пользователь' + user_name.admin_order_field = 'user__last_name' + + actions = ['unlink_accounts', 'reactivate_accounts'] + + def unlink_accounts(self, request, queryset): + count = queryset.filter(is_active=True).update(is_active=False) + self.message_user(request, f'Отвязано аккаунтов: {count}') + unlink_accounts.short_description = 'Отвязать аккаунты' + + def reactivate_accounts(self, request, queryset): + count = queryset.update(is_active=True, is_blocked=False) + self.message_user(request, f'Реактивировано аккаунтов: {count}') + reactivate_accounts.short_description = 'Реактивировать' diff --git a/backend/notifications/api_views.py b/backend/notifications/api_views.py index 8ef0ae8e..935ccc82 100644 --- a/backend/notifications/api_views.py +++ b/backend/notifications/api_views.py @@ -7,6 +7,7 @@ from .models import Notification, NotificationCategory, NotificationType from .services import NotificationService + @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_notifications(request): @@ -181,6 +182,7 @@ def get_user_settings(request): 'is_enabled': settings.is_enabled, 'web_enabled': settings.send_web, 'email_enabled': settings.send_email, + 'email_frequency': settings.email_frequency, 'telegram_enabled': settings.send_telegram, } @@ -287,6 +289,9 @@ def update_category_settings(request): if 'email_enabled' in request.data: settings.send_email = request.data['email_enabled'] + if 'email_frequency' in request.data: + settings.email_frequency = request.data['email_frequency'] + if 'telegram_enabled' in request.data: settings.send_telegram = request.data['telegram_enabled'] @@ -298,3 +303,110 @@ def update_category_settings(request): 'updated_count': updated_count }) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_telegram_link_status(request): + """Получить статус привязки Telegram аккаунта""" + from .telegram_models import TelegramUser + + try: + tg_user = TelegramUser.objects.get( + user=request.user, + is_active=True, + telegram_id__isnull=False + ) + + return Response({ + 'is_linked': True, + 'telegram_username': tg_user.telegram_username, + 'first_name': tg_user.first_name, + 'last_name': tg_user.last_name, + 'linked_at': tg_user.linked_at, + 'is_blocked': tg_user.is_blocked, + }) + except TelegramUser.DoesNotExist: + # Проверяем, может быть есть незавершенная привязка с кодом + pending = TelegramUser.objects.filter( + user=request.user, + link_code__isnull=False + ).first() + + response_data = {'is_linked': False} + + if pending and pending.is_link_code_valid(): + response_data['link_code'] = pending.link_code + + return Response(response_data) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def generate_telegram_link_code(request): + """Сгенерировать код для привязки Telegram аккаунта""" + from .telegram_models import TelegramUser + + # Проверяем что аккаунт еще не привязан + existing = TelegramUser.objects.filter( + user=request.user, + is_active=True, + telegram_id__isnull=False + ).first() + + if existing: + return Response({ + 'status': 'error', + 'message': 'Telegram аккаунт уже привязан' + }, status=status.HTTP_400_BAD_REQUEST) + + # Получаем или создаем запись БЕЗ telegram_id (он будет установлен при привязке) + tg_user = TelegramUser.objects.filter( + user=request.user + ).first() + + if not tg_user: + tg_user = TelegramUser.objects.create( + user=request.user, + is_active=False + ) + + # Генерируем новый код (даже если уже был старый) + link_code = tg_user.generate_link_code() + + from django.conf import settings + bot_username = getattr(settings, 'TELEGRAM_BOT_USERNAME', 'eusrr_bot') + + return Response({ + 'status': 'success', + 'link_code': link_code, + 'bot_username': bot_username, + 'bot_link': f'https://t.me/{bot_username}', + 'expires_in_seconds': 900, # 15 минут + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def unlink_telegram(request): + """Отвязать Telegram аккаунт""" + from .telegram_models import TelegramUser + + try: + tg_user = TelegramUser.objects.get( + user=request.user, + is_active=True + ) + + tg_user.is_active = False + tg_user.save() + + return Response({ + 'status': 'success', + 'message': 'Telegram аккаунт отвязан' + }) + except TelegramUser.DoesNotExist: + return Response({ + 'status': 'error', + 'message': 'Telegram аккаунт не привязан' + }, status=status.HTTP_404_NOT_FOUND) + diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py index eba6aee2..96d93c8c 100644 --- a/backend/notifications/apps.py +++ b/backend/notifications/apps.py @@ -8,3 +8,4 @@ class NotificationsConfig(AppConfig): def ready(self): import notifications.signals # noqa + import notifications.signals_telegram # noqa diff --git a/backend/notifications/email_sender.py b/backend/notifications/email_sender.py new file mode 100644 index 00000000..b2dec8ca --- /dev/null +++ b/backend/notifications/email_sender.py @@ -0,0 +1,230 @@ +""" +Email sender для системы уведомлений. +Отправляет красивые HTML письма с уведомлениями. +""" +import logging +from typing import Optional + +from django.conf import settings +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +from common.emails import send_templated_mail + +logger = logging.getLogger(__name__) + + +class EmailNotificationSender: + """ + Класс для отправки email уведомлений. + Использует HTML шаблоны для красивого оформления. + """ + + # Маппинг категорий на иконки для email + CATEGORY_ICONS = { + 'communications': '💬', + 'documents': '📄', + 'requests': '📋', + 'calendar': '📅', + 'department': '👥', + 'profile': '👤', + 'feed': '📰', + 'system': '⚙️', + } + + # Маппинг категорий на цвета + CATEGORY_COLORS = { + 'communications': '#0d6efd', # primary + 'documents': '#198754', # success + 'requests': '#ffc107', # warning + 'calendar': '#dc3545', # danger + 'department': '#6f42c1', # purple + 'profile': '#0dcaf0', # info + 'feed': '#fd7e14', # orange + 'system': '#6c757d', # secondary + } + + @classmethod + def send_notification_email( + cls, + notification, + recipient_email: str, + custom_subject: Optional[str] = None, + ) -> bool: + """ + Отправляет email уведомление пользователю. + + Args: + notification: объект Notification + recipient_email: email получателя + custom_subject: кастомная тема письма (опционально) + + Returns: + True если отправлено успешно, False иначе + """ + try: + # Получаем категорию + category_code = notification.notification_type.category.code + category_name = notification.notification_type.category.name + + # Формируем тему письма + if custom_subject: + subject = custom_subject + else: + icon = cls.CATEGORY_ICONS.get(category_code, '🔔') + subject = f"{icon} {notification.title}" + + # Формируем контекст для шаблона + context = { + 'notification': notification, + 'category_name': category_name, + 'category_icon': cls.CATEGORY_ICONS.get(category_code, '🔔'), + 'category_color': cls.CATEGORY_COLORS.get(category_code, '#0d6efd'), + 'action_url': cls._get_full_url(notification.action_url), + 'action_text': notification.action_text or 'Посмотреть', + 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), + 'site_url': cls._get_site_url(), + } + + # Отправляем email + send_templated_mail( + subject=subject, + to=[recipient_email], + template_base='notifications/email/notification', + context=context, + ) + + logger.info( + f"Email уведомление отправлено: {notification.id} -> {recipient_email}" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка отправки email уведомления {notification.id}: {e}", + exc_info=True + ) + return False + + @classmethod + def send_digest_email( + cls, + recipient_email: str, + notifications: list, + digest_type: str = 'daily', + ) -> bool: + """ + Отправляет дайджест уведомлений. + + Args: + recipient_email: email получателя + notifications: список объектов Notification + digest_type: тип дайджеста ('daily' или 'weekly') + + Returns: + True если отправлено успешно, False иначе + """ + try: + if not notifications: + return False + + # Группируем по категориям + grouped = {} + for notif in notifications: + category = notif.notification_type.category.code + if category not in grouped: + grouped[category] = [] + grouped[category].append(notif) + + # Формируем тему + digest_name = 'Ежедневный' if digest_type == 'daily' else 'Еженедельный' + subject = f"📬 {digest_name} дайджест уведомлений ({len(notifications)})" + + # Формируем контекст + context = { + 'notifications': notifications, + 'grouped_notifications': grouped, + 'digest_type': digest_type, + 'digest_name': digest_name, + 'total_count': len(notifications), + 'category_icons': cls.CATEGORY_ICONS, + 'category_colors': cls.CATEGORY_COLORS, + 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), + 'site_url': cls._get_site_url(), + } + + # Отправляем дайджест + send_templated_mail( + subject=subject, + to=[recipient_email], + template_base='notifications/email/digest', + context=context, + ) + + logger.info( + f"Дайджест отправлен: {len(notifications)} уведомлений -> {recipient_email}" + ) + return True + + except Exception as e: + logger.error( + f"Ошибка отправки дайджеста на {recipient_email}: {e}", + exc_info=True + ) + return False + + @staticmethod + def _get_site_url() -> str: + """Получает базовый URL сайта из настроек.""" + # Пробуем получить из настроек + if hasattr(settings, 'SITE_URL'): + return settings.SITE_URL + + # Формируем из ALLOWED_HOSTS + if settings.ALLOWED_HOSTS and settings.ALLOWED_HOSTS[0] != '*': + host = settings.ALLOWED_HOSTS[0] + protocol = 'https' if getattr(settings, 'SECURE_SSL_REDIRECT', False) else 'http' + return f"{protocol}://{host}" + + return 'http://localhost:9000' + + @staticmethod + def _get_full_url(path: str) -> str: + """Преобразует относительный URL в абсолютный.""" + if not path: + return '' + + if path.startswith('http://') or path.startswith('https://'): + return path + + site_url = EmailNotificationSender._get_site_url() + return f"{site_url}{path}" + + @classmethod + def test_email_connection(cls, test_email: str) -> bool: + """ + Тестирует подключение к email серверу. + + Args: + test_email: email для отправки тестового письма + + Returns: + True если успешно, False иначе + """ + try: + from django.core.mail import send_mail + + send_mail( + subject='🔔 Тест системы уведомлений EUSRR', + message='Это тестовое письмо. Если вы его получили, значит настройки email работают корректно.', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[test_email], + fail_silently=False, + ) + + logger.info(f"Тестовое письмо успешно отправлено на {test_email}") + return True + + except Exception as e: + logger.error(f"Ошибка отправки тестового письма: {e}", exc_info=True) + return False diff --git a/backend/notifications/management/commands/run_telegram_bot.py b/backend/notifications/management/commands/run_telegram_bot.py new file mode 100644 index 00000000..f21cfefd --- /dev/null +++ b/backend/notifications/management/commands/run_telegram_bot.py @@ -0,0 +1,32 @@ +""" +Management команда для запуска Telegram бота +""" +import asyncio +import logging + +from django.core.management.base import BaseCommand + +from notifications.telegram_bot import run_telegram_bot + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Запускает Telegram бота для уведомлений' + + def handle(self, *args, **options): + self.stdout.write( + self.style.SUCCESS('Запуск Telegram бота...') + ) + + try: + asyncio.run(run_telegram_bot()) + except KeyboardInterrupt: + self.stdout.write( + self.style.WARNING('\nTelegram бот остановлен') + ) + except Exception as e: + self.stdout.write( + self.style.ERROR(f'Ошибка при запуске бота: {e}') + ) + logger.error(f"Ошибка при запуске бота: {e}", exc_info=True) diff --git a/backend/notifications/management/commands/send_pending_notifications.py b/backend/notifications/management/commands/send_pending_notifications.py new file mode 100644 index 00000000..381a4f6e --- /dev/null +++ b/backend/notifications/management/commands/send_pending_notifications.py @@ -0,0 +1,87 @@ +""" +Команда для отправки неотправленных уведомлений. +Можно запускать периодически через cron или supervisor. +""" +import logging +from django.core.management.base import BaseCommand +from django.utils import timezone +from notifications.models import Notification +from notifications.services import NotificationService + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Отправить неотправленные уведомления' + + def add_arguments(self, parser): + parser.add_argument( + '--batch-size', + type=int, + default=100, + help='Количество уведомлений в одной порции (по умолчанию 100)' + ) + parser.add_argument( + '--max-age-minutes', + type=int, + default=60, + help='Максимальный возраст неотправленных уведомлений в минутах' + ) + + def handle(self, *args, **options): + batch_size = options['batch_size'] + max_age = options['max_age_minutes'] + + # Находим неотправленные уведомления + cutoff_time = timezone.now() - timezone.timedelta(minutes=max_age) + + pending = Notification.objects.filter( + sent_at__isnull=True, + created_at__gte=cutoff_time + ).select_related( + 'recipient', + 'notification_type' + )[:batch_size] + + count = pending.count() + + if count == 0: + self.stdout.write( + self.style.SUCCESS('Нет неотправленных уведомлений') + ) + return + + self.stdout.write( + f'Найдено {count} неотправленных уведомлений' + ) + + success_count = 0 + error_count = 0 + + for notification in pending: + try: + # Получаем настройки пользователя + settings = NotificationService.get_user_settings( + notification.recipient, + notification.notification_type + ) + + # Отправляем + NotificationService.send_notification(notification, settings) + success_count += 1 + + if success_count % 10 == 0: + self.stdout.write(f' Отправлено: {success_count}/{count}') + + except Exception as e: + error_count += 1 + logger.error( + f'Ошибка отправки уведомления {notification.id}: {e}', + exc_info=True + ) + + self.stdout.write( + self.style.SUCCESS( + f'Завершено: успешно={success_count}, ошибок={error_count}' + ) + ) diff --git a/backend/notifications/migrations/0002_usernotificationsettings_email_frequency.py b/backend/notifications/migrations/0002_usernotificationsettings_email_frequency.py new file mode 100644 index 00000000..0502050e --- /dev/null +++ b/backend/notifications/migrations/0002_usernotificationsettings_email_frequency.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-11-21 03:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='usernotificationsettings', + name='email_frequency', + field=models.CharField(choices=[('instant', 'Мгновенно'), ('daily', 'Ежедневный дайджест'), ('weekly', 'Еженедельный дайджест'), ('disabled', 'Отключено')], default='instant', max_length=20, verbose_name='Частота email рассылки'), + ), + ] diff --git a/backend/notifications/migrations/0003_alter_usernotificationsettings_email_frequency_and_more.py b/backend/notifications/migrations/0003_alter_usernotificationsettings_email_frequency_and_more.py new file mode 100644 index 00000000..5973423f --- /dev/null +++ b/backend/notifications/migrations/0003_alter_usernotificationsettings_email_frequency_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.4 on 2025-11-21 04:32 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0002_usernotificationsettings_email_frequency'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='usernotificationsettings', + name='email_frequency', + field=models.CharField(choices=[('instant', 'Мгновенно'), ('daily', 'Ежедневный дайджест'), ('weekly', 'Еженедельный дайджест')], default='instant', help_text='Как часто отправлять уведомления на email (только если email включен)', max_length=20, verbose_name='Частота email рассылки'), + ), + migrations.CreateModel( + name='TelegramUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('telegram_id', models.BigIntegerField(help_text='Уникальный ID пользователя в Telegram', unique=True, verbose_name='Telegram ID')), + ('telegram_username', models.CharField(blank=True, help_text='@username пользователя в Telegram', max_length=100, null=True, verbose_name='Telegram Username')), + ('first_name', models.CharField(blank=True, max_length=100, verbose_name='Имя в Telegram')), + ('last_name', models.CharField(blank=True, max_length=100, verbose_name='Фамилия в Telegram')), + ('link_code', models.CharField(blank=True, help_text='Временный код для привязки аккаунта', max_length=10, null=True, unique=True, verbose_name='Код привязки')), + ('link_code_created_at', models.DateTimeField(blank=True, null=True, verbose_name='Код создан')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ('is_blocked', models.BooleanField(default=False, help_text='Пользователь заблокировал бота', verbose_name='Заблокирован')), + ('linked_at', models.DateTimeField(auto_now_add=True, verbose_name='Привязан')), + ('last_interaction_at', models.DateTimeField(blank=True, null=True, verbose_name='Последнее взаимодействие')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Обновлено')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='telegram_account', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Telegram аккаунт', + 'verbose_name_plural': 'Telegram аккаунты', + 'db_table': 'notifications_telegram_user', + 'indexes': [models.Index(fields=['telegram_id'], name='notificatio_telegra_4cbfaa_idx'), models.Index(fields=['link_code'], name='notificatio_link_co_58a9a9_idx'), models.Index(fields=['user'], name='notificatio_user_id_d2ff2e_idx')], + }, + ), + ] diff --git a/backend/notifications/migrations/0004_alter_telegramuser_telegram_id.py b/backend/notifications/migrations/0004_alter_telegramuser_telegram_id.py new file mode 100644 index 00000000..965c35d1 --- /dev/null +++ b/backend/notifications/migrations/0004_alter_telegramuser_telegram_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-11-21 05:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0003_alter_usernotificationsettings_email_frequency_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='telegramuser', + name='telegram_id', + field=models.BigIntegerField(blank=True, help_text='Уникальный ID пользователя в Telegram', null=True, unique=True, verbose_name='Telegram ID'), + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 4b1ef14e..b6d34542 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -4,6 +4,9 @@ from django.utils import timezone from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +# Импортируем Telegram модель +from .telegram_models import TelegramUser # noqa: F401 + class NotificationCategory(models.Model): """Категории уведомлений для группировки""" @@ -308,6 +311,21 @@ class UserNotificationSettings(models.Model): default=False, verbose_name='Отправлять на email' ) + + # Частота email рассылки + EMAIL_FREQUENCY_CHOICES = [ + ('instant', 'Мгновенно'), + ('daily', 'Ежедневный дайджест'), + ('weekly', 'Еженедельный дайджест'), + ] + email_frequency = models.CharField( + max_length=20, + choices=EMAIL_FREQUENCY_CHOICES, + default='instant', + verbose_name='Частота email рассылки', + help_text='Как часто отправлять уведомления на email (только если email включен)' + ) + send_telegram = models.BooleanField( default=False, verbose_name='Отправлять в Telegram' diff --git a/backend/notifications/services.py b/backend/notifications/services.py index 310b5ff6..3e8a1c9e 100644 --- a/backend/notifications/services.py +++ b/backend/notifications/services.py @@ -2,6 +2,7 @@ Сервисный слой для работы с уведомлениями """ import json +import logging from typing import Any, Dict, Optional from django.contrib.auth import get_user_model from django.utils import timezone @@ -12,6 +13,10 @@ from .models import ( NotificationType, UserNotificationSettings, ) +from .email_sender import EmailNotificationSender +from .telegram_sender import TelegramNotificationSender + +logger = logging.getLogger(__name__) User = get_user_model() @@ -48,12 +53,20 @@ class NotificationService: Returns: Созданное уведомление или None если тип не найден """ + logger.info( + f"[create_notification] Starting for recipient={recipient.id} " + f"type={notification_type_code} send_immediately={send_immediately}" + ) + try: notification_type = NotificationType.objects.get( code=notification_type_code, is_active=True ) except NotificationType.DoesNotExist: + logger.warning( + f"[create_notification] Type {notification_type_code} not found" + ) return None # Проверить настройки пользователя @@ -62,6 +75,9 @@ class NotificationService: ) if not settings.is_enabled: + logger.info( + f"[create_notification] Skipped - disabled for user {recipient.id}" + ) return None # Создать краткое сообщение @@ -92,26 +108,117 @@ class NotificationService: settings: Optional[UserNotificationSettings] = None ): """Отправить уведомление по всем каналам""" + logger.info( + f"[send_notification] Starting for notification={notification.id}" + ) + if settings is None: settings = NotificationService.get_user_settings( notification.recipient, notification.notification_type ) + logger.info( + f"[send_notification] Channels: web={settings.send_web} " + f"email={settings.send_email} telegram={settings.send_telegram}" + ) + # Отправить на веб (WebSocket) if settings.send_web: NotificationService.send_web_notification(notification) notification.sent_web = True - # Отправить на email (будет реализовано позже) + # Отправить на email if settings.send_email: - # TODO: Реализовать email отправку - notification.sent_email = False + try: + recipient_email = notification.recipient.email + if recipient_email: + success = EmailNotificationSender.send_notification_email( + notification=notification, + recipient_email=recipient_email, + ) + notification.sent_email = success + if success: + logger.info( + f"Email отправлен для уведомления {notification.id}" + ) + else: + notification.sent_email = False + logger.warning( + f"У пользователя {notification.recipient} нет email" + ) + except Exception as e: + notification.sent_email = False + logger.error( + f"Ошибка отправки email для {notification.id}: {e}", + exc_info=True + ) - # Отправить в Telegram (будет реализовано позже) + # Отправить в Telegram if settings.send_telegram: - # TODO: Реализовать Telegram отправку - notification.sent_telegram = False + logger.info( + f"[Telegram] Attempting to send for notification {notification.id} " + f"to user {notification.recipient.id}" + ) + try: + # Проверяем наличие Telegram Chat ID в профиле + telegram_chat_id = getattr( + notification.recipient, 'telegram', '' + ) + if telegram_chat_id: + telegram_chat_id = telegram_chat_id.strip() + + logger.info( + f"[Telegram] Chat ID for user {notification.recipient.id}: " + f"'{telegram_chat_id}'" + ) + + if telegram_chat_id: + # Получаем site_url для формирования полных ссылок + from django.conf import settings as django_settings + site_url = getattr( + django_settings, + 'SITE_URL', + 'http://localhost:9000' + ) + + logger.info( + f"[Telegram] Sending via TelegramNotificationSender " + f"to chat_id={telegram_chat_id}" + ) + + # Отправляем через Bot API по chat_id + from .telegram_sender import TelegramNotificationSender + success = TelegramNotificationSender.send_notification( + notification=notification, + chat_id=telegram_chat_id, + site_url=site_url + ) + notification.sent_telegram = success + + if success: + logger.info( + f"[Telegram] Successfully sent notification " + f"{notification.id}" + ) + else: + logger.warning( + f"[Telegram] Failed to send notification " + f"{notification.id}" + ) + else: + notification.sent_telegram = False + logger.debug( + f"[Telegram] Chat ID empty for user " + f"{notification.recipient.id}" + ) + + except Exception as e: + notification.sent_telegram = False + logger.error( + f"[Telegram] Error sending notification {notification.id}: {e}", + exc_info=True + ) # Обновить время отправки notification.sent_at = timezone.now() @@ -193,6 +300,14 @@ class NotificationService: ), } ) + + logger.debug( + f"[Settings] User {user.id} type={notification_type.code} " + f"created={created} enabled={settings.is_enabled} " + f"web={settings.send_web} email={settings.send_email} " + f"telegram={settings.send_telegram}" + ) + return settings @staticmethod diff --git a/backend/notifications/signals_telegram.py b/backend/notifications/signals_telegram.py new file mode 100644 index 00000000..c0cf7bc7 --- /dev/null +++ b/backend/notifications/signals_telegram.py @@ -0,0 +1,71 @@ +""" +Signals для автоматической отвязки Telegram при изменении контактных данных +""" +from django.db.models.signals import pre_save +from django.dispatch import receiver +from employees.models import Employee +from notifications.telegram_models import TelegramUser +import logging + +logger = logging.getLogger(__name__) + + +@receiver(pre_save, sender=Employee) +def check_telegram_field_change(sender, instance, **kwargs): + """ + Отвязывает Telegram аккаунт если пользователь изменил поле telegram + """ + # Проверяем что это обновление существующего объекта + if not instance.pk: + return + + try: + # Получаем старое значение из БД + old_instance = Employee.objects.get(pk=instance.pk) + old_telegram = old_instance.telegram.strip().lower() + new_telegram = instance.telegram.strip().lower() + + # Если поле telegram изменилось + if old_telegram != new_telegram: + # Ищем привязанный Telegram аккаунт + tg_users = TelegramUser.objects.filter( + user=instance, + is_active=True + ) + + if tg_users.exists(): + # Отвязываем все активные привязки + count = tg_users.update(is_active=False) + + logger.info( + f"Telegram отвязан для {instance.email}: " + f"поле изменено с '{old_telegram}' на '{new_telegram}' " + f"(отвязано аккаунтов: {count})" + ) + + # Опционально: отправить уведомление пользователю + from notifications.telegram_sender import ( + TelegramNotificationSender + ) + + for tg_user in tg_users: + if tg_user.telegram_id: + try: + TelegramNotificationSender.send_message( + telegram_id=tg_user.telegram_id, + text=( + "⚠️ Аккаунт отвязан\n\n" + "Ваш Telegram аккаунт был отвязан от EUSRR, " + "так как вы изменили поле 'Telegram' " + "в своём профиле.\n\n" + "Для повторной привязки используйте /link" + ), + ) + except Exception as e: + logger.error( + f"Не удалось отправить уведомление об отвязке: {e}" + ) + + except Employee.DoesNotExist: + # Объект был удален между проверкой и сохранением + pass diff --git a/backend/notifications/telegram_bot.py b/backend/notifications/telegram_bot.py new file mode 100644 index 00000000..d88bb191 --- /dev/null +++ b/backend/notifications/telegram_bot.py @@ -0,0 +1,515 @@ +""" +Telegram bot handlers для обработки команд пользователей +""" +import logging +from typing import Optional + +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove +from telegram.ext import ( + Application, + CommandHandler, + ContextTypes, + MessageHandler, + filters, + ConversationHandler, +) +from telegram.constants import ParseMode +from telegram.error import TelegramError + +from django.conf import settings +from django.utils import timezone + +from .telegram_models import TelegramUser +from employees.models import Employee + +logger = logging.getLogger(__name__) + +# Состояния для ConversationHandler +WAITING_FOR_CONTACT = 1 + + +class TelegramBotHandlers: + """ + Обработчики команд Telegram бота + """ + + @staticmethod + async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /start - приветствие и инструкции + """ + user = update.effective_user + + # Проверяем, привязан ли аккаунт + try: + tg_user = TelegramUser.objects.get( + telegram_id=user.id, + is_active=True + ) + + # Аккаунт уже привязан + employee = tg_user.user + await update.message.reply_text( + f"👋 Привет, {employee.first_name}!\n\n" + f"Ваш аккаунт уже привязан к EUSRR.\n\n" + f"Доступные команды:\n" + f"/settings - Настройки уведомлений\n" + f"/unlink - Отвязать аккаунт\n" + f"/help - Справка", + parse_mode=ParseMode.HTML + ) + + # Обновляем время взаимодействия + tg_user.update_last_interaction() + + except TelegramUser.DoesNotExist: + # Аккаунт не привязан - показываем инструкцию + await update.message.reply_text( + "👋 Добро пожаловать в бот EUSRR!\n\n" + "Этот бот будет отправлять вам уведомления из системы EUSRR.\n\n" + "🔗 Для привязки аккаунта:\n" + "1. Войдите в личный кабинет EUSRR\n" + "2. Перейдите в настройки уведомлений\n" + "3. Нажмите 'Привязать Telegram'\n" + "4. Получите код привязки\n" + "5. Отправьте команду: /link ВАШ_КОД\n\n" + "❓ Помощь: /help", + parse_mode=ParseMode.HTML + ) + + @staticmethod + async def link_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /link CODE - запрашивает код и контакт для привязки + """ + user = update.effective_user + + # Проверяем что код указан + if not context.args or len(context.args) == 0: + await update.message.reply_text( + "❌ Укажите код привязки.\n\n" + "Использование: /link ВАШ_КОД\n\n" + "Код можно получить в настройках уведомлений EUSRR.", + parse_mode=ParseMode.HTML + ) + return + + link_code = context.args[0].upper() + + # Проверяем что аккаунт еще не привязан + if TelegramUser.objects.filter( + telegram_id=user.id, + is_active=True + ).exists(): + await update.message.reply_text( + "⚠️ Ваш Telegram уже привязан к аккаунту EUSRR.\n\n" + "Если хотите привязать другой аккаунт, " + "сначала используйте /unlink", + parse_mode=ParseMode.HTML + ) + return + + # Ищем пользователя с таким кодом + try: + tg_user = TelegramUser.objects.select_related('user').get( + link_code=link_code, + telegram_id__isnull=True + ) + + # Проверяем срок действия кода + if not tg_user.is_link_code_valid(): + await update.message.reply_text( + "❌ Код привязки истёк.\n\n" + "Пожалуйста, получите новый код в настройках EUSRR.", + parse_mode=ParseMode.HTML + ) + return + + # Сохраняем код в контексте для проверки контакта + context.user_data['pending_link_code'] = link_code + context.user_data['pending_tg_user_id'] = tg_user.id + + # Создаем кнопку "Поделиться контактом" + from telegram import KeyboardButton, ReplyKeyboardMarkup + + keyboard = ReplyKeyboardMarkup( + [[KeyboardButton("📱 Поделиться контактом", + request_contact=True)]], + one_time_keyboard=True, + resize_keyboard=True + ) + + await update.message.reply_text( + "🔐 Подтверждение личности\n\n" + "Для безопасности нажмите кнопку ниже, " + "чтобы поделиться вашим контактом.\n\n" + "Мы проверим что он совпадает с данными, " + "указанными при регистрации в EUSRR.", + parse_mode=ParseMode.HTML, + reply_markup=keyboard + ) + + except TelegramUser.DoesNotExist: + await update.message.reply_text( + "❌ Неверный код привязки.\n\n" + "Проверьте код и попробуйте снова.\n" + "Если код истёк, получите новый в настройках EUSRR.", + parse_mode=ParseMode.HTML + ) + + @staticmethod + async def contact_received( + update: Update, + context: ContextTypes.DEFAULT_TYPE + ): + """ + Обработка полученного контакта для верификации + """ + from telegram import ReplyKeyboardRemove + + user = update.effective_user + contact = update.message.contact + + # Проверяем что контакт от самого пользователя + if contact.user_id != user.id: + await update.message.reply_text( + "❌ Пожалуйста, поделитесь своим контактом, " + "а не контактом другого пользователя.", + parse_mode=ParseMode.HTML, + reply_markup=ReplyKeyboardRemove() + ) + return + + # Проверяем что есть pending привязка + pending_code = context.user_data.get('pending_link_code') + pending_tg_user_id = context.user_data.get('pending_tg_user_id') + + if not pending_code or not pending_tg_user_id: + await update.message.reply_text( + "❌ Нет активной попытки привязки.\n\n" + "Сначала используйте команду /link КОД", + parse_mode=ParseMode.HTML, + reply_markup=ReplyKeyboardRemove() + ) + return + + try: + tg_user = TelegramUser.objects.select_related('user').get( + id=pending_tg_user_id, + link_code=pending_code + ) + + employee = tg_user.user + + # Проверяем совпадение данных + telegram_field = employee.telegram.strip().lower() + + # Получаем username и phone из контакта + contact_username = f"@{contact.username}".lower() \ + if contact.username else None + contact_phone = contact.phone_number + + # Проверка совпадения + match_found = False + match_type = "" + + if telegram_field: + # Проверка по username + if contact_username and telegram_field == contact_username: + match_found = True + match_type = f"username ({contact_username})" + + # Проверка по номеру телефона + elif contact_phone: + # Нормализуем номера (убираем + и пробелы) + normalized_contact = contact_phone.replace('+', '') \ + .replace(' ', '').replace('-', '') + normalized_field = telegram_field.replace('+', '') \ + .replace(' ', '').replace('-', '') + + if normalized_contact == normalized_field: + match_found = True + match_type = f"номер телефона ({contact_phone})" + + if not match_found: + await update.message.reply_text( + "❌ Данные не совпадают\n\n" + f"Ваш контакт:\n" + f"Username: {contact_username or 'не указан'}\n" + f"Телефон: {contact_phone or 'не указан'}\n\n" + f"В профиле EUSRR указано: {telegram_field or 'не указано'}\n\n" + "Обновите поле 'Telegram' в вашем профиле EUSRR " + "и попробуйте снова.", + parse_mode=ParseMode.HTML, + reply_markup=ReplyKeyboardRemove() + ) + + # Очищаем pending данные + context.user_data.clear() + return + + # ✅ Данные совпали - привязываем аккаунт + tg_user.telegram_id = user.id + tg_user.telegram_username = user.username or '' + tg_user.first_name = user.first_name or '' + tg_user.last_name = user.last_name or '' + tg_user.is_active = True + tg_user.is_blocked = False + tg_user.clear_link_code() + tg_user.update_last_interaction() + tg_user.save() + + await update.message.reply_text( + f"✅ Аккаунт успешно привязан!\n\n" + f"Подтверждено через: {match_type}\n\n" + f"👤 Привязан к: {employee.get_full_name()}\n" + f"📧 Email: {employee.email}\n\n" + f"Теперь вы будете получать уведомления EUSRR в Telegram.\n\n" + f"Доступные команды:\n" + f"/settings - Настройки уведомлений\n" + f"/unlink - Отвязать аккаунт\n" + f"/help - Справка", + parse_mode=ParseMode.HTML, + reply_markup=ReplyKeyboardRemove() + ) + + logger.info( + f"Telegram привязан через {match_type}: " + f"{user.id} -> {employee.email}" + ) + + # Очищаем pending данные + context.user_data.clear() + + except TelegramUser.DoesNotExist: + await update.message.reply_text( + "❌ Код привязки истёк или недействителен.\n\n" + "Получите новый код в настройках EUSRR.", + parse_mode=ParseMode.HTML, + reply_markup=ReplyKeyboardRemove() + ) + context.user_data.clear() + + @staticmethod + async def unlink_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /unlink - отвязка аккаунта + """ + user = update.effective_user + + try: + tg_user = TelegramUser.objects.get( + telegram_id=user.id, + is_active=True + ) + + employee_name = tg_user.user.get_full_name() + + # Отвязываем аккаунт + tg_user.is_active = False + tg_user.save() + + await update.message.reply_text( + f"✅ Аккаунт отвязан\n\n" + f"Связь с {employee_name} удалена.\n" + f"Вы больше не будете получать уведомления.\n\n" + f"Для повторной привязки используйте /link", + parse_mode=ParseMode.HTML + ) + + logger.info(f"Telegram аккаунт отвязан: {user.id}") + + except TelegramUser.DoesNotExist: + await update.message.reply_text( + "⚠️ Ваш аккаунт не привязан к EUSRR.\n\n" + "Используйте /start для инструкций по привязке.", + parse_mode=ParseMode.HTML + ) + + @staticmethod + async def settings_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /settings - показать текущие настройки + """ + user = update.effective_user + + try: + tg_user = TelegramUser.objects.get( + telegram_id=user.id, + is_active=True + ) + + employee = tg_user.user + + # Получаем настройки уведомлений + from .models import UserNotificationSettings, NotificationCategory + + settings_text = f"⚙️ Настройки уведомлений\n\n" + settings_text += f"👤 Пользователь: {employee.get_full_name()}\n" + settings_text += f"📧 Email: {employee.email}\n" + settings_text += f"🔗 Telegram: @{tg_user.telegram_username or 'не указан'}\n\n" + settings_text += "Категории уведомлений:\n\n" + + categories = NotificationCategory.objects.all() + for category in categories: + try: + user_settings = UserNotificationSettings.objects.get( + user=employee, + category=category + ) + + # Эмодзи для категорий + emoji_map = { + 'communications': '💬', + 'documents': '📄', + 'requests': '📋', + 'calendar': '📅', + 'department': '👥', + 'profile': '👤', + 'feed': '📰', + 'system': '⚙️', + } + emoji = emoji_map.get(category.code, '🔔') + + status = "✅" if user_settings.send_telegram else "❌" + settings_text += f"{emoji} {category.name}: {status}\n" + + except UserNotificationSettings.DoesNotExist: + pass + + # Создаем кнопку для перехода в настройки + site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000') + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton( + "🔧 Изменить настройки", + url=f"{site_url}/notifications/settings/" + )] + ]) + + await update.message.reply_text( + settings_text, + parse_mode=ParseMode.HTML, + reply_markup=keyboard + ) + + tg_user.update_last_interaction() + + except TelegramUser.DoesNotExist: + await update.message.reply_text( + "⚠️ Ваш аккаунт не привязан к EUSRR.\n\n" + "Используйте /start для инструкций по привязке.", + parse_mode=ParseMode.HTML + ) + + @staticmethod + async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Команда /help - справка + """ + await update.message.reply_text( + "📖 Справка по командам бота EUSRR\n\n" + "Основные команды:\n\n" + "/start - Приветствие и инструкции\n" + "/link КОД - Привязать аккаунт EUSRR\n" + "/unlink - Отвязать аккаунт\n" + "/settings - Показать текущие настройки\n" + "/help - Эта справка\n\n" + "Как привязать аккаунт:\n" + "1. Войдите в личный кабинет EUSRR\n" + "2. Откройте настройки уведомлений\n" + "3. Нажмите 'Привязать Telegram'\n" + "4. Скопируйте код привязки\n" + "5. Отправьте: /link ВАШ_КОД\n\n" + "О боте:\n" + "Этот бот отправляет уведомления из системы EUSRR " + "о новых сообщениях, документах, заявках и других событиях.\n\n" + "Настроить какие уведомления получать можно в личном кабинете EUSRR.", + parse_mode=ParseMode.HTML + ) + + @staticmethod + async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """ + Обработчик неизвестных команд и текстовых сообщений + """ + await update.message.reply_text( + "❓ Неизвестная команда.\n\n" + "Используйте /help для списка доступных команд.", + parse_mode=ParseMode.HTML + ) + + +def create_telegram_bot_application() -> Optional[Application]: + """ + Создает и настраивает приложение Telegram бота + + Returns: + Настроенное приложение или None если токен не настроен + """ + token = getattr(settings, 'TELEGRAM_BOT_TOKEN', None) + if not token: + logger.error("TELEGRAM_BOT_TOKEN не настроен в settings") + return None + + # Создаем приложение + application = Application.builder().token(token).build() + + # Регистрируем handlers + handlers = TelegramBotHandlers + + application.add_handler(CommandHandler("start", handlers.start_command)) + application.add_handler(CommandHandler("link", handlers.link_command)) + application.add_handler(CommandHandler("unlink", handlers.unlink_command)) + application.add_handler( + CommandHandler("settings", handlers.settings_command) + ) + application.add_handler(CommandHandler("help", handlers.help_command)) + + # Обработчик контактов (для верификации при привязке) + application.add_handler( + MessageHandler(filters.CONTACT, handlers.contact_received) + ) + + # Обработчик неизвестных команд и текстовых сообщений + application.add_handler( + MessageHandler( + filters.COMMAND | filters.TEXT, + handlers.unknown_command + ) + ) + + logger.info("Telegram bot application создан") + + return application + + +async def run_telegram_bot(): + """ + Запускает Telegram бота в режиме polling + """ + application = create_telegram_bot_application() + if not application: + logger.error("Не удалось создать Telegram bot application") + return + + logger.info("Запуск Telegram бота...") + + try: + # Инициализация + await application.initialize() + await application.start() + + # Запуск polling + await application.updater.start_polling( + allowed_updates=Update.ALL_TYPES + ) + + logger.info("Telegram бот запущен и слушает обновления") + + # Ожидание остановки + await application.updater.stop() + await application.stop() + await application.shutdown() + + except Exception as e: + logger.error(f"Ошибка при запуске Telegram бота: {e}", exc_info=True) diff --git a/backend/notifications/telegram_models.py b/backend/notifications/telegram_models.py new file mode 100644 index 00000000..37f5fce3 --- /dev/null +++ b/backend/notifications/telegram_models.py @@ -0,0 +1,146 @@ +""" +Модели для интеграции с Telegram +""" +from django.db import models +from django.conf import settings +from django.utils import timezone + + +class TelegramUser(models.Model): + """Связь пользователя системы с Telegram аккаунтом""" + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='telegram_account', + verbose_name='Пользователь' + ) + + telegram_id = models.BigIntegerField( + unique=True, + null=True, + blank=True, + verbose_name='Telegram ID', + help_text='Уникальный ID пользователя в Telegram' + ) + + telegram_username = models.CharField( + max_length=100, + blank=True, + null=True, + verbose_name='Telegram Username', + help_text='@username пользователя в Telegram' + ) + + first_name = models.CharField( + max_length=100, + blank=True, + verbose_name='Имя в Telegram' + ) + + last_name = models.CharField( + max_length=100, + blank=True, + verbose_name='Фамилия в Telegram' + ) + + # Код для привязки аккаунта + link_code = models.CharField( + max_length=10, + blank=True, + null=True, + unique=True, + verbose_name='Код привязки', + help_text='Временный код для привязки аккаунта' + ) + + link_code_created_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Код создан' + ) + + # Статус + is_active = models.BooleanField( + default=True, + verbose_name='Активен' + ) + + is_blocked = models.BooleanField( + default=False, + verbose_name='Заблокирован', + help_text='Пользователь заблокировал бота' + ) + + # Даты + linked_at = models.DateTimeField( + auto_now_add=True, + verbose_name='Привязан' + ) + + last_interaction_at = models.DateTimeField( + null=True, + blank=True, + verbose_name='Последнее взаимодействие' + ) + + updated_at = models.DateTimeField( + auto_now=True, + verbose_name='Обновлено' + ) + + class Meta: + verbose_name = 'Telegram аккаунт' + verbose_name_plural = 'Telegram аккаунты' + db_table = 'notifications_telegram_user' + indexes = [ + models.Index(fields=['telegram_id']), + models.Index(fields=['link_code']), + models.Index(fields=['user']), + ] + + def __str__(self): + if self.telegram_username: + username = f"@{self.telegram_username}" + elif self.telegram_id: + username = str(self.telegram_id) + else: + username = "не привязан" + return f'{self.user.get_short_name()} ({username})' + + def generate_link_code(self): + """Генерирует уникальный код для привязки аккаунта""" + import random + import string + + # Генерируем 6-значный код + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + + # Проверяем уникальность + while TelegramUser.objects.filter(link_code=code).exists(): + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + + self.link_code = code + self.link_code_created_at = timezone.now() + self.save(update_fields=['link_code', 'link_code_created_at']) + + return code + + def is_link_code_valid(self): + """Проверяет, действителен ли код привязки (не старше 15 минут)""" + if not self.link_code or not self.link_code_created_at: + return False + + age = timezone.now() - self.link_code_created_at + return age.total_seconds() < 900 # 15 минут + + def clear_link_code(self): + """Удаляет код привязки""" + self.link_code = None + self.link_code_created_at = None + self.save(update_fields=['link_code', 'link_code_created_at']) + + def update_last_interaction(self): + """Обновляет время последнего взаимодействия""" + self.last_interaction_at = timezone.now() + self.save(update_fields=['last_interaction_at']) diff --git a/backend/notifications/telegram_sender.py b/backend/notifications/telegram_sender.py new file mode 100644 index 00000000..42c788cd --- /dev/null +++ b/backend/notifications/telegram_sender.py @@ -0,0 +1,381 @@ +""" +Telegram sender для отправки уведомлений через Telegram бот +""" +import logging +from typing import Optional +import asyncio + +from django.conf import settings +from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.error import TelegramError +from telegram.constants import ParseMode + +logger = logging.getLogger(__name__) + + +class TelegramNotificationSender: + """ + Класс для отправки уведомлений через Telegram бот. + """ + + # Маппинг категорий на emoji + CATEGORY_EMOJI = { + 'communications': '💬', + 'documents': '📄', + 'requests': '📋', + 'calendar': '📅', + 'department': '👥', + 'profile': '👤', + 'feed': '📰', + 'system': '⚙️', + } + + @classmethod + def get_bot(cls) -> Optional[Bot]: + """Получить экземпляр бота""" + token = getattr(settings, 'TELEGRAM_BOT_TOKEN', None) + if not token: + logger.error("TELEGRAM_BOT_TOKEN не настроен в settings") + return None + + return Bot(token=token) + + @classmethod + async def send_notification_async( + cls, + telegram_id: int, + notification, + site_url: str = None + ) -> bool: + """ + Асинхронная отправка уведомления в Telegram + + Args: + telegram_id: ID пользователя в Telegram + notification: объект Notification + site_url: базовый URL сайта + + Returns: + True если отправлено успешно, False иначе + """ + bot = cls.get_bot() + if not bot: + return False + + try: + # Получаем категорию + category_code = notification.notification_type.category.code + category_name = notification.notification_type.category.name + emoji = cls.CATEGORY_EMOJI.get(category_code, '🔔') + + # Формируем текст сообщения + text = f"{emoji} {notification.title}\n\n" + text += f"{notification.message}\n\n" + text += f"Категория: {category_name}" + + # Создаем кнопку действия если есть URL + keyboard = None + if notification.action_url: + action_text = notification.action_text or 'Посмотреть' + + # Формируем полный URL + if site_url: + if notification.action_url.startswith('http'): + url = notification.action_url + else: + # Убираем trailing slash из site_url если есть + base_url = site_url.rstrip('/') + url = f"{base_url}{notification.action_url}" + else: + url = notification.action_url + + # Telegram не принимает: + # 1. URL вида "http://site.com/" (только корень) + # 2. localhost URL в inline кнопках + url_without_trailing = url.rstrip('/') + base_without_trailing = ( + site_url.rstrip('/') if site_url else '' + ) + + is_valid_url = ( + url_without_trailing and + url_without_trailing != base_without_trailing and + 'localhost' not in url.lower() and + '127.0.0.1' not in url + ) + + if is_valid_url: + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton(action_text, url=url)] + ]) + elif 'localhost' in url.lower() or '127.0.0.1' in url: + # Добавляем URL в текст сообщения + text += f"\n\n🔗 Ссылка: {url}" + + # Отправляем сообщение + await bot.send_message( + chat_id=telegram_id, + text=text, + parse_mode=ParseMode.HTML, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + + logger.info( + f"Telegram уведомление отправлено: {notification.id} -> {telegram_id}" + ) + return True + + except TelegramError as e: + error_msg = str(e) + + # Обработка специфичных ошибок + if "chat not found" in error_msg.lower(): + logger.warning( + f"Пользователь с Chat ID {telegram_id} не отправил /start боту" + ) + elif "bot was blocked" in error_msg.lower() or "forbidden" in error_msg.lower(): + logger.warning( + f"Пользователь с Chat ID {telegram_id} заблокировал бота" + ) + else: + logger.error( + f"Ошибка отправки Telegram уведомления {notification.id}: {e}", + exc_info=True + ) + + return False + + except Exception as e: + logger.error( + f"Неожиданная ошибка отправки Telegram: {e}", + exc_info=True + ) + return False + + @classmethod + def send_notification( + cls, + notification, + chat_id: Optional[str] = None, + site_url: str = None + ) -> bool: + """ + Синхронная обёртка для отправки уведомления + + Args: + notification: объект Notification + chat_id: Telegram Chat ID получателя (строка или число) + site_url: базовый URL сайта + + Returns: + True если отправлено успешно, False иначе + """ + # Преобразуем chat_id в int + if not chat_id: + logger.warning( + f"Chat ID не указан для {notification.recipient}" + ) + return False + + try: + telegram_id = int(chat_id) + except (ValueError, TypeError): + logger.error( + f"Неверный формат Chat ID: {chat_id}" + ) + return False + + # Получить site_url если не передан + if not site_url: + site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000') + + # Запускаем асинхронную отправку + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + return loop.run_until_complete( + cls.send_notification_async( + telegram_id, + notification, + site_url + ) + ) + + @classmethod + def send_notification_by_username( + cls, + notification, + telegram_username: str, + site_url: str = None + ) -> bool: + """ + Отправка уведомления по @username без привязки аккаунта + + Args: + notification: объект Notification + telegram_username: @username или chat_id + site_url: базовый URL сайта + + Returns: + True если отправлено успешно + """ + if not site_url: + site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000') + + # Убираем @ если есть + username = telegram_username.strip().lstrip('@') + + # Пробуем отправить по username + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def send_by_username(): + bot = cls.get_bot() + if not bot: + return False + + try: + # Формируем сообщение + category_code = notification.notification_type.category.code + emoji = cls.CATEGORY_EMOJI.get(category_code, '🔔') + + text = f"{emoji} {notification.title}\n\n" + text += f"{notification.message}\n\n" + + # Кнопка действия + keyboard = None + if notification.action_url: + action_text = notification.action_text or 'Посмотреть' + if notification.action_url.startswith('http'): + url = notification.action_url + else: + url = f"{site_url}{notification.action_url}" + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton(action_text, url=url)] + ]) + + # Отправляем по @username + await bot.send_message( + chat_id=f"@{username}", + text=text, + parse_mode=ParseMode.HTML, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + + logger.info( + f"Telegram отправлено @{username}: {notification.id}" + ) + return True + + except TelegramError as e: + error_msg = str(e) + if "chat not found" in error_msg.lower(): + logger.warning( + f"Пользователь @{username} не начал диалог с ботом. " + f"Необходимо отправить /start боту." + ) + elif "bot was blocked" in error_msg.lower(): + logger.warning( + f"Пользователь @{username} заблокировал бота." + ) + else: + logger.error( + f"Ошибка отправки @{username}: {e}", + exc_info=True + ) + return False + + return loop.run_until_complete(send_by_username()) + + @classmethod + async def send_message_async( + cls, + telegram_id: int, + text: str, + keyboard: Optional[InlineKeyboardMarkup] = None + ) -> bool: + """ + Отправка произвольного сообщения + + Args: + telegram_id: ID получателя + text: текст сообщения + keyboard: inline клавиатура (опционально) + + Returns: + True если отправлено успешно + """ + bot = cls.get_bot() + if not bot: + return False + + try: + await bot.send_message( + chat_id=telegram_id, + text=text, + parse_mode=ParseMode.HTML, + reply_markup=keyboard, + disable_web_page_preview=True, + ) + return True + except TelegramError as e: + logger.error(f"Ошибка отправки сообщения в Telegram: {e}") + return False + + @classmethod + def send_message( + cls, + telegram_id: int, + text: str, + keyboard: Optional[InlineKeyboardMarkup] = None + ) -> bool: + """Синхронная обёртка для send_message_async""" + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + return loop.run_until_complete( + cls.send_message_async(telegram_id, text, keyboard) + ) + + @classmethod + def test_bot_connection(cls) -> bool: + """ + Тестирует подключение к Telegram боту + + Returns: + True если бот доступен и настроен правильно + """ + bot = cls.get_bot() + if not bot: + return False + + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def test(): + try: + me = await bot.get_me() + logger.info( + f"Бот подключен: @{me.username} ({me.first_name})" + ) + return True + except TelegramError as e: + logger.error(f"Ошибка подключения к боту: {e}") + return False + + return loop.run_until_complete(test()) diff --git a/backend/notifications/test_views.py b/backend/notifications/test_views.py index d7b1a5d5..714773e9 100644 --- a/backend/notifications/test_views.py +++ b/backend/notifications/test_views.py @@ -14,7 +14,8 @@ def create_test_notification(request): notification_type_code='system_announcement', title='🎉 Тестовое уведомление', message='Это тестовое сообщение отправлено через API. Вы должны увидеть toast!', - action_url='/', + action_url='/notifications/', + action_text='Открыть уведомления' ) if notification: diff --git a/backend/notifications/views.py b/backend/notifications/views.py index a591db01..939a9bc6 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -5,11 +5,20 @@ from django.contrib.auth.decorators import login_required @login_required def notification_list(request): """Страница списка уведомлений с фильтрами и пагинацией""" - return render(request, 'notifications/notification_list_new.html') + return render(request, 'notifications/notification_list.html') @login_required def notification_settings(request): """Страница настроек уведомлений""" - return render(request, 'notifications/notification_settings_new.html') + from django.conf import settings + + context = { + 'telegram_bot_username': getattr( + settings, + 'TELEGRAM_BOT_USERNAME', + 'eusrr_bot' + ) + } + return render(request, 'notifications/notification_settings.html', context) diff --git a/backend/static/css/components/document-list.css b/backend/static/css/components/document-list.css index 38342c93..27cd1692 100644 --- a/backend/static/css/components/document-list.css +++ b/backend/static/css/components/document-list.css @@ -20,6 +20,22 @@ padding: var(--space-2) !important; margin: 0 !important; border-bottom: 1px solid var(--bs-border-color); + transition: background-color 0.2s ease; + cursor: pointer; +} + +#docList .doc-row:hover { + background-color: color-mix(in srgb, var(--bs-primary) 4%, transparent); +} + +#docList .doc-row.doc-unacked { + background-color: color-mix(in srgb, var(--bs-warning) 8%, transparent); + border-left: 3px solid var(--bs-warning); + padding-left: calc(var(--space-2) - 3px) !important; +} + +#docList .doc-row.doc-unacked:hover { + background-color: color-mix(in srgb, var(--bs-warning) 15%, transparent); } #docList .doc-row:last-child { @@ -50,6 +66,26 @@ display: flex; align-items: center; gap: .4rem; + flex-wrap: wrap; +} + +@media (max-width: 576px) { + #docList .feed-hd { + grid-template-columns: 28px 1fr; + gap: .5rem; + } + + .doc-actions { + grid-column: 2; + margin-top: .5rem; + justify-content: flex-start; + } + + .doc-actions .btn, + .doc-actions .btn-icon { + font-size: .85rem; + padding: .35rem .65rem; + } } .scope-switch .btn { diff --git a/backend/static/js/components/documentAcknowledgeHandler.js b/backend/static/js/components/documentAcknowledgeHandler.js new file mode 100644 index 00000000..41d5e5d7 --- /dev/null +++ b/backend/static/js/components/documentAcknowledgeHandler.js @@ -0,0 +1,127 @@ +/** + * @module documentAcknowledgeHandler + * @description Обработчик автоматического ознакомления с документами. + * При клике на неознакомленный документ автоматически отправляет POST запрос + * на /api/v1/documents/{id}/acknowledge/ и обновляет UI. + * + * Использование: + * import { initDocumentAcknowledge } from './documentAcknowledgeHandler.js'; + * + * initDocumentAcknowledge({ + * apiDetailBase: '/api/v1/documents/', + * headers: { 'Authorization': 'Bearer TOKEN' } + * }); + */ + +/** + * Инициализирует обработчик автоматического ознакомления с документами. + * @param {Object} options - Опции инициализации + * @param {string} options.apiDetailBase - Базовый URL для API документов + * @param {Object} [options.headers={}] - HTTP заголовки для запросов + * @returns {Object} API с методом destroy + */ +export function initDocumentAcknowledge(options) { + const { + apiDetailBase, + headers = {} + } = options; + + const listElement = document.getElementById('docList'); + + if (!listElement) { + console.warn('initDocumentAcknowledge: элемент #docList не найден'); + return { destroy: () => {} }; + } + + /** + * Отправляет ознакомление на сервер. + * @param {number} docId - ID документа + * @returns {Promise} - true если успешно + */ + async function acknowledgeDocument(docId) { + try { + const response = await fetch(`${apiDetailBase}${docId}/acknowledge/`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json' + } + }); + + if (response.ok || response.status === 201) { + return true; + } else { + console.error('Не удалось отметить ознакомление:', response.status); + return false; + } + } catch (error) { + console.error('Ошибка при отправке ознакомления:', error); + return false; + } + } + + /** + * Обновляет UI документа после ознакомления. + * @param {HTMLElement} docRow - Элемент строки документа + */ + function updateDocumentUI(docRow) { + // Убираем класс неознакомленного + docRow.classList.remove('doc-unacked'); + + // Обновляем data-атрибут + docRow.dataset.isAcked = '1'; + + // Добавляем бейдж "Ознакомлен" если его еще нет + const actions = docRow.querySelector('.doc-actions'); + if (actions && !actions.querySelector('.badge-acked')) { + const badge = document.createElement('span'); + badge.className = 'badge-acked'; + badge.textContent = '✓ Ознакомлен'; + actions.insertBefore(badge, actions.firstChild); + } + } + + /** + * Обработчик клика по документу. + */ + async function handleDocumentClick(e) { + // Проверяем, что клик был по ссылке на документ + const link = e.target.closest('.doc-link'); + if (!link) return; + + const docId = link.dataset.docId; + if (!docId) return; + + // Находим строку документа + const docRow = link.closest('.doc-row'); + if (!docRow) return; + + // Проверяем, не ознакомлен ли уже + const isAcked = docRow.dataset.isAcked === '1'; + if (isAcked) return; // Уже ознакомлен, ничего не делаем + + // Отправляем ознакомление асинхронно (не блокируем открытие файла) + acknowledgeDocument(docId).then(success => { + if (success) { + updateDocumentUI(docRow); + } + }); + } + + // Установка обработчика + listElement.addEventListener('click', handleDocumentClick); + + /** + * Функция для удаления обработчика. + */ + function destroy() { + listElement.removeEventListener('click', handleDocumentClick); + } + + return { destroy }; +} + +// Экспорт для совместимости с неModular кодом +if (typeof window !== 'undefined') { + window.initDocumentAcknowledge = initDocumentAcknowledge; +} diff --git a/backend/static/js/components/documentCrudHandler.js b/backend/static/js/components/documentCrudHandler.js index 4fce6012..15f79908 100644 --- a/backend/static/js/components/documentCrudHandler.js +++ b/backend/static/js/components/documentCrudHandler.js @@ -17,12 +17,15 @@ */ /** - * Переключает видимость блока получателей в зависимости от чекбокса "Отправить всем". + * Переключает видимость блоков получателей в зависимости от чекбокса "Отправить всем". * @param {HTMLInputElement} checkbox - Чекбокс "sent_to_all" - * @param {HTMLElement} block - Блок с виджетом получателей + * @param {HTMLElement} deptBlock - Блок с виджетом отделов + * @param {HTMLElement} recipBlock - Блок с виджетом получателей */ -function toggleRecipientsBlock(checkbox, block) { - block.hidden = checkbox.checked; // Скрыть, если "всем" +function toggleRecipientsBlocks(checkbox, deptBlock, recipBlock) { + const hidden = checkbox.checked; + if (deptBlock) deptBlock.hidden = hidden; + if (recipBlock) recipBlock.hidden = hidden; } /** @@ -37,6 +40,64 @@ function appendRecipientIds(formData, ids) { } } +/** + * Добавляет ID отделов в FormData. + * @param {FormData} formData - Объект FormData + * @param {Array} ids - Массив ID отделов + */ +function appendDepartmentIds(formData, ids) { + // API ожидает повторяемые department_ids + for (const id of ids) { + formData.append('department_ids', String(id)); + } +} + +/** + * Загружает список отделов из API и заполняет select элементы. + * @param {string} departmentsApi - URL API для получения списка отделов + * @param {Object} headers - HTTP заголовки для запросов + * @param {HTMLSelectElement} createSelect - Select для формы создания + * @param {HTMLSelectElement} editSelect - Select для формы редактирования + */ +async function loadDepartments(departmentsApi, headers, createSelect, editSelect) { + if (!departmentsApi || (!createSelect && !editSelect)) { + console.warn('loadDepartments: отсутствует URL API или select элементы'); + return; + } + + try { + const response = await fetch(departmentsApi, { headers }); + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + + const data = await response.json(); + const departments = data.results || data || []; + + // Заполняем оба select'а + [createSelect, editSelect].forEach(select => { + if (!select) return; + + select.innerHTML = ''; + departments.forEach(dept => { + const option = document.createElement('option'); + option.value = dept.id; + option.textContent = dept.name; + select.appendChild(option); + }); + }); + + console.log(`Загружено отделов: ${departments.length}`); + } catch (error) { + console.error('Ошибка загрузки отделов:', error); + // Показываем сообщение в select'ах + [createSelect, editSelect].forEach(select => { + if (!select) return; + select.innerHTML = ''; + }); + } +} + /** * Инициализирует обработчики CRUD операций для документов. * @param {Object} options - Опции инициализации @@ -45,6 +106,7 @@ function appendRecipientIds(formData, ids) { * @param {Object} [options.headers={}] - HTTP заголовки для запросов * @param {Object} options.createPicker - Экземпляр RecipientPicker для формы создания * @param {Object} options.editPicker - Экземпляр RecipientPicker для формы редактирования + * @param {string} [options.departmentsApi] - URL API для получения списка отделов * @returns {Object} API с методом destroy */ export function initDocumentCrud(options) { @@ -53,19 +115,24 @@ export function initDocumentCrud(options) { apiDetailBase, headers = {}, createPicker, - editPicker + editPicker, + departmentsApi } = options; // Элементы формы создания const createForm = document.getElementById('docCreateForm'); const createAllCheckbox = document.getElementById('createSentToAll'); - const createBlock = document.getElementById('createRecipientsBlock'); + const createDeptBlock = document.getElementById('createDepartmentsBlock'); + const createRecipBlock = document.getElementById('createRecipientsBlock'); + const createDeptSelect = document.getElementById('createDepartments'); // Элементы формы редактирования const editModal = document.getElementById('docEditModal'); const editForm = document.getElementById('docEditForm'); const editAllCheckbox = document.getElementById('editSentToAll'); - const editBlock = document.getElementById('editRecipientsBlock'); + const editDeptBlock = document.getElementById('editDepartmentsBlock'); + const editRecipBlock = document.getElementById('editRecipientsBlock'); + const editDeptSelect = document.getElementById('editDepartments'); // Список документов const listElement = document.getElementById('docList'); @@ -75,18 +142,23 @@ export function initDocumentCrud(options) { return { destroy: () => {} }; } + // Загружаем список отделов + if (departmentsApi) { + loadDepartments(departmentsApi, headers, createDeptSelect, editDeptSelect); + } + // Инициализация переключения блоков получателей - if (createAllCheckbox && createBlock) { - toggleRecipientsBlock(createAllCheckbox, createBlock); + if (createAllCheckbox) { + toggleRecipientsBlocks(createAllCheckbox, createDeptBlock, createRecipBlock); createAllCheckbox.addEventListener('change', () => { - toggleRecipientsBlock(createAllCheckbox, createBlock); + toggleRecipientsBlocks(createAllCheckbox, createDeptBlock, createRecipBlock); }); } - if (editAllCheckbox && editBlock) { - toggleRecipientsBlock(editAllCheckbox, editBlock); + if (editAllCheckbox) { + toggleRecipientsBlocks(editAllCheckbox, editDeptBlock, editRecipBlock); editAllCheckbox.addEventListener('change', () => { - toggleRecipientsBlock(editAllCheckbox, editBlock); + toggleRecipientsBlocks(editAllCheckbox, editDeptBlock, editRecipBlock); }); } @@ -100,14 +172,31 @@ export function initDocumentCrud(options) { formData.set('sent_to_all', createAllCheckbox.checked ? 'true' : 'false'); if (!createAllCheckbox.checked) { - const ids = createPicker.getIds(); - if (ids.length === 0) { - alert('Выберите получателей или включите «Отправить всем».'); + // Собираем выбранные ID отделов + const deptIds = createDeptSelect + ? Array.from(createDeptSelect.selectedOptions).map(opt => opt.value) + : []; + + // Собираем выбранные ID получателей + const recipientIds = createPicker.getIds(); + + if (deptIds.length === 0 && recipientIds.length === 0) { + alert('Выберите отделы, получателей или включите «Отправить всем».'); return; } - appendRecipientIds(formData, ids); + + // Добавляем отделы + if (deptIds.length > 0) { + appendDepartmentIds(formData, deptIds); + } + + // Добавляем получателей + if (recipientIds.length > 0) { + appendRecipientIds(formData, recipientIds); + } } else { formData.delete('recipient_ids'); + formData.delete('department_ids'); } try { @@ -141,26 +230,42 @@ export function initDocumentCrud(options) { const sentToAll = button.getAttribute('data-doc-sent_to_all') === '1'; editAllCheckbox.checked = sentToAll; - toggleRecipientsBlock(editAllCheckbox, editBlock); + toggleRecipientsBlocks(editAllCheckbox, editDeptBlock, editRecipBlock); - // Загружаем текущих получателей документа + // Загружаем текущие данные документа (получатели и отделы) try { const response = await fetch(apiDetailBase + docId + '/', { headers }); if (response.ok) { const data = await response.json(); + + // Заполняем получателей const recipients = (data && data.recipients) ? data.recipients.map(r => ({ id: r.id, display_name: r.display_name || r.full_name || r.email || ('#' + r.id), email: r.email || '' })) : []; editPicker.setSelected(recipients); + + // Заполняем отделы + if (editDeptSelect && data.departments) { + const deptIds = data.departments.map(d => String(d.id)); + Array.from(editDeptSelect.options).forEach(option => { + option.selected = deptIds.includes(option.value); + }); + } } else { editPicker.setSelected([]); + if (editDeptSelect) { + Array.from(editDeptSelect.options).forEach(opt => opt.selected = false); + } } } catch (error) { - console.error('Ошибка загрузки получателей:', error); + console.error('Ошибка загрузки данных документа:', error); editPicker.setSelected([]); + if (editDeptSelect) { + Array.from(editDeptSelect.options).forEach(opt => opt.selected = false); + } } bootstrap.Modal.getOrCreateInstance(editModal).show(); @@ -177,14 +282,31 @@ export function initDocumentCrud(options) { formData.set('sent_to_all', editAllCheckbox.checked ? 'true' : 'false'); if (!editAllCheckbox.checked) { - const ids = editPicker.getIds(); - if (ids.length === 0) { - alert('Выберите получателей или включите «Отправить всем».'); + // Собираем выбранные ID отделов + const deptIds = editDeptSelect + ? Array.from(editDeptSelect.selectedOptions).map(opt => opt.value) + : []; + + // Собираем выбранные ID получателей + const recipientIds = editPicker.getIds(); + + if (deptIds.length === 0 && recipientIds.length === 0) { + alert('Выберите отделы, получателей или включите «Отправить всем».'); return; } - appendRecipientIds(formData, ids); + + // Добавляем отделы + if (deptIds.length > 0) { + appendDepartmentIds(formData, deptIds); + } + + // Добавляем получателей + if (recipientIds.length > 0) { + appendRecipientIds(formData, recipientIds); + } } else { formData.delete('recipient_ids'); + formData.delete('department_ids'); } // Если файл не выбран — не отправляем ключ diff --git a/backend/static/js/notifications/notification-settings.js b/backend/static/js/notifications/notification-settings.js index 9faef34e..50f6d81e 100644 --- a/backend/static/js/notifications/notification-settings.js +++ b/backend/static/js/notifications/notification-settings.js @@ -64,6 +64,7 @@ class NotificationSettingsManager { is_enabled: true, web_enabled: true, email_enabled: false, + email_frequency: 'instant', telegram_enabled: false }; @@ -112,23 +113,34 @@ class NotificationSettingsManager { id="email_${cat.code}" data-category="${cat.code}" data-field="email_enabled" - ${setting.email_enabled ? 'checked' : ''} - disabled> -