feat: requests detail view - stage 4: функциональность комментариев

- Добавлена функция удаления комментариев в views_front.py
- Добавлена обработка удаления в URL routing
- Расширен JavaScript модуль для работы с добавлением/удалением комментариев
- Добавлена обработка формы добавления комментариев с JSON
- Улучшена обработка ошибок при добавлении комментария
- Добавлена инициализация emoji picker в модали
- Обновлен счетчик комментариев при добавлении/удалении
- Обновлены шаблоны для работы с данными из API
- Добавлена поддержка перезагрузки содержимого модали после каждого действия
This commit is contained in:
Игорь Надеин 2025-12-26 16:25:18 +03:00
parent 816aa8dbaa
commit 4d4a264b98
4 changed files with 162 additions and 20 deletions

View File

@ -5,6 +5,7 @@ from .views_front import (
RequestDetailView,
request_comments,
request_comment_add,
request_comment_delete,
)
app_name = "requests"
@ -18,4 +19,8 @@ urlpatterns = [
request_comment_add,
name="request_comment_add",
),
]
path(
"comments/<int:pk>/delete/<int:comment_id>/",
request_comment_delete,
name="request_comment_delete", ),
]

View File

@ -396,3 +396,25 @@ def request_comment_add(request: HttpRequest, pk: int) -> JsonResponse:
if not ok and st != 201:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
return JsonResponse(data, status=201)
def request_comment_delete(request: HttpRequest, pk: int, comment_id: int) -> JsonResponse:
"""Прокси: удаление комментария.
DELETE /api/v1/requests/{pk}/comments/{comment_id}/
Требует авторизации и проверяет права (только автор может удалить свой комментарий).
"""
if request.method != "POST":
return JsonResponse({"detail": "Method not allowed"}, status=405)
if not request.user.is_authenticated:
return JsonResponse({"detail": "Authentication required"}, status=401)
api = get_api_client(request)
ok, data, st = _api_unpack(
api.delete(f"v1/requests/{pk}/comments/{comment_id}/")
)
if not ok:
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
return JsonResponse({"detail": "Comment deleted"}, status=204)

View File

