lizerk 51aecacf09 feat(communications): Refactor Chat model to universal GenericForeignKey
BREAKING CHANGES (backward compatible with data migration):
- Add context_object (GenericFK) to replace department FK
- Add flags (JSONField) to replace is_main boolean
- Add extra_data (JSONField) for extensibility
- Rename include_all_employees → include_all_users

NEW FEATURES:
- Generic context support (Department, Project, Team, etc.)
- Flexible flags system without migrations
- Settings-based participant resolver callback
- Zero data loss migrations (4 migrations)

MIGRATIONS:
- 0031: Add new fields (context_object, flags, extra_data)
- 0032: Migrate department → context_object (15 chats)
- 0033: Migrate is_main → flags['is_primary'] (15 chats)
- 0034: Rename include_all_employees → include_all_users

FILES CHANGED:
- models.py: Refactored Chat.get_participants() with callback support
- employees/utils.py: Added resolve_chat_participants() callback
- settings.py: Added COMMUNICATIONS_PARTICIPANT_RESOLVER setting
- verify_chat_migration.py: Command to verify data integrity

TESTED:
✅ All 32 chats migrated successfully
✅ Legacy department FK still works
✅ Context-based chats work (GenericFK)
✅ Global/private/announcement chats work
✅ Participants resolver callback works
2026-03-11 14:21:57 +03:00

