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:
admin i 2025-11-21 12:46:43 +03:00
parent 0d693fff10
commit d7c025f120
49 changed files with 3583 additions and 557 deletions

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -7,5 +7,4 @@ class DocumentsConfig(AppConfig):
def ready(self):
# импортируем сигналы, чтобы они подхватились
import documents.signals # noqa
import documents.notification_signals # noqa

View 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='Отделы-получатели'),
),
]

View File

@ -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=_('Получатели'),

View File

@ -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}")

View File

@ -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}"
)

View File

@ -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}")

View File

@ -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"))

View 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'),
),
]

View File

@ -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)

View File

@ -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")

View File

@ -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 = 'Реактивировать'

View File

@ -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)

View File

@ -8,3 +8,4 @@ class NotificationsConfig(AppConfig):
def ready(self):
import notifications.signals # noqa
import notifications.signals_telegram # noqa

View 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

View File

@ -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)

View File

@ -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}'
)
)

View File

@ -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 рассылки'),
),
]

View File

@ -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')],
},
),
]

View File

@ -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'),
),
]

View File

@ -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'

View File

@ -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

View 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

View 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)

View 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'])

View 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())

View File

@ -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:

View File

@ -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)

View File

@ -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 {

View 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;
}

View File

@ -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');
}
// Если файл не выбран — не отправляем ключ

View File

@ -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();
}
});

Binary file not shown.

View File

@ -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
});
// Инициализация модалки ознакомлений

View File

@ -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 %}

View File

@ -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">

View File

@ -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>

View File

@ -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>

View 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>

View 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 }}

View 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>

View 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 }}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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>

View File

@ -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)