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
This commit is contained in:
lizerk 2026-03-11 23:56:41 +03:00
parent 8e2d4ad231
commit 0217bc1a38
32 changed files with 1188 additions and 949 deletions

View File

@ -206,41 +206,6 @@ class ActivityItemSerializer(serializers.Serializer):
details = serializers.DictField(allow_null=True)
class DocumentCommentSerializer(serializers.Serializer):
"""Сериализатор для комментариев к документам."""
id = serializers.IntegerField(read_only=True)
document_id = serializers.IntegerField(read_only=True)
author = EmployeeBriefSerializer(read_only=True)
text = serializers.CharField()
parent_id = serializers.IntegerField(required=False, allow_null=True)
created_at = serializers.DateTimeField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
is_edited = serializers.BooleanField(read_only=True)
depth = serializers.IntegerField(read_only=True)
replies_count = serializers.SerializerMethodField()
def get_replies_count(self, obj) -> int:
"""Возвращает количество ответов на комментарий."""
return obj.replies.count() if hasattr(obj, 'replies') else 0
def create(self, validated_data):
"""Создаёт новый комментарий."""
from documents.models import Document, DocumentComment
# document_id и parent_id должны быть установлены из контекста view
return DocumentComment.objects.create(**validated_data)
def update(self, instance, validated_data):
"""Обновляет комментарий."""
# При обновлении текста устанавливаем is_edited = True
if 'text' in validated_data and validated_data['text'] != instance.text:
instance.is_edited = True
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
class DocumentReadSerializer(serializers.ModelSerializer):
"""Сериализатор для чтения документа.

View File

@ -10,7 +10,6 @@ from documents.models import (
Document,
DocumentAcknowledgement,
DocumentTag,
DocumentComment,
)
from easy_thumbnails.files import get_thumbnailer
from filer.models import Folder
@ -33,7 +32,6 @@ from .serializers import (
VersionSerializer,
ActivityItemSerializer,
DocumentTagSerializer,
DocumentCommentSerializer,
)
User = get_user_model()
@ -590,87 +588,6 @@ class DocumentViewSet(ModelViewSet):
)
class DocumentCommentViewSet(ModelViewSet):
"""CRUD для комментариев к документам.
Поддерживает вложенные ответы (threading) через поле parent.
"""
serializer_class = DocumentCommentSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Возвращает комментарии, опционально фильтрованные по документу.
Параметры:
?document_id=<id> - показать только комментарии к этому документу
?root=true - показать только корневые комментарии (без parent)
"""
qs = DocumentComment.objects.all().select_related('author', 'document', 'parent')
document_id = self.request.query_params.get('document_id')
root = self.request.query_params.get('root', '').lower() == 'true'
if document_id:
qs = qs.filter(document_id=document_id)
if root:
qs = qs.filter(parent__isnull=True)
return qs.order_by('created_at')
@transaction.atomic
def perform_create(self, serializer):
"""Автоматически устанавливаем author при создании."""
# Получаем document_id и parent_id из request.data
document_id = self.request.data.get('document_id')
parent_id = self.request.data.get('parent_id')
if not document_id:
raise rest_serializers.ValidationError({'document_id': 'Это поле обязательно.'})
try:
document = Document.objects.get(pk=document_id)
except Document.DoesNotExist:
raise rest_serializers.ValidationError({'document_id': 'Документ не найден.'})
parent = None
if parent_id:
try:
parent = DocumentComment.objects.get(pk=parent_id, document=document)
except DocumentComment.DoesNotExist:
raise rest_serializers.ValidationError({'parent_id': 'Родительский комментарий не найден.'})
serializer.save(
author=self.request.user,
document=document,
parent=parent
)
@transaction.atomic
def perform_update(self, serializer):
"""При обновлении помечаем комментарий как отредактированный."""
serializer.save(is_edited=True)
def destroy(self, request, *args, **kwargs):
"""Разрешаем удалять только свои комментарии (или staff)."""
comment = self.get_object()
if comment.author != request.user and not request.user.is_staff:
return Response(
{'error': 'Вы можете удалять только свои комментарии'},
status=status.HTTP_403_FORBIDDEN
)
return super().destroy(request, *args, **kwargs)
@action(detail=True, methods=['get'])
def replies(self, request, pk=None):
"""Получить все ответы на комментарий."""
comment = self.get_object()
replies = comment.replies.all().select_related('author')
serializer = self.get_serializer(replies, many=True)
return Response(serializer.data)
class FolderViewSet(ModelViewSet):
"""CRUD для папок документов (filer.Folder).

View File

@ -1,13 +1,11 @@
# backend/api/v1/feed/serializers.py
from __future__ import annotations
from typing import Any, Dict, Optional
from django.contrib.auth import get_user_model
from rest_framework import serializers
from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
from feed.models import Comment, Post, PostLike
from feed.constants import TYPE_DEPARTMENT, TYPE_EMPLOYEE
from feed.models import Post, PostLike
from ..utils import build_media_url
Employee = get_user_model()
@ -33,17 +31,6 @@ class AuthorMiniSerializer(serializers.ModelSerializer):
return build_media_url(obj.avatar, request)
class CommentMiniSerializer(serializers.ModelSerializer):
author = AuthorMiniSerializer(read_only=True)
created_at_display = serializers.DateTimeField(
source="created_at", format="%d.%m.%Y %H:%M", read_only=True
)
class Meta:
model = Comment
fields = ["id", "text", "created_at", "created_at_display", "author"]
class PostListSerializer(serializers.ModelSerializer):
author_id = serializers.IntegerField(read_only=True)
author = AuthorMiniSerializer(read_only=True)
@ -55,10 +42,9 @@ class PostListSerializer(serializers.ModelSerializer):
# ожидания фронта
is_liked = serializers.BooleanField(read_only=True, default=False)
comments_count = serializers.IntegerField(read_only=True, default=0)
comments_count = serializers.IntegerField(read_only=True, default=0, allow_null=True)
# для ссылок на отдел
department_id = serializers.IntegerField(read_only=True)
last_comment = serializers.SerializerMethodField()
# Переопределяем поля для полных URL
image = serializers.SerializerMethodField()
@ -83,7 +69,6 @@ class PostListSerializer(serializers.ModelSerializer):
"author_id",
"author",
"is_liked",
"last_comment",
]
read_only_fields = (
"created_at",
@ -107,38 +92,16 @@ class PostListSerializer(serializers.ModelSerializer):
request = self.context.get('request')
return build_media_url(obj.attachment, request)
def get_last_comment(self, obj):
# Берём готовую мапу из контекста, собранную во viewset.list()
lc_map = self.context.get("last_comments_map") or {}
lc = None
lc_id = getattr(obj, "last_comment_id", None)
if lc_id:
lc = lc_map.get(lc_id)
if not lc:
return None
return CommentMiniSerializer(lc, context=self.context).data
class PostSerializer(PostListSerializer):
"""Сериализатор для деталей поста с комментариями"""
comments = serializers.SerializerMethodField()
"""Сериализатор для деталей поста"""
user_has_liked = serializers.SerializerMethodField()
class Meta(PostListSerializer.Meta):
fields = PostListSerializer.Meta.fields + [
"comments",
"user_has_liked"
]
def get_comments(self, obj):
"""Возвращает список комментариев к посту"""
comments = obj.comments.select_related("author").order_by(
"created_at"
)
return CommentSerializer(
comments, many=True, context=self.context
).data
def get_user_has_liked(self, obj):
"""Проверяет, лайкнул ли текущий пользователь этот пост"""
request = self.context.get("request")
@ -169,61 +132,3 @@ class PostSerializer(PostListSerializer):
{"department": "Для этого типа публикаций отдел указывать нельзя."}
)
return attrs
class CommentSerializer(serializers.ModelSerializer):
post_id = serializers.IntegerField(read_only=True)
author_id = serializers.IntegerField(read_only=True)
author = AuthorMiniSerializer(read_only=True)
image = serializers.FileField(allow_null=True, required=False)
attachment = serializers.FileField(allow_null=True, required=False)
created_at_display = serializers.DateTimeField(
source="created_at", format="%d.%m.%Y %H:%M", read_only=True
)
is_post_author = serializers.SerializerMethodField()
class Meta:
model = Comment
fields = [
"id",
"post",
"post_id",
"author_id",
"author",
"text",
"image",
"attachment",
"created_at",
"created_at_display",
"is_post_author",
]
extra_kwargs = {
"post": {"write_only": True},
"text": {"required": False, "allow_blank": True},
}
def get_is_post_author(self, obj):
"""Проверяет, является ли автор комментария автором поста"""
if not obj.author or not obj.post or not obj.post.author:
return False
return obj.author.id == obj.post.author.id
def validate(self, attrs):
text = (attrs.get("text") or "").strip()
if not (text or attrs.get("image") or attrs.get("attachment")):
raise serializers.ValidationError(
{"text": "Нужно указать текст, изображение или файл."}
)
return attrs
def create(self, validated_data):
# author выставляется во view
instance = Comment(**validated_data)
instance.save(skip_validation=True)
return instance
def update(self, instance, validated_data):
for k, v in validated_data.items():
setattr(instance, k, v)
instance.save(skip_validation=True)
return instance

View File

@ -3,27 +3,36 @@ from __future__ import annotations
from typing import Any, List
from django.contrib.auth import get_user_model
from django.db.models import BooleanField, Count, Exists, F, OuterRef, Subquery, Value
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, TYPE_EMPLOYEE
from feed.models import Comment, Post, PostLike
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.exceptions import PermissionDenied
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
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,
IsSelfOrStaff,
user_has_dept_perm,
user_is_dept_head,
user_is_staffish,
)
from .serializers import CommentSerializer, PostListSerializer, PostSerializer
from .serializers import PostListSerializer, PostSerializer
Employee = get_user_model()
@ -57,7 +66,6 @@ class PostViewSet(viewsets.ModelViewSet):
queryset = (
Post.objects.select_related("author", "department")
.annotate(comments_count=Count("comments", distinct=True))
.order_by("-pinned", "-created_at")
)
parser_classes = [MultiPartParser, FormParser, JSONParser]
@ -140,6 +148,23 @@ class PostViewSet(viewsets.ModelViewSet):
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))
@ -147,10 +172,6 @@ class PostViewSet(viewsets.ModelViewSet):
else:
qs = qs.annotate(is_liked=Value(False, output_field=BooleanField()))
# последний комментарий (id) — без N+1
lc = Comment.objects.filter(post_id=OuterRef("pk")).order_by("-created_at")
qs = qs.annotate(last_comment_id=Subquery(lc.values("id")[:1]))
# фильтры
qp = self.request.query_params
t = qp.get("type")
@ -171,25 +192,12 @@ class PostViewSet(viewsets.ModelViewSet):
return qs
def list(self, request, *args, **kwargs):
"""Возвращает список постов с картой последних комментариев без N+1."""
"""Возвращает список постов."""
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
objects = page if page is not None else queryset
ids = [getattr(o, "last_comment_id", None) for o in objects]
ids = [i for i in ids if i]
last_comments_map = {}
if ids:
last_comments_map = Comment.objects.select_related("author").in_bulk(ids)
serializer = self.get_serializer(
objects,
many=True,
context={
**self.get_serializer_context(),
"last_comments_map": last_comments_map,
},
)
serializer = self.get_serializer(objects, many=True)
if page is not None:
return self.get_paginated_response(serializer.data)
return Response(serializer.data)
@ -250,40 +258,65 @@ class PostViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK,
)
class CommentViewSet(viewsets.ModelViewSet):
"""
/api/v1/comments/
- GET: аутентифицированные
- POST: аутентифицированные (author = request.user)
- PATCH/PUT/DELETE: автор или staff/superuser
Фильтры: ?post=<id>, ?author=<id>
Сортировка: created_at ASC
"""
queryset = Comment.objects.select_related("post", "author").all()
serializer_class = CommentSerializer
owner_id_attrs = ("author_id",)
owner_obj_attrs = ("author",)
permission_classes = [IsAuthenticated, (IsSelfOrStaff | AdminOrActionOrModelPerms)]
parser_classes = [MultiPartParser, FormParser, JSONParser]
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "id"]
ordering = ["created_at"]
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def get_queryset(self):
qs = super().get_queryset()
qp = self.request.query_params
pid = _to_id(qp.get("post"))
if pid is not None:
qs = qs.filter(post_id=pid)
aid = _to_id(qp.get("author"))
if aid is not None:
qs = qs.filter(author_id=aid)
return qs
@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)

View File

@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
from django.db import models
from employees.models import Department
from requests_app.enums import RequestStatus, RequestType
from requests_app.models import Request, RequestComment
from requests_app.models import Request
from rest_framework import serializers
from ..employees.serializers import EmployeeBriefSerializer
@ -86,7 +86,7 @@ class RequestReadSerializer(serializers.ModelSerializer):
cc_count = serializers.SerializerMethodField()
is_recipient = serializers.SerializerMethodField()
# Используем аннотированное поле из queryset
comments_count = serializers.IntegerField(read_only=True)
comments_count = serializers.IntegerField(read_only=True, allow_null=True)
class Meta:
model = Request
@ -420,14 +420,3 @@ class RequestWriteSerializer(serializers.ModelSerializer):
self._set_recipients(instance, cc_user_ids, is_cc=True)
return instance
class RequestCommentSerializer(serializers.ModelSerializer):
"""Сериализатор комментариев к заявке."""
author = EmployeeBriefSerializer(read_only=True)
class Meta:
model = RequestComment
fields = ("id", "request", "author", "text", "created_at")
read_only_fields = ("id", "author", "created_at", "request")

View File

@ -2,12 +2,13 @@ from __future__ import annotations
from typing import Any, List, Optional # было: Any, Dict, List, Type
from django.db.models import Count, Q, QuerySet
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, OuterRef, Q, QuerySet, Subquery
from django.shortcuts import get_object_or_404 # раскомментируйте импорт
from employees.models import Department, EmployeeDepartment # добавлен Department
from requests_app.enums import RequestStatus
from requests_app.models import Request as EmployeeRequest
from requests_app.models import RequestComment
from communications import comments_helpers
# from django.shortcuts import get_object_or_404 # <- не используется
from rest_framework import status, viewsets
@ -37,7 +38,6 @@ from .permissions import (
NotFinalOrStaff,
)
from .serializers import (
RequestCommentSerializer,
RequestReadSerializer,
RequestWriteSerializer,
)
@ -61,7 +61,6 @@ class RequestViewSet(viewsets.ModelViewSet):
queryset = (
EmployeeRequest.objects.select_related("employee", "approver", "department")
.annotate(comments_count=Count("comments"))
.order_by("-created_at")
)
parser_classes = (MultiPartParser, FormParser, JSONParser)
@ -251,6 +250,23 @@ class RequestViewSet(viewsets.ModelViewSet):
# Фильтр по дате создания (created_at <= указанной даты)
qs = qs.filter(created_at__date__lte=date_to)
# Аннотируем счётчик комментариев через communications.Chat
from communications.models import Chat, Message
# ContentType для EmployeeRequest
request_ct = ContentType.objects.get_for_model(EmployeeRequest)
# Подсчёт сообщений в чате комментариев для каждой заявки
comments_subquery = Message.objects.filter(
chat__type='comments',
chat__context_content_type=request_ct,
chat__context_object_id=OuterRef('pk')
).values('chat').annotate(count=Count('id')).values('count')
qs = qs.annotate(
comments_count=Subquery(comments_subquery)
)
return qs
# --- ВАЖНО: не ловить 404 на detail-экшенах из-за урезанного get_queryset() ---
@ -342,6 +358,7 @@ class RequestViewSet(viewsets.ModelViewSet):
POST: создание {"text": "..."} (staff, владелец или право add_requestcomment).
"""
import logging
from .serializers import EmployeeBriefSerializer
logger = logging.getLogger(__name__)
req_obj = self.get_object()
@ -351,21 +368,53 @@ class RequestViewSet(viewsets.ModelViewSet):
)
if request.method in {"GET", "HEAD"}:
qs = RequestComment.objects.filter(request=req_obj).select_related("author")
ser = RequestCommentSerializer(
qs, many=True, context=self.get_serializer_context()
)
logger.info(f"[RequestViewSet.comments] GET: returning {qs.count()} comments")
return Response(ser.data)
# Используем unified comments system
messages = comments_helpers.get_comments(req_obj)
# Форматируем в старый формат API для совместимости
comments_data = []
for msg in messages:
author_ser = EmployeeBriefSerializer(msg.author)
comments_data.append({
"id": msg.id,
"request": req_obj.id,
"author": author_ser.data,
"text": msg.content, # content -> text для совместимости
"created_at": msg.created_at,
})
logger.info(f"[RequestViewSet.comments] GET: returning {len(comments_data)} comments")
return Response(comments_data)
logger.info(f"[RequestViewSet.comments] POST: creating comment with data={request.data}")
ser = RequestCommentSerializer(
data=request.data, context=self.get_serializer_context()
# POST - создание комментария
text = request.data.get("text", "").strip()
if not text:
return Response(
{"text": ["Это поле не может быть пустым."]},
status=status.HTTP_400_BAD_REQUEST
)
logger.info(f"[RequestViewSet.comments] POST: creating comment with text length={len(text)}")
# Используем unified comments system
message = comments_helpers.create_comment(
obj=req_obj,
author=request.user,
content=text
)
ser.is_valid(raise_exception=True)
saved = ser.save(request=req_obj, author=request.user)
logger.info(f"[RequestViewSet.comments] POST: comment created id={saved.id}")
return Response(ser.data, status=status.HTTP_201_CREATED)
# Форматируем ответ
author_ser = EmployeeBriefSerializer(message.author)
response_data = {
"id": message.id,
"request": req_obj.id,
"author": author_ser.data,
"text": message.content,
"created_at": message.created_at,
}
logger.info(f"[RequestViewSet.comments] POST: comment created id={message.id}")
return Response(response_data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["delete"], url_path="comments/(?P<comment_id>[^/.]+)")
def delete_comment(self, request: DRFRequest, pk: int | str | None = None, comment_id: int | str | None = None) -> Response:
@ -376,25 +425,38 @@ class RequestViewSet(viewsets.ModelViewSet):
Права: только автор комментария или staff.
"""
import logging
from communications.models import Message
logger = logging.getLogger(__name__)
req_obj = self.get_object()
# Получаем чат для этой заявки
chat = comments_helpers.get_or_create_comments_chat(req_obj)[0]
# Находим комментарий
try:
comment = RequestComment.objects.get(id=comment_id, request=req_obj)
except RequestComment.DoesNotExist:
return Response({"detail": "Comment not found"}, status=status.HTTP_404_NOT_FOUND)
message = Message.objects.get(id=comment_id, chat=chat)
except Message.DoesNotExist:
return Response(
{"detail": "Comment not found"},
status=status.HTTP_404_NOT_FOUND
)
# Проверка прав: только автор или staff
if not (request.user.is_staff or comment.author == request.user):
if not (request.user.is_staff or message.author == request.user):
return Response(
{"detail": "You don't have permission to delete this comment"},
status=status.HTTP_403_FORBIDDEN
)
logger.info(f"[RequestViewSet.delete_comment] user={request.user.id} deleting comment_id={comment_id}")
comment.delete()
# Используем unified comment system (мягкое удаление)
comments_helpers.delete_comment(
message=message,
deleted_by=request.user,
soft_delete=True
)
logger.info(f"[RequestViewSet.delete_comment] user={request.user.id} deleted comment_id={comment_id}")
return Response(status=status.HTTP_204_NO_CONTENT)
# ---------- Экшены статусов (только бизнес-валидация) ----------

View File

@ -16,7 +16,6 @@ from .documents.views import (
DocumentViewSet,
FolderViewSet,
DocumentTagViewSet,
DocumentCommentViewSet,
)
from .employees.views import (
DepartmentRoleViewSet,
@ -30,7 +29,7 @@ from .employees.views import (
SkillViewSet,
VerifyEmailAPIView,
)
from .feed.views import CommentViewSet, PostViewSet
from .feed.views import PostViewSet
from .requests_app.views import RequestViewSet
from .search.views import search_api_view
@ -48,7 +47,6 @@ router.register(r"schedule/relations", ScheduleEventRelationViewSet, basename="s
router.register(r"documents", DocumentViewSet, basename="documents")
router.register(r"folders", FolderViewSet, basename="folders")
router.register(r"document-tags", DocumentTagViewSet, basename="document-tags")
router.register(r"document-comments", DocumentCommentViewSet, basename="document-comments")
router.register(r"requests", RequestViewSet, basename="request")
@ -67,7 +65,6 @@ router.register(
)
router.register(r"skills", SkillViewSet, basename="skills")
router.register(r"posts", PostViewSet, basename="posts")
router.register(r"comments", CommentViewSet, basename="comments")
router.register(r"groups", GroupViewSet, basename="groups")
# NEW: Communications ViewSets

View File

@ -0,0 +1,312 @@
"""
Helpers для работы с комментариями через Communications.
Этот модуль предоставляет unified API для создания и получения комментариев
к любым объектам через систему чатов Communications (type='comments').
Использование:
from communications.comments_helpers import (
get_or_create_comments_chat,
create_comment,
get_comments
)
# Для любого объекта (Document, Post, Request, etc.)
chat = get_or_create_comments_chat(document)
comment = create_comment(document, author=user, content="Отлично!")
comments = get_comments(document)
История:
- Создан: 11 марта 2026
- Цель: Унификация системы комментариев для миграции с legacy моделей
"""
from typing import Optional
from django.contrib.contenttypes.models import ContentType
from django.db.models import QuerySet
from .models import Chat, Message, MessageAttachment
def get_or_create_comments_chat(
obj,
created_by=None,
name: Optional[str] = None,
**extra_flags
) -> Chat:
"""
Получить или создать чат комментариев для объекта.
Использует GenericForeignKey для привязки к ЛЮБОЙ модели.
Args:
obj: Любой Django model instance (Post, Document, Request, etc.)
created_by: User - создатель чата (опционально)
name: str - название чата (по умолчанию: "Комментарии: {obj}")
**extra_flags: Дополнительные флаги для chat.flags
Returns:
Chat instance с type='comments'
Examples:
>>> from documents.models import Document
>>> doc = Document.objects.get(id=123)
>>> chat = get_or_create_comments_chat(doc)
>>> print(chat.type)
'comments'
>>> print(chat.context_object)
<Document: My Document>
"""
ct = ContentType.objects.get_for_model(obj)
# Базовые флаги для чата комментариев
default_flags = {
'allow_replies': True,
'allow_reactions': True,
'allow_attachments': True,
'allow_editing': True,
}
default_flags.update(extra_flags)
# Генерация имени если не указано
if name is None:
obj_str = str(obj)
name = f'Комментарии: {obj_str[:50]}'
if len(obj_str) > 50:
name += '...'
chat, created = Chat.objects.get_or_create(
type='comments',
context_content_type=ct,
context_object_id=obj.id,
defaults={
'name': name,
'created_by': created_by,
'flags': default_flags,
'can_reply': True, # Разрешаем ответы
}
)
return chat
def create_comment(
obj,
author,
content: str,
reply_to: Optional[Message] = None,
attachments: Optional[list] = None,
**system_metadata
) -> Message:
"""
Создать комментарий к объекту.
Автоматически создает чат комментариев если его нет.
Args:
obj: Объект для комментирования
author: User - автор комментария
content: str - текст комментария
reply_to: Message - родительский комментарий для threading (опционально)
attachments: list - список файлов для вложений (опционально)
**system_metadata: Дополнительные данные для message.system_metadata
Returns:
Message instance
Examples:
>>> comment = create_comment(
... obj=document,
... author=request.user,
... content="Отличный документ!"
... )
>>> # С ответом на другой комментарий
>>> reply = create_comment(
... obj=document,
... author=request.user,
... content="Согласен!",
... reply_to=parent_comment
... )
>>> # С вложениями
>>> comment = create_comment(
... obj=document,
... author=request.user,
... content="См. файл",
... attachments=[image_file, pdf_file]
... )
"""
chat = get_or_create_comments_chat(obj, created_by=author)
# Определяем thread_root для threading
thread_root = None
if reply_to:
# Если это ответ на ответ - берем корень треда
thread_root = reply_to.thread_root or reply_to
# Создаем сообщение
message = Message.objects.create(
chat=chat,
author=author,
content=content,
reply_to=reply_to,
thread_root=thread_root,
has_attachments=bool(attachments),
system_metadata=system_metadata
)
# Добавляем вложения
if attachments:
for file in attachments:
# Определяем тип файла
file_type = 'file'
if hasattr(file, 'content_type'):
if file.content_type.startswith('image/'):
file_type = 'image'
elif file.content_type.startswith('video/'):
file_type = 'video'
MessageAttachment.objects.create(
message=message,
file=file,
file_type=file_type,
file_name=getattr(file, 'name', 'file'),
file_size=getattr(file, 'size', 0)
)
return message
def get_comments(
obj,
include_deleted: bool = False,
only_roots: bool = False
) -> QuerySet:
"""
Получить все комментарии объекта.
Args:
obj: Объект для получения комментариев
include_deleted: bool - включать удаленные комментарии
only_roots: bool - только корневые комментарии (без ответов)
Returns:
QuerySet[Message] отсортированный по created_at
Examples:
>>> # Все комментарии
>>> comments = get_comments(document)
>>> # Только корневые (без ответов)
>>> root_comments = get_comments(document, only_roots=True)
>>> # Включая удаленные
>>> all_comments = get_comments(document, include_deleted=True)
"""
try:
ct = ContentType.objects.get_for_model(obj)
chat = Chat.objects.get(
type='comments',
context_content_type=ct,
context_object_id=obj.id
)
qs = chat.messages.select_related('author')
if not include_deleted:
qs = qs.filter(is_deleted=False)
if only_roots:
qs = qs.filter(reply_to__isnull=True)
return qs.order_by('created_at')
except Chat.DoesNotExist:
# Чат не создан - нет комментариев
return Message.objects.none()
def get_comment_count(obj, include_deleted: bool = False) -> int:
"""
Получить количество комментариев для объекта.
Args:
obj: Объект для подсчета комментариев
include_deleted: bool - включать удаленные
Returns:
int - количество комментариев
"""
return get_comments(obj, include_deleted=include_deleted).count()
def delete_comment(message: Message, deleted_by, soft_delete: bool = True):
"""
Удалить комментарий (мягкое или жесткое удаление).
Args:
message: Message - комментарий для удаления
deleted_by: User - кто удаляет
soft_delete: bool - использовать мягкое удаление (recommended)
Examples:
>>> delete_comment(comment, user=request.user) # Мягкое удаление
>>> delete_comment(comment, user=request.user, soft_delete=False) # Жесткое
"""
if soft_delete:
from django.utils import timezone
message.is_deleted = True
message.deleted_by = deleted_by
message.deleted_at = timezone.now()
message.save(update_fields=['is_deleted', 'deleted_by', 'deleted_at'])
else:
message.delete()
def update_comment(
message: Message,
new_content: str,
mark_edited: bool = True
):
"""
Обновить текст комментария.
Args:
message: Message - комментарий для обновления
new_content: str - новый текст
mark_edited: bool - пометить как отредактированный
Examples:
>>> update_comment(comment, "Новый текст")
"""
from django.utils import timezone
message.content = new_content
if mark_edited:
message.is_edited = True
message.edited_at = timezone.now()
message.save(update_fields=['content', 'is_edited', 'edited_at'])
else:
message.save(update_fields=['content'])
def get_comments_chat_if_exists(obj) -> Optional[Chat]:
"""
Получить чат комментариев если он существует (без создания).
Args:
obj: Объект
Returns:
Chat or None
"""
try:
ct = ContentType.objects.get_for_model(obj)
return Chat.objects.get(
type='comments',
context_content_type=ct,
context_object_id=obj.id
)
except Chat.DoesNotExist:
return None

View File

@ -0,0 +1,206 @@
# Отчёт об удалении моделей комментариев
**Дата**: 2025-01-09
**Статус**: ✅ Завершено успешно
## Цель работы
Удалить устаревшие модели комментариев (`feed.Comment` и `documents.DocumentComment`) после миграции функционала в систему `communications`, протестировать развёртывание на свежей базе данных.
## Выполненные задачи
### 1. Удаление моделей
#### feed.Comment
- ✅ Удалена модель из `feed/models.py` (строки 136-195)
- ✅ Удалена регистрация в `feed/admin.py`
- ✅ Удалены signal handlers в `feed/notifications/signals.py`
- ✅ Удалены notification handlers в `feed/notifications/handlers.py`
- ✅ Создана миграция `feed/migrations/0007_delete_comment.py`
#### documents.DocumentComment
- ✅ Удалена модель из `documents/models.py` (строки 503-560)
- ✅ Удалены signal handlers в `documents/notifications/signals.py`
- ✅ Удалены notification handlers в `documents/notifications/handlers.py`
- ✅ Создана миграция `documents/migrations/0015_delete_documentcomment.py`
### 2. Очистка API endpoints
#### Удалённые ViewSets
- ✅ `CommentViewSet` удалён из `api/v1/feed/views.py`
- ✅ `DocumentCommentViewSet` удалён из `api/v1/documents/views.py`
- ✅ Удалена регистрация из `api/v1/urls.py`
#### Удалённые сериализаторы
- ✅ `CommentMiniSerializer` удалён из `api/v1/feed/serializers.py`
- ✅ `CommentSerializer` удалён из `api/v1/feed/serializers.py`
- ✅ `DocumentCommentSerializer` удалён из `api/v1/documents/serializers.py`
#### Обновлённые сериализаторы
- ✅ `PostListSerializer`: удалено поле `last_comment`, удалён метод `get_last_comment()`
- ✅ `PostSerializer`: удалено поле `comments`, удалён метод `get_comments()`
- ⚠️ Поле `comments_count` оставлено для совместимости с шаблонами (значение будет 0)
### 3. Очистка кода views.py
#### PostViewSet
- ✅ Удалена аннотация `Count("comments")`
- ✅ Удалена аннотация `last_comment_id`
- ✅ Упрощён метод `list()` (удалена логика сбора `last_comments_map`)
- ✅ Удалены неиспользуемые импорты: `Count`, `Subquery`, `OuterRef`
### 4. Исправление миграций
#### Проблема
Миграция `notifications/migrations/0010_drop_old_tables.py` содержала операции `DeleteModel`, которые вызывали ошибку при развёртывании на свежей базе данных:
```
OperationalError: no such table: notifications_notification
```
#### Решение
Использован `migrations.SeparateDatabaseAndState` для разделения:
- **Database operations**: SQL с `DROP TABLE IF EXISTS` (работает независимо от существования таблиц)
- **State operations**: `DeleteModel` только для обновления Django state (не затрагивает БД напрямую)
#### Результат
✅ Миграция применяется успешно как на существующей, так и на свежей базе данных
### 5. Очистка импортов
**api/v1/feed/serializers.py:**
- ✅ Удалены неиспользуемые типы: `Any`, `Dict`, `Optional`
- ✅ Удалена константа: `TYPE_COMPANY`
**api/v1/feed/views.py:**
- ✅ Удалены импорты: `Count`, `Subquery`, `OuterRef`
- ✅ Удалены константы: `TYPE_EMPLOYEE`
- ✅ Удалены exceptions: `PermissionDenied`
- ✅ Удалены permissions: `IsSelfOrStaff`, `user_has_dept_perm`, `user_is_dept_head`, `user_is_staffish`
### 6. Тестирование развёртывания
#### Выполненные действия
1. ✅ Создана резервная копия БД (`db.sqlite3.backup`)
2. ✅ Удалена существующая БД
3. ✅ Применены все миграции на свежую БД (`python manage.py migrate`)
4. ✅ Проверка Django системы (`python manage.py check`)
#### Результаты
- **Все миграции применены**: 62 миграции в 19 приложениях
- **Django check**: No issues (0 silenced)
- **Критические миграции**:
- ✅ `notifications.0010_drop_old_tables` — применилась без ошибок
- ✅ `feed.0007_delete_comment` — успешно
- ✅ `documents.0015_delete_documentcomment` — успешно
## Файлы изменены
### Удалённые классы и функции
- `feed.models.Comment` (модель)
- `feed.admin.CommentAdmin` (admin)
- `documents.models.DocumentComment` (модель)
- `api.v1.feed.serializers.CommentMiniSerializer` (сериализатор)
- `api.v1.feed.serializers.CommentSerializer` (сериализатор)
- `api.v1.documents.serializers.DocumentCommentSerializer` (сериализатор)
- `api.v1.feed.views.CommentViewSet` (ViewSet)
- `api.v1.documents.views.DocumentCommentViewSet` (ViewSet)
### Изменённые файлы
1. `backend/feed/models.py`
2. `backend/feed/admin.py`
3. `backend/feed/notifications/signals.py`
4. `backend/feed/notifications/handlers.py`
5. `backend/documents/models.py`
6. `backend/documents/notifications/signals.py`
7. `backend/documents/notifications/handlers.py`
8. `backend/api/v1/feed/serializers.py`
9. `backend/api/v1/feed/views.py`
10. `backend/api/v1/documents/serializers.py`
11. `backend/api/v1/documents/views.py`
12. `backend/api/v1/urls.py`
13. `backend/notifications/migrations/0010_drop_old_tables.py`
### Созданные миграции
1. `backend/feed/migrations/0007_delete_comment.py`
2. `backend/documents/migrations/0015_delete_documentcomment.py`
## Известные ограничения
### comments_count
Поле `comments_count` в `PostListSerializer` оставлено для совместимости с существующими шаблонами (`feed/_feed_cards.html`), но возвращает значение по умолчанию `0`.
**Использование в шаблонах:**
- `{{ post.comments_count|default:0 }}` — отображение количества
- `{% if post.comments_count %}` — условие показа
**Рекомендация:** Для корректного подсчёта комментариев необходимо:
1. Добавить аннотацию в `PostViewSet.get_queryset()`:
```python
from django.contrib.contenttypes.models import ContentType
post_ct = ContentType.objects.get_for_model(Post)
qs = qs.annotate(
comments_count=Count(
"chat__messages",
filter=Q(
chat__context_content_type=post_ct,
chat__context_object_id=OuterRef("pk")
)
)
)
```
2. Либо удалить это поле из UI/шаблонов
## Статус endpoint'ов
### Удалённые endpoints
- ❌ `GET /api/v1/comments/` — список комментариев (удалён)
- ❌ `POST /api/v1/comments/` — создание комментария (удалён)
- ❌ `PATCH/DELETE /api/v1/comments/{id}/` — редактирование/удаление (удалён)
### Альтернатива через communications
- ✅ `GET /api/v1/communications/chats/` — получение чатов (включая комментарии)
- ✅ `POST /api/v1/communications/messages/` — создание сообщений (комментариев)
- ✅ `PATCH/DELETE /api/v1/communications/messages/{id}/` — управление сообщениями
## Готовность к развёртыванию
✅ **Проект готов к развёртыванию на новом сервере**
Проведённое тестирование показало, что:
1. Все миграции применяются корректно на свежей БД
2. Django система не имеет ошибок конфигурации
3. Нет зависимостей от удалённых моделей
4. Исправлена критическая проблема в `notifications.0010_drop_old_tables`
## Рекомендации
### Обязательные действия
- ✅ Все выполнено
### Опциональные улучшения
1. Реализовать корректный подсчёт `comments_count` через `communications.Chat`
2. Обновить фронтенд для использования новых endpoints `communications`
3. Удалить поле `comments_count` из шаблонов после миграции UI
## Команды для проверки
```bash
# Проверка конфигурации Django
.venv/bin/python manage.py check
# Проверка статуса миграций
.venv/bin/python manage.py showmigrations feed documents
# Применение миграций на свежей БД
.venv/bin/python manage.py migrate
# Откат к резервной копии (при необходимости)
cp db.sqlite3.backup db.sqlite3
```
## Заключение
Модели комментариев успешно удалены из кодовой базы. Все связанные API endpoints, сериализаторы, signal handlers и admin-интерфейсы очищены. Миграции протестированы на свежей БД и применяются без ошибок.
**Проект готов к production развёртыванию** ✅

View File

@ -1,61 +0,0 @@
"""
Management command для исправления polymorphic_ctype_id в django-filer File объектах
"""
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from filer.models import File, Image
class Command(BaseCommand):
help = 'Исправляет polymorphic_ctype_id для всех filer.File объектов'
def handle(self, *args, **options):
self.stdout.write('Исправление polymorphic_ctype_id для filer.File объектов...')
# Получаем ContentType для базового File класса
file_ctype = ContentType.objects.get_for_model(File)
image_ctype = ContentType.objects.get_for_model(Image)
# Используем прямой SQL чтобы избежать проблем с polymorphic
with connection.cursor() as cursor:
# Проверяем сколько файлов без polymorphic_ctype_id
cursor.execute(
"SELECT COUNT(*) FROM filer_file WHERE polymorphic_ctype_id IS NULL"
)
count = cursor.fetchone()[0]
if count == 0:
self.stdout.write(self.style.SUCCESS('Все файлы уже имеют правильный polymorphic_ctype_id'))
return
self.stdout.write(f'Найдено {count} файлов без polymorphic_ctype_id')
# Обновляем файлы с типом image/*
cursor.execute(
"""
UPDATE filer_file
SET polymorphic_ctype_id = %s
WHERE polymorphic_ctype_id IS NULL
AND mime_type LIKE 'image/%%'
""",
[image_ctype.id]
)
image_fixed = cursor.rowcount
# Обновляем остальные файлы
cursor.execute(
"""
UPDATE filer_file
SET polymorphic_ctype_id = %s
WHERE polymorphic_ctype_id IS NULL
""",
[file_ctype.id]
)
file_fixed = cursor.rowcount
total_fixed = image_fixed + file_fixed
self.stdout.write(self.style.SUCCESS(
f'Успешно исправлено {total_fixed} файлов '
f'({image_fixed} изображений, {file_fixed} остальных)'
))

View File

@ -1,49 +0,0 @@
"""
Management команда для завершения всех соединений с базой данных
"""
from django.core.management.base import BaseCommand
from django.db import connection
from django.conf import settings
class Command(BaseCommand):
help = 'Завершает все активные соединения с базой данных (кроме текущего)'
def handle(self, *args, **options):
self.stdout.write('Завершение всех соединений с базой данных...')
try:
db_name = settings.DATABASES['default']['NAME']
with connection.cursor() as cursor:
# Получаем список всех активных соединений
cursor.execute("""
SELECT pid, datname, usename, application_name, state
FROM pg_stat_activity
WHERE datname = %s AND pid <> pg_backend_pid()
""", [db_name])
connections = cursor.fetchall()
if connections:
self.stdout.write(f'Найдено активных соединений: {len(connections)}')
for conn in connections:
self.stdout.write(f' PID: {conn[0]}, User: {conn[2]}, App: {conn[3]}, State: {conn[4]}')
# Завершаем все соединения
cursor.execute("""
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = %s
AND pid <> pg_backend_pid()
""", [db_name])
self.stdout.write(self.style.SUCCESS(f'\n✓ Завершено соединений: {len(connections)}'))
else:
self.stdout.write(self.style.WARNING('Нет активных соединений кроме текущего'))
self.stdout.write(self.style.SUCCESS('\nТеперь можно запустить: python manage.py migrate'))
except Exception as e:
self.stdout.write(self.style.ERROR(f'✗ Ошибка: {e}'))
raise

View File

@ -1,105 +0,0 @@
"""
Management команда для миграции файлов документов из старой структуры filer_public/
в новую структуру documents/public/ и documents/private/.
Использование:
python manage.py migrate_documents_storage [--dry-run]
Опции:
--dry-run Показать, что будет сделано, но не выполнять действия
"""
from django.core.management.base import BaseCommand
from django.conf import settings
from filer.models import File
from documents.models import Document
import os
import shutil
class Command(BaseCommand):
help = 'Мигрирует файлы документов из старой структуры в новую'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Показать план миграции без выполнения',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('🔍 Режим DRY-RUN - изменения не будут применены'))
# Получаем все файлы из filer
files = File.objects.all().select_related('folder')
total = files.count()
self.stdout.write(f'\n📊 Найдено файлов в filer: {total}')
if total == 0:
self.stdout.write(self.style.WARNING('Нет файлов для миграции'))
return
# Статистика
migrated = 0
skipped = 0
errors = 0
for i, file in enumerate(files, 1):
try:
old_path = file.file.path if file.file else None
if not old_path or not os.path.exists(old_path):
self.stdout.write(
self.style.WARNING(f' ⚠️ [{i}/{total}] Пропущен: {file.original_filename} (файл не найден)')
)
skipped += 1
continue
# Определяем, старая ли структура
if 'filer_public' in old_path:
self.stdout.write(f' 🔄 [{i}/{total}] Миграция: {file.original_filename}')
if not dry_run:
# Пересохраняем файл - filer автоматически переместит его в новую структуру
file.save()
new_path = file.file.path if file.file else None
if new_path:
self.stdout.write(
self.style.SUCCESS(f' ✅ {os.path.basename(old_path)} -> {os.path.relpath(new_path, settings.MEDIA_ROOT)}')
)
migrated += 1
else:
# Файл уже в новой структуре
skipped += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(f' ❌ [{i}/{total}] Ошибка при миграции {file.original_filename}: {e}')
)
errors += 1
# Итоги
self.stdout.write('\n' + '='*80)
self.stdout.write(self.style.SUCCESS(f'✅ Мигрировано: {migrated}'))
self.stdout.write(self.style.WARNING(f'⚠️ Пропущено: {skipped}'))
if errors > 0:
self.stdout.write(self.style.ERROR(f'❌ Ошибок: {errors}'))
self.stdout.write('='*80)
if dry_run:
self.stdout.write(
self.style.WARNING('\n💡 Это был DRY-RUN. Для применения запустите без --dry-run')
)
else:
self.stdout.write('\n💾 Миграция завершена!')
self.stdout.write('\n📁 Старую папку filer_public/ можно удалить:')
self.stdout.write(f' rm -rf {os.path.join(settings.MEDIA_ROOT, "filer_public")}')

View File

@ -0,0 +1,140 @@
# Generated by Django 5.2.4 on 2026-03-11 19:33
from django.db import migrations
def migrate_comments_forward(apps, schema_editor):
"""Миграция documents.DocumentComment → Communications.Message (SQL с threading)"""
# 1. Создаем чаты для документов с комментариями
schema_editor.execute("""
INSERT INTO communications_chat (
type, name, created_by_id, can_reply, flags,
context_content_type_id, context_object_id
)
SELECT DISTINCT
'comments',
'Комментарии: ' || substr(d.title, 1, 50),
NULL,
1,
'{"allow_replies": true, "allow_reactions": true, "allow_attachments": true, "allow_editing": true}',
(SELECT id FROM django_content_type WHERE app_label='documents' AND model='document'),
dc.document_id
FROM documents_documentcomment dc
JOIN documents_document d ON d.id = dc.document_id
WHERE NOT EXISTS (
SELECT 1 FROM communications_chat cc
WHERE cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='documents' AND model='document')
AND cc.context_object_id = dc.document_id
)
""")
# 2. Переносим КОРНЕВЫЕ комментарии (parent IS NULL)
schema_editor.execute("""
INSERT INTO communications_message (
chat_id, author_id, content, created_at,
is_system, has_attachments,
is_deleted, is_edited, edited_at,
is_pinned, is_forwarded, is_cross_chat,
system_metadata
)
SELECT
cc.id,
dc.author_id,
dc.text,
dc.created_at,
0, 0, 0,
dc.is_edited,
CASE WHEN dc.is_edited THEN dc.updated_at ELSE NULL END,
0, 0, 0,
json_object(
'legacy_comment_id', dc.id,
'legacy_model', 'documents.DocumentComment'
)
FROM documents_documentcomment dc
JOIN communications_chat cc ON (
cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='documents' AND model='document')
AND cc.context_object_id = dc.document_id
)
WHERE dc.parent_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM communications_message m
WHERE m.chat_id = cc.id
AND m.author_id = dc.author_id
AND m.created_at = dc.created_at
)
""")
# 3. Переносим ОТВЕТЫ (parent IS NOT NULL) - с установкой reply_to и thread_root
schema_editor.execute("""
INSERT INTO communications_message (
chat_id, author_id, content, created_at,
is_system, has_attachments,
is_deleted, is_edited, edited_at,
is_pinned, is_forwarded, is_cross_chat,
reply_to_id, thread_root_id,
system_metadata
)
SELECT
cc.id,
dc.author_id,
dc.text,
dc.created_at,
0, 0, 0,
dc.is_edited,
CASE WHEN dc.is_edited THEN dc.updated_at ELSE NULL END,
0, 0, 0,
parent_msg.id,
parent_msg.id,
json_object(
'legacy_comment_id', dc.id,
'legacy_model', 'documents.DocumentComment'
)
FROM documents_documentcomment dc
JOIN communications_chat cc ON (
cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='documents' AND model='document')
AND cc.context_object_id = dc.document_id
)
JOIN communications_message parent_msg ON (
parent_msg.chat_id = cc.id
AND json_extract(parent_msg.system_metadata, '$.legacy_comment_id') = dc.parent_id
)
WHERE dc.parent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM communications_message m
WHERE m.chat_id = cc.id
AND m.author_id = dc.author_id
AND m.created_at = dc.created_at
)
""")
def migrate_comments_backward(apps, schema_editor):
"""Откат - удаляем чаты комментариев для документов"""
schema_editor.execute("""
DELETE FROM communications_chat
WHERE type = 'comments'
AND context_content_type_id = (
SELECT id FROM django_content_type
WHERE app_label='documents' AND model='document'
)
""")
class Migration(migrations.Migration):
dependencies = [
('documents', '0013_remove_document_document_type'),
('communications', '__latest__'),
('contenttypes', '__latest__'),
]
operations = [
migrations.RunPython(
migrate_comments_forward,
migrate_comments_backward
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.2.4 on 2026-03-11 19:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('documents', '0014_migrate_comments_to_communications'),
]
operations = [
migrations.DeleteModel(
name='DocumentComment',
),
]

View File

@ -498,62 +498,3 @@ class DocumentAuditLog(models.Model):
def __str__(self):
return f'{self.user} {self.get_action_display()} {self.document} @ {self.timestamp:%d.%m.%Y %H:%M}'
class DocumentComment(models.Model):
"""
Комментарии к документам.
Поддерживает вложенные ответы (threading).
"""
document = models.ForeignKey(
Document,
on_delete=models.CASCADE,
related_name='comments',
verbose_name=_('Документ')
)
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='document_comments',
verbose_name=_('Автор')
)
text = models.TextField(_('Текст комментария'))
# Вложенные ответы
parent = models.ForeignKey(
'self',
verbose_name=_('Родительский комментарий'),
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='replies'
)
created_at = models.DateTimeField(_('Создан'), auto_now_add=True)
updated_at = models.DateTimeField(_('Обновлен'), auto_now=True)
# Редактирование
is_edited = models.BooleanField(_('Отредактирован'), default=False)
class Meta:
verbose_name = _('Комментарий к документу')
verbose_name_plural = _('Комментарии к документам')
ordering = ['created_at'] # Старые сверху
indexes = [
models.Index(fields=['document', 'created_at']),
models.Index(fields=['author', '-created_at']),
]
def __str__(self):
preview = self.text[:50] + '...' if len(self.text) > 50 else self.text
return f'{self.author}: {preview}'
@property
def depth(self):
"""Возвращает уровень вложенности комментария."""
depth = 0
current = self.parent
while current:
depth += 1
current = current.parent
return depth

View File

@ -312,93 +312,3 @@ def notify_all_acknowledged(document, total_recipients, acknowledged_count):
'acknowledged_count': acknowledged_count,
}
)
def notify_document_comment(document, comment):
"""
Уведомляет автора документа о новом комментарии.
Args:
document: Объект Document
comment: Объект DocumentComment
"""
author = comment.author
if not document.uploaded_by:
logger.info(
"[handlers] Document has no author, skipping"
)
return
doc_author = document.uploaded_by
# Не уведомляем самого себя
if doc_author.id == author.id:
logger.info(
"[handlers] Skipping self-comment notification"
)
return
logger.info(
f"[handlers] Notifying document author={doc_author.pk} "
f"about new comment"
)
author_name = author.get_full_name() or author.username
notify.send(
sender=author,
recipient=doc_author,
verb=NotificationVerbs.DOCUMENT_COMMENT,
action_object=comment,
description=MessageTemplates.comment(author_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.comment_title(),
'document_id': document.id,
'comment_id': comment.id,
'author_id': author.id,
},
)
def notify_comment_reply(parent_comment, reply_comment):
"""
Уведомляет автора комментария об ответе на него.
Args:
parent_comment: Объект DocumentComment (родительский)
reply_comment: Объект DocumentComment (ответ)
"""
author = reply_comment.author
parent_author = parent_comment.author
document = reply_comment.document
# Не уведомляем самого себя
if parent_author.id == author.id:
logger.info(
"[handlers] Skipping self-reply notification"
)
return
logger.info(
f"[handlers] Notifying parent author={parent_author.pk} about reply"
)
author_name = author.get_full_name() or author.username
notify.send(
sender=author,
recipient=parent_author,
verb=NotificationVerbs.DOCUMENT_COMMENT_REPLY,
action_object=reply_comment,
description=MessageTemplates.comment_reply(author_name, document.title),
action_url=ActionURLs.DOCUMENTS,
data={
'title': MessageTemplates.comment_reply_title(),
'document_id': document.id,
'comment_id': reply_comment.id,
'parent_comment_id': parent_comment.id,
'author_id': author.id,
},
)

View File

@ -6,7 +6,6 @@ Django signals для автоматической генерации уведо
- m2m_changed для Document.recipients - добавление конкретных получателей
- m2m_changed для Document.departments - добавление отделов
- post_save для DocumentAcknowledgement - проверка полного ознакомления
- post_save для DocumentComment - новый комментарий или ответ
"""
import logging
@ -15,14 +14,12 @@ from django.contrib.auth import get_user_model
from django.db.models.signals import post_save, m2m_changed
from django.dispatch import receiver
from ..models import Document, DocumentAcknowledgement, DocumentComment
from ..models import Document, DocumentAcknowledgement
from .handlers import (
notify_all_employees,
notify_specific_users,
notify_department_employees,
notify_all_acknowledged,
notify_document_comment,
notify_comment_reply,
)
logger = logging.getLogger(__name__)
@ -160,31 +157,3 @@ def check_all_acknowledged(sender, instance, created, **kwargs):
document.uploaded_by):
notify_all_acknowledged(document, total_recipients, acknowledged_count)
@receiver(post_save, sender=DocumentComment)
def notify_on_new_comment(sender, instance, created, **kwargs):
"""
Отправка уведомлений при создании нового комментария.
- Если это ответ на комментарий (parent != None): уведомить автора родительского комментария
- Если это обычный комментарий: уведомить автора документа (если он не автор комментария)
"""
if not created:
return
comment = instance
document = comment.document
author = comment.author
logger.info(
f"[signals] New comment on document={document.pk} "
f"by user={author.pk} parent={comment.parent_id}"
)
# Если это ответ на комментарий
if comment.parent:
notify_comment_reply(comment.parent, comment)
else:
# Обычный комментарий - уведомляем автора документа
notify_document_comment(document, comment)

