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:
parent
0c00652c8e
commit
0164c328f7
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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='Включить всех пользователей'),
|
||||
),
|
||||
]
|
||||
@ -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 "Канал"
|
||||
|
||||
@ -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()
|
||||
"""
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user