Добавил редактор статей, исправил комментарий, отредактировал отображение статей

This commit is contained in:
NikDizell 2026-02-24 21:30:51 +03:00
parent 2ba5793115
commit 0a2088e493
16 changed files with 484 additions and 12 deletions

View File

@ -89,6 +89,8 @@ INSTALLED_APPS = [
'django.contrib.sites',
'django.contrib.sitemaps',
'blog.apps.BlogConfig',
'taggit',
'django_ckeditor_5',
]
MIDDLEWARE = [
@ -221,4 +223,36 @@ SERVER_EMAIL = EMAIL_HOST_USER
# ADMIN_EMAILS = os.getenv('ADMIN_EMAILS', 'nikdizell@gmail.com').split(',')
ADMIN_EMAILS = os.getenv('ADMIN_EMAILS').split(',')
# Путь для загрузки файлов редактором
CKEDITOR_5_UPLOAD_FILE_TYPES = ['jpeg', 'jpg', 'png', 'gif', 'bmp', 'webp'] # допустимые типы
CKEDITOR_5_UPLOAD_PATH = "uploads/ckeditor/" # папка внутри MEDIA_ROOT
CKEDITOR_5_CONFIGS = {
'default': {
'toolbar': {
'items': [
'heading', '|',
'bold', 'italic', 'underline', 'strikethrough', '|',
'alignment',
'link', 'bulletedList', 'numberedList', 'blockQuote', '|',
'code', 'codeBlock', 'insertTable', '|',
'imageUpload', 'mediaEmbed', '|',
'undo', 'redo',
'fontSize', 'fontFamily', 'highlight', 'horizontalLine', 'removeFormat', '|',
]
},
'image': {
'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight'],
'styles': ['alignLeft', 'alignCenter', 'alignRight'],
},
'table': {
'contentToolbar': ['tableColumn', 'tableRow', 'mergeTableCells'],
},
'mediaEmbed': {
'previewsInData': True, # для встраивания видео (YouTube и др.)
},
'height': 500,
'width': '100%',
}
}

View File

