From 2433adfbdef926edbb182d7408fd1dd6bfeaf225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 12 Aug 2025 08:11:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=BE=D0=B2=20+=20=D0=BF=D0=BE=D0=BB=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BB=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20-=20communications:=20=D1=80=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D0=B9=D0=BD=20chat=5Flist=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA,=20=D0=B1=D1=8B=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20(?= =?UTF-8?q?=D0=B2=D1=81=D0=B5/=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B5/=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B5=D0=BB=D1=8B/=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9),=20=D1=81=D0=BD=D0=B8=D0=BF?= =?UTF-8?q?=D0=BF=D0=B5=D1=82=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=20=D0=B4=D0=B0=D1=82=D0=B0,=20=D0=B0?= =?UTF-8?q?=D0=BA=D0=BA=D1=83=D1=80=D0=B0=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20-=20employees:=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20Invite?= =?UTF-8?q?ToDepartmentForm=20(queryset=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20departments=5Flinks),=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0?= =?UTF-8?q?=20=C2=AB=D0=9F=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=C2=BB=20=D0=B8=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D1=81?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D0=B0=20-=20employees:=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D1=81=D0=BE=D1=82=D1=80=D1=83?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=E2=80=94=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D1=81=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B9=20=D1=82=D0=B8=D0=BF=D0=BE=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=84=D0=B8=D0=BA=D0=BE=D0=B9=20-=20feed:=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=20=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=20(?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D1=8F/=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB/=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B5),=20?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=88=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8F;=20post=5Fd?= =?UTF-8?q?etail=20=E2=80=94=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=BD=D0=B0=D0=B7=D0=B0=D0=B4=C2=BB=20(=D0=B2=20=D0=BD?= =?UTF-8?q?=D1=83=D0=B6=D0=BD=D1=83=D1=8E=20=D0=BB=D0=B5=D0=BD=D1=82=D1=83?= =?UTF-8?q?),=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=C2=AB=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=C2=BB=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=20-=20feed:=20urls/views=20=E2=80=94=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B/update=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D1=8B=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F/=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2=20(?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BB=D1=8F=20CRUD=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/employees/forms.py | 23 ++- backend/employees/views_front.py | 56 +++---- .../templates/communications/chat_list.html | 146 +++++++++++++++--- 3 files changed, 172 insertions(+), 53 deletions(-) diff --git a/backend/employees/forms.py b/backend/employees/forms.py index 0fc6955d..91edf0d9 100644 --- a/backend/employees/forms.py +++ b/backend/employees/forms.py @@ -121,18 +121,27 @@ class DepartmentForm(forms.ModelForm): class InviteToDepartmentForm(forms.Form): - """Форма приглашения в отдел — динамически наполняется списком сотрудников""" - employee = forms.ModelChoiceField( - queryset=Employee.objects.none(), label="Сотрудник" + queryset=Employee.objects.none(), + label="Сотрудник", + widget=forms.Select(attrs={"class": "form-select"}) ) def __init__(self, department, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["employee"].queryset = Employee.objects.exclude( - employeedepartment__department=department, - employeedepartment__is_active=True, - ) + + qs = (Employee.objects + .all() + # НЕ показываем тех, кто уже активен в отделе + .exclude( + departments_links__department=department, + departments_links__is_active=True, + ) + # по желанию: не показывать руководителя, если он уже назначен + .exclude(id=department.head_id if department.head_id else None) + .order_by("last_name", "first_name")) + + self.fields["employee"].queryset = qs # ========================= diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 34ec9a6a..62c0021a 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -116,7 +116,11 @@ def resend_sms(request): @login_required def profile(request): employee = request.user - posts = Post.objects.filter(author=employee).order_by("-created_at")[:10] + posts = ( + Post.objects.filter(author=employee) + .select_related("author") + .order_by("-created_at")[:10] + ) show_edit = False if request.method == "POST": @@ -147,7 +151,11 @@ def employee_detail(request, pk): employee = get_object_or_404(Employee, pk=pk) if employee == request.user: return redirect("employees:profile") - posts = Post.objects.filter(author=employee).order_by("-created_at")[:10] + posts = ( + Post.objects.filter(author=employee) + .select_related("author") + .order_by("-created_at")[:10] + ) return render( request, "employees/profile.html", @@ -166,29 +174,30 @@ def avatar_remove(request): # ========================= # Сотрудники # ========================= -class EmployeeListView(ListView): +class EmployeeListView(LoginRequiredMixin, ListView): model = Employee template_name = "employees/employees_list.html" context_object_name = "employees" ordering = ["last_name", "first_name"] def get_queryset(self): - qs = super().get_queryset().order_by("last_name", "first_name") + qs = super().get_queryset().order_by("last_name", "first_name").select_related() dept_id = self.request.GET.get("department") if dept_id: - # Активные связи сотрудник-отдел - from .models import EmployeeDepartment - employee_ids = ( - EmployeeDepartment.objects - .filter(department_id=dept_id, is_active=True) - .values_list("employee_id", flat=True) - ) - qs = qs.filter(id__in=employee_ids) + # фильтруем по активным связям в отделе, без отдельного запроса на ids: + qs = qs.filter( + departments_links__department_id=dept_id, + departments_links__is_active=True, + ).distinct() return qs def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx["filter_department_id"] = self.request.GET.get("department") + dept_id = self.request.GET.get("department") + ctx["filter_department_id"] = dept_id + ctx["current_department"] = ( + Department.objects.filter(pk=dept_id).first() if dept_id else None + ) return ctx @@ -212,8 +221,7 @@ class DepartmentDetailView(LoginRequiredMixin, DetailView): department = self.object # Список позиций (EmployeePosition) связан через related_name="employees" ctx["emp_links"] = ( - EmployeeDepartment.objects - .filter(department=department, is_active=True) + EmployeeDepartment.objects.filter(department=department, is_active=True) .select_related("employee") .order_by("employee__last_name", "employee__first_name") ) @@ -281,20 +289,16 @@ def invite_to_department(request, pk): emp_dep.is_active = True emp_dep.save() - # На всякий случай поддержим участников у чата (хотя у нас есть динамическая выборка) - chat, _ = Chat.objects.get_or_create( - type="department", department=department, is_main=True - ) - chat.participants.add(employee) - if department.head: - chat.participants.add(department.head) - - messages.success( - request, f"{employee.get_full_name()} приглашён в отдел и чат!" - ) + # участников в чат можно не добавлять — get_participants собирает динамически + messages.success(request, f"{employee.get_full_name()} приглашён в отдел!") return redirect("employees:department_detail", pk=pk) else: form = InviteToDepartmentForm(department) + if not form.fields["employee"].queryset.exists(): + messages.info( + request, + "Нет сотрудников, которых можно пригласить — похоже, все уже в отделе.", + ) return render( request, diff --git a/backend/templates/communications/chat_list.html b/backend/templates/communications/chat_list.html index 7d2ce7eb..7376f03f 100644 --- a/backend/templates/communications/chat_list.html +++ b/backend/templates/communications/chat_list.html @@ -1,40 +1,146 @@ {# backend/templates/communications/chat_list.html #} {% extends "base.html" %} +{% load static django_bootstrap5 %} {% block title %}Мои чаты{% endblock %} {% block content %} -
-

Мои чаты

-
+
+ +
+
+ +

Мои чаты

+
+ +
+ + +
+
+ + + + +
+
+ + +
{% for chat in chats %} -
-
-
-
- {% if chat.type == 'global' %} - Глобальный чат - {% elif chat.type == 'department' %} - {{ chat.department.name }} + {% with last=chat.messages.last %} +
+ +
+
+
+
+ {% if chat.type == 'global' %} + + {% elif chat.type == 'department' %} + + {% else %} + + {% endif %} +
+ {% if chat.type == 'global' %} + Глобальный чат + {% elif chat.type == 'department' %} + Чат отдела: {{ chat.department.name }} + {% else %} + {% with names="" %} + {% for u in chat.participants.all %} + {% if u != user %}{{ u.get_full_name|default:u }}{% if not forloop.last %}, {% endif %}{% endif %} + {% endfor %} + {% endwith %} + {% endif %} +
+
+ + {% if last %}{{ last.created_at|date:"d.m.Y H:i" }}{% endif %} + +
+ +

+ {% if last %} + {{ last.author.get_full_name|default:last.author }}: {{ last.content|truncatechars:90 }} {% else %} - - {% for u in chat.participants.all %} - {% if u != user %}{{ u.get_full_name|default:u }}{% endif %} - {% endfor %} + Сообщений пока нет. {% endif %} -

- -

- Сообщений: {{ chat.messages.count }}

+ +
+ + {{ chat.messages.count }} + + + Открыть + +
+
+ {% endwith %} {% empty %}
-
Чатов пока нет.
+
+ Чатов пока нет. Начните с поиска сотрудника и откройте личный чат. +
{% endfor %}
+ + + {% endblock %}