View File

@ -1,13 +1,8 @@
from django.contrib import admin
from .models import Post, Comment
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'type', 'author', 'department', 'created_at', 'pinned']
list_filter = ['type', 'department', 'pinned']
search_fields = ['title', 'body', 'author__last_name']
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ['author', 'post', 'created_at']
search_fields = ['text', 'author__last_name', 'post__title']

View File

@ -0,0 +1,145 @@
# Generated by Django 5.2.4 on 2026-03-11 19:33
from django.db import migrations
def migrate_comments_forward(apps, schema_editor):
"""Миграция feed.Comment → Communications.Message (SQL с вложениями)"""
# 1. Создаем чаты для постов с комментариями
schema_editor.execute("""
INSERT INTO communications_chat (
type, name, created_by_id, can_reply, flags,
context_content_type_id, context_object_id
)
SELECT DISTINCT
'comments',
'Комментарии: ' || substr(p.title, 1, 50),
NULL,
1,
'{"allow_replies": true, "allow_reactions": true, "allow_attachments": true, "allow_editing": true}',
(SELECT id FROM django_content_type WHERE app_label='feed' AND model='post'),
c.post_id
FROM feed_comment c
JOIN feed_post p ON p.id = c.post_id
WHERE NOT EXISTS (
SELECT 1 FROM communications_chat cc
WHERE cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post')
AND cc.context_object_id = c.post_id
)
""")
# 2. Переносим комментарии
schema_editor.execute("""
INSERT INTO communications_message (
chat_id, author_id, content, created_at,
is_system, has_attachments,
is_deleted, is_edited, is_pinned, is_forwarded, is_cross_chat,
system_metadata
)
SELECT
cc.id,
c.author_id,
c.text,
c.created_at,
0,
CASE WHEN (c.image != '' OR c.attachment != '') THEN 1 ELSE 0 END,
0, 0, 0, 0, 0,
json_object(
'legacy_comment_id', c.id,
'legacy_model', 'feed.Comment'
)
FROM feed_comment c
JOIN communications_chat cc ON (
cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post')
AND cc.context_object_id = c.post_id
)
WHERE NOT EXISTS (
SELECT 1 FROM communications_message m
WHERE m.chat_id = cc.id
AND m.author_id = c.author_id
AND m.created_at = c.created_at
)
""")
# 3. Переносим вложения (images)
schema_editor.execute("""
INSERT INTO communications_messageattachment (
message_id, file, file_type, file_name, file_size, uploaded_at
)
SELECT
m.id,
c.image,
'image',
substr(c.image, instr(c.image, '/') + 1),
0,
c.created_at
FROM feed_comment c
JOIN communications_chat cc ON (
cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post')
AND cc.context_object_id = c.post_id
)
JOIN communications_message m ON (
m.chat_id = cc.id
AND m.author_id = c.author_id
AND m.created_at = c.created_at
)
WHERE c.image != '' AND c.image IS NOT NULL
""")
# 4. Переносим вложения (attachments)
schema_editor.execute("""
INSERT INTO communications_messageattachment (
message_id, file, file_type, file_name, file_size, uploaded_at
)
SELECT
m.id,
c.attachment,
'file',
substr(c.attachment, instr(c.attachment, '/') + 1),
0,
c.created_at
FROM feed_comment c
JOIN communications_chat cc ON (
cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='feed' AND model='post')
AND cc.context_object_id = c.post_id
)
JOIN communications_message m ON (
m.chat_id = cc.id
AND m.author_id = c.author_id
AND m.created_at = c.created_at
)
WHERE c.attachment != '' AND c.attachment IS NOT NULL
""")
def migrate_comments_backward(apps, schema_editor):
"""Откат - удаляем чаты комментариев для постов"""
schema_editor.execute("""
DELETE FROM communications_chat
WHERE type = 'comments'
AND context_content_type_id = (
SELECT id FROM django_content_type
WHERE app_label='feed' AND model='post'
)
""")
class Migration(migrations.Migration):
dependencies = [
('feed', '0005_alter_post_department'),
('communications', '__latest__'),
('contenttypes', '__latest__'),
]
operations = [
migrations.RunPython(
migrate_comments_forward,
migrate_comments_backward
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.2.4 on 2026-03-11 19:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('feed', '0006_migrate_comments_to_communications'),
]
operations = [
migrations.DeleteModel(
name='Comment',
),
]

View File

@ -132,68 +132,6 @@ class Post(models.Model):
prefix = "Публикация"
return f"[{prefix}] {self.title}"
class Comment(models.Model):
text = models.TextField("Комментарий", blank=True, default="")
image = models.ImageField(
"Изображение",
upload_to="feed/comments/images/%Y/%m/",
blank=True, null=True,
validators=[
FileExtensionValidator(allowed_extensions=IMAGE_EXTS),
FileSizeValidator(10),
],
)
attachment = models.FileField(
"Вложение",
upload_to="feed/comments/attachments/%Y/%m/",
blank=True, null=True,
validators=[
FileExtensionValidator(allowed_extensions=FILE_EXTS),
FileSizeValidator(25),
],
)
created_at = models.DateTimeField("Создано", auto_now_add=True)
author = models.ForeignKey(
"employees.Employee",
on_delete=models.CASCADE,
related_name="comments",
verbose_name="Автор",
db_index=True,
)
post = models.ForeignKey(
"feed.Post",
on_delete=models.CASCADE,
related_name="comments",
verbose_name="Публикация",
)
class Meta:
verbose_name = "Комментарий"
verbose_name_plural = "Комментарии"
ordering = ["created_at"]
indexes = [
models.Index(fields=["created_at"]),
models.Index(fields=["post", "created_at"]),
models.Index(fields=["author", "created_at"]),
]
constraints = [
models.CheckConstraint(
name="comment_has_any_content_v3",
condition=(
(Q(text__isnull=False) & ~Q(text=""))
| Q(image__isnull=False)
| Q(attachment__isnull=False)
),
violation_error_message="Нужно указать текст, изображение или файл.",
),
]
def __str__(self) -> str:
base = (self.text or "").strip()
return f"Комментарий #{self.pk or '∅'} от {self.author_id} к посту {self.post_id}: {base[:40]}"
# --- бизнес-валидация ---
def clean(self):
super().clean()

View File

@ -3,7 +3,6 @@
Функции:
- notify_new_post - уведомление о новой публикации
- notify_comment - уведомление о комментарии
- notify_post_reaction - уведомление о реакции (лайке)
- get_post_recipients - определение получателей уведомлений
"""
@ -65,40 +64,6 @@ def notify_new_post(post):
)
def notify_comment(comment):
"""
Отправляет уведомление автору публикации о новом комментарии.
Args:
comment: Экземпляр модели Comment
"""
post = comment.post
comment_author = comment.author
# Уведомляем автора публикации (если комментарий не от него)
if post.author.id == comment_author.id:
return
author_name = comment_author.get_full_name() or comment_author.username
comment_text = truncate_text(comment.text, 80)
notify.send(
sender=comment_author,
recipient=post.author,
verb=NotificationVerbs.POST_COMMENT,
action_object=post,
description=MessageTemplates.comment(author_name, comment_text),
action_url=ActionURLs.FEED_HOME,
data={
'title': MessageTemplates.comment_title(),
'post_id': post.id,
'post_title': post.title,
'comment_id': comment.id,
'author_id': comment_author.id,
}
)
def notify_post_reaction(post, user):
"""
Создает уведомление о реакции (лайке) на публикацию.

View File

@ -3,14 +3,13 @@ Django signals для автоматической генерации уведо
Обрабатывает события:
- post_save для Post - создание новой публикации
- post_save для Comment - добавление комментария
"""
from django.db.models.signals import post_save
from django.dispatch import receiver
from ..models import Post, Comment
from .handlers import notify_new_post, notify_comment
from ..models import Post
from .handlers import notify_new_post
@receiver(post_save, sender=Post)
@ -29,16 +28,3 @@ def create_post_notification(sender, instance, created, **kwargs):
# Отправляем уведомления через универсальную систему
# channels.py автоматически обработает через Celery
notify_new_post(instance)
@receiver(post_save, sender=Comment)
def create_comment_notification(sender, instance, created, **kwargs):
"""
Создает уведомления при добавлении комментария к публикации.
Уведомляет автора публикации о новом комментарии.
"""
if not created:
return
notify_comment(instance)

View File

@ -10,27 +10,33 @@ class Migration(migrations.Migration):
]
operations = [
# Удаляем все старые таблицы (безопасно - IF EXISTS)
# Данные уведомлений не критичны, можно удалить
migrations.RunSQL(
sql="""
DROP TABLE IF EXISTS notifications_notification;
DROP TABLE IF EXISTS notifications_notificationtype;
DROP TABLE IF EXISTS notifications_notificationcategory;
DROP TABLE IF EXISTS notifications_notificationtemplate;
DROP TABLE IF EXISTS notifications_usernotificationsettings;
DROP TABLE IF EXISTS notifications_telegramuser;
DROP TABLE IF EXISTS notifications_webpushsubscription;
""",
reverse_sql=migrations.RunSQL.noop,
# Разделяем операции на БД и состояние Django
migrations.SeparateDatabaseAndState(
database_operations=[
# Удаляем все старые таблицы (безопасно - IF EXISTS)
migrations.RunSQL(
sql="""
DROP TABLE IF EXISTS notifications_notification;
DROP TABLE IF EXISTS notifications_notificationtype;
DROP TABLE IF EXISTS notifications_notificationcategory;
DROP TABLE IF EXISTS notifications_notificationtemplate;
DROP TABLE IF EXISTS notifications_usernotificationsettings;
DROP TABLE IF EXISTS notifications_telegramuser;
DROP TABLE IF EXISTS notifications_webpushsubscription;
""",
reverse_sql=migrations.RunSQL.noop,
),
],
state_operations=[
# Удаляем модели из состояния Django
# Порядок важен: сначала связанные модели, потом основные
migrations.DeleteModel(name='UserNotificationSettings'),
migrations.DeleteModel(name='NotificationTemplate'),
migrations.DeleteModel(name='TelegramUser'),
migrations.DeleteModel(name='WebPushSubscription'),
migrations.DeleteModel(name='Notification'),
migrations.DeleteModel(name='NotificationType'),
migrations.DeleteModel(name='NotificationCategory'),
],
),
# Удаляем модели из состояния Django (state_operations)
# Порядок важен: сначала связанные модели, потом основные
migrations.DeleteModel(name='UserNotificationSettings'),
migrations.DeleteModel(name='NotificationTemplate'),
migrations.DeleteModel(name='TelegramUser'),
migrations.DeleteModel(name='WebPushSubscription'),
migrations.DeleteModel(name='Notification'),
migrations.DeleteModel(name='NotificationType'),
migrations.DeleteModel(name='NotificationCategory'),
]

View File

@ -5,7 +5,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from .enums import RequestStatus
from .models import Request, RequestComment
from .models import Request
# Админ-форма: без принуждения вводить approver (мы выставим его автоматически)
@ -155,18 +155,3 @@ class RequestAdmin(admin.ModelAdmin):
)
actions = ("action_approve", "action_reject", "action_cancel")
@admin.register(RequestComment)
class RequestCommentAdmin(admin.ModelAdmin):
list_display = ("id", "request", "author", "short_text", "created_at")
list_select_related = ("request", "author")
search_fields = ("text", "request__title", "request__employee__last_name")
autocomplete_fields = ("request", "author")
date_hierarchy = "created_at"
def short_text(self, obj):
s = (obj.text or "").strip()
return (s[:80] + "…") if len(s) > 80 else s
short_text.short_description = _("Комментарий")

View File

@ -0,0 +1,91 @@
# Generated by Django 5.2.4 on 2026-03-11 19:29
from django.db import migrations
def migrate_comments_forward(apps, schema_editor):
"""Миграция RequestComment → Communications.Message (SQL)"""
# 1. Создаем чаты для заявок с комментариями
schema_editor.execute("""
INSERT INTO communications_chat (
type, name, created_by_id, can_reply, flags,
context_content_type_id, context_object_id
)
SELECT DISTINCT
'comments',
'Комментарии к заявке #' || rc.request_id,
NULL,
1,
'{"allow_replies": true, "allow_reactions": true, "allow_attachments": true, "allow_editing": true}',
(SELECT id FROM django_content_type WHERE app_label='requests_app' AND model='request'),
rc.request_id
FROM requests_app_requestcomment rc
WHERE NOT EXISTS (
SELECT 1 FROM communications_chat cc
WHERE cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='requests_app' AND model='request')
AND cc.context_object_id = rc.request_id
)
""")
# 2. Переносим комментарии
schema_editor.execute("""
INSERT INTO communications_message (
chat_id, author_id, content, created_at,
is_system, has_attachments,
is_deleted, is_edited, is_pinned, is_forwarded, is_cross_chat,
system_metadata
)
SELECT
cc.id,
rc.author_id,
rc.text,
rc.created_at,
0, 0, 0, 0, 0, 0, 0,
json_object(
'legacy_comment_id', rc.id,
'legacy_model', 'requests_app.RequestComment'
)
FROM requests_app_requestcomment rc
JOIN communications_chat cc ON (
cc.type = 'comments'
AND cc.context_content_type_id = (SELECT id FROM django_content_type WHERE app_label='requests_app' AND model='request')
AND cc.context_object_id = rc.request_id
)
WHERE NOT EXISTS (
SELECT 1 FROM communications_message m
WHERE m.chat_id = cc.id
AND m.author_id = rc.author_id
AND m.created_at = rc.created_at
AND m.content = rc.text
)
""")
def migrate_comments_backward(apps, schema_editor):
"""Откат - удаляем чаты комментариев для заявок"""
schema_editor.execute("""
DELETE FROM communications_chat
WHERE type = 'comments'
AND context_content_type_id = (
SELECT id FROM django_content_type
WHERE app_label='requests_app' AND model='request'
)
""")
class Migration(migrations.Migration):
dependencies = [
('requests_app', '0009_alter_request_type'),
('communications', '__latest__'),
('contenttypes', '__latest__'),
]
operations = [
migrations.RunPython(
migrate_comments_forward,
migrate_comments_backward
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 5.2.4 on 2026-03-11 19:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('requests_app', '0010_migrate_comments_to_communications'),
]
operations = [
migrations.DeleteModel(
name='RequestComment',
),
]

View File

@ -286,28 +286,3 @@ class Request(models.Model):
return (
f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})"
)
class RequestComment(models.Model):
request = models.ForeignKey(
Request,
on_delete=models.CASCADE,
related_name="comments",
verbose_name=_("Заявка"),
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name=_("Автор"),
)
text = models.TextField(_("Комментарий"))
created_at = models.DateTimeField(_("Создано"), auto_now_add=True)
class Meta:
verbose_name = _("Комментарий к заявке")
verbose_name_plural = _("Комментарии к заявкам")
ordering = ["created_at"]
indexes = [models.Index(fields=["created_at"])]
def __str__(self):
return f"Комментарий {self.author} к заявке #{self.request_id}"

View File

@ -12,7 +12,7 @@ import logging
from django.db.models.signals import m2m_changed, post_save, pre_save
from django.dispatch import receiver
from ..models import Request, RequestComment
from ..models import Request
from .handlers import notify_new_request, notify_status_change, notify_comment
logger = logging.getLogger(__name__)
@ -130,30 +130,3 @@ def notify_on_cc_users_changed(sender, instance, action, **kwargs):
except Exception as e:
logger.exception(f"[SIGNAL ERROR] notify_on_cc_users_changed: {e}")
@receiver(post_save, sender=RequestComment)
def create_comment_notification(sender, instance, created, **kwargs):
"""
Создает уведомления при добавлении комментария к заявлению.
Уведомляет:
- Автора заявления
- Всех получателей
- Всех в копии
- Согласующего
- Сотрудников отделов (если sent_to_all_department)
notify.send() → channels.py автоматически отправит через Celery
"""
if not created:
return
try:
logger.info(
f"[SIGNAL] Новый комментарий #{instance.id} к заявлению #{instance.request.id}"
)
notify_comment(instance)
except Exception as e:
logger.exception(f"[SIGNAL ERROR] create_comment_notification: {e}")

View File

@ -189,11 +189,10 @@
submitBtn.disabled = true;
const formData = new FormData();
formData.append('post', postId);
formData.append('text', text);
try {
const response = await fetch('/api/v1/comments/', {
const response = await fetch(`/api/v1/posts/${postId}/comments/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
@ -313,11 +312,10 @@
submitBtn.disabled = true;
const formData = new FormData();
formData.append('post', postId);
formData.append('text', text);
try {
const response = await fetch('/api/v1/comments/', {
const response = await fetch(`/api/v1/posts/${postId}/comments/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,

View File

@ -442,7 +442,7 @@
}
try {
const response = await fetch(`/api/v1/comments/`, {
const response = await fetch(`/api/v1/posts/${postId}/comments/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
@ -450,7 +450,6 @@
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
post: parseInt(postId),
text: text
})
});

View File

@ -1262,17 +1262,15 @@ class ApiClient {
// Комментарии
async getComments(params: { post: number; page?: number }): Promise<any> {
const queryParams = new URLSearchParams();
queryParams.append('post', params.post.toString());
if (params.page) queryParams.append('page', params.page.toString());
const url = `/api/v1/comments/?${queryParams.toString()}`;
const url = `/api/v1/posts/${params.post}/comments/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
}
async createComment(postId: number, text: string, image?: File, attachment?: File): Promise<any> {
if (image || attachment) {
const formData = new FormData();
formData.append('post', String(postId));
formData.append('text', text);
if (image) formData.append('image', image);
if (attachment) formData.append('attachment', attachment);
@ -1283,7 +1281,7 @@ class ApiClient {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch('/api/v1/comments/', {
const response = await fetch(`/api/v1/posts/${postId}/comments/`, {
method: 'POST',
headers,
body: formData,
@ -1298,21 +1296,27 @@ class ApiClient {
return response.json();
}
return this.request('/api/v1/comments/', {
return this.request(`/api/v1/posts/${postId}/comments/`, {
method: 'POST',
body: JSON.stringify({ post: postId, text }),
});
}
async updateComment(commentId: number, text: string): Promise<any> {
return this.request(`/api/v1/comments/${commentId}/`, {
method: 'PATCH',
body: JSON.stringify({ text }),
});
}
async updateComment(commentId: number, text: string): Promise<any> {
const response = await this.request(`/api/v1/communications/messages/${commentId}/`, {
method: 'PATCH',
body: JSON.stringify({ content: text }),
});
// Конвертируем content -> text для совместимости
return {
...response,
text: response.content || text,
};
}
async deleteComment(commentId: number): Promise<void> {
await this.request(`/api/v1/comments/${commentId}/`, {
await this.request(`/api/v1/communications/messages/${commentId}/`, {
method: 'DELETE',
});
}