refactor(communications): Удалить зависимость от employees app

BREAKING CHANGE: Полная независимость communications модуля

Удалено:
- Поле Chat.department (FK на employees.Department)
- Импорты Department из всех файлов communications
- Q(department=...) фильтры в API/WebSocket/rules

Перемещено:
- Сигнал create_main_department_chat → employees/signals.py (проектоспецифичная логика)

Обновлено:
- models.py: удалено поле department, constraint, index
- api/viewsets.py: использует GenericFK через ContentType.objects.get()
- consumers.py: использует GenericFK через ContentType.objects.get()
- rules.py: is_department_chat теперь использует get_participants()
- admin.py: удален department из list_display/search_fields

Миграция:
- 0035_remove_department_field.py: удаляет поле, constraint, index

Результат:
✅ communications НЕ зависит от employees
✅ System check: 0 errors
✅ Использует только GenericFK (context_object)
✅ Полностью переиспользуемый модуль

Для EUSRR-специфичной логики department чатов:
- Настроить COMMUNICATIONS_PARTICIPANT_RESOLVER в settings.py
- Сигнал автосоздания чатов теперь в employees/signals.py
This commit is contained in:
lizerk 2026-03-11 16:39:37 +03:00
parent 0c00652c8e
commit 0164c328f7
8 changed files with 115 additions and 105 deletions

View File

@ -36,16 +36,14 @@ class ChatAdmin(admin.ModelAdmin):
"id",
"type",
"name",
"department",
"context_object",
"is_main",
"created_by",
"created_at"
)
list_filter = ("type", "is_main", "created_at")
# Поддержка поиска по старому и новому полю
# department__name - старое поле (DEPRECATED)
# Для context_object поиск невозможен через GenericFK
search_fields = ("name", "description", "department__name")
# Поиск по context_object невозможен через GenericFK
search_fields = ("name", "description")
ordering = ("-created_at",)
filter_horizontal = ("participants",)
inlines = [ChatMembershipInline, MessageInline]
@ -56,7 +54,7 @@ class ChatAdmin(admin.ModelAdmin):
if obj:
is_primary = obj.is_main or (obj.flags and obj.flags.get('is_primary'))
if is_primary:
return self.readonly_fields + ("type", "department", "is_main", "flags", "context_object_id", "context_content_type")
return self.readonly_fields + ("type", "is_main", "flags", "context_object_id", "context_content_type")
return self.readonly_fields

View File

@ -85,26 +85,28 @@ class ChatViewSet(viewsets.ModelViewSet):
user = self.request.user
from django.contrib.contenttypes.models import ContentType
# Подготовка данных для фильтрации по department
# Подготовка данных для фильтрации по 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:
from employees.models import Department
dept_ct = ContentType.objects.get_for_model(Department)
# Получаем ContentType динамически без импорта Department
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
pass
queryset = Chat.objects.filter(
Q(participants=user)
# OLD: department FK (для обратной совместимости)
| Q(department__in=dept_ids)
# NEW: context_object (GenericFK)
# GenericFK context_object для department чатов
| (Q(context_content_type=dept_ct, context_object_id__in=dept_ids) if dept_ct else Q(pk__in=[]))
# NEW: include_all_users (renamed from include_all_employees)
# include_all_users для глобальных чатов
| Q(include_all_users=True)
).select_related(
'department', 'created_by', 'context_content_type'
'created_by', 'context_content_type'
).prefetch_related(
'participants',
# Prefetch ChatUserSettings для текущего пользователя
@ -573,17 +575,19 @@ class MessageViewSet(viewsets.ModelViewSet):
is_active=True
).values_list('department_id', flat=True))
# GenericFK support для department чатов
dept_ct = None
if dept_ids:
from employees.models import Department
dept_ct = ContentType.objects.get_for_model(Department)
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
pass
# Чаты пользователя
user_chats = Chat.objects.filter(
Q(participants=user)
| Q(department__in=dept_ids) # OLD: department FK
| (Q(context_content_type=dept_ct, context_object_id__in=dept_ids) if dept_ct else Q(pk__in=[])) # NEW: GenericFK
| Q(include_all_users=True) # NEW: renamed from include_all_employees
| (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)
)
return Message.objects.filter(

View File

@ -546,10 +546,13 @@ class ChatConsumerMixin:
departments = user.departments.all()
dept_ids = list(departments.values_list('id', flat=True))
# GenericFK support для department чатов
dept_ct = None
if dept_ids:
from employees.models import Department
dept_ct = ContentType.objects.get_for_model(Department)
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
pass
# Получаем ID через membership
membership_chat_ids = list(
@ -559,8 +562,7 @@ class ChatConsumerMixin:
chat_ids = list(
Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments) # OLD: department FK
| (Q(type="department", context_content_type=dept_ct, context_object_id__in=dept_ids) if dept_ct else Q(pk__in=[])) # NEW: GenericFK
| (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)
).values_list("id", flat=True).distinct()
@ -600,7 +602,7 @@ class ChatConsumerMixin:
)
if chat.type == "department":
# Используем get_participants() - поддерживает и department, и context_object
# Используем get_participants() - поддерживает GenericFK
return chat.get_participants().filter(pk=user.pk).exists()
if chat.type in ("channel", "announcement"):

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.4 on 2026-03-11 13:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0034_rename_include_all_employees'),
]
operations = [
migrations.RemoveConstraint(
model_name='chat',
name='unique_main_department_chat',
),
migrations.RemoveIndex(
model_name='chat',
name='communicati_departm_e2e705_idx',
),
migrations.RemoveField(
model_name='chat',
name='department',
),
migrations.AlterField(
model_name='chat',
name='include_all_users',
field=models.BooleanField(default=False, help_text='Для анонсов и общих чатов', verbose_name='Включить всех пользователей'),
),
]