@ -16,8 +16,6 @@ Including another URLconf
"""
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path
from OneCprogsite import settings
from programmer.views import *
from django.urls import path, include
@ -26,6 +24,7 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('', include('programmer.urls')),
path('blog/', include('blog.urls')),
path('ckeditor5/', include('django_ckeditor_5.urls')),
# path('', index, name='home'),
# path('about/', about, name='about'),
# path('solution/', solution, name='solution'),

View File

@ -1,5 +1,10 @@
from django.contrib import admin
from .models import Category, Article, Comment
from django.db import models
from django_ckeditor_5.widgets import CKEditor5Widget
from django.utils.html import format_html
from django.urls import reverse
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
@ -9,14 +14,47 @@ class CategoryAdmin(admin.ModelAdmin):
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ('title', 'category', 'author', 'is_published', 'views_count', 'time_create')
list_filter = ('is_published', 'category', 'time_create')
search_fields = ('title', 'content')
formfield_overrides = {
models.TextField: {'widget': CKEditor5Widget(config_name='default')},
}
list_display = ('title', 'category', 'author', 'is_published', 'views_count', 'time_create', 'tag_list')
list_filter = ('is_published', 'category', 'tags', 'time_create')
search_fields = ('title', 'content', 'tags__name')
prepopulated_fields = {'slug': ('title',)}
autocomplete_fields = ('author',) # вместо raw_id_fields для удобства
# filter_horizontal = ('tags',) # для удобного выбора тегов
raw_id_fields = ('author',) # удобно при большом количестве пользователей
date_hierarchy = 'time_create'
actions = ['make_published', 'make_unpublished']
fieldsets = (
(None, {
'fields': ('title', 'slug', 'category', 'author', 'tags', 'is_published')
}),
('Содержание', {
'fields': ('content', 'image'),
'description': 'Основной текст статьи. Используйте редактор для форматирования.'
}),
('SEO', {
'fields': ('meta_description',),
'classes': ('collapse',),
}),
('Служебное', {
'fields': ('views_count',),
'classes': ('collapse',),
}),
)
readonly_fields = ('views_count',) # только для просмотра
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags')
def tag_list(self, obj):
return ", ".join(o.name for o in obj.tags.all())
tag_list.short_description = "Теги"
def make_published(self, request, queryset):
queryset.update(is_published=True)
make_published.short_description = "Опубликовать выбранные статьи"
@ -25,6 +63,17 @@ class ArticleAdmin(admin.ModelAdmin):
queryset.update(is_published=False)
make_unpublished.short_description = "Снять с публикации"
# Добавляем кнопку предпросмотра для черновиков
def view_on_site(self, obj):
if obj.is_published:
return obj.get_absolute_url()
else:
# ссылка на предпросмотр черновика
return reverse('blog:draft_preview', args=[obj.slug])
view_on_site.short_description = "Предпросмотр"
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ('author_name', 'article', 'is_moderated', 'time_create')

View File

@ -1,5 +1,8 @@
from django import forms
from .models import Comment
from django.core.exceptions import ValidationError
from django.utils.html import strip_tags
class CommentForm(forms.ModelForm):
class Meta:
@ -8,6 +11,17 @@ class CommentForm(forms.ModelForm):
widgets = {
'author_name': forms.TextInput(attrs={'class': 'form-input', 'placeholder': 'Ваше имя'}),
'author_email': forms.EmailInput(attrs={'class': 'form-input', 'placeholder': 'Ваш email'}),
'content': forms.Textarea(attrs={'class': 'form-textarea', 'rows': 4, 'placeholder': 'Текст комментария'}),
'content': forms.Textarea(attrs={'class': 'form-textarea', 'rows': 4, 'placeholder': 'Текст комментария',
'autocomplete': 'off'}),
}
def clean_content(self):
content = self.cleaned_data['content']
# Удаляем HTML-теги и проверяем, остался ли текст
clean_text = strip_tags(content)
if not clean_text.strip():
raise ValidationError("Комментарий не должен состоять только из HTML-тегов")
# Если хотите запретить любые теги, можно сравнить content и clean_text
if content != clean_text:
raise ValidationError("HTML-теги в комментариях запрещены")
return content

View File

@ -2,6 +2,7 @@ from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.contrib.auth import get_user_model
from taggit.managers import TaggableManager
User = get_user_model()
@ -20,6 +21,7 @@ class Category(models.Model):
class Article(models.Model):
"""Статья блога"""
tags = TaggableManager(blank=True, verbose_name="Теги")
title = models.CharField(max_length=200, verbose_name="Заголовок")
slug = models.SlugField(max_length=220, unique=True, verbose_name="URL")
category = models.ForeignKey(
@ -38,6 +40,12 @@ class Article(models.Model):
is_published = models.BooleanField(default=True, verbose_name="Опубликовано")
time_create = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
time_update = models.DateTimeField(auto_now=True, verbose_name="Дата изменения")
meta_description = models.CharField(
max_length=160,
blank=True,
verbose_name="Meta-описание",
help_text="Краткое описание для поисковых систем (до 160 символов)"
)
class Meta:
verbose_name = "Статья"

View File

@ -3,7 +3,7 @@
{% load django_bootstrap5 %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'programmer/css/recall.css' %}">
<link rel="stylesheet" href="{% static 'programmer/css/recall.css' %}">
{% endblock %}
{% block content %}
@ -21,13 +21,13 @@
<img src="{{ article.image.url }}" alt="{{ article.title }}" style="max-width: 100%; margin-bottom: 1rem;">
{% endif %}
<div class="card-content">
{{ article.content|linebreaks }}
{{ article.content|safe }}
</div>
</div>
<!-- Комментарии -->
<h3>Комментарии</h3>
{% for comment in article.comments.all %}
{% 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>

View File

@ -1,5 +1,6 @@
{% extends 'programmer/base.html' %}
{% load static %}
{% load blog_extras %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'programmer/css/recall.css' %}">
@ -23,7 +24,7 @@
{% for article in articles %}
<div class="modern-card">
<h2><a href="{{ article.get_absolute_url }}">{{ article.title }}</a></h2>
<p>{{ article.content|truncatewords:30 }}</p>
<p>{{ article.content|clean_html|truncatewords:30 }}</p>
<div class="meta">
<span>📅 {{ article.time_create|date:"d.m.Y" }}</span>
<span>👁 {{ article.views_count }}</span>

View File

View File

@ -0,0 +1,14 @@
from django import template
from django.utils.html import strip_tags
import html
register = template.Library()
@register.filter
def clean_html(value):
"""Удаляет HTML-теги и заменяет сущности на символы."""
# Декодируем HTML-сущности (например, &nbsp; → пробел)
value = html.unescape(value)
# Удаляем все HTML-теги
return strip_tags(value)

View File

@ -7,5 +7,6 @@ urlpatterns = [
path('', views.ArticleListView.as_view(), name='article_list'),
path('category/<slug:category_slug>/', views.ArticleListView.as_view(), name='category_detail'),
path('<slug:slug>/', views.ArticleDetailView.as_view(), name='article_detail'),
path('draft/<slug:slug>/', views.ArticleDraftPreviewView.as_view(), name='draft_preview'),
]

View File

@ -12,6 +12,7 @@ from .services import (
from .forms import CommentForm
from django.http import Http404
from django.shortcuts import redirect
from django.contrib.admin.views.decorators import staff_member_required
class ArticleListView(MenuContextMixin, ListView):
@ -66,6 +67,12 @@ class ArticleDetailView(MenuContextMixin, DetailView, CreateView):
else:
return self.form_invalid(form)
def get_initial(self):
"""Возвращает пустые начальные данные для формы комментария."""
initial = super().get_initial()
initial['content'] = ''
return initial
def form_valid(self, form):
comment = add_comment_to_article(
article=self.object,
@ -74,4 +81,19 @@ class ArticleDetailView(MenuContextMixin, DetailView, CreateView):
messages.success(self.request, "Ваш комментарий отправлен на модерацию.")
return redirect(self.object.get_absolute_url())
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем только одобренные комментарии
context['moderated_comments'] = self.object.comments.filter(is_moderated=True)
return context
@method_decorator(staff_member_required, name='dispatch')
class ArticleDraftPreviewView(DetailView):
model = Article
template_name = 'blog/article_detail.html' # используем тот же шаблон
context_object_name = 'article'
def get_queryset(self):
# Для предпросмотра показываем даже неопубликованные статьи
return Article.objects.all() # без фильтрации по is_published

View File

@ -21,7 +21,7 @@ class MenuContextMixin(ContextMixin):
menu = [
{'title': "Главная", 'url_name': 'home'},
{'title': "Проекты", 'url_name': 'solution'},
# {'title': "Статьи", 'url_name': 'blog'},
{'title': "Статьи", 'url_name': 'blog'},
{'title': "Отзывы", 'url_name': 'recall'},
{'title': "Обо мне", 'url_name': 'about'}
]

View File

@ -0,0 +1,139 @@
/* Основная типографика */
.article-content {
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px; /* при необходимости */
margin: 0 auto;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4,
.article-content h5,
.article-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
.article-content p {
margin-bottom: 1.2em;
}
.article-content blockquote {
border-left: 4px solid #ddd;
padding-left: 1em;
margin-left: 0;
margin-right: 0;
font-style: italic;
color: #666;
}
.article-content pre {
background: #f5f5f5;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
}
.article-content code {
background: #f0f0f0;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
/* Таблицы */
.article-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.article-content th,
.article-content td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.article-content th {
background-color: #f8f8f8;
}
/* Изображения */
.article-content figure.image {
display: inline-block;
margin: 1em 0;
max-width: 100%;
}
.article-content figure.image img {
max-width: 100%;
height: auto;
}
/* Выравнивание изображений (классы, используемые CKEditor 5) */
.article-content .image-style-align-left {
float: left;
margin-right: 1.5em;
margin-bottom: 1em;
max-width: 50%;
}
.article-content .image-style-align-right {
float: right;
margin-left: 1.5em;
margin-bottom: 1em;
max-width: 50%;
}
.article-content .image-style-align-center {
display: block;
margin-left: auto;
margin-right: auto;
max-width: 100%;
}
/* Подписи к изображениям (если используются) */
.article-content figure.image figcaption {
text-align: center;
font-size: 0.9em;
color: #666;
margin-top: 0.3em;
}
/* Встроенные медиа (видео) */
.article-content .media {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
margin: 1.5em 0;
}
.article-content .media iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
/* Списки */
.article-content ul,
.article-content ol {
padding-left: 2em;
margin-bottom: 1em;
}
/* Горизонтальная линия */
.article-content hr {
border: none;
border-top: 2px solid #eee;
margin: 2em 0;
}

View File

@ -160,6 +160,14 @@
/* Адаптивность для мобильных устройств */
@media (max-width: 768px) {
.content-card .image-style-align-left,
.content-card .image-style-align-right {
float: none !important;
margin-left: auto !important;
margin-right: auto !important;
max-width: 100% !important;
}
.recall-content {
flex-direction: column;
gap: 1.5rem;
@ -226,3 +234,21 @@
background: linear-gradient(transparent, rgba(0,0,0,0.7));
}
}
.content-card img {
max-width: 100% !important;
height: auto !important;
}
.content-card figure.image {
max-width: 100%;
margin: 1rem auto;
text-align: center;
}
.content-card figure.image img {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}

View File

@ -867,7 +867,25 @@ body {
border: 1px solid var(--border-light);
margin-bottom: 2rem;
position: relative;
overflow: hidden;
/* overflow: visible !important; */
}
.content-card img {
max-width: 100% !important;
height: auto !important;
}
.content-card figure.image {
max-width: 100%;
margin: 1rem auto;
text-align: center;
}
.content-card figure.image img {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
.content-card::before {
@ -1298,6 +1316,14 @@ body {
.breadcrumbs {
font-size: 0.8rem;
}
.content-card .image-style-align-left,
.content-card .image-style-align-right {
float: none !important;
margin-left: auto !important;
margin-right: auto !important;
max-width: 100% !important;
}
}
@media (max-width: 480px) {

View File

@ -0,0 +1,139 @@
/* Основная типографика */
.article-content {
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px; /* при необходимости */
margin: 0 auto;
}
.article-content h1,
.article-content h2,
.article-content h3,
.article-content h4,
.article-content h5,
.article-content h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
.article-content p {
margin-bottom: 1.2em;
}
.article-content blockquote {
border-left: 4px solid #ddd;
padding-left: 1em;
margin-left: 0;
margin-right: 0;
font-style: italic;
color: #666;
}
.article-content pre {
background: #f5f5f5;
padding: 1em;
border-radius: 4px;
overflow-x: auto;
}
.article-content code {
background: #f0f0f0;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
/* Таблицы */
.article-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
.article-content th,
.article-content td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.article-content th {
background-color: #f8f8f8;
}
/* Изображения */
.article-content figure.image {
display: inline-block;
margin: 1em 0;
max-width: 100%;
}
.article-content figure.image img {
max-width: 100%;
height: auto;
}
/* Выравнивание изображений (классы, используемые CKEditor 5) */
.article-content .image-style-align-left {
float: left;
margin-right: 1.5em;
margin-bottom: 1em;
max-width: 50%;
}
.article-content .image-style-align-right {
float: right;
margin-left: 1.5em;
margin-bottom: 1em;
max-width: 50%;
}
.article-content .image-style-align-center {
display: block;
margin-left: auto;
margin-right: auto;
max-width: 100%;
}
/* Подписи к изображениям (если используются) */
.article-content figure.image figcaption {
text-align: center;
font-size: 0.9em;
color: #666;
margin-top: 0.3em;
}
/* Встроенные медиа (видео) */
.article-content .media {
position: relative;
width: 100%;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
margin: 1.5em 0;
}
.article-content .media iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
/* Списки */
.article-content ul,
.article-content ol {
padding-left: 2em;
margin-bottom: 1em;
}
/* Горизонтальная линия */
.article-content hr {
border: none;
border-top: 2px solid #eee;
margin: 2em 0;
}