Добавил новый раздел "Блог"

This commit is contained in:
NikDizell 2026-02-23 14:17:53 +03:00
parent a2c25ccec6
commit 2cbc32d5b2
16 changed files with 424 additions and 0 deletions

View File

@ -92,6 +92,7 @@ INSTALLED_APPS = [
'django_extensions',
'django.contrib.sites',
'django.contrib.sitemaps',
'blog.apps.BlogConfig',
]
MIDDLEWARE = [

View File

@ -25,6 +25,7 @@ from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('programmer.urls')),
path('blog/', include('blog.urls')),
# path('', index, name='home'),
# path('about/', about, name='about'),
# path('solution/', solution, name='solution'),

0
blog/__init__.py Normal file
View File

43
blog/admin.py Normal file
View File

@ -0,0 +1,43 @@
from django.contrib import admin
from .models import Category, Article, Comment
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'description')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',)
@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')
prepopulated_fields = {'slug': ('title',)}
raw_id_fields = ('author',) # удобно при большом количестве пользователей
date_hierarchy = 'time_create'
actions = ['make_published', 'make_unpublished']
def make_published(self, request, queryset):
queryset.update(is_published=True)
make_published.short_description = "Опубликовать выбранные статьи"
def make_unpublished(self, request, queryset):
queryset.update(is_published=False)
make_unpublished.short_description = "Снять с публикации"
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ('author_name', 'article', 'is_moderated', 'time_create')
list_filter = ('is_moderated', 'time_create')
search_fields = ('author_name', 'author_email', 'content')
actions = ['approve_comments', 'reject_comments']
def approve_comments(self, request, queryset):
queryset.update(is_moderated=True)
approve_comments.short_description = "Одобрить выбранные комментарии"
def reject_comments(self, request, queryset):
queryset.update(is_moderated=False)
reject_comments.short_description = "Отклонить выбранные комментарии"

6
blog/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'blog'

13
blog/forms.py Normal file
View File

@ -0,0 +1,13 @@
from django import forms
from .models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ['author_name', 'author_email', 'content']
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': 'Текст комментария'}),
}

View File

83
blog/models.py Normal file
View File

@ -0,0 +1,83 @@
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.contrib.auth import get_user_model
User = get_user_model()
class Category(models.Model):
"""Категория статей"""
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
slug = models.SlugField(max_length=120, unique=True, verbose_name="URL")
description = models.TextField(blank=True, verbose_name="Описание")
class Meta:
verbose_name = "Категория"
verbose_name_plural = "Категории"
def __str__(self):
return self.name
class Article(models.Model):
"""Статья блога"""
title = models.CharField(max_length=200, verbose_name="Заголовок")
slug = models.SlugField(max_length=220, unique=True, verbose_name="URL")
category = models.ForeignKey(
Category, on_delete=models.PROTECT, related_name="articles",
verbose_name="Категория"
)
author = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
verbose_name="Автор"
)
content = models.TextField(verbose_name="Содержание")
image = models.ImageField(
upload_to="blog/%Y/%m/%d/", blank=True, verbose_name="Изображение"
)
views_count = models.PositiveIntegerField(default=0, verbose_name="Просмотры")
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="Дата изменения")
class Meta:
verbose_name = "Статья"
verbose_name_plural = "Статьи"
ordering = ["-time_create"]
indexes = [
models.Index(fields=["-time_create", "is_published"]),
]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("blog:article_detail", kwargs={"slug": self.slug})
def get_seo_title(self):
return f"{self.title} | Блог программиста 1С"
def get_seo_description(self):
clean = self.content[:160].replace("\n", " ").strip()
return f"{clean}..."
class Comment(models.Model):
"""Комментарий к статье"""
article = models.ForeignKey(
Article, on_delete=models.CASCADE, related_name="comments",
verbose_name="Статья"
)
author_name = models.CharField(max_length=100, verbose_name="Имя")
author_email = models.EmailField(verbose_name="Email")
content = models.TextField(verbose_name="Комментарий")
is_moderated = models.BooleanField(default=False, verbose_name="Промодерировано")
time_create = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
verbose_name = "Комментарий"
verbose_name_plural = "Комментарии"
ordering = ["time_create"]
def __str__(self):
return f"{self.author_name} - {self.article.title}"