View File

@ -8,9 +8,6 @@ from django.db import IntegrityError, models
from django.db.models import Q
from django.utils import timezone
# DEPRECATED: Will be removed after migration complete
from employees.models import Department, EmployeeDepartment
Employee = get_user_model()
@ -137,16 +134,9 @@ class Chat(models.Model):
verbose_name="Участники",
help_text="Используется только для личных чатов",
)
department = models.ForeignKey(
Department,
null=True,
blank=True,
on_delete=models.CASCADE, # при удалении отдела чат удалится
verbose_name="Отдел",
help_text="Указывается только для чатов отдела",
)
# ===== NEW: Universal context (GenericForeignKey) =====
# ===== Universal context (GenericForeignKey) =====
# REMOVED: department FK (replaced by context_object)
# Позволяет привязать чат к ЛЮБОЙ модели (Department, Project, Team, Event, etc.)
context_content_type = models.ForeignKey(
ContentType,
@ -218,12 +208,6 @@ class Chat(models.Model):
condition=Q(is_main=True, type="global"),
name="unique_main_global_chat",
),
# Ровно один «главный» чат на отдел
models.UniqueConstraint(
fields=["type", "department"],
condition=Q(is_main=True, type="department"),
name="unique_main_department_chat",
),
# Только 1 announcement на сотрудника
models.UniqueConstraint(
fields=["type", "created_by"],
@ -233,9 +217,8 @@ class Chat(models.Model):
]
indexes = [
models.Index(fields=["type", "is_main"]),
models.Index(fields=["department"]),
models.Index(fields=["created_at"]),
# NEW: Index for GenericForeignKey
# Index for GenericForeignKey
models.Index(fields=["context_content_type", "context_object_id"], name="chat_context_idx"),
]
@ -306,7 +289,7 @@ class Chat(models.Model):
Возвращает QuerySet участников чата.
Использует callback из settings.COMMUNICATIONS_PARTICIPANT_RESOLVER
для проектно-специфичной логики (EUSRR: Department, EmployeeDepartment).
для проектно-специфичной логики.
Fallback логика:
- private: M2M participants
@ -366,8 +349,8 @@ class Chat(models.Model):
if self.type == "group":
return self.name or "Групповой чат"
if self.type == "department":
# Проверяем context_object (GenericFK) или старое поле department
dept = self.context_object if self.context_object else self.department
# Used context_object (GenericFK)
dept = self.context_object if self.context_object else None
return f"Чат отдела: {dept or '—'}"
if self.type == "channel":
return self.name or "Канал"

