feat(communications): Phase 2 - Update views, signals, rules for GenericFK

BREAKING CHANGES (backward compatible):
- All code updated to support both old and new fields
- context_object (GenericFK) and flags now primary
- department and is_main still work (DEPRECATED)

FILES UPDATED:

1. signals.py:
   - create_main_department_chat() uses context_object + flags
   - Creates both old and new fields for compatibility
   - Checks existence using Q() for both fields

2. views.py (4 critical updates):
   - has_user_access_to_chat(): uses get_participants() (supports both)
   - ChatListView.get_queryset(): checks department OR context_object
   - ChatDetailView.get_queryset(): checks department OR context_object
   - mark_as_read_ajax(): uses get_participants() and include_all_users

3. rules.py:
   - is_department_chat(): checks both department and context_object
   - Supports GenericFK for Department context

4. apps.py:
   - create_main_global_chat(): checks and sets flags['is_primary']
   - Backward compatible with is_main

5. admin.py:
   - get_readonly_fields(): checks flags['is_primary'] OR is_main
   - Prevents editing primary chats

6. notifications/config.py:
   - get_chat_display_name(): uses context_object OR department

7. models.py:
   - Chat.__str__(): uses context_object OR department

TESTED:
✅ Signal creates chat with context_object + flags
✅ get_participants() works with both fields
✅ __str__ displays correctly
✅ is_department_chat rule works
✅ Backward compatibility preserved
This commit is contained in:
lizerk 2026-03-11 14:30:23 +03:00
parent 66f6473e6e
commit f56e256a2a
7 changed files with 107 additions and 24 deletions

View File

@ -42,6 +42,9 @@ class ChatAdmin(admin.ModelAdmin):
"created_at"
)
list_filter = ("type", "is_main", "created_at")
# Поддержка поиска по старому и новому полю
# department__name - старое поле (DEPRECATED)
# Для context_object поиск невозможен через GenericFK
search_fields = ("name", "description", "department__name")
ordering = ("-created_at",)
filter_horizontal = ("participants",)
@ -49,8 +52,11 @@ class ChatAdmin(admin.ModelAdmin):
readonly_fields = ("created_at",)
def get_readonly_fields(self, request, obj=None):
if obj and obj.is_main:
return self.readonly_fields + ("type", "department", "is_main")
# Главные чаты (is_main=True или flags['is_primary']=True) нельзя редактировать
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

View File

@ -12,8 +12,26 @@ class CommunicationsConfig(AppConfig):
import communications.rules # django-rules: регистрация предикатов и правил доступа
def create_main_global_chat(sender, **kwargs):
"""
Создает главный глобальный чат при первом запуске.
Использует flags['is_primary'] вместо is_main.
Для обратной совместимости также устанавливает is_main=True.
"""
from communications.models import Chat
from django.db.models import Q
if not Chat.objects.filter(type="global", is_main=True).exists():
Chat.objects.create(type="global", is_main=True)
# Проверяем по обоим полям (старому и новому)
if not Chat.objects.filter(
Q(type="global") & (
Q(is_main=True) | Q(flags__is_primary=True)
)
).exists():
Chat.objects.create(
type="global",
# NEW: flags
flags={'is_primary': True},
# DEPRECATED: для обратной совместимости
is_main=True
)

View File

@ -366,7 +366,9 @@ class Chat(models.Model):
if self.type == "group":
return self.name or "Групповой чат"
if self.type == "department":
return f"Чат отдела: {self.department or '—'}"
# Проверяем context_object (GenericFK) или старое поле department
dept = self.context_object if self.context_object else self.department
return f"Чат отдела: {dept or '—'}"
if self.type == "channel":
return self.name or "Канал"
if self.type == "announcement":

View File

@ -144,8 +144,11 @@ def get_chat_name(chat) -> str:
if chat.type == 'global':
return 'Глобальный чат'
if chat.type == 'department' and chat.department:
return f'Чат отдела: {chat.department.name}'
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}'
if chat.type == 'private':
# Для приватного чата можно вернуть имена участников

