feat(comments): добавлен флаг is_post_author в API

- Добавлено поле is_post_author в CommentSerializer
- Сервер теперь явно указывает, является ли автор комментария автором поста
- Фронтенд использует этот флаг (с fallback на ручное сравнение)
- Добавлена детальная отладка для диагностики проблемы
- Это устраняет проблему с неправильным определением автора
This commit is contained in:
admin i 2025-11-29 16:15:39 +03:00
parent f955185b91
commit 34ddc5588f
2 changed files with 364 additions and 225 deletions

View File

@ -161,6 +161,7 @@ class CommentSerializer(serializers.ModelSerializer):
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
@ -175,12 +176,19 @@ class CommentSerializer(serializers.ModelSerializer):
"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")):

View File

@ -1,16 +1,23 @@
/**
* postDetailModal.js
* Модуль для открытия деталей поста в модальном окне
* Полностью переработанная версия
*/
(function() {
'use strict';
// Константы
const INITIAL_COMMENTS_COUNT = 10;
const DEBUG = true;
// Состояние
let modal = null;
let modalElement = null;
let currentPost = null;
/**
* Инициализация модального окна
* Инициализация
*/
function initPostDetailModal() {
modalElement = document.getElementById('postDetailModal');
@ -21,7 +28,6 @@
modal = new bootstrap.Modal(modalElement);
// Очистка при закрытии
modalElement.addEventListener('hidden.bs.modal', function() {
const contentDiv = document.getElementById('postDetailContent');
if (contentDiv) {
@ -33,14 +39,14 @@
</div>
`;
}
currentPost = null;
});
// Добавляем обработчики на ссылки постов
attachPostLinkHandlers();
}
/**
* Получение CSRF токена из cookie
* Утилиты для работы с cookies
*/
function getCsrfToken() {
const cookies = document.cookie.split(';');
@ -53,9 +59,6 @@
return null;
}
/**
* Получение JWT токена из cookie (для загрузки данных)
*/
function getAccessToken() {
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
@ -68,197 +71,20 @@
}
/**
* Загрузка деталей поста через API
* Утилиты
*/
async function loadPostDetail(postId) {
const accessToken = getAccessToken();
const headers = {
'Accept': 'application/json'
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
try {
const response = await fetch(`/api/v1/posts/${postId}/`, {
method: 'GET',
headers: headers
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json();
renderPostDetail(post);
} catch (error) {
console.error('Ошибка загрузки поста:', error);
showError('Не удалось загрузить пост');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Отображение деталей поста в модальном окне
*/
function renderPostDetail(post) {
const contentDiv = document.getElementById('postDetailContent');
// Сохраняем ID автора для определения его комментариев
const postAuthorId = post.author?.id;
// Формируем HTML с кнопкой закрытия
let html = '<button type="button" class="btn-close position-absolute top-0 end-0 m-3" data-bs-dismiss="modal" aria-label="Закрыть" style="z-index: 1050;"></button>';
html += '<article class="card">';
// Заголовок с автором
html += '<header class="card-header">';
html += '<div class="card-icon">';
if (post.author && post.author.avatar) {
html += `<img src="${post.author.avatar}" alt="">`;
} else {
html += '<i class="bi-person"></i>';
}
html += '</div>';
html += '<div class="feed-info">';
html += `<div class="card-title">${post.author?.full_name || 'Аноним'}</div>`;
html += `<time class="feed-date">${formatDate(post.created_at)}</time>`;
html += '</div>';
// Закрепленный пост
if (post.pinned) {
html += '<span class="feed-pin"><i class="bi-pin-angle"></i></span>';
}
html += '</header>';
// Изображение
if (post.image) {
html += `<div class="feed-img"><img src="${post.image}" alt="${post.title || 'Публикация'}"></div>`;
}
// Тело
html += '<div class="card-body">';
html += `<h3 class="feed-title mb-3">${post.title || 'Без названия'}</h3>`;
if (post.body) {
html += `<div class="feed-text mb-3">${formatBody(post.body)}</div>`;
}
html += '</div>';
// Футер с лайками и комментариями
html += '<footer class="feed-ft">';
html += '<div class="feed-stats">';
// Лайки
const likeIcon = post.user_has_liked ? 'bi-heart-fill text-danger' : 'bi-heart';
html += `<button class="btn btn-ghost btn-sm like-btn" data-post-id="${post.id}">`;
html += `<i class="${likeIcon}"></i> ${post.likes_count || 0}`;
html += '</button>';
// Комментарии
// html += `<span class="text-muted ms-3"><i class="bi-chat"></i> ${post.comments_count || 0}</span>`;
html += '</div>';
html += '</footer>';
// Секция комментариев внутри карточки
html += '<div class="comments-section-wrapper">';
if (post.comments && post.comments.length > 0) {
html += '<div class="px-3 pt-3">';
html += '<hr class="my-3">';
html += '<div class="comments-section-header">';
html += '<i class="bi-chat-dots comments-icon"></i>';
html += '<h5 class="comments-title">Комментарии</h5>';
html += `<span class="comments-count">(${post.comments.length})</span>`;
html += '</div>';
html += '<div class="comments-list comments-scrollable" id="commentsListContainer">';
post.comments.forEach(comment => {
html += renderComment(comment, postAuthorId);
});
html += '</div>';
html += '</div>';
}
// Форма добавления комментария (sticky)
html += '<div class="comment-form-sticky">';
html += '<div class="px-3 py-2 border-top">';
html += '<form class="comment-form-modal" data-post-id="' + post.id + '">';
html += '<div class="message-field message-field--compact">';
// Кнопка выбора эмодзи
html += '<div class="dropdown message-emoji">';
html += '<button type="button" class="btn btn-ghost btn-emoji message-icon-btn" ';
html += 'data-bs-toggle="dropdown" aria-expanded="false" title="Вставить смайлик">';
html += '<i class="bi-emoji-smile"></i>';
html += '</button>';
html += '<div class="dropdown-menu dropdown-menu-start message-emoji-menu">';
html += '<emoji-picker data-emoji-picker class="chat-emoji-picker"></emoji-picker>';
html += '</div>';
html += '</div>';
html += '<textarea name="text" class="form-control message-input" rows="1" placeholder="Оставить комментарий…" required></textarea>';
html += '<button class="btn btn-primary message-send" type="submit" title="Отправить"><i class="bi-send"></i></button>';
html += '</div>';
html += '</form>';
html += '</div>';
html += '</div>';
html += '</div>'; // comments-section-wrapper
html += '</article>';
contentDiv.innerHTML = html;
// Прокручиваем к нижней части комментариев после того, как DOM обновится
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const commentsContainer = document.getElementById('commentsListContainer');
if (commentsContainer) {
commentsContainer.scrollTop = commentsContainer.scrollHeight;
}
});
});
// Добавляем обработчики
attachLikeHandlers();
function getAuthorId(authorObj) {
if (!authorObj) return null;
// API возвращает author.id
return authorObj.id || null;
}
/**
* Отображение комментария
* @param {Object} comment - Объект комментария
* @param {number} postAuthorId - ID автора поста
*/
function renderComment(comment, postAuthorId) {
const isAuthor = comment.author?.id === postAuthorId;
const commentClass = isAuthor ? 'comment-item comment-item--author' : 'comment-item';
let html = `<div class="${commentClass}">`;
html += '<div class="comment-header">';
// Автор и дата
const authorName = comment.author?.full_name || 'Аноним';
const authorId = comment.author?.id;
if (authorId) {
html += `<a href="/employees/employee/${authorId}/" class="comment-author">${authorName}</a>`;
} else {
html += `<span class="comment-author">${authorName}</span>`;
}
html += '<span class="comment-separator">·</span>';
html += `<time class="comment-date">${formatDate(comment.created_at)}</time>`;
html += '</div>';
// Текст комментария
html += `<div class="comment-body">${formatBody(comment.text)}</div>`;
html += '</div>';
return html;
}
/**
* Форматирование даты
*/
function formatDate(dateString) {
const date = new Date(dateString);
const now = new Date();
@ -276,43 +102,261 @@
}
}
/**
* Форматирование текста (простая обработка переносов строк)
*/
function formatBody(text) {
return text.replace(/\n/g, '<br>');
}
/**
* Показать ошибку
*/
function showError(message) {
const contentDiv = document.getElementById('postDetailContent');
contentDiv.innerHTML = `
<div class="alert alert-danger" role="alert">
<i class="bi-exclamation-triangle"></i> ${message}
</div>
`;
function pluralizeComments(count) {
const cases = [2, 0, 1, 1, 1, 2];
const titles = ['комментарий', 'комментария', 'комментариев'];
return titles[(count % 100 > 4 && count % 100 < 20) ? 2 : cases[(count % 10 < 5) ? count % 10 : 5]];
}
/**
* Открыть модальное окно с постом
* Загрузка поста
*/
function openPostModal(postId) {
if (!modal) {
console.error('Modal не инициализирован');
return;
async function loadPostDetail(postId) {
const accessToken = getAccessToken();
const headers = { 'Accept': 'application/json' };
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
modal.show();
loadPostDetail(postId);
try {
const response = await fetch(`/api/v1/posts/${postId}/`, {
method: 'GET',
headers: headers
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const post = await response.json();
if (DEBUG) {
console.log('=== POST DEBUG ===');
console.log('Post:', post);
console.log('Post author:', post.author);
console.log('Post author ID:', getAuthorId(post.author), typeof getAuthorId(post.author));
if (post.comments && post.comments.length > 0) {
const firstComment = post.comments[0];
console.log('First comment:', firstComment);
console.log('Comment author:', firstComment.author);
console.log('Comment author ID:', getAuthorId(firstComment.author), typeof getAuthorId(firstComment.author));
}
console.log('==================');
}
currentPost = post;
renderPostDetail(post);
} catch (error) {
console.error('Ошибка загрузки поста:', error);
showError('Не удалось загрузить пост');
}
}
/**
* Добавление обработчиков на ссылки постов
* Рендеринг
*/
function renderPostDetail(post) {
const contentDiv = document.getElementById('postDetailContent');
const postAuthorId = getAuthorId(post.author);
if (DEBUG) {
console.log('Rendering with postAuthorId:', postAuthorId);
}
let html = '<button type="button" class="btn-close position-absolute top-0 end-0 m-3" data-bs-dismiss="modal" aria-label="Закрыть" style="z-index: 1050;"></button>';
html += '<article class="card">';
html += renderPostHeader(post);
if (post.image) {
html += `<div class="feed-img"><img src="${post.image}" alt="${escapeHtml(post.title || 'Публикация')}"></div>`;
}
html += '<div class="card-body">';
html += `<h3 class="feed-title mb-3">${escapeHtml(post.title || 'Без названия')}</h3>`;
if (post.body) {
html += `<div class="feed-text mb-3">${formatBody(post.body)}</div>`;
}
html += '</div>';
html += renderPostFooter(post);
html += renderCommentsSection(post, postAuthorId);
html += '</article>';
contentDiv.innerHTML = html;
if (post.comments && post.comments.length > 0) {
contentDiv.dataset.allComments = JSON.stringify(post.comments);
contentDiv.dataset.postAuthorId = postAuthorId || '';
}
scrollToBottom();
attachLikeHandlers();
attachLoadMoreCommentsHandler();
attachCommentFormHandler();
}
function renderPostHeader(post) {
let html = '<header class="card-header">';
html += '<div class="card-icon">';
if (post.author && post.author.avatar) {
html += `<img src="${post.author.avatar}" alt="">`;
} else {
html += '<i class="bi-person"></i>';
}
html += '</div>';
html += '<div class="feed-info">';
html += `<div class="card-title">${escapeHtml(post.author?.full_name || 'Аноним')}</div>`;
html += `<time class="feed-date">${formatDate(post.created_at)}</time>`;
html += '</div>';
if (post.pinned) {
html += '<span class="feed-pin"><i class="bi-pin-angle"></i></span>';
}
html += '</header>';
return html;
}
function renderPostFooter(post) {
let html = '<footer class="feed-ft">';
html += '<div class="feed-stats">';
const likeIcon = post.user_has_liked ? 'bi-heart-fill text-danger' : 'bi-heart';
html += `<button class="btn btn-ghost btn-sm like-btn" data-post-id="${post.id}">`;
html += `<i class="${likeIcon}"></i> ${post.likes_count || 0}`;
html += '</button>';
html += '</div>';
html += '</footer>';
return html;
}
function renderCommentsSection(post, postAuthorId) {
let html = '<div class="comments-section-wrapper">';
if (post.comments && post.comments.length > 0) {
html += '<div class="px-3 pt-3">';
html += '<hr class="my-3">';
html += '<div class="comments-section-header">';
html += '<i class="bi-chat-dots comments-icon"></i>';
html += '<h5 class="comments-title">Комментарии</h5>';
html += `<span class="comments-count">(${post.comments.length})</span>`;
html += '</div>';
if (post.comments.length > INITIAL_COMMENTS_COUNT) {
const remainingCount = post.comments.length - INITIAL_COMMENTS_COUNT;
html += `<button class="btn btn-link btn-sm mb-2 load-more-comments">`;
html += `<i class="bi-chevron-up"></i> Показать ещё ${remainingCount} ${pluralizeComments(remainingCount)}`;
html += '</button>';
}
html += '<div class="comments-list comments-scrollable" id="commentsListContainer">';
const visibleComments = post.comments.slice(-INITIAL_COMMENTS_COUNT);
visibleComments.forEach(comment => {
html += renderComment(comment, postAuthorId);
});
html += '</div>';
html += '</div>';
}
html += renderCommentForm(post.id);
html += '</div>';
return html;
}
function renderComment(comment, postAuthorId) {
// Используем флаг из API (приоритет) или fallback на сравнение ID
let isAuthor = false;
if (comment.hasOwnProperty('is_post_author')) {
// Используем флаг из API
isAuthor = comment.is_post_author;
} else {
// Fallback: сравниваем ID вручную
const commentAuthorId = getAuthorId(comment.author);
isAuthor = (commentAuthorId !== null && postAuthorId !== null &&
String(commentAuthorId) === String(postAuthorId));
}
if (DEBUG) {
console.log(`Comment by ${comment.author?.full_name}:`, {
is_post_author_flag: comment.is_post_author,
calculated: isAuthor,
comment_author_id: getAuthorId(comment.author),
post_author_id: postAuthorId
});
}
const commentClass = isAuthor ? 'comment-item comment-item--author' : 'comment-item';
let html = `<div class="${commentClass}">`;
html += '<div class="comment-header">';
const authorName = comment.author?.full_name || 'Аноним';
const authorId = getAuthorId(comment.author);
if (authorId) {
html += `<a href="/employees/employee/${authorId}/" class="comment-author">${escapeHtml(authorName)}</a>`;
} else {
html += `<span class="comment-author">${escapeHtml(authorName)}</span>`;
}
html += '<span class="comment-separator">·</span>';
html += `<time class="comment-date">${formatDate(comment.created_at)}</time>`;
html += '</div>';
html += `<div class="comment-body">${formatBody(comment.text)}</div>`;
html += '</div>';
return html;
}
function renderCommentForm(postId) {
let html = '<div class="comment-form-sticky">';
html += '<div class="px-3 py-2">';
html += `<form class="comment-form-modal" data-post-id="${postId}">`;
html += '<div class="message-field message-field--compact">';
html += '<div class="dropdown message-emoji">';
html += '<button type="button" class="btn btn-ghost btn-emoji message-icon-btn" ';
html += 'data-bs-toggle="dropdown" aria-expanded="false" title="Вставить смайлик">';
html += '<i class="bi-emoji-smile"></i>';
html += '</button>';
html += '<div class="dropdown-menu dropdown-menu-start message-emoji-menu">';
html += '<emoji-picker data-emoji-picker class="chat-emoji-picker"></emoji-picker>';
html += '</div>';
html += '</div>';
html += '<textarea name="text" class="form-control message-input" rows="1" placeholder="Оставить комментарий…" required></textarea>';
html += '<button class="btn btn-primary message-send" type="submit" title="Отправить"><i class="bi-send"></i></button>';
html += '</div>';
html += '</form>';
html += '</div>';
html += '</div>';
return html;
}
/**
* Обработчики
*/
function scrollToBottom() {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const container = document.getElementById('commentsListContainer');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
});
}
function attachPostLinkHandlers() {
// Обработка кликов по ссылкам на посты
document.addEventListener('click', function(e) {
const link = e.target.closest('a[data-post-link]');
if (link) {
@ -325,9 +369,83 @@
});
}
/**
* Обработчики лайков в модальном окне
*/
function attachLoadMoreCommentsHandler() {
const loadMoreBtn = document.querySelector('.load-more-comments');
if (!loadMoreBtn) return;
loadMoreBtn.addEventListener('click', function() {
const contentDiv = document.getElementById('postDetailContent');
const allComments = JSON.parse(contentDiv.dataset.allComments || '[]');
const postAuthorId = contentDiv.dataset.postAuthorId;
const container = document.getElementById('commentsListContainer');
if (!container || allComments.length === 0) return;
const scrollHeight = container.scrollHeight;
const scrollTop = container.scrollTop;
let html = '';
allComments.forEach(comment => {
html += renderComment(comment, postAuthorId);
});
container.innerHTML = html;
const newScrollHeight = container.scrollHeight;
const heightDiff = newScrollHeight - scrollHeight;
container.scrollTop = scrollTop + heightDiff;
this.remove();
});
}
function attachCommentFormHandler() {
const form = document.querySelector('.comment-form-modal');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const textarea = this.querySelector('textarea[name="text"]');
const text = textarea.value.trim();
if (!text) return;
const postId = this.dataset.postId;
await submitComment(postId, text);
textarea.value = '';
});
}
async function submitComment(postId, text) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
alert('Необходимо авторизоваться');
return;
}
try {
const response = await fetch(`/api/v1/posts/${postId}/comments/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ text: text })
});
if (response.ok) {
// Перезагружаем пост
await loadPostDetail(postId);
} else {
alert('Ошибка при отправке комментария');
}
} catch (error) {
console.error('Ошибка:', error);
alert('Ошибка сети');
}
}
function attachLikeHandlers() {
const likeButtons = document.querySelectorAll('#postDetailContent .like-btn');
likeButtons.forEach(btn => {
@ -339,9 +457,6 @@
});
}
/**
* Переключение лайка
*/
async function toggleLike(postId, button) {
const csrfToken = getCsrfToken();
if (!csrfToken) {
@ -366,7 +481,6 @@
if (response.ok) {
const data = await response.json();
// Обновляем иконку и счетчик
if (isLiked) {
icon.classList.remove('bi-heart-fill', 'text-danger');
icon.classList.add('bi-heart');
@ -375,7 +489,6 @@
icon.classList.add('bi-heart-fill', 'text-danger');
}
// Обновляем счетчик
const countText = button.childNodes[1];
if (countText) {
countText.textContent = ' ' + (data.likes_count || 0);
@ -389,14 +502,32 @@
}
}
// Инициализация при загрузке DOM
function showError(message) {
const contentDiv = document.getElementById('postDetailContent');
contentDiv.innerHTML = `
<div class="alert alert-danger" role="alert">
<i class="bi-exclamation-triangle"></i> ${escapeHtml(message)}
</div>
`;
}
function openPostModal(postId) {
if (!modal) {
console.error('Modal не инициализирован');
return;
}
modal.show();
loadPostDetail(postId);
}
// Инициализация
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPostDetailModal);
} else {
initPostDetailModal();
}
// Экспорт для использования извне
window.openPostModal = openPostModal;
})();