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 @@

Комментарии

-{% for comment in moderated_comments %} -
-

{{ comment.author_name }} ({{ comment.time_create|date:"d.m.Y H:i" }})

-

{{ comment.content|linebreaks }}

- {% if not comment.is_moderated %} -

Комментарий ожидает модерации

- {% endif %} -
-{% empty %} -

Пока нет комментариев. Будьте первым!

-{% endfor %} +
+ {% show_comments comments=root_comments %} +
-
+

Добавить комментарий

- {% if user_is_authenticated %} + {% if user_is_authenticated %}
{% csrf_token %} {{ form.as_p }} + {{ form.parent_id }}
{% else %} @@ -73,7 +67,6 @@
- {% endblock %} @@ -81,18 +74,41 @@ {% block extra_js %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/blog/templates/blog/includes/comment_tree.html b/blog/templates/blog/includes/comment_tree.html new file mode 100644 index 0000000..64b87f3 --- /dev/null +++ b/blog/templates/blog/includes/comment_tree.html @@ -0,0 +1,34 @@ +{% load blog_extras %} +{% load static %} + +{% for comment in comments %} +
+
+ {{ comment.author_name }} + #{{ comment.id }} + {{ comment.time_create|date:"d.m.Y H:i" }} + {% if not comment.is_moderated %} + ⏳ На модерации + {% endif %} +
+
+ {{ comment.content|linebreaks }} +
+ {% if user.is_authenticated %} + + {% endif %} + {% with replies=comment.replies.all %} + {% if replies %} +
+ {% show_comments replies depth=forloop.parentloop.counter0|add:depth %} +
+ {% endif %} + {% endwith %} +
+{% empty %} +

Комментариев пока нет.

+{% 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-рассылка и оповещения

+{% endblock %} + +{% block extra_js %} + {% endblock %} \ No newline at end of file diff --git a/programmer/urls.py b/programmer/urls.py index 817e673..960c02a 100644 --- a/programmer/urls.py +++ b/programmer/urls.py @@ -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'), ] diff --git a/programmer/views.py b/programmer/views.py index 4c9723a..c400cf9 100644 --- a/programmer/views.py +++ b/programmer/views.py @@ -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}) diff --git a/static/programmer/js/floating-button.js b/static/programmer/js/floating-button.js index 16d5613..b2b3268 100644 --- a/static/programmer/js/floating-button.js +++ b/static/programmer/js/floating-button.js @@ -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) { diff --git a/static/programmer/js/profile.js b/static/programmer/js/profile.js new file mode 100644 index 0000000..e1594ba --- /dev/null +++ b/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