View File

@ -111,12 +111,25 @@ def is_public_chat(user, chat):
@rules.predicate
def is_department_chat(user, chat):
"""Чат отдела пользователя"""
"""
Чат отдела пользователя.
Проверяет как старое поле department (DEPRECATED),
так и новое context_object (GenericFK).
"""
if chat is None or not hasattr(user, 'department'):
return False
if hasattr(chat, 'department'):
return chat.department == user.department
# 1. Проверяем старое поле department (для обратной совместимости)
if hasattr(chat, 'department') and chat.department:
if chat.department == user.department:
return True
# 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

View File

@ -1,6 +1,7 @@
# backend\communications\signals.py
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.db.models import F
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
@ -11,16 +12,41 @@ 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:
if not Chat.objects.filter(
type="department", department=instance, is_main=True
).exists():
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}"
)

View File

@ -76,14 +76,15 @@ def user_can_access_chat(chat: Chat, user) -> bool:
print(f"[CHAT DEBUG] Group chat, participants={in_participants}, membership={in_membership}, result={result}")
return result
if chat.type == "department" and chat.department_id:
result = chat.get_participants.filter(pk=user.pk).exists()
if chat.type == "department":
# Проверяем через get_participants() (поддерживает и department, и context_object)
result = chat.get_participants().filter(pk=user.pk).exists()
print(f"[CHAT DEBUG] Department chat, result={result}")
return result
if chat.type in ("channel", "announcement"):
# Для каналов и объявлений может быть include_all или membership
if chat.include_all_employees:
# Для каналов и объявлений может быть include_all_users или membership
if chat.include_all_users:
return user.is_active
# Проверяем и participants и membership для гибкости
result = (
@ -136,14 +137,21 @@ class ChatListView(LoginRequiredMixin, ListView):
# Получаем ID чатов, где пользователь является участником
# через membership
from communications.models import ChatMembership
from django.contrib.contenttypes.models import ContentType
membership_chat_ids = ChatMembership.objects.filter(
user=user
).values_list('chat_id', flat=True)
# Для department: проверяем и старое поле department, и новое context_object
dept_ct = ContentType.objects.get_for_model(departments.model)
dept_ids = list(departments.values_list('id', flat=True))
qs = (
Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments)
| Q(type="department", department__in=departments) # старое поле
| Q(type="department", context_content_type=dept_ct, context_object_id__in=dept_ids) # новое поле
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids) # group/channel/announcement
)
@ -229,6 +237,8 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
# Получаем ID чатов через membership
from communications.models import ChatMembership
from django.contrib.contenttypes.models import ContentType
membership_chat_ids = list(
ChatMembership.objects.filter(user=user).values_list('chat_id', flat=True)
)
@ -241,10 +251,14 @@ class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
f"User departments: {list(departments.values_list('id', flat=True))}, "
f"membership_chat_ids: {membership_chat_ids}"
)
# Для department: проверяем и старое поле, и новое
dept_ct = ContentType.objects.get_for_model(departments.model)
qs = Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments)
| Q(type="department", department__in=departments) # старое поле
| Q(type="department", context_content_type=dept_ct, context_object_id__in=dept_ids) # новое поле
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids)
).distinct()
@ -470,11 +484,12 @@ def chat_mark_read(request, pk: int):
return True
if c.type in ("private", "group"): # Групповые и личные чаты
return c.participants.filter(pk=u.pk).exists()
if c.type == "department" and c.department_id:
return c.get_participants.filter(pk=u.pk).exists()
if c.type == "department":
# Используем get_participants() - поддерживает и department, и context_object
return c.get_participants().filter(pk=u.pk).exists()
if c.type in ("channel", "announcement"):
# Для каналов и объявлений проверяем participants или include_all
if c.include_all_employees:
# Для каналов и объявлений проверяем participants или include_all_users
if c.include_all_users:
return u.is_active
return c.participants.filter(pk=u.pk).exists()
return False