303 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import phonenumbers
from django.conf import settings
from django.contrib.auth import get_user_model
from employees.models import (
Department,
DepartmentPermission,
DeptPerm,
EmployeeDepartment,
)
from phonenumbers import PhoneNumberFormat
Employee = get_user_model()
def _to_bool(val: str | None) -> bool | None:
if val is None:
return None
s = str(val).strip().lower()
if s in {"1", "true", "yes", "да"}:
return True
if s in {"0", "false", "no", "нет"}:
return False
return None
def _normalize_phone(raw: str | None) -> str | None:
if not raw:
return None
if phonenumbers is None:
return str(raw).strip()
region = getattr(settings, "PHONE_DEFAULT_REGION", "RU")
try:
pn = phonenumbers.parse(str(raw), region)
if not phonenumbers.is_valid_number(pn):
return None
return phonenumbers.format_number(pn, PhoneNumberFormat.E164)
except Exception:
return None
def _detect_phone_field() -> str | None:
for n in ("phone", "phone_number", "mobile", "msisdn", "tel"):
if any(f.name == n for f in Employee._meta.fields):
return n
return None
def _validate_head_active(
dept: Department,
employee_id: int,
require_email_verified: bool = True,
) -> tuple[bool, dict | None]:
"""
Проверить, можно ли назначить сотрудника руководителем отдела.
Parameters
----------
dept : Department
Отдел, в который назначается руководитель.
employee_id : int
Кандидат на роль руководителя.
require_email_verified : bool, optional
Требовать ли подтверждение email. Для действий, совершаемых не-руководителем,
оставляем True; для действий текущего руководителя можно ослабить до False.
Returns
-------
(True, None) | (False, {"head_id": [..]})
True — назначать можно; False — нельзя (причина в словаре).
"""
employee_model = Department._meta.get_field("head").remote_field.model
emp = employee_model.objects.filter(id=employee_id).first()
if not emp:
return False, {"head_id": ["Employee not found."]}
# Должен быть включён аккаунт
if getattr(emp, "is_active", True) is False:
return False, {"head_id": ["Employee is inactive."]}
# Требование верификации email — опционально
if require_email_verified and getattr(emp, "email_verified", True) is False:
return False, {"head_id": ["Employee is inactive."]}
return True, None
def _ensure_department_permissions() -> list[dict]:
"""
Гарантирует наличие записей DepartmentPermission на основе DeptPerm.CHOICES.
Возвращает список словарей {id, code, name} в порядке CHOICES.
"""
items: list[dict] = []
# пробежимся по CHOICES, создадим/обновим имя, соберём выдачу
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": label}
)
# если имя в БД отстаёт от CHOICES — мягко синхронизируем
if obj.name != label:
obj.name = label
obj.save(update_fields=["name"])
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
return items
def _head_choices_for_dept(dept: Department, serializer) -> list[dict]:
"""
Вернёт список кандидатов для назначения руководителя отдела.
Формат элемента:
{
"id": int, # ID сотрудника
"name": str, # display_name из EmployeeBriefSerializer
"email": str # email сотрудника (может быть пустой строкой)
}
"""
choices, seen = [], set()
qs = (
EmployeeDepartment.objects.filter(department_id=dept.id)
.select_related("employee")
.order_by(
"employee__last_name",
"employee__first_name",
"employee__patronymic",
"employee_id",
)
)
for link in qs:
data = serializer(link.employee).data
if data["id"] not in seen:
choices.append(
{
"id": data["id"],
"name": data["display_name"],
"email": data.get("email", "") or "",
}
)
seen.add(data["id"])
if dept.head_id and dept.head_id not in seen:
head_data = serializer(dept.head).data
choices.insert(
0,
{
"id": head_data["id"],
"name": head_data["display_name"],
"email": head_data.get("email", "") or "",
},
)
return choices
def _perm_choices_synced() -> list[dict]:
"""
Возвращает справочник прав для ролей отдела, синхронизируя записи с DeptPerm.CHOICES.
"""
items = []
for code, label in DeptPerm.CHOICES:
obj, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": label}
)
if obj.name != label:
obj.name = label
obj.save(update_fields=["name"])
items.append({"id": obj.id, "code": obj.code, "name": obj.name})
return items
def _build_links_for_dept(dept: Department, serializer) -> list[dict]:
"""
Возвращает список линков отдела:
[{ "employee": <EmployeeBriefSerializer.data>, "role": {"id","name"}|None, "is_active": bool }, ...]
"""
links: list[dict] = []
qs = (
EmployeeDepartment.objects.filter(department_id=dept.id)
.select_related("employee", "role")
.order_by(
"employee__last_name",
"employee__first_name",
"employee__patronymic",
"employee_id",
)
)
for link in qs:
emp_data = serializer(link.employee).data # содержит display_name
role = {"id": link.role_id, "name": link.role.name} if link.role_id else None
links.append(
{"employee": emp_data, "role": role, "is_active": bool(link.is_active)}
)
# гарантируем присутствие head
if dept.head_id and all(item["employee"]["id"] != dept.head_id for item in links):
head_data = serializer(dept.head).data
links.insert(0, {"employee": head_data, "role": None, "is_active": True})
return links
# ===== Communications callbacks (EUSRR-specific) =====
def resolve_chat_participants_for_department(context_object, **kwargs):
"""
EUSRR callback для разрешения участников чата отдела.
Args:
context_object: Department instance
**kwargs: Дополнительные параметры (chat, type и т.д.)
Returns:
QuerySet Employee objects - участники чата
"""
from django.db.models import Q
# Проверяем что это Department
if not isinstance(context_object, Department):
return Employee.objects.none()
department = context_object
# Получаем активных сотрудников отдела
employee_ids = EmployeeDepartment.objects.filter(
department_id=department.id,
is_active=True
).values_list("employee_id", flat=True)
# Включаем руководителя отдела
return Employee.objects.filter(
Q(id__in=employee_ids) | Q(id=department.head_id)
).distinct()
def resolve_chat_participants(chat):
"""
Главная функция для разрешения участников чата в EUSRR.
Вызывается из Chat.get_participants() через настройки.
Args:
chat: Chat instance
Returns:
QuerySet Employee objects или None (если не обработали)
"""
from django.db.models import Q
# 1. Приватные чаты - используем M2M participants
if chat.type == "private":
return chat.participants.all()
# 2. Глобальные чаты - все активные пользователи
if chat.type == "global":
return Employee.objects.filter(is_active=True)
# 3. Announcement / Channel с include_all_users
if chat.type in ["announcement", "channel"] and chat.include_all_users:
return Employee.objects.filter(is_active=True)
# 4. Context-based чаты
if chat.context_object:
# Department context
if isinstance(chat.context_object, Department):
return resolve_chat_participants_for_department(
chat.context_object,
chat=chat,
type=chat.type
)
# Можно добавить другие типы контекстов:
# - Project
# - Team
# - Event
# и т.д.
# 5. Legacy: department field (DEPRECATED, для обратной совместимости)
if chat.type == "department" and hasattr(chat, 'department') and chat.department_id:
employee_ids = EmployeeDepartment.objects.filter(
department_id=chat.department_id,
is_active=True
).values_list("employee_id", flat=True)
return Employee.objects.filter(
Q(id__in=employee_ids) | Q(id=chat.department.head_id)
).distinct()
# 6. Fallback: ChatMembership или participants
from communications.models import ChatMembership
membership_ids = ChatMembership.objects.filter(
chat=chat
).values_list("user_id", flat=True)
participant_ids = chat.participants.values_list('id', flat=True)
return Employee.objects.filter(
Q(id__in=participant_ids) | Q(id__in=membership_ids)
).distinct()