refactor(communications): Полное удаление проектно-специфичного типа department

BREAKING CHANGE: Тип чата 'department' полностью удален из модуля.

Для миграции существующих department чатов используйте:
- type='group' вместо type='department'
- context_object (GenericFK) для привязки к объектам
- flags={'is_department': True} для пометки
- COMMUNICATIONS_PARTICIPANT_RESOLVER callback для логики участников

Удалено из:
- models.py: тип 'department' из CHAT_TYPE_CHOICES
- models.py: упоминания Department из комментариев
- api/viewsets.py: логика фильтрации по department (30 строк)
- consumers.py: WebSocket логика для department (19 строк)
- utils.py: проверки доступа для department
- rules.py: is_department_chat → deprecated_is_department_chat (disabled)
- notifications/: обработка department в handlers и config
- signals.py: комментарии обновлены на универсальные

Добавлено:
- migrations/0037: удаление department из choices
- Обновлен help_text для context_content_type (без Department)
- Поддержка type='comments' в utils.py и consumers.py

Результат:
✅ 100% независимость от проекта EUSRR
✅ Нет импортов из employees
✅ Нет упоминаний department (кроме deprecated функции)
✅ Универсальный модуль готов для PyPI

Статистика: 9 файлов, -85/+44 строк
This commit is contained in:
lizerk 2026-03-11 17:16:10 +03:00
parent 6ddd3f69ba
commit 5ebf9dd71a
9 changed files with 44 additions and 85 deletions

View File

