Переделал систему комметариев и сделал профиль более дружелюбным

This commit is contained in:
NikDizell 2026-06-19 16:27:14 +03:00
parent 8bde7db28a
commit d4967bc4e5
21 changed files with 470 additions and 43 deletions

View File

@ -5,9 +5,11 @@ from django.utils.html import strip_tags
class CommentForm(forms.ModelForm):
parent_id = forms.IntegerField(widget=forms.HiddenInput, required=False)
class Meta:
model = Comment
fields = ['author_name', 'author_email', 'content']
fields = ['author_name', 'author_email', 'content', 'parent_id']
widgets = {
'author_name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Ваше имя'}),
'author_email': forms.EmailInput(attrs={'class': 'form-input', 'placeholder': 'Ваш email'}),

View File

@ -81,6 +81,18 @@ class Comment(models.Model):
)
author_name = models.CharField(max_length=100, verbose_name="Имя")
author_email = models.EmailField(verbose_name="Email")
user = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
verbose_name="Пользователь"
)
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='replies',
verbose_name='Ответ на'
)
content = models.TextField(verbose_name="Комментарий")
is_moderated = models.BooleanField(default=False, verbose_name="Промодерировано")
time_create = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")

View File

@ -2,6 +2,11 @@ from typing import Optional, List
from django.db.models import F, QuerySet
from django.core.cache import cache
from .models import Article, Category, Comment
from django.contrib.auth import get_user_model
User = get_user_model()
def get_published_articles(category_slug: Optional[str] = None) -> QuerySet[Article]:
"""
@ -29,13 +34,16 @@ def increment_article_views(article: Article) -> None:
"""Увеличивает счётчик просмотров статьи."""
Article.objects.filter(pk=article.pk).update(views_count=F('views_count') + 1)
def add_comment_to_article(article: Article, data: dict) -> Comment:
def add_comment_to_article(article: Article, data: dict, user: User = None, parent: Comment = None) -> Comment:
"""Создание комментария к статье (без модерации)."""
comment = Comment.objects.create(
article=article,
author_name=data['author_name'],
author_email=data['author_email'],
content=data['content']
content=data['content'],
user=user,
parent=parent,
is_moderated=False
)
# Здесь можно отправить уведомление администратору
return comment

13
blog/signals.py Normal file
View File

@ -0,0 +1,13 @@
# blog/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Article
from programmer.models import Profile
from django.core.mail import send_mail
@receiver(post_save, sender=Article)
def notify_about_new_article(sender, instance, created, **kwargs):
if created and instance.is_published:
profiles = Profile.objects.filter(notify_new_articles=True, user__is_active=True)
emails = [p.user.email for p in profiles if p.user.email]
# отправка письма (можно асинхронно)

View File

@ -88,3 +88,28 @@
background: none;
color: inherit;
}
.comment-item {
border-left: 2px solid var(--border-light);
padding-left: 15px;
margin-bottom: 15px;
}
.comment-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.badge-warning {
background-color: #ffc107;
color: #212529;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.comment-content {
margin: 5px 0;
}
.reply-btn {
margin-top: 5px;
}

View File

@ -2,6 +2,7 @@
{% load static %}
{% load django_bootstrap5 %}
{% load seo_tags %}
{% load blog_extras %}
{% block extra_css %}
<link id="theme-css-1c" rel="stylesheet" href="{% static 'programmer/css/1c-light.min.css' %}">
@ -29,26 +30,19 @@
<!-- Комментарии -->
<h3>Комментарии</h3>
{% for comment in moderated_comments %}
<div class="modern-card" style="margin-bottom: 1rem;">
<p><strong>{{ comment.author_name }}</strong> ({{ comment.time_create|date:"d.m.Y H:i" }})</p>
<p>{{ comment.content|linebreaks }}</p>
{% if not comment.is_moderated %}
<p><em>Комментарий ожидает модерации</em></p>
{% endif %}
</div>
{% empty %}
<p>Пока нет комментариев. Будьте первым!</p>
{% endfor %}
<div id="comments-section">
{% show_comments comments=root_comments %}
</div>
<!-- Форма добавления комментария -->
<div class="modern-card">
<div class="modern-card" id="comment-form">
<h4>Добавить комментарий</h4>
{% if user_is_authenticated %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
{{ form.parent_id }}
<button type="submit" class="btn btn-primary">Отправить</button>
</form>
{% else %}
@ -73,7 +67,6 @@
</div>
</div>
<!-- Скрипт для модального окна -->
<script src="{% static 'programmer/js/recall.js' %}"></script>
{% endblock %}
@ -81,18 +74,41 @@
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Находим все изображения внутри .content-card (основной контент статьи)
// Клик по изображениям в контенте
document.querySelectorAll('.content-card img').forEach(function(img) {
// Игнорируем, если изображение уже обёрнуто в ссылку
if (img.closest('a')) return;
// Меняем курсор, чтобы показать, что изображение кликабельно
img.style.cursor = 'pointer';
img.addEventListener('click', function() {
// Вызываем глобальную функцию openModal из recall.js
ImageModal.open(this.src, this.alt || 'Изображение из статьи');
});
});
// Обработка кнопок "Ответить" (делегирование)
document.getElementById('comments-section').addEventListener('click', function(e) {
const btn = e.target.closest('.reply-btn');
if (!btn) return;
e.preventDefault();
const parentId = btn.dataset.commentId;
const author = btn.dataset.author;
// Находим текст комментария
const commentItem = btn.closest('.comment-item');
const contentDiv = commentItem.querySelector('.comment-content');
let quotedText = contentDiv.textContent.trim();
// Формируем цитату
const quote = `> @${author}: ${quotedText}\n\n`;
const textarea = document.getElementById('id_content');
textarea.value = quote;
// Устанавливаем parent_id
document.getElementById('id_parent_id').value = parentId;
// Фокус на поле ввода и прокрутка к форме
textarea.focus();
document.getElementById('comment-form').scrollIntoView({ behavior: 'smooth' });
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% load blog_extras %}
{% load static %}
{% for comment in comments %}
<div class="comment-item" style="margin-left: {{ depth|add:20 }}px; border-left: 2px solid var(--border-light); padding-left: 15px; margin-bottom: 15px;">
<div class="comment-meta">
<strong>{{ comment.author_name }}</strong>
<span style="color: var(--text-light); font-size: 0.9rem;">#{{ comment.id }}</span>
<span style="color: var(--text-light); font-size: 0.9rem;">{{ comment.time_create|date:"d.m.Y H:i" }}</span>
{% if not comment.is_moderated %}
<span class="badge badge-warning">На модерации</span>
{% endif %}
</div>
<div class="comment-content" style="margin: 5px 0;">
{{ comment.content|linebreaks }}
</div>
{% if user.is_authenticated %}
<button class="btn btn-sm btn-outline-primary reply-btn"
data-comment-id="{{ comment.id }}"
data-author="{{ comment.author_name }}">
Ответить
</button>
{% endif %}
{% with replies=comment.replies.all %}
{% if replies %}
<div class="replies" style="margin-top: 10px;">
{% show_comments replies depth=forloop.parentloop.counter0|add:depth %}
</div>
{% endif %}
{% endwith %}
</div>
{% empty %}
<p>Комментариев пока нет.</p>
{% endfor %}

View File

@ -1,10 +1,12 @@
from django import template
from django.utils.html import strip_tags
from blog.models import Comment
import html
register = template.Library()
@register.filter
def clean_html(value):
"""Удаляет HTML-теги и заменяет сущности на символы."""
@ -12,3 +14,22 @@ def clean_html(value):
value = html.unescape(value)
# Удаляем все HTML-теги
return strip_tags(value)
@register.inclusion_tag('blog/includes/comment_tree.html', takes_context=True)
def show_comments(context, comments, depth=0):
request = context.get('request')
user = request.user if request and request.user.is_authenticated else None
filtered = []
for c in comments:
if c.is_moderated:
filtered.append(c)
elif user and c.user == user:
filtered.append(c)
return {
'comments': filtered,
'depth': depth,
'user': user
}

View File

@ -4,7 +4,7 @@ from django.urls import reverse_lazy
from django.contrib import messages
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from .models import Article, Category
from .models import Article, Category, Comment
from .services import (
get_published_articles, get_article_by_slug,
increment_article_views, add_comment_to_article
@ -103,9 +103,21 @@ class ArticleDetailView(MenuContextMixin, BreadcrumbMixin, DetailView):
form = CommentForm(request.POST, request=request)
if form.is_valid():
parent_id = form.cleaned_data.get('parent_id')
parent = None
if parent_id:
try:
parent = Comment.objects.get(id=parent_id, article=self.object)
except Comment.DoesNotExist:
pass
add_comment_to_article(
article=self.object,
data=form.cleaned_data
data=form.cleaned_data,
user=request.user,
parent=parent
)
messages.success(request, "✅ Ваш комментарий отправлен на модерацию.")
return redirect(self.object.get_absolute_url())
@ -121,6 +133,9 @@ class ArticleDetailView(MenuContextMixin, BreadcrumbMixin, DetailView):
if 'form' not in kwargs:
context['form'] = CommentForm(request=self.request)
root_comments = self.object.comments.filter(parent__isnull=True, is_moderated=True)
context['root_comments'] = root_comments
context.update({
'moderated_comments': self.object.comments.filter(is_moderated=True),
'user_is_authenticated': self.request.user.is_authenticated,

View File

@ -3,7 +3,7 @@ from django.utils.html import format_html
from django.shortcuts import render
from django_ckeditor_5.widgets import CKEditor5Widget
from .models import ProductCategory, Configuration, Product, Order
from programmer.utils.email_notifications import send_multiple_order_notifications
from programmer.utils.email_notifications import send_multiple_order_notifications, send_multiple_order_notifications_async
@admin.register(ProductCategory)
@ -85,7 +85,8 @@ class OrderAdmin(admin.ModelAdmin):
"""
Переотправляет уведомления для выбранных заказов.
"""
count = send_multiple_order_notifications(queryset)
# count = send_multiple_order_notifications(queryset)
count = send_multiple_order_notifications_async(queryset)
self.message_user(request, f'Уведомления отправлены для {count} заказов.')
resend_order_notification.short_description = 'Переотправить email уведомления'

View File

@ -196,6 +196,8 @@ class Profile(models.Model):
specialization = models.CharField(max_length=100, blank=True, verbose_name='Специализация')
avatar = models.ImageField(upload_to='avatars/%Y/%m/%d/', blank=True, verbose_name='Аватар')
email_notifications = models.BooleanField(default=True, verbose_name='Получать уведомления')
notify_new_articles = models.BooleanField(default=True, verbose_name="О новых статьях")
notify_new_products = models.BooleanField(default=True, verbose_name="О новых продуктах")
def __str__(self):
return f'Профиль {self.user.username}'

View File

@ -0,0 +1,30 @@
// programmer/static/programmer/js/profile.js
function openModalContent(el) {
const url = el.dataset.url;
const title = el.querySelector('h4').innerText;
const modal = document.getElementById('contentModal');
const body = document.getElementById('contentModalBody');
const titleEl = document.getElementById('contentModalTitle');
titleEl.textContent = title;
body.innerHTML = '<p>Загрузка...</p>';
modal.style.display = 'block';
fetch(url)
.then(response => response.text())
.then(html => {
body.innerHTML = html;
})
.catch(() => {
body.innerHTML = '<p style="color:red;">Ошибка загрузки. Попробуйте позже.</p>';
});
}
function closeContentModal() {
document.getElementById('contentModal').style.display = 'none';
}
// Закрытие по клику на фон и ESC
document.addEventListener('click', function(e) {
if (e.target === document.getElementById('contentModal')) closeContentModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeContentModal();
});

View File

@ -432,6 +432,19 @@
</div>
</div>
<!-- Универсальное модальное окно -->
<div id="contentModal" class="modal">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<h3 id="contentModalTitle">Заголовок</h3>
<button class="modal-close" onclick="closeContentModal()">&times;</button>
</div>
<div class="modal-body" id="contentModalBody">
<!-- сюда подгружается содержимое -->
</div>
</div>
</div>
<!-- Плавающая кнопка -->
<div id="floatingButton" class="floating-btn" style="position: fixed; bottom: 30px; right: 30px; z-index: 10000; display: none;">
<button onclick="CallbackModal.open()" class="btn btn-primary pulse">🎯 Получить консультацию</button>

View File

@ -0,0 +1,80 @@
<style>
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
margin-right: 0.5rem;
}
.status-badge.new { background: #dc3545; color: #fff; }
.status-badge.read { background: #28a745; color: #fff; }
.status-badge.processed { background: #17a2b8; color: #fff; }
.status-badge.unprocessed { background: #ffc107; color: #212529; }
.status-badge.sent { background: #6c757d; color: #fff; }
.list-item {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light);
}
.list-item:last-child { border-bottom: none; }
.list-item .meta { font-size: 0.9rem; color: var(--text-light); }
</style>
<h3>📞 Мои заявки на звонок</h3>
{% if callbacks %}
{% for cb in callbacks %}
<div class="list-item">
<div>
<strong>{{ cb.name }}</strong>
<span class="status-badge {% if cb.is_read %}read{% else %}new{% endif %}">
{% if cb.is_read %}✅ Прочитано{% else %}🆕 Новое{% endif %}
</span>
<span class="status-badge {% if cb.is_processed %}processed{% else %}unprocessed{% endif %}">
{% if cb.is_processed %}✅ Обработано{% else %}⏳ В обработке{% endif %}
</span>
</div>
<div class="meta">
{{ cb.time_create|date:"d.m.Y H:i" }} •
📞 {{ cb.phone }} •
✉️ {{ cb.email|default:"email не указан" }}
{% if cb.question %}
<br><span style="font-style:italic;">{{ cb.question|truncatechars:80 }}</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p>Заявок пока нет.</p>
{% endif %}
<hr>
<h3>🛒 Мои заказы</h3>
{% if orders %}
{% for order in orders %}
<div class="list-item">
<div>
<strong>{{ order.product.title }}</strong>
<span class="status-badge {% if order.is_processed %}processed{% else %}unprocessed{% endif %}">
{% if order.is_processed %}✅ Обработан{% else %}⏳ В обработке{% endif %}
</span>
{% if order.notification_sent %}
<span class="status-badge sent">📨 Уведомление отправлено</span>
{% endif %}
</div>
<div class="meta">
{{ order.created_at|date:"d.m.Y H:i" }} •
📞 {{ order.phone }} •
✉️ {{ order.email }}
{% if order.configuration %}
<br>⚙️ Конфигурация: {{ order.configuration.name }}
{% endif %}
{% if order.comment %}
<br><span style="font-style:italic;">{{ order.comment|truncatechars:80 }}</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p>Заказов пока нет.</p>
{% endif %}

View File

@ -0,0 +1,59 @@
<style>
.comment-status {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
margin-left: 0.5rem;
}
.comment-status.moderation {
background: #ffc107;
color: #212529;
}
.comment-status.approved {
background: #28a745;
color: #fff;
}
.list-item {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light);
}
.list-item:last-child { border-bottom: none; }
.list-item .meta { font-size: 0.9rem; color: var(--text-light); }
.list-item .article-link {
font-weight: 600;
color: var(--primary);
text-decoration: none;
transition: color 0.2s;
}
.list-item .article-link:hover {
color: var(--primary-dark);
text-decoration: underline;
}
</style>
<h3>💬 Мои комментарии</h3>
{% if comments %}
{% for comment in comments %}
<div class="list-item">
<div>
<a href="{{ comment.article.get_absolute_url }}" class="article-link">
{{ comment.article.title }}
</a>
{% if not comment.is_moderated %}
<span class="comment-status moderation">На модерации</span>
{% else %}
<span class="comment-status approved">✅ Одобрен</span>
{% endif %}
</div>
<div class="meta">
{{ comment.time_create|date:"d.m.Y H:i" }}
<br>
{{ comment.content|truncatechars:150 }}
</div>
</div>
{% endfor %}
{% else %}
<p>Вы ещё не оставили ни одного комментария.</p>
{% endif %}

View File

@ -0,0 +1,25 @@
<h3>🔔 Настройки уведомлений</h3>
<form id="notif-form">
{% csrf_token %}
<label>
<input type="checkbox" name="notify_new_articles" {% if profile.notify_new_articles %}checked{% endif %}>
Уведомлять о новых статьях
</label><br>
<label>
<input type="checkbox" name="notify_new_products" {% if profile.notify_new_products %}checked{% endif %}>
Уведомлять о новых продуктах
</label><br>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
<script>
document.getElementById('notif-form').addEventListener('submit', function(e) {
e.preventDefault();
fetch('{% url "notification_settings" %}', {
method: 'POST',
body: new FormData(this),
headers: { 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value }
})
.then(response => response.json())
.then(data => { if (data.status === 'ok') alert('Настройки сохранены!'); });
});
</script>

View File

@ -1,5 +1,6 @@
{% extends 'programmer/base.html' %}
{% load django_bootstrap5 %}
{% load static %}
{% block content %}
<div class="page-header">
@ -31,21 +32,22 @@
</div>
</div>
<div class="content-card mt-3">
<h3>Мои действия</h3>
<div class="skills-grid">
<div class="skill-category">
<div class="skills-grid">
<div class="skill-category" data-url="{% url 'my_callbacks' %}" onclick="openModalContent(this)">
<h4>📋 Мои заявки</h4>
<p>Просмотр истории обращений</p>
</div>
<div class="skill-category">
<div class="skill-category" data-url="{% url 'my_comments' %}" onclick="openModalContent(this)">
<h4>💬 Мои комментарии</h4>
<p>Управление комментариями к статьям</p>
</div>
<div class="skill-category">
<div class="skill-category" data-url="{% url 'notification_settings' %}" onclick="openModalContent(this)">
<h4>🔔 Настройки уведомлений</h4>
<p>Email-рассылка и оповещения</p>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'programmer/js/profile.js' %}"></script>
{% endblock %}

View File

@ -35,6 +35,9 @@ urlpatterns = [
path('privacy/', PrivacyPolicyView.as_view(), name='privacy'),
path('yandex_cdc16c33291495b9.html/', yandex_html, name='yandex_cdc16c33291495b9'),
path('requisites/', RequisitesPageView.as_view(), name='requisites'),
path('profile/callbacks/', views.my_callbacks, name='my_callbacks'),
path('profile/comments/', views.my_comments, name='my_comments'),
path('profile/notifications/', views.notification_settings, name='notification_settings'),
]

View File

@ -21,6 +21,8 @@ from .services import get_published_queryset, track_page_view
from typing import Any, Dict, Type
from django.template.loader import render_to_string
from django.utils.http import url_has_allowed_host_and_scheme
from blog.models import Comment
from products.models import Order
import os
@ -484,3 +486,29 @@ def robots_txt(request):
def yandex_html(request: HttpRequest) -> HttpResponse:
"""Отдает yandex_cdc16c33291495b9.html."""
return render(request, 'programmer/yandex_cdc16c33291495b9.html', content_type='text/html')
@login_required
def my_callbacks(request):
callbacks = CallbackRequest.objects.filter(email=request.user.email).order_by('-time_create')
# Можно также фильтровать по телефону, если он заполнен в профиле, но email — универсальнее.
orders = Order.objects.filter(email=request.user.email).order_by('-created_at') # если поле есть
return render(request, 'programmer/includes/my_callbacks.html', {
'callbacks': callbacks,
'orders': orders,
})
@login_required
def my_comments(request):
comments = Comment.objects.filter(user=request.user).select_related('article').order_by('-time_create')
return render(request, 'programmer/includes/my_comments.html', {'comments': comments})
@login_required
def notification_settings(request):
profile = request.user.profile
if request.method == 'POST':
# обработка формы (можно через AJAX POST)
profile.notify_new_articles = request.POST.get('notify_new_articles') == 'on'
profile.notify_new_products = request.POST.get('notify_new_products') == 'on'
profile.save()
return JsonResponse({'status': 'ok'})
return render(request, 'programmer/includes/notification_settings.html', {'profile': profile})

View File

@ -16,9 +16,17 @@ const CallbackModal = {
}, 5000);
},
open() {
open(productTitle) {
const modal = document.getElementById(this.modalId);
if (modal) {
if (productTitle) {
const questionField = modal.querySelector('#id_question');
if (questionField) {
questionField.value = `Запрос цены по ПП "${productTitle}"`;
}
}
modal.style.display = 'block';
const floatingBtn = document.getElementById(this.floatingBtnId);
if (floatingBtn && this.shown) {

View File

@ -0,0 +1,30 @@
// programmer/static/programmer/js/profile.js
function openModalContent(el) {
const url = el.dataset.url;
const title = el.querySelector('h4').innerText;
const modal = document.getElementById('contentModal');
const body = document.getElementById('contentModalBody');
const titleEl = document.getElementById('contentModalTitle');
titleEl.textContent = title;
body.innerHTML = '<p>Загрузка...</p>';
modal.style.display = 'block';
fetch(url)
.then(response => response.text())
.then(html => {
body.innerHTML = html;
})
.catch(() => {
body.innerHTML = '<p style="color:red;">Ошибка загрузки. Попробуйте позже.</p>';
});
}
function closeContentModal() {
document.getElementById('contentModal').style.display = 'none';
}
// Закрытие по клику на фон и ESC
document.addEventListener('click', function(e) {
if (e.target === document.getElementById('contentModal')) closeContentModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeContentModal();
});