43
blog/services.py Normal file
View File

@ -0,0 +1,43 @@
from typing import Optional, List
from django.db.models import QuerySet
from django.core.cache import cache
from .models import Article, Category, Comment
def get_published_articles(category_slug: Optional[str] = None) -> QuerySet[Article]:
"""
Возвращает опубликованные статьи с возможностью фильтрации по категории.
Результат кэшируется на 5 минут.
"""
cache_key = f"articles_list_{category_slug or 'all'}"
articles = cache.get(cache_key)
if articles is None:
qs = Article.objects.filter(is_published=True).select_related('category')
if category_slug:
qs = qs.filter(category__slug=category_slug)
articles = qs.order_by('-time_create')
cache.set(cache_key, articles, 300) # 5 минут
return articles
def get_article_by_slug(slug: str) -> Optional[Article]:
"""Получение статьи по slug с учётом публикации."""
try:
return Article.objects.get(slug=slug, is_published=True)
except Article.DoesNotExist:
return None
def increment_article_views(article: Article) -> None:
"""Увеличивает счётчик просмотров статьи."""
article.views_count += 1
article.save(update_fields=['views_count'])
def add_comment_to_article(article: Article, data: dict) -> Comment:
"""Создание комментария к статье (без модерации)."""
comment = Comment.objects.create(
article=article,
author_name=data['author_name'],
author_email=data['author_email'],
content=data['content']
)
# Здесь можно отправить уведомление администратору
return comment

View File

@ -0,0 +1,45 @@
{% extends 'programmer/base.html' %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ article.title }}</h1>
<p class="page-subtitle">
Категория: <a href="{% url 'blog:category_detail' article.category.slug %}">{{ article.category.name }}</a> |
{{ article.time_create|date:"d.m.Y" }} |
Просмотров: {{ article.views_count }}
</p>
</div>
<div class="content-card">
{% if article.image %}
<img src="{{ article.image.url }}" alt="{{ article.title }}" style="max-width: 100%; margin-bottom: 1rem;">
{% endif %}
<div class="card-content">
{{ article.content|linebreaks }}
</div>
</div>
<!-- Комментарии -->
<h3>Комментарии</h3>
{% for comment in article.comments.all %}
<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 class="modern-card">
<h4>Добавить комментарий</h4>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Отправить</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends 'programmer/base.html' %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Блог программиста 1С</h1>
<p class="page-subtitle">Полезные статьи и заметки</p>
</div>
<div class="categories">
{% for cat in categories %}
<a href="{% url 'blog:category_detail' cat.slug %}" class="btn btn-outline">
{{ cat.name }}
</a>
{% endfor %}
</div>
<div class="grid grid-2">
{% 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>
<div class="meta">
<span>📅 {{ article.time_create|date:"d.m.Y" }}</span>
<span>👁 {{ article.views_count }}</span>
</div>
</div>
{% empty %}
<p>Статей пока нет.</p>
{% endfor %}
</div>
{% if page_obj.has_other_pages %}
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">&laquo; Первая</a>
<a href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
{% endif %}
<span class="current">
Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Следующая</a>
<a href="?page={{ page_obj.paginator.num_pages }}">Последняя &raquo;</a>
{% endif %}
</span>
</div>
{% endif %}
{% endblock %}

51
blog/tests.py Normal file
View File

@ -0,0 +1,51 @@
from django.test import TestCase
from django.urls import reverse
from .models import Article, Category
from .services import get_published_articles
class ArticleModelTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.category = Category.objects.create(name="Новости", slug="news")
cls.article = Article.objects.create(
title="Тест",
slug="test",
category=cls.category,
content="Контент"
)
def test_article_creation(self):
self.assertEqual(self.article.title, "Тест")
self.assertTrue(self.article.is_published)
def test_get_absolute_url(self):
url = self.article.get_absolute_url()
self.assertEqual(url, reverse('blog:article_detail', kwargs={'slug': 'test'}))
class ArticleListViewTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.category = Category.objects.create(name="Новости", slug="news")
for i in range(15):
Article.objects.create(
title=f"Статья {i}",
slug=f"statya-{i}",
category=cls.category,
content="Контент"
)
def test_view_url_exists(self):
resp = self.client.get('/blog/')
self.assertEqual(resp.status_code, 200)
def test_pagination_is_ten(self):
resp = self.client.get(reverse('blog:article_list'))
self.assertEqual(resp.status_code, 200)
self.assertTrue('is_paginated' in resp.context)
self.assertTrue(resp.context['is_paginated'])
self.assertEqual(len(resp.context['articles']), 10)
def test_service_returns_published(self):
articles = get_published_articles()
self.assertEqual(articles.count(), 15)

11
blog/urls.py Normal file
View File

@ -0,0 +1,11 @@
from django.urls import path
from . import views
app_name = 'blog'
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'),
]

