Corp/backend/feed/views.py
Игорь Надеин 2c85086f78 feat(feed,employees,communications): dept news, invites, chat sync; forms/admin cleanup; templates polish
## Что сделано

### Модели
- **employees**
  - : теперь включает отделы, где сотрудник является **руководителем**.
  - Уточнены :
    -  →  у ,  у .
  - : добавлены удобные геттеры , ; улучшён .
  - Модели , ,  приведены к актуальным полям (без start_date/end_date/level и т.п., чтобы не конфликтовать с формами/админкой).

- **feed**
  - Вынесены константы типов постов: , ,  + .
  - Жёсткие **CheckConstraint**:
    - для департаментских постов ,
    - для остальных — .
  - Индексы по , , .

- **communications**
  - :
    -  для  (чаты отдела удаляются вместе с отделом).
    - Метод : активные сотрудники отдела + **руководитель**; для глобального — все активные.
    - Запрет удаления **основных** чатов (global/department), но обычные удаляются нормально.

### Админка
- Приведена в соответствие моделям: убраны несуществующие поля, добавлены фильтры/поиск по реальным полям, inlines для отделов (связи сотрудников).
- Исправлены ошибки system check (E033/E108/E116).

### Формы
- , : валидация аватара, корректные required/optional, PhoneNumberField для WhatsApp/WeChat.
- ,  (динамический queryset сотрудников, не состоящих в отделе).
- , , : соответствуют актуальным моделям, с базовой валидацией диапазонов дат (если применимо).

### Вьюхи (employees)
- Переписаны аккуратно и проще:
  - Списки/детали отделов на CBV (, ), в контексте — сотрудники, новости отдела, новые сотрудники.
  - Профиль и чужой профиль использует общий шаблон с подключаемыми блоками.
  - : доступ только **админу/руководителю**; добавление связи  и автодобавление участника (и руководителя) в **чат отдела**.
  - : подготовлено к фильтрации по .

### Вьюхи (feed)
-  — **универсальная** форма:
  - контекст определяется автоматически из GET:  | ,
  - скрываем поля , чтобы пользователь не ломал контекст,
  - редирект — на **красивые** страницы: главная лента / страница отдела / лента сотрудника.
- : только админ — для компании, только **руководитель отдела** — для департамента.
- : комментарии + форма добавления.
- **Добавлены** CRUD для постов и комментариев:
  - ,  — автор/админ/руководитель отдела.
  - ,  — только автор комментария.

### URL-ы
- : добавлены маршруты , , , .
- : все нужные эндпоинты в рабочем состоянии,  на месте.

### Шаблоны
- : опрятная шапка, линк на «Все сотрудники отдела», кнопка «Пригласить» (для админа/руководителя), внизу — include **новостей отдела**.
- : приведение к общему стилю, кнопка «Новая публикация» с автоподстановкой department.
- : отображает бейдж контекста (Компания/Отдел/Личное), **одна** кнопка «Отмена»,  приходит из вьюхи.
- : контекстная кнопка «Назад к ленте» (определяется автоматически), кнопки **Редактировать/Удалить** (по правам), комментарии с кнопками **ред./удал.** у автора.
- Исправлены include-пути (везде , без обратных слешей).

## Почему всё ок
- Нет циклических импортов,  выровнены.
- Никакой магии в шаблонах: вся логика определения контекста — во вьюхах.
- Чаты отдела удаляются вместе с отделом; основные чаты защищены от удаления.
- Учитываем **руководителя отдела** везде, где нужно (новости, чат, права).

## Что проверить локально
1) Миграции:
   - python manage.py makemigrations
   - python manage.py migrate
2) Создать отдел, назначить руководителя.
3) Пригласить сотрудника → автоматически попадёт в чат отдела.
4) Создать новости:
   - с главной → тип company,
   - со страницы отдела → тип department, поле Department скрыто,
   - из ленты сотрудника → тип employee.
5) Закрепление новостей:
   - компания — только staff,
   - отдел — только head.
6) Редактирование/удаление постов и комментариев — по правам.

## Примечания
- Если ранее в БД были посты с типом 'personal' — вручную мигрируйте на 'employee'.
2025-08-08 15:16:19 +03:00

