From ca80321034e60823c90a7ca2793ba64caea3b8f4 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 10 Mar 2026 13:28:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B5=D0=B5=20=D0=B2?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BC=D1=83=D0=BD=D0=B8=D0=BA=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B8=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MESSENGER_IMPLEMENTATION_COMPLETE.md | 345 ------- .../communications/MESSENGER_IMPROVEMENTS.md | 865 ------------------ backend/communications/context_processors.py | 49 - .../notification_signals.py.old | 307 ------- backend/communications/tests.py | 3 - backend/communications/urls.py | 38 - backend/eusrr_backend/settings.py | 1 - backend/eusrr_backend/urls.py | 1 - 8 files changed, 1609 deletions(-) delete mode 100644 backend/communications/MESSENGER_IMPLEMENTATION_COMPLETE.md delete mode 100644 backend/communications/MESSENGER_IMPROVEMENTS.md delete mode 100644 backend/communications/context_processors.py delete mode 100644 backend/communications/notification_signals.py.old delete mode 100644 backend/communications/tests.py delete mode 100644 backend/communications/urls.py diff --git a/backend/communications/MESSENGER_IMPLEMENTATION_COMPLETE.md b/backend/communications/MESSENGER_IMPLEMENTATION_COMPLETE.md deleted file mode 100644 index 2290f6a7..00000000 --- a/backend/communications/MESSENGER_IMPLEMENTATION_COMPLETE.md +++ /dev/null @@ -1,345 +0,0 @@ -# Документация по обновлению мессенджера - -## Обзор - -Проведено полное обновление системы чатов EUSRR для превращения её в полнофункциональный корпоративный мессенджер. - ---- - -## 📋 Реализованные возможности - -### 1. **Модели данных** (Этапы 1-11) ✅ - -#### Обновленные модели: - -**Chat** -- Новые поля: `name`, `description`, `avatar`, `created_by`, `include_all_employees` -- Расширенные типы: `private`, `group`, `department`, `channel`, `announcement`, `global` - -**ChatUserSettings** (НОВАЯ) -- `is_pinned`, `pinned_at`, `pin_order` - закрепление чатов -- `notifications_enabled` - управление уведомлениями -- `custom_name` - персональное название чата -- `is_hidden` - скрытие чатов - -**Message** -- `reply_to` - ответы на сообщения -- `is_edited`, `edited_at`, `edit_history` - редактирование с историей -- `is_deleted`, `deleted_at`, `deleted_by` - мягкое удаление -- `has_attachments` - флаг вложений -- `is_system`, `system_type`, `system_metadata` - системные сообщения -- `is_pinned`, `pinned_by`, `pinned_at` - закрепление важных сообщений -- `reactions` (JSON) - эмодзи-реакции -- `is_forwarded`, `is_cross_chat` - специальные типы -- `thread_root`, `thread_reply_count` - треды - -**MessageAttachment** (НОВАЯ) -- Поддержка файлов: image, video, audio, file -- Автоматические превью для изображений -- Метаданные: размер, MIME-тип, оригинальное имя - -**ForwardedMessage** (НОВАЯ) -- Отслеживание цепочек пересылки -- Сохранение оригинального автора и контента -- Счетчик количества пересылок - -**MessageReply** (НОВАЯ) -- Расширенная информация об ответах -- Типы: inline, quote, thread -- Кросс-чат ответы -- Сохранение контекста при удалении оригинала - -**ChatAccessPermission** (НОВАЯ) -- Гибкие уровни доступа: read, write, moderate -- Временные права с `expires_at` -- Аудит: кто предоставил, когда - -**CrossChatMessage** (НОВАЯ) -- Отправка в чаты без членства -- Система модерации: pending, approved, rejected -- Заметки модератора - -**ChatMembership** (НОВАЯ) -- Роли: owner, admin, moderator, member, guest -- Детальные права: отправка, добавление/удаление участников, закрепление -- История: joined_at, left_at, invited_by - -**ChatReadState** (ОБНОВЛЕНА) -- `last_read_message` - явная связь -- `unread_mentions_count` - счетчик @упоминаний -- `unread_thread_replies_count` - счетчик ответов в тредах -- `is_typing`, `typing_updated_at` - индикатор набора текста - ---- - -### 2. **API Endpoints** (Этап 12) ✅ - -Создано 15 REST API эндпоинтов в `communications/api_views.py`: - -#### Управление чатами -- `POST /communications/api/chat/create/` - создание group/channel/announcement -- `POST /communications/api/chat//update/` - обновление названия/описания/аватара -- `POST /communications/api/chat//pin/` - закрепление/открепление чата -- `POST /communications/api/chat//notifications/` - управление уведомлениями - -#### Действия с сообщениями -- `POST /communications/api/message//edit/` - редактирование с историей -- `POST /communications/api/message//delete/` - мягкое удаление -- `POST /communications/api/message//react/` - добавить реакцию -- `POST /communications/api/message//unreact/` - удалить реакцию -- `POST /communications/api/message//pin/` - закрепить сообщение - -#### Вложения -- `POST /communications/api/attachment/upload/` - загрузка файлов - -#### Пересылка и ответы -- `POST /communications/api/message//forward/` - пересылка с цепочкой -- `POST /communications/api/message//reply/` - ответ (inline/quote/thread) - -#### Участники -- `POST /communications/api/chat//member/add/` - добавить участника -- `POST /communications/api/chat//member/remove/` - удалить участника - ---- - -### 3. **WebSocket Consumers** (Этап 13) ✅ - -Обновлен `communications/consumers.py`: - -#### ChatConsumer - обработка действий: -- `send_message` - отправка с поддержкой ответов -- `edit_message` - редактирование в реальном времени -- `delete_message` - удаление с уведомлением всех -- `add_reaction` / `remove_reaction` - реакции -- `typing` / `stop_typing` - индикатор набора текста - -#### Real-time события: -- `chat.message` - новое сообщение -- `chat.message_edited` - сообщение изменено -- `chat.message_deleted` - сообщение удалено -- `chat.reaction_added` / `removed` - реакции обновлены -- `chat.user_typing` / `stopped_typing` - статус набора - -#### Расширенная сериализация: -- Полная информация о сообщении -- Вложения с URL и превью -- Информация о пересылке -- Превью ответов -- Реакции всех пользователей - ---- - -### 4. **Frontend (UI/UX)** (Этап 14) ✅ - -#### JavaScript модули: - -**`chat-detail-enhanced.js`** - улучшенный интерфейс чата: -- Real-time обновления через WebSocket -- Редактирование сообщений с индикатором -- Удаление сообщений -- Быстрые реакции (6 эмодзи по наведению) -- Ответы на сообщения с превью -- Индикатор "печатает..." -- Автопереподключение WebSocket -- Отображение вложений и превью -- Информация о пересылке - -**`chat-list-enhanced.js`** - улучшенный список чатов: -- Real-time обновление последних сообщений -- Закрепление чатов с перемещением наверх -- Контекстное меню (ПКМ): - - Закрепить/открепить - - Выключить уведомления - - Скрыть чат - - Отметить прочитанным -- Индикатор "печатает..." в списке -- Анимация обновления чатов - -#### CSS стили: - -**`chat-enhanced.css`** - стили для чата: -- Превью ответов с border-left -- Информация о пересылке -- Кнопки действий при наведении -- Стили реакций (активные/неактивные) -- Быстрые реакции на hover -- Отображение вложений -- Индикатор редактирования -- Значок закрепленного сообщения -- Системные сообщения -- Анимация новых сообщений - -**`chat-list-enhanced.css`** - стили для списка: -- Контекстное меню -- Анимация обновления чата -- Стили закрепленных чатов (желтый фон) -- Значок закрепления -- Аватары чатов -- Групповые аватары - ---- - -### 5. **Админ-панель** (Этап 12) ✅ - -Обновлен `communications/admin.py`: -- Админки для всех новых моделей -- Inline-редактирование вложений -- Inline-редактирование участников -- Фильтры и поиск -- Отображение статусов (edited, deleted, pinned) - ---- - -## 🔧 Технические детали - -### Миграции -Создано 12 миграций: -- `0006` - добавление полей в Chat -- `0007` - обновление типов чатов -- `0008` - модель ChatUserSettings -- `0009` - расширение Message (19 полей) -- `0010` - модель MessageAttachment -- `0011` - ForwardedMessage, MessageReply, ChatAccessPermission, CrossChatMessage -- `0012` - ChatMembership и обновление ChatReadState - -### Зависимости -- Django Channels - WebSocket -- Pillow - обработка изображений -- Bootstrap 5 - UI компоненты -- Bootstrap Icons - иконки - -### Структура файлов -``` -communications/ -├── models.py (обновлено - 10 моделей) -├── views.py (существующие view) -├── api_views.py (НОВОЕ - 15 эндпоинтов) -├── consumers.py (обновлено - real-time) -├── admin.py (обновлено - админки) -├── urls.py (обновлено - роуты API) -└── migrations/ (12 миграций) - -static/ -├── js/ -│ ├── chat-detail-enhanced.js (НОВОЕ) -│ └── chat-list-enhanced.js (НОВОЕ) -└── css/ - └── components/ - ├── chat-enhanced.css (НОВОЕ) - └── chat-list-enhanced.css (НОВОЕ) -``` - ---- - -## 📝 Использование - -### Создание группового чата -```javascript -fetch('/communications/api/chat/create/', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-CSRFToken': getCsrfToken() - }, - body: new URLSearchParams({ - type: 'group', - name: 'Название группы', - description: 'Описание' - }) -}); -``` - -### Редактирование сообщения -```javascript -sendAction('edit_message', { - message_id: 123, - content: 'Новый текст' -}); -``` - -### Добавление реакции -```javascript -sendAction('add_reaction', { - message_id: 123, - emoji: '👍' -}); -``` - -### Закрепление чата -```javascript -fetch(`/communications/api/chat/${chatId}/pin/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-CSRFToken': getCsrfToken() - }, - body: 'pinned=true' -}); -``` - ---- - -## 🎯 Следующие шаги (опционально) - -### Возможные улучшения: -1. Упоминания (@username) с подсветкой и уведомлениями -2. Поиск по содержимому сообщений -3. Голосовые сообщения -4. Видеозвонки -5. Шифрование сообщений E2E -6. Экспорт истории чата -7. Боты и автоматизация -8. Rich text редактор (Markdown, форматирование) -9. Статусы пользователей (онлайн/офлайн/занят) -10. Push-уведомления - ---- - -## ⚠️ Известные ограничения - -1. **SQLite предупреждения** - deferrable constraints не поддерживаются (неблокирующее) -2. **Lint warnings** - несколько строк >79 символов (несущественно) -3. **UI требует подключения скриптов** - нужно добавить в шаблоны: - ```django - {% block extra_js %} - - - {% endblock %} - ``` - ---- - -## 📊 Статистика - -- **Модели**: 10 (4 новые, 6 обновленных) -- **Миграции**: 12 -- **API эндпоинты**: 15 -- **WebSocket события**: 10 типов -- **JS файлы**: 2 новых модуля (~800 строк) -- **CSS файлы**: 2 новых файла (~400 строк) -- **Общий объем кода**: ~3500 строк - ---- - -## ✅ Готовность к продакшену - -### Выполнено: -- ✅ Все миграции применены -- ✅ Модели протестированы (создание/обновление) -- ✅ API эндпоинты реализованы -- ✅ WebSocket consumers обновлены -- ✅ Frontend код написан -- ✅ Админ-панель расширена - -### Требуется: -- ⏳ Подключить новые JS/CSS файлы в шаблоны -- ⏳ Интеграционное тестирование -- ⏳ Тестирование производительности -- ⏳ Настройка прав доступа -- ⏳ Документация для пользователей - ---- - -Автор: GitHub Copilot -Дата: 20 ноября 2025 -Версия: 1.0 diff --git a/backend/communications/MESSENGER_IMPROVEMENTS.md b/backend/communications/MESSENGER_IMPROVEMENTS.md deleted file mode 100644 index 0d8c368a..00000000 --- a/backend/communications/MESSENGER_IMPROVEMENTS.md +++ /dev/null @@ -1,865 +0,0 @@ -# Улучшения модели мессенджера - -## 1. Добавить базовые поля в модель Chat - -```python -class Chat(models.Model): - # Существующие поля... - type = models.CharField(max_length=16, choices=CHAT_TYPE_CHOICES) - - # НОВЫЕ ПОЛЯ: - name = models.CharField( - max_length=255, - blank=True, - verbose_name="Название чата", - help_text="Для групповых/канальных чатов" - ) - description = models.TextField( - blank=True, - verbose_name="Описание чата" - ) - avatar = models.ImageField( - upload_to='chat_avatars/%Y/%m/', - null=True, - blank=True, - verbose_name="Аватар чата" - ) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - on_delete=models.SET_NULL, - related_name='created_chats', - verbose_name="Создатель" - ) - - # Для гибкой настройки участников - include_all_employees = models.BooleanField( - default=False, - verbose_name="Включить всех сотрудников", - help_text="Для анонсов и общих чатов" - ) -``` - -## 2. Создать модель для закрепления чатов (персональная настройка) - -```python -class ChatUserSettings(models.Model): - """Персональные настройки пользователя для чата""" - chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='user_settings') - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - - # Закрепление - is_pinned = models.BooleanField(default=False, verbose_name="Закреплен") - pinned_at = models.DateTimeField(null=True, blank=True, verbose_name="Время закрепления") - pin_order = models.IntegerField(default=0, verbose_name="Порядок закрепленных") - - # Уведомления - notifications_enabled = models.BooleanField(default=True, verbose_name="Уведомления") - - # Кастомное название (если пользователь переименовал) - custom_name = models.CharField(max_length=255, blank=True, verbose_name="Свое название") - - # Скрыть чат - is_hidden = models.BooleanField(default=False, verbose_name="Скрыт") - - class Meta: - unique_together = [('chat', 'user')] - ordering = ['-is_pinned', '-pinned_at'] -``` - -## 3. Расширить типы чатов - -```python -class Chat(models.Model): - CHAT_TYPE_CHOICES = [ - ('private', 'Личный'), # 1-на-1 - ('group', 'Групповой'), # Произвольная группа сотрудников - ('department', 'Отдел'), # Привязан к отделу - ('channel', 'Канал'), # Только админы пишут, все читают - ('announcement', 'Объявления'), # Системный канал - ] -``` - -## 4. Добавить роли участников - -```python -class ChatMembership(models.Model): - """Участие в чате с ролями""" - ROLE_CHOICES = [ - ('owner', 'Владелец'), - ('admin', 'Администратор'), - ('moderator', 'Модератор'), - ('member', 'Участник'), - ('readonly', 'Только чтение'), - ] - - chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='memberships') - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='member') - joined_at = models.DateTimeField(auto_now_add=True) - invited_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - on_delete=models.SET_NULL, - related_name='+' - ) - - # Разрешения - can_send_messages = models.BooleanField(default=True) - can_add_members = models.BooleanField(default=False) - can_edit_chat = models.BooleanField(default=False) - - class Meta: - unique_together = [('chat', 'user')] -``` - -## 5. Расширить модель Message - -```python -class Message(models.Model): - # Существующие поля... - chat = models.ForeignKey(Chat, related_name="messages", on_delete=models.CASCADE) - author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - content = models.TextField() - created_at = models.DateTimeField(auto_now_add=True, db_index=True) - - # НОВЫЕ ПОЛЯ: - - # Ответ на сообщение (упрощенная связь, детали в MessageReply) - reply_to = models.ForeignKey( - 'self', - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='direct_replies', - help_text="Простая связь для быстрого доступа" - ) - - # Редактирование - is_edited = models.BooleanField(default=False) - edited_at = models.DateTimeField(null=True, blank=True) - edit_history = models.JSONField( - default=list, - blank=True, - help_text="История редактирования [{timestamp, old_content}, ...]" - ) - - # Удаление - is_deleted = models.BooleanField(default=False) - deleted_at = models.DateTimeField(null=True, blank=True) - deleted_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='+', - help_text="Кто удалил (может отличаться от автора)" - ) - - # Вложения - has_attachments = models.BooleanField(default=False) - - # Системные сообщения (вступление, выход и т.д.) - is_system = models.BooleanField(default=False) - system_type = models.CharField(max_length=50, blank=True) - # Типы: 'user_joined', 'user_left', 'chat_created', 'chat_renamed', - # 'user_promoted', 'user_demoted', 'settings_changed' - system_metadata = models.JSONField( - default=dict, - blank=True, - help_text="Дополнительные данные для системных сообщений" - ) - - # Важные/закрепленные - is_pinned = models.BooleanField(default=False) - pinned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='+' - ) - pinned_at = models.DateTimeField(null=True, blank=True) - - # Реакции (можно хранить как JSON) - reactions = models.JSONField(default=dict, blank=True) - # Формат: {'👍': [user_id1, user_id2], '❤️': [user_id3]} - - # Флаги для специальных типов сообщений - is_forwarded = models.BooleanField(default=False) - is_cross_chat = models.BooleanField( - default=False, - help_text="Сообщение отправлено не участником чата" - ) - - # Треды (для организации обсуждений) - thread_root = models.ForeignKey( - 'self', - null=True, - blank=True, - on_delete=models.CASCADE, - related_name='thread_messages', - help_text="Корневое сообщение треда" - ) - thread_reply_count = models.IntegerField(default=0) - - class Meta: - ordering = ['created_at'] - indexes = [ - models.Index(fields=['chat', 'created_at']), - models.Index(fields=['chat', 'is_deleted', 'created_at']), - models.Index(fields=['author', 'created_at']), - models.Index(fields=['thread_root', 'created_at']), - models.Index(fields=['is_pinned', 'chat']), - ] -``` - -## 6. Добавить модель вложений - -```python -class MessageAttachment(models.Model): - ATTACHMENT_TYPE_CHOICES = [ - ('image', 'Изображение'), - ('file', 'Файл'), - ('video', 'Видео'), - ('audio', 'Аудио'), - ] - - message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='attachments') - file = models.FileField(upload_to='chat_attachments/%Y/%m/%d/') - file_type = models.CharField(max_length=20, choices=ATTACHMENT_TYPE_CHOICES) - file_name = models.CharField(max_length=255) - file_size = models.BigIntegerField() - mime_type = models.CharField(max_length=100) - uploaded_at = models.DateTimeField(auto_now_add=True) - - # Для изображений - thumbnail = models.ImageField(upload_to='chat_thumbnails/%Y/%m/%d/', null=True, blank=True) -``` - -## 6a. Улучшить модель пересылки сообщений - -```python -class ForwardedMessage(models.Model): - """Информация о пересланных сообщениях (поддержка цепочки пересылок)""" - message = models.OneToOneField( - Message, - on_delete=models.CASCADE, - related_name='forward_info', - help_text="Текущее пересланное сообщение" - ) - - # Оригинальное сообщение (первое в цепочке) - original_message = models.ForeignKey( - Message, - on_delete=models.SET_NULL, - null=True, - related_name='forwarded_copies', - help_text="Самое первое сообщение в цепочке пересылок" - ) - - # Непосредственный источник (откуда переслали) - immediate_source = models.ForeignKey( - Message, - on_delete=models.SET_NULL, - null=True, - related_name='+', - help_text="Сообщение, которое было непосредственно переслано" - ) - - original_chat = models.ForeignKey( - Chat, - on_delete=models.SET_NULL, - null=True, - related_name='+' - ) - original_author = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name='+' - ) - - forwarded_at = models.DateTimeField(auto_now_add=True) - forwarded_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='forwarded_messages', - help_text="Кто переслал" - ) - - # Счетчик пересылок (для оригинала) - forward_count = models.IntegerField(default=0, help_text="Сколько раз переслали") - - # Сохранить контент на случай удаления оригинала - preserved_content = models.TextField(blank=True) - preserved_author_name = models.CharField(max_length=255, blank=True) - - class Meta: - indexes = [ - models.Index(fields=['original_message']), - models.Index(fields=['forwarded_by', 'forwarded_at']), - ] -``` - -## 6b. Добавить модель для отправки сообщений в чужие чаты - -```python -class CrossChatMessage(models.Model): - """Сообщения, отправленные в чаты, где пользователь не является участником""" - - message = models.OneToOneField( - Message, - on_delete=models.CASCADE, - related_name='cross_chat_info' - ) - - sender = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='cross_chat_messages', - help_text="Отправитель (не член этого чата)" - ) - - target_chat = models.ForeignKey( - Chat, - on_delete=models.CASCADE, - related_name='cross_messages', - help_text="Чат-получатель" - ) - - # Разрешение на отправку - approved = models.BooleanField(default=False, help_text="Одобрено модератором") - approved_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='+', - help_text="Кто одобрил" - ) - approved_at = models.DateTimeField(null=True, blank=True) - - # Причина/контекст отправки - reason = models.CharField( - max_length=500, - blank=True, - help_text="Причина отправки сообщения в чужой чат" - ) - - # Режим отображения - DISPLAY_MODE_CHOICES = [ - ('guest', 'Гостевое (с пометкой "Внешнее сообщение")'), - ('forwarded', 'Как пересланное'), - ('announcement', 'Как объявление'), - ] - display_mode = models.CharField( - max_length=20, - choices=DISPLAY_MODE_CHOICES, - default='guest' - ) - - sent_at = models.DateTimeField(auto_now_add=True) - - class Meta: - indexes = [ - models.Index(fields=['target_chat', 'approved']), - models.Index(fields=['sender', 'sent_at']), - ] -``` - -## 7. Улучшить ChatReadState - -```python -class ChatReadState(models.Model): - chat = models.ForeignKey("Chat", on_delete=models.CASCADE, related_name="read_states") - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - - last_read_at = models.DateTimeField(null=True, blank=True) - last_read_message = models.ForeignKey( - Message, - null=True, - blank=True, - on_delete=models.SET_NULL, - help_text="Последнее прочитанное сообщение" - ) - - # Статус набора текста - is_typing = models.BooleanField(default=False) - typing_updated_at = models.DateTimeField(null=True, blank=True) - - updated_at = models.DateTimeField(auto_now=True) -``` - -## 8. Добавить модель для ответов на сообщения (расширенная) - -```python -class MessageReply(models.Model): - """Дополнительная информация об ответе на сообщение""" - - message = models.OneToOneField( - Message, - on_delete=models.CASCADE, - related_name='reply_info', - help_text="Сообщение-ответ" - ) - - replied_to = models.ForeignKey( - Message, - on_delete=models.SET_NULL, - null=True, - related_name='replies', - help_text="Исходное сообщение, на которое отвечают" - ) - - # Для ответов через чаты (если отвечаешь в другом чате) - is_cross_chat_reply = models.BooleanField(default=False) - original_chat = models.ForeignKey( - Chat, - null=True, - blank=True, - on_delete=models.SET_NULL, - related_name='+', - help_text="Чат оригинального сообщения (если отличается)" - ) - - # Сохраненный контент на случай удаления - preserved_text = models.TextField(blank=True) - preserved_author_name = models.CharField(max_length=255, blank=True) - - # Тип ответа - REPLY_TYPE_CHOICES = [ - ('inline', 'Обычный ответ'), - ('quote', 'Цитирование'), - ('thread', 'В треде'), - ] - reply_type = models.CharField( - max_length=20, - choices=REPLY_TYPE_CHOICES, - default='inline' - ) - - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - indexes = [ - models.Index(fields=['replied_to']), - ] -``` - -## 9. Добавить права доступа к чатам - -```python -class ChatAccessPermission(models.Model): - """Права пользователей на отправку сообщений в чужие чаты""" - - chat = models.ForeignKey( - Chat, - on_delete=models.CASCADE, - related_name='access_permissions' - ) - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='chat_permissions' - ) - - # Типы доступа - PERMISSION_TYPE_CHOICES = [ - ('send_messages', 'Отправка сообщений'), - ('read_only', 'Только чтение'), - ('send_with_approval', 'Отправка с модерацией'), - ] - permission_type = models.CharField( - max_length=30, - choices=PERMISSION_TYPE_CHOICES, - default='send_with_approval' - ) - - # Кто выдал право - granted_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name='+' - ) - granted_at = models.DateTimeField(auto_now_add=True) - - # Срок действия - expires_at = models.DateTimeField( - null=True, - blank=True, - help_text="Когда истекает право доступа" - ) - - is_active = models.BooleanField(default=True) - - class Meta: - unique_together = [('chat', 'user')] - indexes = [ - models.Index(fields=['chat', 'is_active']), - models.Index(fields=['user', 'is_active']), - ] -``` - -## 9. Убрать ограничения для гибкости - -Удалить constraint'ы: -- `unique_main_global_chat` -- `unique_main_department_chat` - -Заменить на поле `is_default` для автоматического выбора чата по умолчанию. - -## 10. Добавить настройки чата - -```python -class ChatSettings(models.Model): - """Глобальные настройки чата""" - chat = models.OneToOneField(Chat, on_delete=models.CASCADE, related_name='settings') - - # Кто может писать - only_admins_can_post = models.BooleanField(default=False) - - # Кто может приглашать - members_can_invite = models.BooleanField(default=True) - - # Автоудаление сообщений - auto_delete_messages_after = models.IntegerField( - null=True, - blank=True, - help_text="Автоматически удалять сообщения старше N дней" - ) - - # Максимум участников - max_members = models.IntegerField(null=True, blank=True) - - # Запретить личные сообщения между участниками - disable_dm_between_members = models.BooleanField(default=False) -``` - -## Пример миграции (поэтапно) - -### Этап 1: Базовые поля -```bash -# Добавить name, description, avatar в Chat -python manage.py makemigrations -python manage.py migrate -``` - -### Этап 2: Персональные настройки -```bash -# Создать ChatUserSettings -python manage.py makemigrations -python manage.py migrate -``` - -### Этап 3: Роли участников -```bash -# Создать ChatMembership -python manage.py makemigrations -python manage.py migrate -``` - -### Этап 4: Расширенные сообщения -```bash -# Добавить поля в Message, создать MessageAttachment -python manage.py makemigrations -python manage.py migrate -``` - -## Итоговая архитектура - -``` -Chat (базовая информация + настройки) -├── ChatUserSettings (персональные настройки каждого пользователя) -├── ChatMembership (роли и права участников) -├── ChatSettings (глобальные настройки чата) -├── ChatAccessPermission (права внешних пользователей на отправку сообщений) -├── Message -│ ├── MessageAttachment (файлы, изображения) -│ ├── ForwardedMessage (информация о пересылке с цепочкой) -│ ├── MessageReply (расширенная информация об ответах) -│ └── CrossChatMessage (сообщения из чужих чатов) -└── ChatReadState (статус прочтения) -``` - -## Новые возможности - -### 1. Пересылка сообщений -- **Простая пересылка**: отправить сообщение в другой чат с указанием источника -- **Цепочка пересылок**: отслеживание всей истории пересылки (оригинал → промежуточные → текущее) -- **Счетчик пересылок**: сколько раз сообщение было переслано -- **Сохранение контента**: если оригинал удален, контент сохраняется - -### 2. Ответы на сообщения -- **Внутри чата**: стандартный reply с цитированием -- **Кросс-чат ответы**: ответить на сообщение из другого чата -- **Типы ответов**: inline (обычный), quote (цитата), thread (тред) -- **Сохранение контекста**: текст и автор сохраняются при удалении оригинала - -### 3. Отправка в чужие чаты -- **Режимы доступа**: - - `send_messages` - свободная отправка - - `read_only` - только чтение - - `send_with_approval` - модерация перед публикацией -- **Типы отображения**: - - `guest` - с пометкой "Внешнее сообщение" - - `forwarded` - как пересланное - - `announcement` - как объявление -- **Права доступа**: временные/постоянные, с указанием срока действия -- **Модерация**: одобрение администраторами чата - -### 4. Треды (обсуждения) -- Организация дискуссий в отдельные ветки -- Счетчик ответов в треде -- Корневое сообщение треда - -## Примеры использования - -### Пересылка сообщения -```python -# В views или consumers -def forward_message(original_message_id, target_chat_id, user): - original = Message.objects.get(id=original_message_id) - - # Создаем новое сообщение - forwarded_msg = Message.objects.create( - chat_id=target_chat_id, - author=user, - content=original.content, - is_forwarded=True, - has_attachments=original.has_attachments - ) - - # Копируем вложения - for att in original.attachments.all(): - MessageAttachment.objects.create( - message=forwarded_msg, - file=att.file, - file_type=att.file_type, - file_name=att.file_name, - file_size=att.file_size, - mime_type=att.mime_type - ) - - # Находим оригинал в цепочке - if hasattr(original, 'forward_info'): - # Это уже пересланное - берем самый первый оригинал - root_original = original.forward_info.original_message - else: - root_original = original - - # Создаем информацию о пересылке - ForwardedMessage.objects.create( - message=forwarded_msg, - original_message=root_original, - immediate_source=original, - original_chat=root_original.chat, - original_author=root_original.author, - forwarded_by=user, - preserved_content=root_original.content, - preserved_author_name=root_original.author.get_full_name() - ) - - # Увеличиваем счетчик - if hasattr(root_original, 'forward_info'): - root_original.forward_info.forward_count += 1 - root_original.forward_info.save() - - return forwarded_msg -``` - -### Отправка в чужой чат -```python -def send_to_external_chat(sender, chat_id, content, reason=''): - chat = Chat.objects.get(id=chat_id) - - # Проверяем права доступа - permission = ChatAccessPermission.objects.filter( - chat=chat, - user=sender, - is_active=True - ).first() - - if not permission: - # Создаем запрос на отправку с модерацией - permission = ChatAccessPermission.objects.create( - chat=chat, - user=sender, - permission_type='send_with_approval', - granted_by=None - ) - - # Создаем сообщение - message = Message.objects.create( - chat=chat, - author=sender, - content=content, - is_cross_chat=True - ) - - # Создаем метаданные - CrossChatMessage.objects.create( - message=message, - sender=sender, - target_chat=chat, - approved=(permission.permission_type == 'send_messages'), - reason=reason, - display_mode='guest' - ) - - return message -``` - -### Ответ на сообщение из другого чата -```python -def reply_across_chats(user, original_msg_id, reply_content, target_chat_id): - original_msg = Message.objects.get(id=original_msg_id) - - # Создаем ответ в целевом чате - reply_msg = Message.objects.create( - chat_id=target_chat_id, - author=user, - content=reply_content, - reply_to=original_msg - ) - - # Создаем расширенную информацию - MessageReply.objects.create( - message=reply_msg, - replied_to=original_msg, - is_cross_chat_reply=True, - original_chat=original_msg.chat, - preserved_text=original_msg.content, - preserved_author_name=original_msg.author.get_full_name(), - reply_type='quote' - ) - - return reply_msg -``` - -## Приоритет внедрения (для MVP) - -1. **Высокий приоритет:** - - `name`, `avatar` в Chat - - `ChatUserSettings` (закрепление, уведомления) - - Расширение типов чатов (`group`, `channel`) - - `reply_to`, `is_edited`, `is_forwarded`, `is_cross_chat` в Message - - `MessageAttachment` - - `ForwardedMessage` (базовая пересылка) - - `MessageReply` (ответы в том же чате) - -2. **Средний приоритет:** - - `ChatMembership` (роли) - - `ChatAccessPermission` (права на отправку в чужие чаты) - - `CrossChatMessage` (отправка в чужие чаты с модерацией) - - Кросс-чат ответы - - Реакции на сообщения - - Системные сообщения - - История редактирования - -3. **Низкий приоритет:** - - Треды - - Автоудаление - - Расширенные разрешения - - Статус "печатает" - - Счетчики пересылок - -## Изменения в UI/UX - -После внедрения этих улучшений понадобится: - -### Основные функции -- Модалка редактирования чата (название, аватар, описание) -- Контекстное меню для закрепления чатов -- Управление участниками (добавить/удалить/изменить роль) -- Загрузка файлов в сообщениях -- Ответы на сообщения (quote UI) -- Реакции на сообщения -- Редактирование/удаление своих сообщений - -### Пересылка сообщений -- Кнопка "Переслать" в контекстном меню сообщения -- Модалка выбора чатов для пересылки (с поиском) -- Множественная пересылка (выбрать несколько чатов) -- Отображение "Переслано от [Имя]" в сообщении -- Ссылка на оригинальное сообщение (если доступно) -- Индикатор цепочки пересылок ("Переслано 5 раз") - -### Ответы между чатами -- Возможность ответить в другом чате через контекстное меню -- Выбор целевого чата для ответа -- Цитата с указанием чата-источника -- Ссылка "Перейти к оригиналу" (если есть доступ) - -### Отправка в чужие чаты -- Модалка "Отправить сообщение в чат" -- Поиск доступных чатов (даже без участия) -- Выбор режима отправки (гостевое/пересланное/объявление) -- Поле "Причина отправки" -- Статус модерации: - - "Ожидает одобрения" (желтый значок) - - "Одобрено" (зеленая галочка) - - "Отклонено" (красный крестик) -- Для модераторов: панель одобрения внешних сообщений - -### Визуальные индикаторы -- **Пересланное сообщение**: - ``` - ┌─────────────────────────────────┐ - │ 📤 Переслано от Иван Петров │ - │ Оригинал: Чат отдела IT │ - ├─────────────────────────────────┤ - │ Текст сообщения... │ - └─────────────────────────────────┘ - ``` - -- **Внешнее сообщение**: - ``` - ┌─────────────────────────────────┐ - │ 🔔 Внешнее сообщение │ - │ От: Мария Сидорова │ - │ Причина: Важное объявление │ - ├─────────────────────────────────┤ - │ Текст сообщения... │ - └─────────────────────────────────┘ - ``` - -- **Ответ из другого чата**: - ``` - ┌─────────────────────────────────┐ - │ Иван Петров → Чат отдела IT │ - │ ↳ В ответ на сообщение из │ - │ "Общий чат компании" │ - ├─────────────────────────────────┤ - │ > Исходное сообщение... │ - │ Ответ... │ - └─────────────────────────────────┘ - ``` - -### Контекстное меню сообщения (расширенное) -- Ответить -- Ответить в другом чате → -- Переслать → - - В избранное - - В другой чат... - - В несколько чатов... -- Отправить в чужой чат → -- Скопировать -- Редактировать (свои) -- Удалить (свои/админ) -- Закрепить (админ) -- Пожаловаться - -### Права и разрешения (админ панель чата) -- Настройки чата: - - Кто может писать (все/только участники/только админы) - - Разрешить внешние сообщения (да/нет/с модерацией) - - Кто может приглашать участников -- Управление внешними отправителями: - - Список пользователей с правами - - Добавить/удалить права - - Установить срок действия - - Модерация ожидающих сообщений - diff --git a/backend/communications/context_processors.py b/backend/communications/context_processors.py deleted file mode 100644 index 2ed12500..00000000 --- a/backend/communications/context_processors.py +++ /dev/null @@ -1,49 +0,0 @@ -import datetime - -from django.db import models -from django.db.models import Count, F, Q, Subquery, Value -from django.db.models.functions import Coalesce -from django.utils import timezone - -from .models import Chat, ChatReadState, Message - - -def chat_unread_total(request): - """ - Считает общее количество непрочитанных сообщений пользователя. - Использует last_read_message_id (Telegram-style) вместо last_read_at. - """ - if not request.user.is_authenticated: - return {"chat_unread_total": 0} - - user = request.user - - # last_read_message_id для каждого чата текущего юзера - last_read_msg_sq = ChatReadState.objects.filter( - chat=models.OuterRef("pk"), user=user - ).values("last_read_message_id")[:1] - - # доступные юзеру чаты - deps = getattr(user, "departments", None) - deps_qs = deps.all() if deps is not None else [] - - chats_qs = ( - Chat.objects.filter( - Q(type="global") - | Q(type="private", participants=user) - | Q(type="department", department__in=deps_qs) - ) - .distinct() - .annotate(last_read_msg_id=Coalesce(Subquery(last_read_msg_sq), Value(0))) - .annotate( - unread=Count( - "messages", - filter=Q(messages__id__gt=F("last_read_msg_id")) - & ~Q(messages__author=user), - distinct=True, - ) - ) - ) - - total = chats_qs.aggregate(total=models.Sum("unread"))["total"] or 0 - return {"chat_unread_total": total} diff --git a/backend/communications/notification_signals.py.old b/backend/communications/notification_signals.py.old deleted file mode 100644 index 111bdc4f..00000000 --- a/backend/communications/notification_signals.py.old +++ /dev/null @@ -1,307 +0,0 @@ -""" -Signals для автоматической генерации уведомлений в модуле Communications. - -Обрабатывает события: -- Новое сообщение в чате -- Упоминание (@username) в сообщении -- Ответ на сообщение -- Добавление в чат -""" - -import re -from django.db.models.signals import post_save, m2m_changed -from django.dispatch import receiver -from django.contrib.auth import get_user_model - -from .models import Message, Chat, ChatUserSettings -from notifications.signals import notify - -Employee = get_user_model() - - -def should_send_notification(chat, user): - """ - Проверяет, нужно ли отправлять уведомление пользователю для данного чата. - - Args: - chat: Chat объект - user: Employee объект - - Returns: - bool: True если нужно отправить уведомление, False если пользователь отключил уведомления - """ - try: - settings = ChatUserSettings.objects.get(chat=chat, user=user) - return settings.notifications_enabled - except ChatUserSettings.DoesNotExist: - # Если настройки не найдены, считаем что уведомления включены по умолчанию - return True - - -def get_users_with_notifications_enabled(chat, users): - """ - Получает словарь пользователей с их настройками уведомлений. - Оптимизированная версия - один запрос к БД вместо N. - - Args: - chat: Chat объект - users: QuerySet или список пользователей - - Returns: - dict: {user_id: notifications_enabled} - """ - if not users: - return {} - - user_ids = [u.id for u in users] - - # Один запрос для всех пользователей - settings = ChatUserSettings.objects.filter( - chat=chat, - user_id__in=user_ids - ).values('user_id', 'notifications_enabled') - - # Создаем словарь для быстрого доступа - settings_dict = {s['user_id']: s['notifications_enabled'] for s in settings} - - # Для пользователей без настроек - по умолчанию True - return {uid: settings_dict.get(uid, True) for uid in user_ids} - - -@receiver(post_save, sender=Message) -def create_message_notifications(sender, instance, created, **kwargs): - """ - Создает уведомления при создании нового сообщения. - - ОПТИМИЗАЦИЯ: Вся логика обработки вынесена в асинхронную Celery задачу, - чтобы не блокировать сохранение сообщения. - - Обрабатывает: - 1. Новое сообщение в чате (для всех участников кроме автора) - 2. Упоминания (@username) в тексте - 3. Ответ на сообщение (reply_to) - """ - if not created or instance.is_system or instance.is_deleted: - return - - # Отправляем уведомления напрямую - channels.py автоматически отправит через Celery - _create_message_notifications_sync(instance) - - -def _create_message_notifications_sync(instance): - """ - Синхронная версия обработки уведомлений (fallback для разработки). - Используется только когда Celery недоступен. - """ - - chat = instance.chat - author = instance.author - content = instance.content - - # Получаем всех участников чата кроме автора - # Для announcement/channel/department используем get_participants - if chat.type in ['announcement', 'channel', 'department', 'global']: - participants = chat.get_participants.exclude(id=author.id) - else: - participants = chat.participants.exclude(id=author.id) - - # ОПТИМИЗАЦИЯ: Загружаем настройки уведомлений ОДНИМ запросом для всех участников - participants_list = list(participants) - if not participants_list: - return # Нет получателей - выходим - - notification_settings = get_users_with_notifications_enabled(chat, participants_list) - - # 1. Обработка упоминаний (@username) - mentioned_users = extract_mentions(content) - mentioned_user_ids = [] - - if mentioned_users: - for email in mentioned_users: - try: - user = Employee.objects.get(email=email) - if user in participants_list and user.id != author.id: - if notification_settings.get(user.id, True): - notify.send( - sender=author, - recipient=user, - verb='chat_mention', - action_object=instance, - description=truncate_message(content, 100), - action_url='/messages', - data={ - 'title': f'Вас упомянул {author.get_full_name() or author.username}', - 'chat_id': chat.id, - 'chat_name': get_chat_name(chat), - 'message_id': instance.id, - 'author_id': author.id, - } - ) - mentioned_user_ids.append(user.id) - except Employee.DoesNotExist: - continue - - # 2. Обработка ответов на сообщения - if instance.reply_to and instance.reply_to.author_id != author.id: - original_author = instance.reply_to.author - - if original_author.id not in mentioned_user_ids: - if notification_settings.get(original_author.id, True): - notify.send( - sender=author, - recipient=original_author, - verb='chat_reply', - action_object=instance, - description=truncate_message(content, 100), - action_url='/messages', - data={ - 'title': f'{author.get_full_name() or author.username} ответил на ваше сообщение', - 'chat_id': chat.id, - 'chat_name': get_chat_name(chat), - 'message_id': instance.id, - 'reply_to_id': instance.reply_to.id, - 'author_id': author.id, - } - ) - - # 3. Обычное уведомление о новом сообщении - excluded_ids = set(mentioned_user_ids) - if instance.reply_to and instance.reply_to.author_id != author.id: - excluded_ids.add(instance.reply_to.author_id) - - if chat.type == 'announcement': - notification_type = 'announcement_new_message' - title = f'Новое объявление от {author.get_full_name() or author.username}' - is_announcement = True - else: - notification_type = 'chat_new_message' - if chat.type == 'private': - title = f'Новое сообщение от {author.get_full_name() or author.username}' - else: - title = f'{author.get_full_name() or author.username} в {get_chat_name(chat)}' - is_announcement = False - - for recipient in participants_list: - if recipient.id in excluded_ids: - continue - - if not notification_settings.get(recipient.id, True): - continue - - metadata = { - 'chat_id': chat.id, - 'chat_name': get_chat_name(chat), - 'message_id': instance.id, - 'author_id': author.id, - } - if is_announcement: - metadata['is_announcement'] = True - - notify.send( - sender=author, - recipient=recipient, - verb=notification_type, - action_object=instance, - description=truncate_message(content, 150 if is_announcement else 100), - action_url='/messages', - data={**metadata, 'title': title}, - ) - - -@receiver(m2m_changed, sender=Chat.participants.through) -def create_chat_added_notifications(sender, instance, action, pk_set, **kwargs): - """ - Создает уведомления когда пользователя добавляют в чат. - """ - if action != 'post_add': - return - - chat = instance - - # Получаем информацию о том, кто добавил (если доступно) - # Это может быть последний изменивший или создатель чата - added_by = getattr(chat, 'created_by', None) or chat.participants.first() - - # ОПТИМИЗАЦИЯ: Получаем всех новых пользователей одним запросом - new_users = Employee.objects.filter(id__in=pk_set) - - # ОПТИМИЗАЦИЯ: Загружаем настройки одним запросом - notification_settings = get_users_with_notifications_enabled(chat, new_users) - - # Создаем уведомления для новых участников - for user in new_users: - # Не отправляем уведомление тому, кто сам себя добавил - if added_by and user.id == added_by.id: - continue - - # Проверяем настройки уведомлений (из предзагруженного словаря) - if not notification_settings.get(user.id, True): - continue - - notify.send( - sender=added_by, - recipient=user, - verb='chat_added_to_chat', - action_object=chat, - description=f'Вы были добавлены в чат "{get_chat_name(chat)}"', - action_url='/messages', - data={ - 'title': 'Вас добавили в чат', - 'added_by_id': added_by.id if added_by else None, - } - ) - - -# ===== Вспомогательные функции ===== - -def extract_mentions(text): - """ - Извлекает email'ы из упоминаний вида @email. - - Returns: - list: Список email'ов без символа @ - """ - if not text: - return [] - - # Паттерн: @ + email формат - pattern = r'@([\w.+-]+@[\w.-]+\.[\w]+)' - mentions = re.findall(pattern, text) - - return list(set(mentions)) # Уникальные значения - - -def truncate_message(text, max_length=100): - """ - Обрезает текст сообщения до указанной длины. - """ - if not text: - return '' - - if len(text) <= max_length: - return text - - return text[:max_length - 3] + '...' - - -def get_chat_name(chat): - """ - Возвращает название чата в зависимости от типа. - """ - if chat.name: - return chat.name - - if chat.type == 'global': - return 'Глобальный чат' - - if chat.type == 'department' and chat.department: - return f'Чат отдела: {chat.department.name}' - - if chat.type == 'private': - # Для приватного чата можно вернуть имена участников - participants = chat.participants.all()[:2] - if participants: - names = [p.get_full_name() or p.username for p in participants] - return ', '.join(names) - - return 'Чат' diff --git a/backend/communications/tests.py b/backend/communications/tests.py deleted file mode 100644 index 7ce503c2..00000000 --- a/backend/communications/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/backend/communications/urls.py b/backend/communications/urls.py deleted file mode 100644 index de0cc951..00000000 --- a/backend/communications/urls.py +++ /dev/null @@ -1,38 +0,0 @@ -# backend/communications/urls.py - -from django.urls import path -from django.views.generic import TemplateView - -from . import views - -app_name = "communications" - -urlpatterns = [ - # UI Views - path("chats/", views.ChatListView.as_view(), name="chat_list"), - path( - "chats//", - views.ChatDetailView.as_view(), - name="chat_detail" - ), - path( - "test/websocket-events/", - TemplateView.as_view(template_name="test_websocket_events.html"), - name="test_websocket_events" - ), - path( - "chats/start//", - views.start_private_chat, - name="start_private_chat" - ), - path( - "chats//mark-read/", - views.chat_mark_read, - name="chat_mark_read" - ), - path( - "start/private//", - views.start_private_chat, - name="start_private_chat" - ), -] diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index e29a2680..66fecae1 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -98,7 +98,6 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", - "communications.context_processors.chat_unread_total", "eusrr_backend.context_processors.branding", ], }, diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 4631e071..7bc8f582 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -28,7 +28,6 @@ def serve_service_worker(request): urlpatterns = [ path("admin/", admin.site.urls), path("documents/", include("documents.urls", namespace="documents")), - path("communications/", include("communications.urls", namespace="communications")), path("api/", include(("api.urls", "api"), namespace="api")), # Service Worker должен быть в корне для правильного scope path("sw.js", serve_service_worker, name="sw"),