@ -85,24 +85,8 @@ class ChatViewSet(viewsets.ModelViewSet):
user = self.request.user
from django.contrib.contenttypes.models import ContentType
# Подготовка данных для фильтрации по department через GenericFK
dept_ids = list(user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True))
# GenericFK support для department чатов
dept_ct = None
if dept_ids:
# Получаем ContentType динамически без импорта Department
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
pass
queryset = Chat.objects.filter(
Q(participants=user)
# GenericFK context_object для department чатов
| (Q(context_content_type=dept_ct, context_object_id__in=dept_ids) if dept_ct else Q(pk__in=[]))
# include_all_users для глобальных чатов
| Q(include_all_users=True)
).select_related(
@ -570,23 +554,9 @@ class MessageViewSet(viewsets.ModelViewSet):
user = self.request.user
from django.contrib.contenttypes.models import ContentType
# Подготовка данных для фильтрации
dept_ids = list(user.departments_links.filter(
is_active=True
).values_list('department_id', flat=True))
# GenericFK support для department чатов
dept_ct = None
if dept_ids:
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
pass
# Чаты пользователя
user_chats = Chat.objects.filter(
Q(participants=user)
| (Q(context_content_type=dept_ct, context_object_id__in=dept_ids) if dept_ct else Q(pk__in=[])) # GenericFK
| Q(include_all_users=True)
)

View File

@ -543,17 +543,6 @@ class ChatConsumerMixin:
"""Получить ID всех доступных чатов пользователя"""
from django.contrib.contenttypes.models import ContentType
departments = user.departments.all()
dept_ids = list(departments.values_list('id', flat=True))
# GenericFK support для department чатов
dept_ct = None
if dept_ids:
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
pass
# Получаем ID через membership
membership_chat_ids = list(
ChatMembership.objects.filter(user=user).values_list('chat_id', flat=True)
@ -562,9 +551,9 @@ class ChatConsumerMixin:
chat_ids = list(
Chat.objects.filter(
Q(type="global")
| (Q(type="department", context_content_type=dept_ct, context_object_id__in=dept_ids) if dept_ct else Q(pk__in=[])) # GenericFK
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids)
| Q(include_all_users=True)
).values_list("id", flat=True).distinct()
)
@ -601,11 +590,7 @@ class ChatConsumerMixin:
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
if chat.type == "department":
# Используем get_participants() - поддерживает GenericFK
return chat.get_participants().filter(pk=user.pk).exists()
if chat.type in ("channel", "announcement"):
if chat.type in ("channel", "announcement", "comments"):
if chat.include_all_users:
return user.is_active
return (

View File

@ -0,0 +1,25 @@
# Generated by Django 5.2.4 on 2026-03-11 14:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0036_add_comments_type_deprecate_department'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.AlterField(
model_name='chat',
name='context_content_type',
field=models.ForeignKey(blank=True, help_text='Type of related object (e.g., Project, Team, Event, Document)', null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Context type'),
),
migrations.AlterField(
model_name='chat',
name='type',
field=models.CharField(choices=[('private', 'Личный'), ('group', 'Групповой'), ('channel', 'Канал'), ('announcement', 'Объявления'), ('global', 'Глобальный'), ('comments', 'Комментарии')], db_index=True, help_text="Chat type: private, group, channel, announcement, global, comments. Use 'group' + context_object for domain-specific chats.", max_length=32, verbose_name='Тип чата'),
),
]

View File

@ -84,8 +84,6 @@ class Chat(models.Model):
("announcement", "Объявления"),
("global", "Глобальный"),
("comments", "Комментарии"),
# DEPRECATED: проектно-специфичные типы (для обратной совместимости)
("department", "[Legacy] Отдел"),
]
type = models.CharField(
@ -138,15 +136,14 @@ class Chat(models.Model):
)
# ===== Universal context (GenericForeignKey) =====
# REMOVED: department FK (replaced by context_object)
# Позволяет привязать чат к ЛЮБОЙ модели (Department, Project, Team, Event, etc.)
# Позволяет привязать чат к ЛЮБОЙ модели (Project, Team, Event, Document, etc.)
context_content_type = models.ForeignKey(
ContentType,
null=True,
blank=True,
on_delete=models.CASCADE,
verbose_name="Context type",
help_text="Type of related object (e.g., Department, Project, Team)"
help_text="Type of related object (e.g., Project, Team, Event, Document)"
)
context_object_id = models.PositiveIntegerField(
null=True,
@ -358,10 +355,6 @@ class Chat(models.Model):
return self.name or "Канал"
if self.type == "announcement":
return self.name or "Объявления"
if self.type == "department":
# LEGACY: Use type='group' + context_object instead
dept = self.context_object if self.context_object else None
return f"[Legacy] Чат отдела: {dept or '—'}"
return "Глобальный чат"

View File

@ -144,11 +144,9 @@ def get_chat_name(chat) -> str:
if chat.type == 'global':
return 'Глобальный чат'
if chat.type == 'department':
# Проверяем context_object (GenericFK) или старое поле department
dept = chat.context_object if hasattr(chat, 'context_object') and chat.context_object else getattr(chat, 'department', None)
if dept:
return f'Чат отдела: {dept.name}'
# Use context_object (GenericFK) for any chat type
if chat.context_object:
return f"Чат: {chat.context_object}"
if chat.type == 'private':
# Для приватного чата можно вернуть имена участников

View File

@ -70,7 +70,7 @@ def notify_new_message(message):
content = message.content
# Получаем всех участников чата кроме автора
if chat.type in ['announcement', 'channel', 'department', 'global']:
if chat.type in ['announcement', 'channel', 'global']:
participants = chat.get_participants().exclude(id=author.id)
else:
participants = chat.participants.exclude(id=author.id)

View File

@ -110,21 +110,15 @@ def is_public_chat(user, chat):
@rules.predicate
def is_department_chat(user, chat):
def deprecated_is_department_chat(user, chat):
"""
Чат отдела пользователя.
DEPRECATED: This predicate is deprecated.
Use callback resolver pattern via get_participants() instead.
Проверяет context_object (GenericFK) для чатов типа department.
Использует get_participants() для проверки доступа.
For backward compatibility, always returns False.
Configure COMMUNICATIONS_PARTICIPANT_RESOLVER in settings.py.
"""
if chat is None:
return False
if chat.type != "department":
return False
# Используем get_participants() - поддерживает любую логику определения участников
return chat.get_participants().filter(pk=user.pk).exists()
return False
# -----------------------------------------------------------------------------
@ -370,8 +364,6 @@ from django.db.models import Q
def get_accessible_chats(user):
return Chat.objects.filter(
Q(members=user) | # Участник чата
Q(is_public=True) | # Публичный чат
# Department чаты через get_participants() (поддерживает GenericFK)
Q(type='department') # Будем проверять через get_participants()
Q(is_public=True) # Публичный чат
).distinct()
"""

View File

@ -7,8 +7,8 @@ import logging
logger = logging.getLogger(__name__)
# Signal for auto-creating department chats moved to employees app
# See: employees/signals.py - create_main_department_chat()
# NOTE: Project-specific chat creation signals should be in your project's app
# Example: employees/signals.py - create_main_department_chat()
@receiver(pre_save, sender='communications.Chat')

View File

@ -64,8 +64,8 @@ def user_can_access_chat(chat: Chat, user) -> bool:
- global: доступ всем
- private: только участники (participants)
- group: участники (participants) или ChatMembership
- department: через get_participants() (поддерживает department и context_object)
- channel/announcement: include_all_users или participants/membership
- comments: participants/membership or callback
Args:
chat: Объект Chat
@ -90,11 +90,7 @@ def user_can_access_chat(chat: Chat, user) -> bool:
in_membership = ChatMembership.objects.filter(chat=chat, user=user).exists()
return in_participants or in_membership
if chat.type == "department":
# Проверяем через get_participants() (поддерживает и department, и context_object)
return chat.get_participants().filter(pk=user.pk).exists()
if chat.type in ("channel", "announcement"):
if chat.type in ("channel", "announcement", "comments"):
# Для каналов и объявлений может быть include_all_users или membership
if chat.include_all_users:
return user.is_active