feat: requests detail view - stage 4: функциональность комментариев
- Добавлена функция удаления комментариев в views_front.py - Добавлена обработка удаления в URL routing - Расширен JavaScript модуль для работы с добавлением/удалением комментариев - Добавлена обработка формы добавления комментариев с JSON - Улучшена обработка ошибок при добавлении комментария - Добавлена инициализация emoji picker в модали - Обновлен счетчик комментариев при добавлении/удалении - Обновлены шаблоны для работы с данными из API - Добавлена поддержка перезагрузки содержимого модали после каждого действия
This commit is contained in:
parent
816aa8dbaa
commit
4d4a264b98
@ -5,6 +5,7 @@ from .views_front import (
|
|||||||
RequestDetailView,
|
RequestDetailView,
|
||||||
request_comments,
|
request_comments,
|
||||||
request_comment_add,
|
request_comment_add,
|
||||||
|
request_comment_delete,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "requests"
|
app_name = "requests"
|
||||||
@ -18,4 +19,8 @@ urlpatterns = [
|
|||||||
request_comment_add,
|
request_comment_add,
|
||||||
name="request_comment_add",
|
name="request_comment_add",
|
||||||
),
|
),
|
||||||
]
|
path(
|
||||||
|
"comments/<int:pk>/delete/<int:comment_id>/",
|
||||||
|
request_comment_delete,
|
||||||
|
name="request_comment_delete", ),
|
||||||
|
]
|
||||||
@ -396,3 +396,25 @@ def request_comment_add(request: HttpRequest, pk: int) -> JsonResponse:
|
|||||||
if not ok and st != 201:
|
if not ok and st != 201:
|
||||||
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
|
return JsonResponse(data or {"detail": f"HTTP {st}"}, status=st)
|
||||||
return JsonResponse(data, status=201)
|
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)
|
||||||
@ -111,6 +111,20 @@ export class RequestDetailModal {
|
|||||||
if (cancelBtn) {
|
if (cancelBtn) {
|
||||||
cancelBtn.addEventListener('click', (e) => this.handleCancel(e));
|
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;
|
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 {
|
try {
|
||||||
const formData = new FormData(form);
|
|
||||||
const response = await fetch(form.action, {
|
const response = await fetch(form.action, {
|
||||||
method: 'POST',
|
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'
|
credentials: 'same-origin'
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -140,28 +241,42 @@ export class RequestDetailModal {
|
|||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очистить форму
|
|
||||||
textarea.value = '';
|
|
||||||
|
|
||||||
// Перезагрузить содержимое модали
|
// Перезагрузить содержимое модали
|
||||||
if (this.currentRequestId) {
|
if (this.currentRequestId) {
|
||||||
await this.loadData(this.currentRequestId);
|
await this.loadData(this.currentRequestId);
|
||||||
|
this.updateCommentsCount();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting comment:', error);
|
console.error('Error deleting comment:', error);
|
||||||
alert('Ошибка при добавлении комментария: ' + error.message);
|
alert('Ошибка при удалении комментария: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработчик отозвания заявления
|
* Инициализировать emoji picker если доступен
|
||||||
*/
|
*/
|
||||||
handleCancel(e) {
|
initEmojiPicker() {
|
||||||
e.preventDefault();
|
const emojiPicker = this.container?.querySelector('emoji-picker');
|
||||||
|
const textarea = this.container?.querySelector('textarea[name="text"]');
|
||||||
|
|
||||||
if (confirm('Вы уверены, что хотите отозвать это заявление?')) {
|
if (emojiPicker && textarea) {
|
||||||
// Перейти по ссылке или отправить форму
|
emojiPicker.addEventListener('emoji-click', (e) => {
|
||||||
window.location.href = e.target.href;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -110,25 +110,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<div class="card-title">Комментарии</div>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="card-body pt-0">
|
<div class="card-body pt-0">
|
||||||
{% if request_obj.comments.all %}
|
{% if comments %}
|
||||||
<div class="d-flex flex-column gap-2 mb-3">
|
<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="bg-body-tertiary border rounded p-2">
|
||||||
<div class="small d-flex justify-content-between align-items-start">
|
<div class="small d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{# удалить — только автору комментария (права дополнительно проверяются во вьюхе) #}
|
{# удалить — только автору комментария (права дополнительно проверяются во вьюхе) #}
|
||||||
{% if c.author_id == request.user.id %}
|
{% 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 %}
|
{% csrf_token %}
|
||||||
<button class="btn btn-sm btn-ghost text-danger" type="submit" title="Удалить">
|
<button class="btn btn-sm btn-ghost text-danger" type="submit" title="Удалить">
|
||||||
<i class="bi-trash"></i>
|
<i class="bi-trash"></i>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user