Добавил новый раздел "Блог"
This commit is contained in:
parent
a2c25ccec6
commit
2cbc32d5b2
@ -92,6 +92,7 @@ INSTALLED_APPS = [
|
||||
'django_extensions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.sitemaps',
|
||||
'blog.apps.BlogConfig',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@ -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
0
blog/__init__.py
Normal file
43
blog/admin.py
Normal file
43
blog/admin.py
Normal 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
6
blog/apps.py
Normal 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
13
blog/forms.py
Normal 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': 'Текст комментария'}),
|
||||
}
|
||||
|
||||
0
blog/migrations/__init__.py
Normal file
0
blog/migrations/__init__.py
Normal file
83
blog/models.py
Normal file
83
blog/models.py
Normal 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
43
blog/services.py
Normal 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
|
||||
|
||||
45
blog/templates/blog/article_detail.html
Normal file
45
blog/templates/blog/article_detail.html
Normal 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 %}
|
||||
50
blog/templates/blog/article_list.html
Normal file
50
blog/templates/blog/article_list.html
Normal 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">« Первая</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 }}">Последняя »</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
51
blog/tests.py
Normal file
51
blog/tests.py
Normal 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
11
blog/urls.py
Normal 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
74
blog/views.py
Normal 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())
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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'}
|
||||
]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user