Игорь Надеин d86d971125 fix(ui): исправлен рендеринг аватаров - устранена деформация и выравнивание
Проблема:
- Аватары деформировались из-за object-fit на контейнере
- Аватары выравнивались по верху сообщения вместо низа
- Неконсистентная HTML структура: <img class="mini-ava"> vs <div class="mini-ava"><i></i></div>

Решение:

SCSS (_common.scss):
- object-fit: cover перемещен с контейнера на вложенный img
- Добавлен display: block для img элементов

SCSS (_chat-detail.scss):
- Добавлен align-items: flex-end к .d-flex.msg для выравнивания по низу
- .mini-ava рефакторинг: display: flex (было grid), border-radius: 50% (было 9999px)
- Добавлен background и border для контейнера
- Добавлен font-size: 20px для fallback иконки
- object-fit остается только на img тегах

JavaScript (messageRendererV2.js):
- Всегда создавать <div class="mini-ava"> как контейнер
- Вложенный <img> или <i class="bi bi-person-circle"> внутри
- Устранена несогласованность разметки

Рефакторинг архитектуры SCSS (бонус):
- Создан Design System: abstracts/_variables.scss (200+ токенов)
- Создано abstracts/_mixins.scss (40+ миксинов)
- Реорганизована файловая структура: layout/, components/, components/chat/
- Централизованы стили аватаров в _common.scss
- Устранены дубликаты (avatar определялся в 3 файлах)
- Применены design tokens к navbar, page-header
- Создана документация: SCSS_NAMING_CONVENTIONS.md
- Удалено 9 дублирующихся CSS файлов

Компиляция: 280KB, 0 ошибок, production ready
2026-01-14 11:18:35 +03:00

