Corp/backend/api/v1/feed/views.py
lizerk 0217bc1a38 refactor: migrate comments to unified communications system
- Delete legacy Comment models (feed.Comment, documents.DocumentComment, requests_app.RequestComment)
- Create migration scripts to move data to communications.Message
- Add comments_helpers.py for unified comment operations
- Update API endpoints: /api/v1/posts/{id}/comments/, /api/v1/requests/{id}/comments/
- Fix notifications.0010_drop_old_tables migration with SeparateDatabaseAndState
- Implement real-time comments_count via GenericForeignKey annotations
- Update frontend API client to use new endpoints
- Update JavaScript files for new comment API routes
- Remove deprecated API endpoints and serializers
- Clean up signal handlers and admin registrations

Deployment tested: all migrations apply successfully on fresh database
2026-03-11 23:56:41 +03:00

323 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 __future__ import annotations
from typing import Any, List
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db.models import (
BooleanField,
Count,
Exists,
F,
OuterRef,
Q,
Subquery,
Value,
)
from employees.constants import DeptPerm
from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT
from feed.models import Post, PostLike
from feed.notifications import notify_post_reaction
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import (
FormParser,
JSONParser,
MultiPartParser,
)
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from ..permissions import (
AdminOrActionOrModelPerms,
AdminOrDeptAllowed,
)
from .serializers import PostListSerializer, PostSerializer
Employee = get_user_model()
def _to_id(val):
if val is None:
return None
if isinstance(val, int):
return val
if hasattr(val, "id"):
return getattr(val, "id")
try:
return int(str(val))
except (TypeError, ValueError):
return None
class PostViewSet(viewsets.ModelViewSet):
"""CRUD ленты + like/unlike, pin/unpin.
Доступ (коротко):
- list/retrieve/like/unlike — IsAuthenticated
- create:
* type=company → AdminOrActionOrModelPerms + required_perm_code="feed.publish_company_post"
* type=department → AdminOrDeptAllowed с кодом DeptPerm.CREATE_POST|publish_department_post
- update/partial_update/destroy:
* company → как для company-create
* department → как для department-create
- pin/unpin — только staff/superuser
"""
queryset = (
Post.objects.select_related("author", "department")
.order_by("-pinned", "-created_at")
)
parser_classes = [MultiPartParser, FormParser, JSONParser]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["title", "body"]
ordering_fields = ["created_at", "pinned", "likes_count"]
ordering = ["-pinned", "-created_at"]
class PostDeptPerm(AdminOrDeptAllowed):
"""Департаментные права для операций над постами отдела.
SAFE-методы разрешены (IsAuthenticated стоит на вьюхе).
Коды прав подставляются динамически из get_permissions().
"""
allow_safe_without_code = True
def has_permission(self, request, view) -> bool:
"""На create, если type=department, но department не указан,
не режем запрос — даём сериализатору вернуть 400 вместо 403.
"""
if getattr(view, "action", None) == "create":
t = (request.data.get("type") or "").strip()
if t == TYPE_DEPARTMENT:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
return True # пропускаем к валидации сериализатора
return super().has_permission(request, view)
def get_permissions(self) -> List[Any]:
"""Возвращает DRF-пермишены под текущий action (без вызова get_object())."""
if self.action in {"list", "retrieve", "like", "unlike"}:
return [IsAuthenticated()]
if self.action in {"pin", "unpin"}:
return [IsAdminUser()]
# --- CREATE: смотрим на тип из тела запроса ---
if self.action == "create":
t = (self.request.data.get("type") or "").strip()
if t == TYPE_COMPANY:
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
# department → департаментное право на создание
dept_code = getattr(DeptPerm, "CREATE_POST", "publish_department_post")
perm = self.PostDeptPerm()
perm.required_code = DeptPerm.CREATE_POST
return [IsAuthenticated(), perm]
# --- UPDATE/PATCH/DELETE: определяем тип поста из БД, но без get_object() ---
if self.action in {"update", "partial_update", "destroy"}:
lookup = getattr(self, "lookup_url_kwarg", None) or getattr(
self, "lookup_field", "pk"
)
pk = self.kwargs.get(lookup)
info = (
Post.objects.filter(pk=pk).values("type", "department_id").first() or {}
)
if info.get("type") == TYPE_COMPANY:
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
# department
dept_code = DeptPerm.MANAGE_FEED
perm = self.PostDeptPerm()
perm.required_code_map = {
"update": dept_code,
"partial_update": dept_code,
"destroy": dept_code,
}
return [IsAuthenticated(), perm]
# дефолт: просто аутентификация
return [IsAuthenticated()]
def get_serializer_class(self):
return PostListSerializer if self.action == "list" else PostSerializer
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
# Аннотируем счётчик комментариев через communications.Chat
from communications.models import Chat, Message
# ContentType для Post
post_ct = ContentType.objects.get_for_model(Post)
# Подсчёт сообщений в чате комментариев для каждого поста
comments_subquery = Message.objects.filter(
chat__type='comments',
chat__context_content_type=post_ct,
chat__context_object_id=OuterRef('pk')
).values('chat').annotate(count=Count('id')).values('count')
qs = qs.annotate(
comments_count=Subquery(comments_subquery)
)
if user.is_authenticated:
qs = qs.annotate(
is_liked=Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user))
)
else:
qs = qs.annotate(is_liked=Value(False, output_field=BooleanField()))
# фильтры
qp = self.request.query_params
t = qp.get("type")
if t:
qs = qs.filter(type=t)
dept_id = _to_id(qp.get("department"))
if dept_id is not None:
qs = qs.filter(department_id=dept_id)
author_id = _to_id(qp.get("author"))
if author_id is not None:
qs = qs.filter(author_id=author_id)
pinned = qp.get("pinned")
if pinned in ("true", "1"):
qs = qs.filter(pinned=True)
elif pinned in ("false", "0"):
qs = qs.filter(pinned=False)
return qs
def list(self, request, *args, **kwargs):
"""Возвращает список постов."""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
objects = page if page is not None else queryset
serializer = self.get_serializer(objects, many=True)
if page is not None:
return self.get_paginated_response(serializer.data)
return Response(serializer.data)
# --- ВАЖНО: возвращаем perform_create, чтобы заполнить author ---
def perform_create(self, serializer: PostSerializer) -> None:
"""Сохраняет пост, проставляя автора текущим пользователем.
Raises:
ValidationError: если сериализатор невалиден (обрабатывается DRF).
"""
serializer.save(author=self.request.user)
# --- actions (pin/unpin и лайки) ---
@action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
def pin(self, request, pk=None):
"""Пинует пост (только staff/superuser)."""
post = self.get_object()
if not post.pinned:
Post.objects.filter(pk=post.pk).update(pinned=True)
return Response({"pinned": True})
@action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
def unpin(self, request, pk=None):
"""Снимает пин (только staff/superuser)."""
post = self.get_object()
if post.pinned:
Post.objects.filter(pk=post.pk).update(pinned=False)
return Response({"pinned": False})
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
def like(self, request, pk=None):
"""Ставит лайк текущего пользователя (идемпотентно)."""
post = self.get_object()
_, created = PostLike.objects.get_or_create(post=post, user=request.user)
if created:
Post.objects.filter(pk=post.pk).update(likes_count=F("likes_count") + 1)
# Отправляем уведомление автору публикации
notify_post_reaction(post, request.user)
post.refresh_from_db(fields=["likes_count"])
return Response(
{"liked": True, "likes_count": post.likes_count}, status=status.HTTP_200_OK
)
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
def unlike(self, request, pk=None):
"""Снимает лайк текущего пользователя (идемпотентно)."""
post = self.get_object()
deleted, _ = PostLike.objects.filter(post=post, user=request.user).delete()
if deleted:
Post.objects.filter(pk=post.pk, likes_count__gt=0).update(
likes_count=F("likes_count") - 1
)
post.refresh_from_db(fields=["likes_count"])
return Response(
{"liked": False, "likes_count": post.likes_count},
status=status.HTTP_200_OK,
)
@action(detail=True, methods=["get", "post"], permission_classes=[IsAuthenticated])
def comments(self, request, pk=None):
"""Управление комментариями к посту.
GET: получение списка комментариев
POST: создание комментария {"text": "..."}
"""
from communications import comments_helpers
from ..employees.serializers import EmployeeBriefSerializer
post = self.get_object()
if request.method in {"GET", "HEAD"}:
# Получаем комментарии через unified system
messages = comments_helpers.get_comments(post)
# Форматируем в формат, совместимый со старым API
comments_data = []
for msg in messages:
author_ser = EmployeeBriefSerializer(msg.author)
comments_data.append({
"id": msg.id,
"post": post.id,
"post_id": post.id,
"author": author_ser.data,
"author_id": msg.author.id if msg.author else None,
"text": msg.content,
"created_at": msg.created_at,
"created_at_display": msg.created_at.strftime("%d.%m.%Y %H:%M"),
})
return Response(comments_data)
# POST - создание комментария
text = request.data.get("text", "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST
)
# Создаём комментарий через unified system
message = comments_helpers.create_comment(
obj=post,
author=request.user,
content=text
)
# Форматируем ответ
author_ser = EmployeeBriefSerializer(message.author)
response_data = {
"id": message.id,
"post": post.id,
"post_id": post.id,
"author": author_ser.data,
"author_id": message.author.id if message.author else None,
"text": message.content,
"created_at": message.created_at,
"created_at_display": message.created_at.strftime("%d.%m.%Y %H:%M"),
}
return Response(response_data, status=status.HTTP_201_CREATED)