feat: Add department-based document distribution with department picker in UI
- Added departments ManyToMany field to Document model - Implemented department selection in document create/edit modals - Added automatic notification creation for all department employees - Fixed document filtering to include uploaded documents in 'My Documents' - Fixed acknowledgements API to include department recipients - Added bulk notification support with configurable threshold - Removed acknowledge button, added visual highlighting for unread documents - Implemented server-side acknowledgment flow with redirect to file - Fixed mobile responsive layout for document actions - Removed temporary test files and documentation
This commit is contained in:
parent
0d693fff10
commit
d7c025f120
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -7,5 +7,4 @@ class DocumentsConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
# импортируем сигналы, чтобы они подхватились
|
||||
import documents.signals # noqa
|
||||
import documents.notification_signals # noqa
|
||||
|
||||
19
backend/documents/migrations/0002_document_departments.py
Normal file
19
backend/documents/migrations/0002_document_departments.py
Normal file
@ -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='Отделы-получатели'),
|
||||
),
|
||||
]
|
||||
@ -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=_('Получатели'),
|
||||
|
||||
@ -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}")
|
||||
@ -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}"
|
||||
)
|
||||
|
||||
@ -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}")
|
||||
@ -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"))
|
||||
|
||||
18
backend/employees/migrations/0034_alter_employee_telegram.py
Normal file
18
backend/employees/migrations/0034_alter_employee_telegram.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
@ -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 = 'Реактивировать'
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -8,3 +8,4 @@ class NotificationsConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
import notifications.signals # noqa
|
||||
import notifications.signals_telegram # noqa
|
||||
|
||||
230
backend/notifications/email_sender.py
Normal file
230
backend/notifications/email_sender.py
Normal file
@ -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
|
||||
@ -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)
|
||||
@ -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}'
|
||||
)
|
||||
)
|
||||
@ -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 рассылки'),
|
||||
),
|
||||
]
|
||||
@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
71
backend/notifications/signals_telegram.py
Normal file
71
backend/notifications/signals_telegram.py
Normal file
@ -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=(
|
||||
"⚠️ <b>Аккаунт отвязан</b>\n\n"
|
||||
"Ваш Telegram аккаунт был отвязан от EUSRR, "
|
||||
"так как вы изменили поле 'Telegram' "
|
||||
"в своём профиле.\n\n"
|
||||
"Для повторной привязки используйте /link"
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Не удалось отправить уведомление об отвязке: {e}"
|
||||
)
|
||||
|
||||
except Employee.DoesNotExist:
|
||||
# Объект был удален между проверкой и сохранением
|
||||
pass
|
||||
515
backend/notifications/telegram_bot.py
Normal file
515
backend/notifications/telegram_bot.py
Normal file
@ -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"
|
||||
"🔗 <b>Для привязки аккаунта:</b>\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(
|
||||
"🔐 <b>Подтверждение личности</b>\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(
|
||||
"❌ Пожалуйста, поделитесь <b>своим</b> контактом, "
|
||||
"а не контактом другого пользователя.",
|
||||
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(
|
||||
"❌ <b>Данные не совпадают</b>\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"✅ <b>Аккаунт успешно привязан!</b>\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"✅ <b>Аккаунт отвязан</b>\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"⚙️ <b>Настройки уведомлений</b>\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 += "<b>Категории уведомлений:</b>\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(
|
||||
"📖 <b>Справка по командам бота EUSRR</b>\n\n"
|
||||
"<b>Основные команды:</b>\n\n"
|
||||
"/start - Приветствие и инструкции\n"
|
||||
"/link КОД - Привязать аккаунт EUSRR\n"
|
||||
"/unlink - Отвязать аккаунт\n"
|
||||
"/settings - Показать текущие настройки\n"
|
||||
"/help - Эта справка\n\n"
|
||||
"<b>Как привязать аккаунт:</b>\n"
|
||||
"1. Войдите в личный кабинет EUSRR\n"
|
||||
"2. Откройте настройки уведомлений\n"
|
||||
"3. Нажмите 'Привязать Telegram'\n"
|
||||
"4. Скопируйте код привязки\n"
|
||||
"5. Отправьте: /link ВАШ_КОД\n\n"
|
||||
"<b>О боте:</b>\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)
|
||||
146
backend/notifications/telegram_models.py
Normal file
146
backend/notifications/telegram_models.py
Normal file
@ -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'])
|
||||
381
backend/notifications/telegram_sender.py
Normal file
381
backend/notifications/telegram_sender.py
Normal file
@ -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} <b>{notification.title}</b>\n\n"
|
||||
text += f"{notification.message}\n\n"
|
||||
text += f"<i>Категория: {category_name}</i>"
|
||||
|
||||
# Создаем кнопку действия если есть 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} <b>{notification.title}</b>\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())
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
127
backend/static/js/components/documentAcknowledgeHandler.js
Normal file
127
backend/static/js/components/documentAcknowledgeHandler.js
Normal file
@ -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<boolean>} - 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;
|
||||
}
|
||||
@ -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<number>} 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 = '<option disabled>Ошибка загрузки отделов</option>';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализирует обработчики 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');
|
||||
}
|
||||
|
||||
// Если файл не выбран — не отправляем ключ
|
||||
|
||||
@ -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>
|
||||
<label class="form-check-label text-muted" for="email_${cat.code}">
|
||||
<i class="bi-envelope"></i> Email (скоро)
|
||||
${setting.email_enabled ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="email_${cat.code}">
|
||||
<i class="bi-envelope"></i> Email уведомления
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${setting.email_enabled ? `
|
||||
<div class="ps-4 mb-2">
|
||||
<label class="form-label small text-muted mb-1">Частота отправки:</label>
|
||||
<select class="form-select form-select-sm email-frequency-select"
|
||||
data-category="${cat.code}"
|
||||
id="email_freq_${cat.code}">
|
||||
<option value="instant" ${setting.email_frequency === 'instant' ? 'selected' : ''}>Мгновенно</option>
|
||||
<option value="daily" ${setting.email_frequency === 'daily' ? 'selected' : ''}>Ежедневный дайджест</option>
|
||||
<option value="weekly" ${setting.email_frequency === 'weekly' ? 'selected' : ''}>Еженедельный дайджест</option>
|
||||
</select>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input channel-toggle"
|
||||
type="checkbox"
|
||||
id="telegram_${cat.code}"
|
||||
data-category="${cat.code}"
|
||||
data-field="telegram_enabled"
|
||||
${setting.telegram_enabled ? 'checked' : ''}
|
||||
disabled>
|
||||
<label class="form-check-label text-muted" for="telegram_${cat.code}">
|
||||
<i class="bi-telegram"></i> Telegram (скоро)
|
||||
${setting.telegram_enabled ? 'checked' : ''}>
|
||||
<label class="form-check-label" for="telegram_${cat.code}">
|
||||
<i class="bi-telegram"></i> Telegram
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@ -176,7 +188,23 @@ class NotificationSettingsManager {
|
||||
const field = toggle.dataset.field;
|
||||
const value = toggle.checked;
|
||||
|
||||
await this.updateSetting(category, field, value);
|
||||
// Если включили email, перерисовать карточку для показа частоты
|
||||
if (field === 'email_enabled') {
|
||||
await this.updateSetting(category, field, value);
|
||||
this.renderSettings(); // Перерисовать для показа select
|
||||
} else {
|
||||
await this.updateSetting(category, field, value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики для частоты email
|
||||
document.querySelectorAll('.email-frequency-select').forEach(select => {
|
||||
select.addEventListener('change', async (e) => {
|
||||
const category = select.dataset.category;
|
||||
const value = select.value;
|
||||
|
||||
await this.updateSetting(category, 'email_frequency', value);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -266,9 +294,75 @@ class NotificationSettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Управление привязкой Telegram (упрощенная версия)
|
||||
*/
|
||||
class TelegramLinkManager {
|
||||
constructor() {
|
||||
this.container = document.getElementById('telegramLinkContainer');
|
||||
if (!this.container) return;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.renderInstructions();
|
||||
}
|
||||
|
||||
renderInstructions() {
|
||||
const botUsername = window.TELEGRAM_BOT_USERNAME || 'eusrr_bot';
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col-md-10">
|
||||
<h6 class="mb-3">
|
||||
<i class="bi-telegram text-primary"></i>
|
||||
Настройка Telegram уведомлений
|
||||
</h6>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>📱 Как получать уведомления в Telegram:</strong>
|
||||
<ol class="mb-0 mt-3">
|
||||
<li class="mb-2">
|
||||
<strong>Откройте бота <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> в Telegram</strong><br>
|
||||
<small class="text-muted">Это официальный бот для получения информации о вашем аккаунте</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Отправьте любое сообщение боту</strong><br>
|
||||
<small class="text-muted">Бот ответит вам информацией, включая ваш Chat ID (число)</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Скопируйте ваш Chat ID</strong><br>
|
||||
<small class="text-muted">Например: <code>123456789</code></small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Добавьте Chat ID в <a href="/employees/me/" target="_blank">ваш профиль</a></strong><br>
|
||||
<small class="text-muted">Откройте профиль → Редактировать → вставьте Chat ID в поле "Telegram Chat ID"</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Включите галочки "Telegram" для нужных категорий ниже</strong><br>
|
||||
<small class="text-muted">Выберите категории уведомлений, которые хотите получать в Telegram</small>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>⚠️ ВАЖНО: Отправьте <code>/start</code> нашему боту <code><a href="https://t.me/${botUsername}" target="_blank">@${botUsername}</a></code></strong><br>
|
||||
<small class="text-muted">Иначе бот не сможет отправлять вам сообщения из-за ограничений Telegram API</small>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.getElementById('settingsContainer')) {
|
||||
window.notificationSettingsManager = new NotificationSettingsManager();
|
||||
}
|
||||
if (document.getElementById('telegramLinkContainer')) {
|
||||
window.telegramLinkManager = new TelegramLinkManager();
|
||||
}
|
||||
});
|
||||
|
||||
BIN
backend/static/sounds/notification.mp3
Normal file
BIN
backend/static/sounds/notification.mp3
Normal file
Binary file not shown.
@ -26,16 +26,31 @@
|
||||
{% if documents %}
|
||||
<div id="docList" class="feed-stream">
|
||||
{% for doc in documents %}
|
||||
<div class="doc-row" data-doc-id="{{ doc.id }}">
|
||||
<div class="doc-row {% if doc.id not in acked_ids %}doc-unacked{% endif %}"
|
||||
data-doc-id="{{ doc.id }}"
|
||||
data-is-acked="{% if doc.id in acked_ids %}1{% else %}0{% endif %}">
|
||||
<div class="feed-hd">
|
||||
<div class="feed-ico" aria-hidden="true"><i class="bi-file-earmark-text"></i></div>
|
||||
|
||||
<div class="feed-main">
|
||||
<div class="feed-author">
|
||||
{% if doc.file_url %}
|
||||
<a href="{{ doc.file_url }}" class="text-decoration-none" target="_blank" rel="noopener">
|
||||
{{ doc.title|default:"Без названия" }}
|
||||
</a>
|
||||
{# Для неознакомленных - через acknowledge URL, для ознакомленных - прямо на файл #}
|
||||
{% if doc.id not in acked_ids and scope == 'mine' %}
|
||||
<a href="{% url 'documents:acknowledge' doc.id %}"
|
||||
class="text-decoration-none doc-link"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
{{ doc.title|default:"Без названия" }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ doc.file_url }}"
|
||||
class="text-decoration-none doc-link"
|
||||
target="_blank"
|
||||
rel="noopener">
|
||||
{{ doc.title|default:"Без названия" }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ doc.title|default:"Без названия" }}
|
||||
{% endif %}
|
||||
@ -48,12 +63,10 @@
|
||||
</div>
|
||||
|
||||
<div class="doc-actions">
|
||||
{# Вкладка "Мои" — как раньше #}
|
||||
{# Вкладка "Мои" — просто бейдж для ознакомленных #}
|
||||
{% if scope == 'mine' %}
|
||||
{% if doc.id in acked_ids %}
|
||||
<span class="badge-acked">Ознакомлен</span>
|
||||
{% else %}
|
||||
<a href="{% url 'documents:acknowledge' doc.id %}" class="btn btn-outline-primary">Ознакомиться</a>
|
||||
<span class="badge-acked">✓ Ознакомлен</span>
|
||||
{% endif %}
|
||||
|
||||
{# Вкладка "Все" — кнопка модалки #}
|
||||
@ -104,11 +117,28 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ===== Пагинация (если понадобится — через next_url/prev_url) ===== #}
|
||||
{% if prev_url or next_url %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}" href="{{ prev_url|default:'#' }}">Назад</a>
|
||||
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}" href="{{ next_url|default:'#' }}">Вперёд</a>
|
||||
{# ===== Пагинация ===== #}
|
||||
{% if prev_url or next_url or page > 1 %}
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
{% if prev_url %}
|
||||
<a class="btn btn-outline-secondary" href="{{ prev_url }}">
|
||||
<i class="bi-chevron-left"></i> Назад
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn btn-outline-secondary disabled">
|
||||
<i class="bi-chevron-left"></i> Назад
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if next_url %}
|
||||
<a class="btn btn-outline-secondary" href="{{ next_url }}">
|
||||
Вперёд <i class="bi-chevron-right"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="btn btn-outline-secondary disabled">
|
||||
Вперёд <i class="bi-chevron-right"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -143,9 +173,18 @@
|
||||
<label class="form-check-label" for="createSentToAll">Отправить всем</label>
|
||||
</div>
|
||||
|
||||
<!-- Виджет отделов -->
|
||||
<div class="recipient-block" id="createDepartmentsBlock" hidden>
|
||||
<label class="form-label">Отделы-получатели</label>
|
||||
<select name="department_ids" id="createDepartments" class="form-select" multiple size="5">
|
||||
<!-- Заполняется динамически -->
|
||||
</select>
|
||||
<small class="text-muted">Удерживайте Ctrl/Cmd для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Виджет получателей -->
|
||||
<div class="recipient-block" id="createRecipientsBlock" hidden>
|
||||
<label class="form-label">Получатели</label>
|
||||
<label class="form-label">Получатели (сотрудники)</label>
|
||||
<div id="createRecipients" class="recipient-picker" data-api-employees="{% url 'api:v1:employees-list' %}">
|
||||
<div class="rp-selected" aria-live="polite"></div>
|
||||
<div class="rp-search mb-2">
|
||||
@ -192,9 +231,18 @@
|
||||
<label class="form-check-label" for="editSentToAll">Отправить всем</label>
|
||||
</div>
|
||||
|
||||
<!-- Виджет отделов -->
|
||||
<div class="recipient-block" id="editDepartmentsBlock" hidden>
|
||||
<label class="form-label">Отделы-получатели</label>
|
||||
<select name="department_ids" id="editDepartments" class="form-select" multiple size="5">
|
||||
<!-- Заполняется динамически -->
|
||||
</select>
|
||||
<small class="text-muted">Удерживайте Ctrl/Cmd для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Виджет получателей -->
|
||||
<div class="recipient-block" id="editRecipientsBlock" hidden>
|
||||
<label class="form-label">Получатели</label>
|
||||
<label class="form-label">Получатели (сотрудники)</label>
|
||||
<div id="editRecipients" class="recipient-picker" data-api-employees="{% url 'api:v1:employees-list' %}">
|
||||
<div class="rp-selected" aria-live="polite"></div>
|
||||
<div class="rp-search mb-2">
|
||||
@ -270,6 +318,7 @@
|
||||
const apiListUrl = "{{ api_document_list_url|default:'' }}";
|
||||
const apiDetailBase = "{{ api_document_detail_base|default:'' }}";
|
||||
const employeesApi = "{% url 'api:v1:employees-list' %}";
|
||||
const departmentsApi = "{% url 'api:v1:departments-list' %}";
|
||||
|
||||
// Инициализация RecipientPicker для формы создания
|
||||
const createPicker = new RecipientPicker(
|
||||
@ -289,7 +338,8 @@
|
||||
apiDetailBase: apiDetailBase,
|
||||
headers: headersAuth,
|
||||
createPicker: createPicker,
|
||||
editPicker: editPicker
|
||||
editPicker: editPicker,
|
||||
departmentsApi: departmentsApi
|
||||
});
|
||||
|
||||
// Инициализация модалки ознакомлений
|
||||
|
||||
@ -100,9 +100,9 @@
|
||||
<a href="tel:{{ emp.phone_number }}">{{ emp.phone_number }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.telegram %}<div class="ios-cell"><i class="bi-telegram"></i><span>Telegram: {{ emp.telegram }}</span></div>{% endif %}
|
||||
{% if emp.whatsapp %}<div class="ios-cell"><i class="bi-whatsapp"></i><span>WhatsApp: {{ emp.whatsapp }}</span></div>{% endif %}
|
||||
{% if emp.wechat %}<div class="ios-cell"><i class="bi-wechat"></i><span>WeChat: {{ emp.wechat }}</span></div>{% endif %}
|
||||
{% if emp.telegram %}<div class="ios-cell"><i class="bi-telegram"></i><span>{{ emp.telegram }}</span></div>{% endif %}
|
||||
{% if emp.whatsapp %}<div class="ios-cell"><i class="bi-whatsapp"></i><span>{{ emp.whatsapp }}</span></div>{% endif %}
|
||||
{% if emp.wechat %}<div class="ios-cell"><i class="bi-wechat"></i><span>{{ emp.wechat }}</span></div>{% endif %}
|
||||
{% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %}
|
||||
<div class="ios-cell"><span class="text-secondary">Контакты не указаны</span></div>
|
||||
{% endif %}
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<h5 class="card-title mb-2 d-flex align-items-center gap-2">
|
||||
{{ post.title }}
|
||||
{% if post.pinned %}
|
||||
<span class="badge bg-warning text-dark">Закреплено</span>
|
||||
<span class="badge bg-warning text-dark"></span>
|
||||
{% endif %}
|
||||
{% if department.head == user %}
|
||||
<a href="{% url 'feed:pin_post' post.pk %}" class="btn btn-sm btn-outline-warning ms-2">
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<h5 class="card-title fw-semibold">
|
||||
{{ post.title }}
|
||||
{% if post.pinned %}
|
||||
<span class="badge bg-warning text-dark ms-2">Закреплено</span>
|
||||
<span class="badge bg-warning text-dark ms-2"></span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
<p class="card-text mb-2">{{ post.body|truncatewords:32 }}</p>
|
||||
|
||||
@ -113,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% if post.pinned %}
|
||||
<span class="feed-pin"><i class="bi-pin-angle"></i> закреплено</span>
|
||||
<span class="feed-pin"><i class="bi-pin-angle"></i> </span>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
|
||||
183
backend/templates/notifications/email/digest.html
Normal file
183
backend/templates/notifications/email/digest.html
Normal file
@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ digest_name }} дайджест</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.email-wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.email-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.digest-badge {
|
||||
display: inline-block;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 15px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.email-body {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.digest-intro {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #495057;
|
||||
}
|
||||
.category-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.category-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.category-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
}
|
||||
.notification-item {
|
||||
padding: 15px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.notification-item-title {
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.notification-item-message {
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.notification-item-time {
|
||||
font-size: 12px;
|
||||
color: #adb5bd;
|
||||
}
|
||||
.notification-item-action {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.notification-item-action a {
|
||||
color: #0d6efd;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
.view-all-button {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin: 30px auto 0;
|
||||
padding: 12px 30px;
|
||||
background-color: #667eea;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.email-footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<!-- Header -->
|
||||
<div class="email-header">
|
||||
<h1>📬 {{ site_name }}</h1>
|
||||
<div class="digest-badge">
|
||||
{{ digest_name }} дайджест
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="email-body">
|
||||
<div class="digest-intro">
|
||||
<p>У вас <strong>{{ total_count }}</strong> {% if total_count == 1 %}новое уведомление{% elif total_count < 5 %}новых уведомления{% else %}новых уведомлений{% endif %}</p>
|
||||
</div>
|
||||
|
||||
{% for category_code, notifs in grouped_notifications.items %}
|
||||
<div class="category-section">
|
||||
<div class="category-header" style="border-left-color: {{ category_colors|get_item:category_code }};">
|
||||
<div class="category-icon">{{ category_icons|get_item:category_code }}</div>
|
||||
<div class="category-title">{{ notifs.0.notification_type.category.name }} ({{ notifs|length }})</div>
|
||||
</div>
|
||||
|
||||
{% for notification in notifs %}
|
||||
<div class="notification-item">
|
||||
<div class="notification-item-title">
|
||||
{{ notification.title }}
|
||||
</div>
|
||||
<div class="notification-item-message">
|
||||
{{ notification.message|truncatewords:20 }}
|
||||
</div>
|
||||
<div class="notification-item-time">
|
||||
{{ notification.created_at|date:"d.m.Y H:i" }}
|
||||
</div>
|
||||
{% if notification.action_url %}
|
||||
<div class="notification-item-action">
|
||||
<a href="{{ site_url }}{{ notification.action_url }}">{{ notification.action_text|default:"Посмотреть" }} →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<a href="{{ site_url }}/notifications/" class="view-all-button">
|
||||
Посмотреть все уведомления
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="email-footer">
|
||||
<p>
|
||||
Это автоматический дайджест от системы {{ site_name }}.<br>
|
||||
<a href="{{ site_url }}/notifications/settings/">Управление уведомлениями</a> •
|
||||
<a href="{{ site_url }}/notifications/settings/">Изменить частоту рассылки</a>
|
||||
</p>
|
||||
<p style="margin-top: 10px;">
|
||||
<a href="{{ site_url }}">{{ site_url }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
backend/templates/notifications/email/digest.txt
Normal file
23
backend/templates/notifications/email/digest.txt
Normal file
@ -0,0 +1,23 @@
|
||||
{{ site_name }} - {{ digest_name }} дайджест
|
||||
{{ '=' * 60 }}
|
||||
|
||||
У вас {{ total_count }} {% if total_count == 1 %}новое уведомление{% elif total_count < 5 %}новых уведомления{% else %}новых уведомлений{% endif %}
|
||||
|
||||
{% for category_code, notifs in grouped_notifications.items %}
|
||||
{{ category_icons|get_item:category_code }} {{ notifs.0.notification_type.category.name }} ({{ notifs|length }})
|
||||
{{ '-' * 60 }}
|
||||
|
||||
{% for notification in notifs %}
|
||||
• {{ notification.title }}
|
||||
{{ notification.message|truncatewords:30 }}
|
||||
{{ notification.created_at|date:"d.m.Y H:i" }}
|
||||
{% if notification.action_url %}→ {{ site_url }}{{ notification.action_url }}{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
Посмотреть все уведомления: {{ site_url }}/notifications/
|
||||
|
||||
Управление уведомлениями: {{ site_url }}/notifications/settings/
|
||||
{{ site_url }}
|
||||
146
backend/templates/notifications/email/notification.html
Normal file
146
backend/templates/notifications/email/notification.html
Normal file
@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ notification.title }}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.email-wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.email-header {
|
||||
background-color: {{ category_color }};
|
||||
color: #ffffff;
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.email-body {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
.notification-icon {
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.notification-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
.notification-message {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #495057;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.notification-metadata {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 25px;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.action-button {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
padding: 12px 30px;
|
||||
background-color: {{ category_color }};
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
.email-footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.email-footer a {
|
||||
color: {{ category_color }};
|
||||
text-decoration: none;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-body {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<!-- Header -->
|
||||
<div class="email-header">
|
||||
<h1>{{ site_name }}</h1>
|
||||
<div class="category-badge">
|
||||
{{ category_icon }} {{ category_name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="email-body">
|
||||
<div class="notification-icon">{{ category_icon }}</div>
|
||||
|
||||
<div class="notification-title">
|
||||
{{ notification.title }}
|
||||
</div>
|
||||
|
||||
<div class="notification-message">
|
||||
{{ notification.message|linebreaks }}
|
||||
</div>
|
||||
|
||||
{% if notification.metadata %}
|
||||
<div class="notification-metadata">
|
||||
{% for key, value in notification.metadata.items %}
|
||||
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if action_url %}
|
||||
<a href="{{ action_url }}" class="action-button">
|
||||
{{ action_text }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="email-footer">
|
||||
<p>
|
||||
Это автоматическое уведомление от системы {{ site_name }}.<br>
|
||||
<a href="{{ site_url }}/notifications/settings/">Управление уведомлениями</a>
|
||||
</p>
|
||||
<p style="margin-top: 10px;">
|
||||
<a href="{{ site_url }}">{{ site_url }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
backend/templates/notifications/email/notification.txt
Normal file
23
backend/templates/notifications/email/notification.txt
Normal file
@ -0,0 +1,23 @@
|
||||
{{ site_name }} - {{ category_name }}
|
||||
|
||||
{{ notification.title }}
|
||||
{{ '=' * 60 }}
|
||||
|
||||
{{ notification.message }}
|
||||
|
||||
{% if notification.metadata %}
|
||||
---
|
||||
{% for key, value in notification.metadata.items %}
|
||||
{{ key }}: {{ value }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if action_url %}
|
||||
{{ action_text }}: {{ action_url }}
|
||||
{% endif %}
|
||||
|
||||
---
|
||||
Это автоматическое уведомление от системы {{ site_name }}.
|
||||
Управление уведомлениями: {{ site_url }}/notifications/settings/
|
||||
|
||||
{{ site_url }}
|
||||
@ -3,74 +3,81 @@
|
||||
|
||||
{% block title %}Уведомления{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/notifications/notifications.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0"><i class="bi-bell me-2"></i>Уведомления</h4>
|
||||
<button class="btn btn-sm btn-light" id="markAllRead">
|
||||
<i class="bi-check-all me-1"></i>Отметить все прочитанными
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex gap-2 mb-3 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary category-filter active" data-category="all">
|
||||
Все
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary category-filter" data-category="communications">
|
||||
<i class="bi-chat-dots me-1"></i>Коммуникации
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info category-filter" data-category="documents">
|
||||
<i class="bi-file-earmark-text me-1"></i>Документы
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-success category-filter" data-category="requests">
|
||||
<i class="bi-clipboard-check me-1"></i>Заявления
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-warning category-filter" data-category="calendar">
|
||||
<i class="bi-calendar-event me-1"></i>Календарь
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notificationsList">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi-bell-slash fs-1"></i>
|
||||
<p class="mt-2">Уведомлений пока нет</p>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi-bell"></i> Уведомления</h2>
|
||||
<div class="btn-group">
|
||||
<button id="markAllReadBtn" class="btn btn-outline-primary">
|
||||
<i class="bi-check-all"></i> Отметить все прочитанными
|
||||
</button>
|
||||
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi-gear"></i> Настройки
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loadingSpinner" class="text-center py-3 d-none">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
|
||||
<!-- Фильтры и поиск -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="categoryFilter" class="form-label">Категория</label>
|
||||
<select id="categoryFilter" class="form-select">
|
||||
<option value="">Все категории</option>
|
||||
<option value="communications">💬 Коммуникации</option>
|
||||
<option value="documents">📄 Документы</option>
|
||||
<option value="requests">📋 Заявления</option>
|
||||
<option value="calendar">📅 Календарь</option>
|
||||
<option value="department">👥 Отдел</option>
|
||||
<option value="profile">👤 Профиль</option>
|
||||
<option value="feed">📰 Новости</option>
|
||||
<option value="system">⚙️ Система</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="statusFilter" class="form-label">Статус</label>
|
||||
<select id="statusFilter" class="form-select">
|
||||
<option value="">Все</option>
|
||||
<option value="false">Непрочитанные</option>
|
||||
<option value="true">Прочитанные</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="notificationSearch" class="form-label">Поиск</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="notificationSearch" class="form-control"
|
||||
placeholder="Поиск по заголовку или тексту...">
|
||||
<button id="searchBtn" class="btn btn-primary">
|
||||
<i class="bi-search"></i> Найти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список уведомлений -->
|
||||
<div id="notificationListContainer">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Базовая демонстрационная страница
|
||||
// Полный функционал будет реализован в следующих этапах
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Notification page initialized');
|
||||
console.log('WebSocket URL: ws://' + window.location.host + '/ws/notifications/');
|
||||
|
||||
const categoryFilters = document.querySelectorAll('.category-filter');
|
||||
categoryFilters.forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
categoryFilters.forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
console.log('Filter changed to:', this.dataset.category);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('markAllRead').addEventListener('click', function() {
|
||||
console.log('Mark all as read clicked');
|
||||
alert('Функция "Отметить все прочитанными" будет реализована в следующем этапе');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/notifications/notification-list.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Уведомления{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/notifications/notifications.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi-bell"></i> Уведомления</h2>
|
||||
<div class="btn-group">
|
||||
<button id="markAllReadBtn" class="btn btn-outline-primary">
|
||||
<i class="bi-check-all"></i> Отметить все прочитанными
|
||||
</button>
|
||||
<a href="{% url 'notifications:settings' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi-gear"></i> Настройки
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры и поиск -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="categoryFilter" class="form-label">Категория</label>
|
||||
<select id="categoryFilter" class="form-select">
|
||||
<option value="">Все категории</option>
|
||||
<option value="communications">💬 Коммуникации</option>
|
||||
<option value="documents">📄 Документы</option>
|
||||
<option value="requests">📋 Заявления</option>
|
||||
<option value="calendar">📅 Календарь</option>
|
||||
<option value="department">👥 Отдел</option>
|
||||
<option value="profile">👤 Профиль</option>
|
||||
<option value="feed">📰 Новости</option>
|
||||
<option value="system">⚙️ Система</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="statusFilter" class="form-label">Статус</label>
|
||||
<select id="statusFilter" class="form-select">
|
||||
<option value="">Все</option>
|
||||
<option value="false">Непрочитанные</option>
|
||||
<option value="true">Прочитанные</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="notificationSearch" class="form-label">Поиск</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="notificationSearch" class="form-control"
|
||||
placeholder="Поиск по заголовку или тексту...">
|
||||
<button id="searchBtn" class="btn btn-primary">
|
||||
<i class="bi-search"></i> Найти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список уведомлений -->
|
||||
<div id="notificationListContainer">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/notifications/notification-list.js' %}"></script>
|
||||
{% endblock %}
|
||||
@ -3,92 +3,143 @@
|
||||
|
||||
{% block title %}Настройки уведомлений{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/notifications/notifications.css' %}">
|
||||
<style>
|
||||
.settings-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--bs-info) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.form-check-input:disabled ~ .form-check-label {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0"><i class="bi-gear me-2"></i>Настройки уведомлений</h4>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi-gear"></i> Настройки уведомлений</h2>
|
||||
<a href="{% url 'notifications:list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi-arrow-left"></i> К уведомлениям
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Общие настройки -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-sliders"></i> Общие настройки</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="soundToggle" checked>
|
||||
<label class="form-check-label" for="soundToggle">
|
||||
<i class="bi-volume-up"></i> <strong>Звуковые уведомления</strong>
|
||||
<small class="d-block text-muted">Воспроизводить звук при получении уведомления</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="bi-info-circle"></i>
|
||||
<strong>Браузерные уведомления</strong> уже включены для вашего браузера.
|
||||
Разрешите уведомления в настройках браузера, если они не работают.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram привязка -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-telegram"></i> Telegram бот</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="telegramLinkContainer">
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Настройки по категориям -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi-list-check"></i> Настройки по категориям</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="settingsContainer">
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Информация -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-3"><i class="bi-question-circle"></i> Справка</h6>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Веб-уведомления</dt>
|
||||
<dd class="col-sm-9">Отображаются в интерфейсе системы, в колокольчике и списке уведомлений</dd>
|
||||
|
||||
<dt class="col-sm-3">Email</dt>
|
||||
<dd class="col-sm-9">
|
||||
Отправка уведомлений на вашу почту. Для включения отметьте чекбокс, затем выберите режим:
|
||||
<ul class="mb-0 mt-1">
|
||||
<li><strong>Мгновенно</strong> - каждое уведомление отправляется сразу</li>
|
||||
<li><strong>Ежедневный дайджест</strong> - все уведомления за день в одном письме</li>
|
||||
<li><strong>Еженедельный дайджест</strong> - все уведомления за неделю в одном письме</li>
|
||||
</ul>
|
||||
<small class="text-muted">Чтобы отключить email, снимите галочку с "Email уведомления"</small>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Telegram</dt>
|
||||
<dd class="col-sm-9">
|
||||
Мгновенные уведомления в Telegram бот. Для настройки:
|
||||
<ol class="mb-0 mt-1">
|
||||
<li>Узнайте ваш Chat ID через бота <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a></li>
|
||||
<li>Добавьте Chat ID в поле "Telegram Chat ID" в вашем профиле</li>
|
||||
<li>Отправьте <code>/start</code> нашему боту <code>@{{ telegram_bot_username|default:"eusrr_bot" }}</code></li>
|
||||
<li>Включите галочку "Telegram" для нужных категорий</li>
|
||||
</ol>
|
||||
<small class="text-muted">Подробная инструкция в разделе "Telegram бот" выше</small>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Браузерные уведомления</dt>
|
||||
<dd class="col-sm-9 mb-0">Всплывающие уведомления операционной системы. Управляются в настройках вашего браузера.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">
|
||||
Настройте, какие уведомления вы хотите получать и по каким каналам.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="bi-info-circle me-2"></i>
|
||||
<strong>Этап 1 завершен!</strong> Интерфейс настроек будет реализован в этапе 4.
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4">Доступные категории уведомлений:</h5>
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-chat-dots text-primary me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Коммуникации</strong>
|
||||
<div class="small text-muted">Сообщения в чатах, упоминания, ответы</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-file-earmark-text text-info me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Документы</strong>
|
||||
<div class="small text-muted">Документы на ознакомление, комментарии</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-clipboard-check text-success me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Заявления</strong>
|
||||
<div class="small text-muted">Новые заявки, изменения статусов</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-calendar-event text-warning me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Календарь</strong>
|
||||
<div class="small text-muted">Новые события, напоминания</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-people text-secondary me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Отдел</strong>
|
||||
<div class="small text-muted">Изменения в отделе, объявления</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-person-circle text-dark me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Профиль</strong>
|
||||
<div class="small text-muted">Изменения профиля, безопасность</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-newspaper text-danger me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Новости</strong>
|
||||
<div class="small text-muted">Новые посты, комментарии, реакции</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
<i class="bi-gear text-muted me-3 fs-4"></i>
|
||||
<div class="flex-grow-1">
|
||||
<strong>Система</strong>
|
||||
<div class="small text-muted">Технические работы, важные объявления</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'notifications:list' %}" class="btn btn-secondary">
|
||||
<i class="bi-arrow-left me-1"></i>Назад к уведомлениям
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Передаем имя бота в JavaScript
|
||||
window.TELEGRAM_BOT_USERNAME = '{{ telegram_bot_username|default:"eusrr_bot" }}';
|
||||
</script>
|
||||
<script src="{% static 'js/notifications/notification-settings.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@ -86,7 +86,15 @@
|
||||
<dd class="col-sm-9">Отображаются в интерфейсе системы, в колокольчике и списке уведомлений</dd>
|
||||
|
||||
<dt class="col-sm-3">Email</dt>
|
||||
<dd class="col-sm-9">Отправка уведомлений на вашу почту (будет доступно в следующих версиях)</dd>
|
||||
<dd class="col-sm-9">
|
||||
Отправка уведомлений на вашу почту. Для включения отметьте чекбокс, затем выберите режим:
|
||||
<ul class="mb-0 mt-1">
|
||||
<li><strong>Мгновенно</strong> - каждое уведомление отправляется сразу</li>
|
||||
<li><strong>Ежедневный дайджест</strong> - все уведомления за день в одном письме</li>
|
||||
<li><strong>Еженедельный дайджест</strong> - все уведомления за неделю в одном письме</li>
|
||||
</ul>
|
||||
<small class="text-muted">Чтобы отключить email, снимите галочку с "Email уведомления"</small>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-3">Telegram</dt>
|
||||
<dd class="col-sm-9">Отправка в Telegram бот (будет доступно в следующих версиях)</dd>
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
"""
|
||||
Тестовый скрипт для проверки системы уведомлений
|
||||
Запуск: python backend/test_notifications.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Настройка Django окружения
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'backend'))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings')
|
||||
django.setup()
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from notifications.services import NotificationService
|
||||
from notifications.models import NotificationCategory, NotificationType, Notification
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
print("=" * 80)
|
||||
print("ТЕСТ СИСТЕМЫ УВЕДОМЛЕНИЙ")
|
||||
print("=" * 80)
|
||||
|
||||
# Проверка категорий
|
||||
print("\n1. Проверка категорий:")
|
||||
categories = NotificationCategory.objects.all()
|
||||
print(f" Создано категорий: {categories.count()}")
|
||||
for cat in categories:
|
||||
print(f" - {cat.name} ({cat.code}): {cat.types.count()} типов")
|
||||
|
||||
# Проверка типов
|
||||
print("\n2. Проверка типов:")
|
||||
types = NotificationType.objects.all()
|
||||
print(f" Создано типов: {types.count()}")
|
||||
|
||||
# Получить первого пользователя
|
||||
print("\n3. Тестовое уведомление:")
|
||||
try:
|
||||
user = User.objects.first()
|
||||
if user:
|
||||
print(f" Пользователь: {user.get_full_name() or user.username}")
|
||||
|
||||
# Создать тестовое уведомление
|
||||
notification = NotificationService.create_notification(
|
||||
recipient=user,
|
||||
notification_type_code='system_new_feature',
|
||||
title='Система уведомлений запущена!',
|
||||
message='Базовая инфраструктура системы уведомлений успешно создана и готова к работе. Этап 1 завершен!',
|
||||
action_url='/notifications/',
|
||||
send_immediately=True
|
||||
)
|
||||
|
||||
if notification:
|
||||
print(f" ✅ Уведомление создано: ID={notification.id}")
|
||||
print(f" Заголовок: {notification.title}")
|
||||
print(f" Отправлено на веб: {notification.sent_web}")
|
||||
else:
|
||||
print(" ⚠️ Уведомление не создано (возможно, тип отключен)")
|
||||
else:
|
||||
print(" ⚠️ Пользователи не найдены")
|
||||
except Exception as e:
|
||||
print(f" ❌ Ошибка: {e}")
|
||||
|
||||
# Статистика
|
||||
print("\n4. Статистика:")
|
||||
total_notifications = Notification.objects.count()
|
||||
print(f" Всего уведомлений: {total_notifications}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("ТЕСТ ЗАВЕРШЕН")
|
||||
print("=" * 80)
|
||||
Loading…
x
Reference in New Issue
Block a user