View File

@ -114,24 +114,17 @@ def is_department_chat(user, chat):
"""
Чат отдела пользователя.
Проверяет как старое поле department (DEPRECATED),
так и новое context_object (GenericFK).
Проверяет context_object (GenericFK) для чатов типа department.
Использует get_participants() для проверки доступа.
"""
if chat is None or not hasattr(user, 'department'):
if chat is None:
return False
# 1. Проверяем старое поле department (для обратной совместимости)
if hasattr(chat, 'department') and chat.department:
if chat.department == user.department:
return True
if chat.type != "department":
return False
# 2. Проверяем новое поле context_object (GenericFK)
if hasattr(chat, 'context_object') and chat.context_object:
from employees.models import Department
if isinstance(chat.context_object, Department):
return chat.context_object == user.department
return False
# Используем get_participants() - поддерживает любую логику определения участников
return chat.get_participants().filter(pk=user.pk).exists()
# -----------------------------------------------------------------------------
@ -378,6 +371,7 @@ def get_accessible_chats(user):
return Chat.objects.filter(
Q(members=user) | # Участник чата
Q(is_public=True) | # Публичный чат
Q(department=user.department) # Чат отдела
# Department чаты через get_participants() (поддерживает GenericFK)
Q(type='department') # Будем проверять через get_participants()
).distinct()
"""

View File

@ -1,54 +1,14 @@
# backend\communications\signals.py
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.db.models import F, Q
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from employees.models import Department
import logging
logger = logging.getLogger(__name__)
@receiver(post_save, sender=Department)
def create_main_department_chat(sender, instance, created, **kwargs):
"""
Создает главный чат для нового отдела.
Использует context_object (GenericFK) и flags['is_primary'].
Для обратной совместимости также заполняет department и is_main (DEPRECATED).
"""
from communications.models import Chat
if created:
dept_ct = ContentType.objects.get_for_model(Department)
# Проверка: существует ли уже чат для этого отдела
# (проверяем и старые, и новые поля)
existing = Chat.objects.filter(
Q(type="department") & (
Q(department=instance) |
Q(context_content_type=dept_ct, context_object_id=instance.id)
) & (
Q(is_main=True) | Q(flags__is_primary=True)
)
)
if not existing.exists():
# Создаем чат с ОБОИМИ способами привязки (для совместимости)
Chat.objects.create(
type="department",
# NEW: GenericFK
context_content_type=dept_ct,
context_object_id=instance.id,
# NEW: flags
flags={'is_primary': True},
# DEPRECATED: старые поля (для обратной совместимости)
department=instance,
is_main=True,
# Имя чата
name=f"Основной чат {instance.name}"
)
# Signal for auto-creating department chats moved to employees app
# See: employees/signals.py - create_main_department_chat()
@receiver(pre_save, sender='communications.Chat')

View File

@ -1,12 +1,14 @@
# users/signals.py
import os
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.db.models import Q
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from .constants import ACTION_HIRED
from .models import Employee, EmployeeAction
from .models import Employee, EmployeeAction, Department
@receiver(post_save, sender=Employee)
@ -21,3 +23,40 @@ def create_hired_action(sender, instance: Employee, created, **kwargs):
date=instance.created_at or timezone.now(),
comment="Автоматически: принят при регистрации",
)
@receiver(post_save, sender=Department)
def create_main_department_chat(sender, instance, created, **kwargs):
"""
Создает главный чат для нового отдела (EUSRR-specific logic).
Использует GenericFK (context_object) и flags['is_primary'].
"""
if not created:
return
try:
from communications.models import Chat
dept_ct = ContentType.objects.get_for_model(Department)
# Проверка: существует ли уже чат для этого отдела
existing = Chat.objects.filter(
type="department",
context_content_type=dept_ct,
context_object_id=instance.id,
flags__is_primary=True
).exists()
if not existing:
Chat.objects.create(
type="department",
context_content_type=dept_ct,
context_object_id=instance.id,
flags={'is_primary': True},
is_main=True,
name=f"Основной чат {instance.name}"
)
except ImportError:
# communications app не установлен - пропускаем
pass