diff --git a/blog/forms.py b/blog/forms.py index 620bf01..a9d6060 100644 --- a/blog/forms.py +++ b/blog/forms.py @@ -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'}), diff --git a/blog/models.py b/blog/models.py index 12e8b33..27077ca 100644 --- a/blog/models.py +++ b/blog/models.py @@ -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="Дата создания") diff --git a/blog/services.py b/blog/services.py index 7b3e1d2..24883c3 100644 --- a/blog/services.py +++ b/blog/services.py @@ -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 diff --git a/blog/signals.py b/blog/signals.py new file mode 100644 index 0000000..227c71f --- /dev/null +++ b/blog/signals.py @@ -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] + # отправка письма (можно асинхронно) \ No newline at end of file diff --git a/blog/static/blog/css/article.css b/blog/static/blog/css/article.css index d4c17af..5f8cdb2 100644 --- a/blog/static/blog/css/article.css +++ b/blog/static/blog/css/article.css @@ -87,4 +87,29 @@ padding: 0; 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; } \ No newline at end of file diff --git a/blog/templates/blog/article_detail.html b/blog/templates/blog/article_detail.html index f66916b..584c6fc 100644 --- a/blog/templates/blog/article_detail.html +++ b/blog/templates/blog/article_detail.html @@ -2,6 +2,7 @@ {% load static %} {% load django_bootstrap5 %} {% load seo_tags %} +{% load blog_extras %} {% block extra_css %} @@ -29,26 +30,19 @@
{{ comment.author_name }} ({{ comment.time_create|date:"d.m.Y H:i" }})
-{{ comment.content|linebreaks }}
- {% if not comment.is_moderated %} -Комментарий ожидает модерации
- {% endif %} -Пока нет комментариев. Будьте первым!
-{% endfor %} +Комментариев пока нет.
+{% endfor %} \ No newline at end of file diff --git a/blog/templatetags/blog_extras.py b/blog/templatetags/blog_extras.py index 4dd5b47..9c22933 100644 --- a/blog/templatetags/blog_extras.py +++ b/blog/templatetags/blog_extras.py @@ -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 + } \ No newline at end of file diff --git a/blog/views.py b/blog/views.py index 0db627f..57eec23 100644 --- a/blog/views.py +++ b/blog/views.py @@ -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, diff --git a/products/admin.py b/products/admin.py index 600b8db..1b3ce9f 100644 --- a/products/admin.py +++ b/products/admin.py @@ -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 уведомления' diff --git a/programmer/models.py b/programmer/models.py index 22990e8..ed02373 100644 --- a/programmer/models.py +++ b/programmer/models.py @@ -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}' diff --git a/programmer/static/programmer/js/profile.js b/programmer/static/programmer/js/profile.js new file mode 100644 index 0000000..e1594ba --- /dev/null +++ b/programmer/static/programmer/js/profile.js @@ -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 = 'Загрузка...
'; + modal.style.display = 'block'; + fetch(url) + .then(response => response.text()) + .then(html => { + body.innerHTML = html; + }) + .catch(() => { + body.innerHTML = 'Ошибка загрузки. Попробуйте позже.
'; + }); +} + +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(); +}); \ No newline at end of file diff --git a/programmer/templates/programmer/base.html b/programmer/templates/programmer/base.html index 08138cb..c057df4 100644 --- a/programmer/templates/programmer/base.html +++ b/programmer/templates/programmer/base.html @@ -432,6 +432,19 @@ + +Просмотр истории обращений
-Управление комментариями к статьям
-Email-рассылка и оповещения
-Просмотр истории обращений
+Управление комментариями к статьям
+Email-рассылка и оповещения
Загрузка...
'; + modal.style.display = 'block'; + fetch(url) + .then(response => response.text()) + .then(html => { + body.innerHTML = html; + }) + .catch(() => { + body.innerHTML = 'Ошибка загрузки. Попробуйте позже.
'; + }); +} + +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(); +}); \ No newline at end of file