74
blog/views.py Normal file
View File

@ -0,0 +1,74 @@
from django.views.generic import ListView, DetailView, CreateView
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 .services import (
get_published_articles, get_article_by_slug,
increment_article_views, add_comment_to_article
)
from .forms import CommentForm
from django.http import Http404
from django.shortcuts import redirect
class ArticleListView(ListView):
"""Список статей"""
model = Article
template_name = 'blog/article_list.html'
context_object_name = 'articles'
paginate_by = 10
def get_queryset(self):
category_slug = self.kwargs.get('category_slug')
return get_published_articles(category_slug)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['categories'] = Category.objects.all()
if 'category_slug' in self.kwargs:
context['current_category'] = Category.objects.filter(
slug=self.kwargs['category_slug']
).first()
return context
@method_decorator(cache_page(60 * 5)) # кэширование страницы на 5 минут
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
class ArticleDetailView(DetailView, CreateView):
"""Детальная страница статьи + форма комментария"""
model = Article
template_name = 'blog/article_detail.html'
context_object_name = 'article'
form_class = CommentForm
def get_object(self, queryset=None):
article = get_article_by_slug(self.kwargs['slug'])
if article is None:
raise Http404("Статья не найдена")
return article
def get(self, request, *args, **kwargs):
# Увеличиваем счётчик просмотров при GET запросе
self.object = self.get_object()
increment_article_views(self.object)
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
comment = add_comment_to_article(
article=self.object,
data=form.cleaned_data
)
messages.success(self.request, "Ваш комментарий отправлен на модерацию.")
return redirect(self.object.get_absolute_url())

View File

@ -5,6 +5,7 @@ from django.conf.urls.static import static
from .views import *
from django.contrib.sitemaps.views import sitemap
from .sitemaps import sitemaps
from blog import views
urlpatterns = [
@ -13,6 +14,7 @@ urlpatterns = [
path('solutions/', solution, name='solution'),
# path('competence/', ability, name='ability'),
path('recall/', recall, name='recall'),
path('blog/', views.ArticleListView.as_view(), name='blog'),
path('post/<int:post_id>/', show_post, name='post'),
path('callback/', callback_request, name='callback'),
path('admin/statistics/', statistics_view, name='statistics'),

View File

@ -17,6 +17,7 @@ menu = [
{'title': "Проекты", 'url_name': 'solution'},
# {'title': "Компетенции", 'url_name': 'ability'},
{'title': "Отзывы", 'url_name': 'recall'},
{'title': "Статьи", 'url_name': 'blog'},
{'title': "Обо мне", 'url_name': 'about'}
]