358 lines
13 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 django import forms
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from employees.models import Department, Employee, EmployeeDepartment
from .constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
from .forms import CommentForm, PostForm
from .models import Post, Comment
def feed_list(request):
posts = Post.objects.filter(type=TYPE_COMPANY).order_by("-pinned", "-created_at")
active_employees = sorted(
[emp for emp in Employee.objects.all() if emp.is_actually_active],
key=lambda e: e.created_at,
reverse=True,
)
new_count = max(1, int(len(active_employees) * 0.1))
new_employees = Employee.get_active()[:new_count]
return render(
request, "feed/feed_list.html", {"posts": posts, "new_employees": new_employees}
)
@login_required
def department_feed(request, pk):
"""Осталось для совместимости; основная — employees:department_detail."""
department = get_object_or_404(Department, pk=pk)
posts = Post.objects.filter(type=TYPE_DEPARTMENT, department=department).order_by(
"-pinned", "-created_at"
)
new_employees = department.new_employees
emp_links = (
EmployeeDepartment.objects.filter(department=department, is_active=True)
.select_related("employee")
.order_by("employee__last_name", "employee__first_name")
)
return render(
request,
"feed/department_feed.html",
{
"department": department,
"posts": posts,
"new_employees": new_employees,
"emp_links": emp_links,
},
)
@login_required
def employee_feed(request, pk):
employee = get_object_or_404(Employee, pk=pk)
posts = Post.objects.filter(author=employee).order_by("-created_at")
return render(
request, "feed/employee_feed.html", {"employee": employee, "posts": posts}
)
@login_required
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
comments = post.comments.select_related("author")
if request.method == "POST":
comment_form = CommentForm(request.POST)
if comment_form.is_valid():
comment = comment_form.save(commit=False)
comment.author = request.user
comment.post = post
comment.save()
messages.success(request, "Комментарий добавлен!")
return redirect("feed:post_detail", pk=pk)
else:
comment_form = CommentForm()
return render(
request,
"feed/post_detail.html",
{"post": post, "comments": comments, "comment_form": comment_form},
)
# ---------- Хелперы прав/редиректов ----------
def is_department_head(user, department: Department) -> bool:
return bool(
user and (user.is_staff or (department and department.head_id == user.id))
)
def can_edit_post(user, post: Post) -> bool:
if not user or not user.is_authenticated:
return False
if user.is_staff:
return True
if post.type == TYPE_COMPANY:
return False # только staff
if post.type == TYPE_DEPARTMENT:
return post.author_id == user.id or is_department_head(user, post.department)
return post.author_id == user.id # employee
def back_url_for_post(post: Post, user) -> str:
if post.type == TYPE_COMPANY:
return reverse("feed:feed_list")
if post.type == TYPE_DEPARTMENT and post.department_id:
return reverse("employees:department_detail", args=[post.department_id])
return reverse(
"feed:employee_feed",
args=[user.pk if user and user.is_authenticated else post.author_id],
)
# ---------- Создание ----------
@login_required
def post_create(request):
"""
Универсальная форма создания поста.
?department=<id> -> пост отдела
?type=company -> новость компании
?type=employee -> личная публикация
По умолчанию — company.
"""
dept_id = request.GET.get("department")
force_type = request.GET.get("type")
department = get_object_or_404(Department, pk=dept_id) if dept_id else None
if department:
context_type = TYPE_DEPARTMENT
elif force_type in {TYPE_COMPANY, TYPE_EMPLOYEE}:
context_type = force_type
else:
context_type = TYPE_COMPANY
cancel_url = (
request.GET.get("next")
or (
reverse("employees:department_detail", args=[department.pk])
if department
else None
)
or (
reverse("feed:feed_list")
if context_type == TYPE_COMPANY
else reverse("feed:employee_feed", args=[request.user.pk])
)
)
if request.method == "POST":
form = PostForm(request.POST, request.FILES)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
# фиксируем контекст
if context_type == TYPE_DEPARTMENT:
post.type = TYPE_DEPARTMENT
post.department = department
elif context_type == TYPE_COMPANY:
post.type = TYPE_COMPANY
post.department = None
else:
post.type = TYPE_EMPLOYEE
post.department = None
if post.type == TYPE_DEPARTMENT and not post.department:
messages.error(request, "Для новостей отдела отдел обязателен.")
return render(
request,
"feed/post_form.html",
{
"form": form,
"context_type": context_type,
"department": department,
"cancel_url": cancel_url,
},
)
post.save()
messages.success(request, "Публикация создана!")
return redirect(back_url_for_post(post, request.user))
else:
initial = {}
if context_type == TYPE_DEPARTMENT:
initial.update({"type": TYPE_DEPARTMENT, "department": department})
elif context_type == TYPE_COMPANY:
initial.update({"type": TYPE_COMPANY, "department": None})
else:
initial.update({"type": TYPE_EMPLOYEE})
form = PostForm(initial=initial)
# прячем лишние поля
if "type" in form.fields:
form.fields["type"].initial = initial["type"]
form.fields["type"].widget = forms.HiddenInput()
if context_type == TYPE_DEPARTMENT and "department" in form.fields:
form.fields["department"].initial = department.pk
form.fields["department"].widget = forms.HiddenInput()
else:
form.fields.pop("department", None)
return render(
request,
"feed/post_form.html",
{
"form": form,
"context_type": context_type,
"department": department,
"cancel_url": cancel_url,
},
)
# ---------- Редактирование ----------
@login_required
def post_update(request, pk):
post = get_object_or_404(Post, pk=pk)
if not can_edit_post(request.user, post):
messages.error(request, "Нет прав на редактирование этой публикации.")
return redirect("feed:post_detail", pk=pk)
cancel_url = request.GET.get("next") or back_url_for_post(post, request.user)
if request.method == "POST":
form = PostForm(request.POST, request.FILES, instance=post)
if form.is_valid():
# Не позволяем менять контекст (тип/отдел) из формы
edited = form.save(commit=False)
edited.type = post.type
edited.department = post.department
edited.author = post.author
edited.save()
messages.success(request, "Публикация обновлена!")
return redirect(back_url_for_post(post, request.user))
else:
form = PostForm(instance=post)
# Прячем поля, чтобы не меняли тип/отдел
if "type" in form.fields:
form.fields["type"].widget = forms.HiddenInput()
if "department" in form.fields:
form.fields["department"].widget = forms.HiddenInput()
context_type = post.type
department = post.department if post.type == TYPE_DEPARTMENT else None
return render(
request,
"feed/post_form.html",
{
"form": form,
"context_type": context_type,
"department": department,
"cancel_url": cancel_url,
},
)
# ---------- Удаление ----------
@login_required
def post_delete(request, pk):
post = get_object_or_404(Post, pk=pk)
if not can_edit_post(request.user, post):
messages.error(request, "Нет прав на удаление этой публикации.")
return redirect("feed:post_detail", pk=pk)
cancel_url = request.GET.get("next") or back_url_for_post(post, request.user)
if request.method == "POST":
back_url = back_url_for_post(post, request.user)
post.delete()
messages.success(request, "Публикация удалена.")
return redirect(back_url)
return render(
request,
"feed/post_confirm_delete.html",
{"post": post, "cancel_url": cancel_url},
)
@login_required
def pin_post(request, pk):
post = get_object_or_404(Post, pk=pk)
if post.type == TYPE_COMPANY and not request.user.is_staff:
messages.error(
request, "Только администратор может закреплять новости компании."
)
return redirect("feed:post_detail", pk=pk)
if post.type == TYPE_DEPARTMENT and not is_department_head(
request.user, post.department
):
messages.error(
request, "Только руководитель отдела может закреплять новости отдела."
)
return redirect("feed:post_detail", pk=pk)
post.pinned = not post.pinned
post.save()
messages.success(
request, "Новость закреплена!" if post.pinned else "Новость откреплена!"
)
return redirect(back_url_for_post(post, request.user))
def _can_manage_comment(user, comment: Comment) -> bool:
return user.is_staff or (comment.author_id == user.id)
@login_required
def comment_update(request, pk):
"""
Редактирование комментария.
Доступно автору комментария и staff.
"""
comment = get_object_or_404(Comment, pk=pk)
if not _can_manage_comment(request.user, comment):
messages.error(request, "У вас нет прав для редактирования этого комментария.")
return redirect("feed:post_detail", pk=comment.post_id)
if request.method == "POST":
form = CommentForm(request.POST, instance=comment)
if form.is_valid():
form.save()
messages.success(request, "Комментарий обновлён.")
# Якорь на комментарий (если в шаблоне есть id="c{{ comment.id }}")
return redirect(f"{reverse('feed:post_detail', args=[comment.post_id])}#c{comment.pk}")
else:
form = CommentForm(instance=comment)
return render(
request,
"feed/comment_form.html",
{"form": form, "comment": comment, "post": comment.post},
)
@login_required
def comment_delete(request, pk):
"""
Удаление комментария.
Доступно автору и staff. Подтверждение через GET-страницу.
"""
comment = get_object_or_404(Comment, pk=pk)
if not _can_manage_comment(request.user, comment):
messages.error(request, "У вас нет прав для удаления этого комментария.")
return redirect("feed:post_detail", pk=comment.post_id)
if request.method == "POST":
post_id = comment.post_id
comment.delete()
messages.success(request, "Комментарий удалён.")
return redirect("feed:post_detail", pk=post_id)
return render(
request,
"feed/comment_confirm_delete.html",
{"comment": comment, "post": comment.post},
)