@ -111,6 +111,20 @@ export class RequestDetailModal {
if (cancelBtn) {
cancelBtn.addEventListener('click', (e) => this.handleCancel(e));
}
// Обработчик для кнопок удаления комментариев
const deleteCommentForms = this.container?.querySelectorAll('[action*="request_comment_delete"]');
if (deleteCommentForms) {
deleteCommentForms.forEach(form => {
form.addEventListener('submit', (e) => this.handleDeleteComment(e));
});
}
// Инициализация emoji picker если доступна
this.initEmojiPicker();
// Обновить счетчик комментариев
this.updateCommentsCount();
}
/**
@ -128,11 +142,98 @@ export class RequestDetailModal {
return;
}
// Показать состояние загрузки
const submitBtn = form.querySelector('button[type="submit"]');
const originalBtnText = submitBtn?.textContent || 'Отправить';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Отправка...';
}
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]')?.value || ''
},
body: JSON.stringify({ text: text }),
credentials: 'same-origin'
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
// Очистить форму
textarea.value = '';
// Показать успешное сообщение
if (submitBtn) {
const originalClass = submitBtn.className;
submitBtn.className = submitBtn.className.replace('btn-primary', 'btn-success');
submitBtn.innerHTML = '<i class="bi-check-circle me-2"></i>Комментарий добавлен!';
// Вернуть кнопку в исходное состояние
setTimeout(() => {
submitBtn.className = originalClass;
submitBtn.innerHTML = originalBtnText;
submitBtn.disabled = false;
}, 2000);
}
// Перезагрузить содержимое модали
if (this.currentRequestId) {
// Небольшая задержка для лучшего UX
setTimeout(() => {
this.loadData(this.currentRequestId);
this.updateCommentsCount();
}, 500);
}
} catch (error) {
console.error('Error submitting comment:', error);
// Восстановить кнопку при ошибке
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
}
// Показать ошибку в UI
const alertDiv = form.querySelector('.alert');
if (alertDiv) {
alertDiv.remove();
}
const errorAlert = document.createElement('div');
errorAlert.className = 'alert alert-danger alert-dismissible fade show mb-2';
errorAlert.innerHTML = `
<strong>Ошибка:</strong> ${error.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
form.parentNode.insertBefore(errorAlert, form);
}
}
/**
* Обработчик удаления комментария
*/
async handleDeleteComment(e) {
e.preventDefault();
if (!confirm('Вы уверены, что хотите удалить этот комментарий?')) {
return;
}
const form = e.target;
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]')?.value || ''
},
credentials: 'same-origin'
});
@ -140,28 +241,42 @@ export class RequestDetailModal {
throw new Error(`HTTP ${response.status}`);
}
// Очистить форму
textarea.value = '';
// Перезагрузить содержимое модали
if (this.currentRequestId) {
await this.loadData(this.currentRequestId);
this.updateCommentsCount();
}
} catch (error) {
console.error('Error submitting comment:', error);
alert('Ошибка при добавлении комментария: ' + error.message);
console.error('Error deleting comment:', error);
alert('Ошибка при удалении комментария: ' + error.message);
}
}
/**
* Обработчик отозвания заявления
* Инициализировать emoji picker если доступен
*/
handleCancel(e) {
e.preventDefault();
initEmojiPicker() {
const emojiPicker = this.container?.querySelector('emoji-picker');
const textarea = this.container?.querySelector('textarea[name="text"]');
if (confirm('Вы уверены, что хотите отозвать это заявление?')) {
// Перейти по ссылке или отправить форму
window.location.href = e.target.href;
if (emojiPicker && textarea) {
emojiPicker.addEventListener('emoji-click', (e) => {
textarea.value += e.detail.emoji.native;
textarea.focus();
});
}
}
/**
* Обновить счетчик комментариев
*/
updateCommentsCount() {
const commentsContainer = this.container?.querySelector('.d-flex.flex-column.gap-2.mb-3');
const countSpan = this.container?.querySelector('.comments-count');
if (commentsContainer && countSpan) {
const commentCount = commentsContainer.querySelectorAll('.bg-body-tertiary.border').length;
countSpan.textContent = commentCount;
}
}

View File

@ -110,25 +110,25 @@
</div>
<div class="card-meta">
<div class="card-title">Комментарии</div>
<div class="card-subtitle">Всего: {{ request_obj.comments.count }}</div>
<div class="card-subtitle">Всего: <span class="comments-count">{{ comments|length }}</span></div>
</div>
</header>
<div class="card-body pt-0">
{% if request_obj.comments.all %}
{% if comments %}
<div class="d-flex flex-column gap-2 mb-3">
{% for c in request_obj.comments.all %}
{% for c in comments %}
<div class="bg-body-tertiary border rounded p-2">
<div class="small d-flex justify-content-between align-items-start">
<div>
<span class="fw-semibold">{{ c.author.get_full_name|default:c.author.username }}</span>
<span class="fw-semibold">{{ c.author.full_name|default:c.author_name }}</span>
<span class="text-secondary">·</span>
<time class="text-muted">{{ c.created_at|date:"d.m.Y H:i" }}</time>
<time class="text-muted">{{ c.created_at }}</time>
</div>
{# удалить — только автору комментария (права дополнительно проверяются во вьюхе) #}
{% if c.author_id == request.user.id %}
<form method="post" action="{% url 'requests_app:request_comment_delete' request_obj.pk c.pk %}">
<form method="post" action="{% url 'requests_app:request_comment_delete' request_obj.pk c.id %}">
{% csrf_token %}
<button class="btn btn-sm btn-ghost text-danger" type="submit" title="Удалить">
<i class="bi-trash"></i>