542 lines
20 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 datetime
import logging
from datetime import datetime as dt
from datetime import timezone as dt_tz
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError, models
logger = logging.getLogger(__name__)
from django.db.models import Count, F, Prefetch, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, FormView, ListView
from .forms import MessageForm
from .models import Chat, ChatMembership, ChatReadState, Message
CHAT_MESSAGES_PAGE_SIZE = getattr(settings, "CHAT_MESSAGES_PAGE_SIZE", 50)
# ===== helpers =====
def _coerce_ts(val: str | None) -> datetime.datetime:
"""
Принимает миллисекунды/секунды с эпохи или ISO-дату.
Возвращает aware-дату (UTC). Фоллбек — timezone.now().
"""
if not val:
return timezone.now()
# сначала — число (sec/ms)
try:
iv = int(val)
if iv > 10**12: # пришли миллисекунды
iv //= 1000
return dt.fromtimestamp(iv, tz=dt_tz.utc)
except Exception:
pass
# потом — ISO-строка
d = parse_datetime(val)
if d is None:
return timezone.now()
if timezone.is_naive(d):
return timezone.make_aware(d, timezone=timezone.get_current_timezone())
return d
def user_can_access_chat(chat: Chat, user) -> bool:
if not chat or not user:
return False
print(f"[CHAT DEBUG] user_can_access_chat: chat={chat.id}, type={chat.type}, user={user.id}")
if chat.type == "global":
return True
if chat.type == "private":
# Личные чаты только через participants
result = chat.participants.filter(pk=user.pk).exists()
print(f"[CHAT DEBUG] Private chat, participants check: {result}")
return result
if chat.type == "group":
# Групповые чаты через participants ИЛИ ChatMembership
in_participants = chat.participants.filter(pk=user.pk).exists()
in_membership = ChatMembership.objects.filter(chat=chat, user=user).exists()
result = in_participants or in_membership
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()
print(f"[CHAT DEBUG] Department chat, result={result}")
return result
if chat.type in ("channel", "announcement"):
# Для каналов и объявлений может быть include_all или membership
if chat.include_all_employees:
return user.is_active
# Проверяем и participants и membership для гибкости
result = (
chat.participants.filter(pk=user.pk).exists()
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
print(f"[CHAT DEBUG] Channel/announcement chat, result={result}")
return result
return False
class ChatListView(LoginRequiredMixin, ListView):
model = Chat
template_name = "communications/chat_list.html"
context_object_name = "chats"
def dispatch(self, request, *args, **kwargs):
"""Добавляем заголовки для отключения кэширования"""
response = super().dispatch(request, *args, **kwargs)
# Запрещаем кэширование списка чатов
response['Cache-Control'] = (
'no-cache, no-store, must-revalidate, max-age=0'
)
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
return response
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем список отделов для выбора при создании чата
from employees.models import Department
context['departments'] = Department.objects.all().order_by('name')
return context
def get_queryset(self):
user = self.request.user
# ВАЖНО: у Employee.departments это property, а не менеджер
departments = user.departments # уже queryset
last_qs = (
Message.objects.filter(chat=models.OuterRef("pk"))
.values("created_at")
.order_by("-created_at")[:1]
)
last_read_qs = ChatReadState.objects.filter(
chat=models.OuterRef("pk"), user=user
).values("last_read_at")[:1]
default_dt = timezone.make_aware(
datetime.datetime(1970, 1, 1), timezone.get_current_timezone()
).astimezone(dt_tz.utc)
# Получаем ID чатов, где пользователь является участником
# через membership
from communications.models import ChatMembership
membership_chat_ids = ChatMembership.objects.filter(
user=user
).values_list('chat_id', flat=True)
qs = (
Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments)
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids) # group/channel/announcement
)
.exclude(
# Скрываем заблокированные announcement
Q(type="announcement", is_blocked=True) &
~Q(created_by=user) # Создателю показываем
)
.exclude(
# Скрываем заблокированные announcement и для создателя
# если он не staff (чтобы staff мог разблокировать)
Q(type="announcement", is_blocked=True, created_by=user) &
Q(created_by__is_staff=False)
)
.distinct()
.select_related("department", "created_by", "blocked_by")
.annotate(last_msg_at=Subquery(last_qs))
.annotate(
last_read_at=Coalesce(
Subquery(last_read_qs),
Value(default_dt, output_field=models.DateTimeField()),
output_field=models.DateTimeField(),
)
)
.annotate(
# считаем только чужие сообщения после last_read_at
unread_count=Count(
"messages",
filter=Q(messages__created_at__gt=F("last_read_at"))
& ~Q(messages__author=user),
distinct=True,
)
)
.prefetch_related(
"participants",
Prefetch(
"messages",
queryset=Message.objects.select_related("author").order_by(
"-created_at"
)[:1],
to_attr="last_message",
),
)
.order_by(
models.F("last_msg_at").desc(nulls_last=True),
"-created_at",
)
)
return qs
class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
model = Chat
template_name = "communications/chat_detail.html"
context_object_name = "chat"
form_class = MessageForm
def dispatch(self, request, *args, **kwargs):
"""Добавляем заголовки для отключения кэширования"""
response = super().dispatch(request, *args, **kwargs)
# Запрещаем кэширование страницы чата в браузере
response['Cache-Control'] = (
'no-cache, no-store, must-revalidate, max-age=0'
)
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
return response
def get_queryset(self):
"""Возвращает только чаты, к которым пользователь имеет доступ"""
user = self.request.user
departments = user.departments
print(f"=" * 80)
print(f"[CHAT DEBUG] ChatDetailView.get_queryset called")
print(f"[CHAT DEBUG] User: {user.id} ({user.username or 'NO USERNAME'})")
print(f"[CHAT DEBUG] Requested chat PK: {self.kwargs.get('pk')}")
logger.info(
f"ChatDetailView.get_queryset: user={user.id} ({user.username}), "
f"pk={self.kwargs.get('pk')}"
)
# Получаем ID чатов через membership
from communications.models import ChatMembership
membership_chat_ids = list(
ChatMembership.objects.filter(user=user).values_list('chat_id', flat=True)
)
dept_ids = list(departments.values_list('id', flat=True))
print(f"[CHAT DEBUG] User departments IDs: {dept_ids}")
print(f"[CHAT DEBUG] User membership_chat_ids: {membership_chat_ids}")
logger.info(
f"User departments: {list(departments.values_list('id', flat=True))}, "
f"membership_chat_ids: {membership_chat_ids}"
)
qs = Chat.objects.filter(
Q(type="global")
| Q(type="department", department__in=departments)
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids)
).distinct()
available_ids = list(qs.values_list('id', flat=True))
print(f"[CHAT DEBUG] Available chats for user: {available_ids}")
print(f"[CHAT DEBUG] Access to requested chat: {self.kwargs.get('pk') in available_ids}")
print(f"=" * 80)
logger.info(f"Available chats for user: {list(qs.values_list('id', flat=True))}")
return qs
def get_object(self, queryset=None):
"""Получаем объект чата с дополнительным логированием"""
print(f"[CHAT DEBUG] get_object called")
try:
obj = super().get_object(queryset)
print(f"[CHAT DEBUG] Object found: Chat {obj.id}, type={obj.type}")
return obj
except Exception as e:
print(f"[CHAT DEBUG] Exception in get_object: {type(e).__name__}: {e}")
raise
def get_success_url(self):
return reverse(
"communications:chat_detail", kwargs={"pk": self.object.pk}
)
def _user_has_access(self, chat: Chat, user) -> bool:
return user_can_access_chat(chat, user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
chat: Chat = self.object
user = self.request.user
print(f"[CHAT DEBUG] get_context_data called for chat {chat.id}")
# защита доступа
has_access = self._user_has_access(chat, user)
print(f"[CHAT DEBUG] _user_has_access returned: {has_access}")
if not has_access:
from django.http import Http404
print(f"[CHAT DEBUG] Raising Http404 because user has no access")
raise Http404("Chat not found")
# сообщения + участники
messages_qs = (
chat.messages
.select_related("author")
.prefetch_related("attachments")
.order_by("-created_at")
)
page_size = CHAT_MESSAGES_PAGE_SIZE
raw_messages = list(messages_qs[: page_size + 1])
has_more = len(raw_messages) > page_size
next_cursor = None
if has_more:
next_cursor = raw_messages.pop()
displayed_messages = list(reversed(raw_messages))
oldest_displayed = raw_messages[-1] if raw_messages else next_cursor
context["messages"] = displayed_messages
context["messages_has_more"] = has_more
context["messages_oldest_id"] = (
oldest_displayed.id if oldest_displayed else None
)
context["messages_oldest_ts"] = (
int(oldest_displayed.created_at.timestamp() * 1000)
if oldest_displayed
else None
)
context["messages_page_size"] = page_size
context["form"] = context.get("form", MessageForm())
context["participants"] = chat.get_participants
# отдаём клиенту last_read_at и «первое непрочитанное»
read_state = (
ChatReadState.objects.filter(chat=chat, user=user)
.only("last_read_at", "last_read_message_id")
.first()
)
last_read_at = (
read_state.last_read_at
if read_state and read_state.last_read_at
else None
)
last_read_message_id = (
read_state.last_read_message_id
if read_state and read_state.last_read_message_id
else None
)
context["last_read_at"] = last_read_at
context["last_read_message_id"] = last_read_message_id
# Передаем last_read_message_id в chat объект для data-атрибута
chat.last_read_message_id = last_read_message_id
chat.last_read_timestamp = (
int(last_read_at.timestamp() * 1000) if last_read_at else 0
)
first_unread = None
if last_read_at:
first_unread = (
chat.messages.exclude(author=user)
.filter(created_at__gt=last_read_at)
.order_by("created_at")
.only("id", "created_at")
.first()
)
context["first_unread_id"] = first_unread.id if first_unread else None
if first_unread:
context["unread_from_ts"] = int(
first_unread.created_at.timestamp() * 1000
)
elif last_read_at:
context["unread_from_ts"] = (
int(last_read_at.timestamp() * 1000) + 1
)
else:
context["unread_from_ts"] = None
# Права на отправку сообщений в объявлениях
if chat.type == "announcement":
# Только создатель может писать в объявление
context["can_send_messages"] = (chat.created_by == user)
context["is_announcement_creator"] = (chat.created_by == user)
else:
# В других типах чатов проверяем can_send_messages
membership = ChatMembership.objects.filter(
chat=chat, user=user
).first()
context["can_send_messages"] = (
membership.can_send_messages if membership
else True # По умолчанию можно писать
)
context["is_announcement_creator"] = False
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user = request.user
chat = self.object
if not ChatDetailView._user_has_access(self, chat, user):
from django.http import HttpResponseForbidden
return HttpResponseForbidden("forbidden")
# Проверка прав на отправку сообщений
if chat.type == "announcement":
# Только создатель может писать в объявление
if chat.created_by != user:
from django.http import HttpResponseForbidden
return HttpResponseForbidden(
"Только автор может публиковать в это объявление"
)
else:
# В других типах чатов проверяем can_send_messages
membership = ChatMembership.objects.filter(
chat=chat, user=user
).first()
if membership and not membership.can_send_messages:
from django.http import HttpResponseForbidden
return HttpResponseForbidden(
"У вас нет прав для отправки сообщений"
)
form = self.get_form()
if form.is_valid():
msg = form.save(commit=False)
msg.chat = chat
msg.author = user
msg.save()
# автору сразу «прочитано» — делаем безопасно
ts = msg.created_at
updated = ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(
chat=chat, user=user, last_read_at=ts
)
except IntegrityError:
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
return redirect(self.get_success_url())
return self.render_to_response(self.get_context_data(form=form))
def start_private_chat(request, employee_pk):
from employees.models import Employee
target = get_object_or_404(Employee, pk=employee_pk)
user = request.user
chat = (
Chat.objects.filter(type="private", participants=user)
.filter(participants=target)
.first()
)
if not chat:
chat = Chat.objects.create(type="private")
chat.participants.add(user, target)
# новый чат помечаем прочитанным для создателя (нет непрочитанных)
now_ts = timezone.now()
try:
ChatReadState.objects.create(
chat=chat,
user=user,
last_read_at=now_ts,
)
except IntegrityError:
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=now_ts
).update(last_read_at=now_ts)
return redirect("communications:chat_detail", pk=chat.pk)
@login_required
@require_POST
def chat_mark_read(request, pk: int):
chat = get_object_or_404(Chat, pk=pk)
user = request.user
# доступ
def has_access(c: Chat, u) -> bool:
if c.type == "global":
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 in ("channel", "announcement"):
# Для каналов и объявлений проверяем participants или include_all
if c.include_all_employees:
return u.is_active
return c.participants.filter(pk=u.pk).exists()
return False
if not has_access(chat, user):
return JsonResponse({"ok": False, "error": "forbidden"}, status=403)
# целевая точка времени: приоритет у upto_id, затем upto_ts,
# затем последний месседж
ts = None
upto_id = request.POST.get("upto_id")
if upto_id:
m = (
Message.objects.filter(chat=chat, pk=upto_id)
.only("created_at")
.first()
)
ts = m.created_at if m else None
if ts is None:
ts = _coerce_ts(request.POST.get("upto_ts"))
if ts is None:
ts = (
chat.messages.order_by("-created_at")
.values_list("created_at", flat=True)
.first()
or timezone.now()
)
# обновляем только если новее; без update_or_create — устойчиво
# к SQLite/гонкам
updated = ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(chat=chat, user=user, last_read_at=ts)
except IntegrityError:
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
return JsonResponse(
{"ok": True, "last_read_at": int(ts.timestamp() * 1000)}
)