Переделал систему комметариев и сделал профиль более дружелюбным
This commit is contained in:
parent
8bde7db28a
commit
d4967bc4e5
@ -5,9 +5,11 @@ from django.utils.html import strip_tags
|
|||||||
|
|
||||||
|
|
||||||
class CommentForm(forms.ModelForm):
|
class CommentForm(forms.ModelForm):
|
||||||
|
parent_id = forms.IntegerField(widget=forms.HiddenInput, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Comment
|
model = Comment
|
||||||
fields = ['author_name', 'author_email', 'content']
|
fields = ['author_name', 'author_email', 'content', 'parent_id']
|
||||||
widgets = {
|
widgets = {
|
||||||
'author_name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Ваше имя'}),
|
'author_name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Ваше имя'}),
|
||||||
'author_email': forms.EmailInput(attrs={'class': 'form-input', 'placeholder': 'Ваш email'}),
|
'author_email': forms.EmailInput(attrs={'class': 'form-input', 'placeholder': 'Ваш email'}),
|
||||||
|
|||||||
@ -81,6 +81,18 @@ class Comment(models.Model):
|
|||||||
)
|
)
|
||||||
author_name = models.CharField(max_length=100, verbose_name="Имя")
|
author_name = models.CharField(max_length=100, verbose_name="Имя")
|
||||||
author_email = models.EmailField(verbose_name="Email")
|
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="Комментарий")
|
content = models.TextField(verbose_name="Комментарий")
|
||||||
is_moderated = models.BooleanField(default=False, verbose_name="Промодерировано")
|
is_moderated = models.BooleanField(default=False, verbose_name="Промодерировано")
|
||||||
time_create = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
time_create = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||||
|
|||||||
@ -2,6 +2,11 @@ from typing import Optional, List
|
|||||||
from django.db.models import F, QuerySet
|
from django.db.models import F, QuerySet
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from .models import Article, Category, Comment
|
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]:
|
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)
|
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(
|
comment = Comment.objects.create(
|
||||||
article=article,
|
article=article,
|
||||||
author_name=data['author_name'],
|
author_name=data['author_name'],
|
||||||
author_email=data['author_email'],
|
author_email=data['author_email'],
|
||||||
content=data['content']
|
content=data['content'],
|
||||||
|
user=user,
|
||||||
|
parent=parent,
|
||||||
|
is_moderated=False
|
||||||
)
|
)
|
||||||
# Здесь можно отправить уведомление администратору
|
# Здесь можно отправить уведомление администратору
|
||||||
return comment
|
return comment
|
||||||
|
|||||||
13
blog/signals.py
Normal file
13
blog/signals.py
Normal 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]
|
||||||
|
# отправка письма (можно асинхронно)
|
||||||
@ -87,4 +87,29 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background: none;
|
background: none;
|
||||||
color: inherit;
|
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;
|
||||||
}
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
{% load django_bootstrap5 %}
|
{% load django_bootstrap5 %}
|
||||||
{% load seo_tags %}
|
{% load seo_tags %}
|
||||||
|
{% load blog_extras %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<link id="theme-css-1c" rel="stylesheet" href="{% static 'programmer/css/1c-light.min.css' %}">
|
<link id="theme-css-1c" rel="stylesheet" href="{% static 'programmer/css/1c-light.min.css' %}">
|
||||||
@ -29,26 +30,19 @@
|
|||||||
|
|
||||||
<!-- Комментарии -->
|
<!-- Комментарии -->
|
||||||
<h3>Комментарии</h3>
|
<h3>Комментарии</h3>
|
||||||
{% for comment in moderated_comments %}
|
<div id="comments-section">
|
||||||
<div class="modern-card" style="margin-bottom: 1rem;">
|
{% show_comments comments=root_comments %}
|
||||||
<p><strong>{{ comment.author_name }}</strong> ({{ comment.time_create|date:"d.m.Y H:i" }})</p>
|
</div>
|
||||||
<p>{{ comment.content|linebreaks }}</p>
|
|
||||||
{% if not comment.is_moderated %}
|
|
||||||
<p><em>Комментарий ожидает модерации</em></p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
<p>Пока нет комментариев. Будьте первым!</p>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<!-- Форма добавления комментария -->
|
<!-- Форма добавления комментария -->
|
||||||
<div class="modern-card">
|
<div class="modern-card" id="comment-form">
|
||||||
<h4>Добавить комментарий</h4>
|
<h4>Добавить комментарий</h4>
|
||||||
|
|
||||||
{% if user_is_authenticated %}
|
{% if user_is_authenticated %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
|
{{ form.parent_id }}
|
||||||
<button type="submit" class="btn btn-primary">Отправить</button>
|
<button type="submit" class="btn btn-primary">Отправить</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -73,7 +67,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Скрипт для модального окна -->
|
|
||||||
<script src="{% static 'programmer/js/recall.js' %}"></script>
|
<script src="{% static 'programmer/js/recall.js' %}"></script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -81,18 +74,41 @@
|
|||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Находим все изображения внутри .content-card (основной контент статьи)
|
// Клик по изображениям в контенте
|
||||||
document.querySelectorAll('.content-card img').forEach(function(img) {
|
document.querySelectorAll('.content-card img').forEach(function(img) {
|
||||||
// Игнорируем, если изображение уже обёрнуто в ссылку
|
|
||||||
if (img.closest('a')) return;
|
if (img.closest('a')) return;
|
||||||
|
|
||||||
// Меняем курсор, чтобы показать, что изображение кликабельно
|
|
||||||
img.style.cursor = 'pointer';
|
img.style.cursor = 'pointer';
|
||||||
img.addEventListener('click', function() {
|
img.addEventListener('click', function() {
|
||||||
// Вызываем глобальную функцию openModal из recall.js
|
|
||||||
ImageModal.open(this.src, this.alt || 'Изображение из статьи');
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
34
blog/templates/blog/includes/comment_tree.html
Normal file
34
blog/templates/blog/includes/comment_tree.html
Normal 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 %}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
from django import template
|
from django import template
|
||||||
from django.utils.html import strip_tags
|
from django.utils.html import strip_tags
|
||||||
|
from blog.models import Comment
|
||||||
import html
|
import html
|
||||||
|
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def clean_html(value):
|
def clean_html(value):
|
||||||
"""Удаляет HTML-теги и заменяет сущности на символы."""
|
"""Удаляет HTML-теги и заменяет сущности на символы."""
|
||||||
@ -12,3 +14,22 @@ def clean_html(value):
|
|||||||
value = html.unescape(value)
|
value = html.unescape(value)
|
||||||
# Удаляем все HTML-теги
|
# Удаляем все HTML-теги
|
||||||
return strip_tags(value)
|
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
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ from django.urls import reverse_lazy
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.cache import cache_page
|
from django.views.decorators.cache import cache_page
|
||||||
from .models import Article, Category
|
from .models import Article, Category, Comment
|
||||||
from .services import (
|
from .services import (
|
||||||
get_published_articles, get_article_by_slug,
|
get_published_articles, get_article_by_slug,
|
||||||
increment_article_views, add_comment_to_article
|
increment_article_views, add_comment_to_article
|
||||||
@ -103,9 +103,21 @@ class ArticleDetailView(MenuContextMixin, BreadcrumbMixin, DetailView):
|
|||||||
|
|
||||||
form = CommentForm(request.POST, request=request)
|
form = CommentForm(request.POST, request=request)
|
||||||
if form.is_valid():
|
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(
|
add_comment_to_article(
|
||||||
article=self.object,
|
article=self.object,
|
||||||
data=form.cleaned_data
|
data=form.cleaned_data,
|
||||||
|
user=request.user,
|
||||||
|
parent=parent
|
||||||
)
|
)
|
||||||
messages.success(request, "✅ Ваш комментарий отправлен на модерацию.")
|
messages.success(request, "✅ Ваш комментарий отправлен на модерацию.")
|
||||||
return redirect(self.object.get_absolute_url())
|
return redirect(self.object.get_absolute_url())
|
||||||
@ -121,6 +133,9 @@ class ArticleDetailView(MenuContextMixin, BreadcrumbMixin, DetailView):
|
|||||||
if 'form' not in kwargs:
|
if 'form' not in kwargs:
|
||||||
context['form'] = CommentForm(request=self.request)
|
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({
|
context.update({
|
||||||
'moderated_comments': self.object.comments.filter(is_moderated=True),
|
'moderated_comments': self.object.comments.filter(is_moderated=True),
|
||||||
'user_is_authenticated': self.request.user.is_authenticated,
|
'user_is_authenticated': self.request.user.is_authenticated,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from django.utils.html import format_html
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||||
from .models import ProductCategory, Configuration, Product, Order
|
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)
|
@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} заказов.')
|
self.message_user(request, f'Уведомления отправлены для {count} заказов.')
|
||||||
resend_order_notification.short_description = 'Переотправить email уведомления'
|
resend_order_notification.short_description = 'Переотправить email уведомления'
|
||||||
|
|
||||||
|
|||||||
@ -196,6 +196,8 @@ class Profile(models.Model):
|
|||||||
specialization = models.CharField(max_length=100, blank=True, verbose_name='Специализация')
|
specialization = models.CharField(max_length=100, blank=True, verbose_name='Специализация')
|
||||||
avatar = models.ImageField(upload_to='avatars/%Y/%m/%d/', 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='Получать уведомления')
|
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):
|
def __str__(self):
|
||||||
return f'Профиль {self.user.username}'
|
return f'Профиль {self.user.username}'
|
||||||
|
|||||||
30
programmer/static/programmer/js/profile.js
Normal file
30
programmer/static/programmer/js/profile.js
Normal 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();
|
||||||
|
});
|
||||||
@ -432,6 +432,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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()">×</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;">
|
<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>
|
<button onclick="CallbackModal.open()" class="btn btn-primary pulse">🎯 Получить консультацию</button>
|
||||||
|
|||||||
80
programmer/templates/programmer/includes/my_callbacks.html
Normal file
80
programmer/templates/programmer/includes/my_callbacks.html
Normal 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 %}
|
||||||
59
programmer/templates/programmer/includes/my_comments.html
Normal file
59
programmer/templates/programmer/includes/my_comments.html
Normal 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 %}
|
||||||
@ -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>
|
||||||
@ -1,5 +1,6 @@
|
|||||||
{% extends 'programmer/base.html' %}
|
{% extends 'programmer/base.html' %}
|
||||||
{% load django_bootstrap5 %}
|
{% load django_bootstrap5 %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@ -31,21 +32,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-card mt-3">
|
<div class="skills-grid">
|
||||||
<h3>Мои действия</h3>
|
<div class="skill-category" data-url="{% url 'my_callbacks' %}" onclick="openModalContent(this)">
|
||||||
<div class="skills-grid">
|
<h4>📋 Мои заявки</h4>
|
||||||
<div class="skill-category">
|
<p>Просмотр истории обращений</p>
|
||||||
<h4>📋 Мои заявки</h4>
|
</div>
|
||||||
<p>Просмотр истории обращений</p>
|
<div class="skill-category" data-url="{% url 'my_comments' %}" onclick="openModalContent(this)">
|
||||||
</div>
|
<h4>💬 Мои комментарии</h4>
|
||||||
<div class="skill-category">
|
<p>Управление комментариями к статьям</p>
|
||||||
<h4>💬 Мои комментарии</h4>
|
</div>
|
||||||
<p>Управление комментариями к статьям</p>
|
<div class="skill-category" data-url="{% url 'notification_settings' %}" onclick="openModalContent(this)">
|
||||||
</div>
|
<h4>🔔 Настройки уведомлений</h4>
|
||||||
<div class="skill-category">
|
<p>Email-рассылка и оповещения</p>
|
||||||
<h4>🔔 Настройки уведомлений</h4>
|
|
||||||
<p>Email-рассылка и оповещения</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'programmer/js/profile.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@ -35,6 +35,9 @@ urlpatterns = [
|
|||||||
path('privacy/', PrivacyPolicyView.as_view(), name='privacy'),
|
path('privacy/', PrivacyPolicyView.as_view(), name='privacy'),
|
||||||
path('yandex_cdc16c33291495b9.html/', yandex_html, name='yandex_cdc16c33291495b9'),
|
path('yandex_cdc16c33291495b9.html/', yandex_html, name='yandex_cdc16c33291495b9'),
|
||||||
path('requisites/', RequisitesPageView.as_view(), name='requisites'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,8 @@ from .services import get_published_queryset, track_page_view
|
|||||||
from typing import Any, Dict, Type
|
from typing import Any, Dict, Type
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
from blog.models import Comment
|
||||||
|
from products.models import Order
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
@ -484,3 +486,29 @@ def robots_txt(request):
|
|||||||
def yandex_html(request: HttpRequest) -> HttpResponse:
|
def yandex_html(request: HttpRequest) -> HttpResponse:
|
||||||
"""Отдает yandex_cdc16c33291495b9.html."""
|
"""Отдает yandex_cdc16c33291495b9.html."""
|
||||||
return render(request, 'programmer/yandex_cdc16c33291495b9.html', content_type='text/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})
|
||||||
|
|||||||
@ -16,9 +16,17 @@ const CallbackModal = {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
},
|
},
|
||||||
|
|
||||||
open() {
|
open(productTitle) {
|
||||||
const modal = document.getElementById(this.modalId);
|
const modal = document.getElementById(this.modalId);
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
|
||||||
|
if (productTitle) {
|
||||||
|
const questionField = modal.querySelector('#id_question');
|
||||||
|
if (questionField) {
|
||||||
|
questionField.value = `Запрос цены по ПП "${productTitle}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modal.style.display = 'block';
|
modal.style.display = 'block';
|
||||||
const floatingBtn = document.getElementById(this.floatingBtnId);
|
const floatingBtn = document.getElementById(this.floatingBtnId);
|
||||||
if (floatingBtn && this.shown) {
|
if (floatingBtn && this.shown) {
|
||||||
|
|||||||
30
static/programmer/js/profile.js
Normal file
30
static/programmer/js/profile.js
Normal 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();
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user