diff --git a/OneCprogsite/settings.py b/OneCprogsite/settings.py index edbbfc3..642b4f8 100644 --- a/OneCprogsite/settings.py +++ b/OneCprogsite/settings.py @@ -92,6 +92,7 @@ INSTALLED_APPS = [ 'django_extensions', 'django.contrib.sites', 'django.contrib.sitemaps', + 'blog.apps.BlogConfig', ] MIDDLEWARE = [ diff --git a/OneCprogsite/urls.py b/OneCprogsite/urls.py index 4258005..2df9365 100644 --- a/OneCprogsite/urls.py +++ b/OneCprogsite/urls.py @@ -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'), diff --git a/blog/__init__.py b/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/admin.py b/blog/admin.py new file mode 100644 index 0000000..0629a46 --- /dev/null +++ b/blog/admin.py @@ -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 = "Отклонить выбранные комментарии" + + diff --git a/blog/apps.py b/blog/apps.py new file mode 100644 index 0000000..94788a5 --- /dev/null +++ b/blog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'blog' diff --git a/blog/forms.py b/blog/forms.py new file mode 100644 index 0000000..c50443c --- /dev/null +++ b/blog/forms.py @@ -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': 'Текст комментария'}), + } + diff --git a/blog/migrations/__init__.py b/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/models.py b/blog/models.py new file mode 100644 index 0000000..1a2a4c0 --- /dev/null +++ b/blog/models.py @@ -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}" + + \ No newline at end of file diff --git a/blog/services.py b/blog/services.py new file mode 100644 index 0000000..d781a84 --- /dev/null +++ b/blog/services.py @@ -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 + diff --git a/blog/templates/blog/article_detail.html b/blog/templates/blog/article_detail.html new file mode 100644 index 0000000..e635a51 --- /dev/null +++ b/blog/templates/blog/article_detail.html @@ -0,0 +1,45 @@ +{% extends 'programmer/base.html' %} + +{% block content %} + + +
+ {% if article.image %} + {{ article.title }} + {% endif %} +
+ {{ article.content|linebreaks }} +
+
+ + +

Комментарии

+{% for comment in article.comments.all %} +
+

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

+

{{ comment.content|linebreaks }}

+ {% if not comment.is_moderated %} +

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

+ {% endif %} +
+{% empty %} +

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

+{% endfor %} + + +
+

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

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+{% endblock %} \ No newline at end of file diff --git a/blog/templates/blog/article_list.html b/blog/templates/blog/article_list.html new file mode 100644 index 0000000..cfe712c --- /dev/null +++ b/blog/templates/blog/article_list.html @@ -0,0 +1,50 @@ +{% extends 'programmer/base.html' %} +{% block content %} + + +
+ {% for cat in categories %} + + {{ cat.name }} + + {% endfor %} +
+ +
+ {% for article in articles %} +
+

{{ article.title }}

+

{{ article.content|truncatewords:30 }}

+
+ 📅 {{ article.time_create|date:"d.m.Y" }} + 👁 {{ article.views_count }} +
+
+ {% empty %} +

Статей пока нет.

+ {% endfor %} +
+ +{% if page_obj.has_other_pages %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/blog/tests.py b/blog/tests.py new file mode 100644 index 0000000..7c4cfda --- /dev/null +++ b/blog/tests.py @@ -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) + diff --git a/blog/urls.py b/blog/urls.py new file mode 100644 index 0000000..036363f --- /dev/null +++ b/blog/urls.py @@ -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//', views.ArticleListView.as_view(), name='category_detail'), + path('/', views.ArticleDetailView.as_view(), name='article_detail'), +] + diff --git a/blog/views.py b/blog/views.py new file mode 100644 index 0000000..65aa864 --- /dev/null +++ b/blog/views.py @@ -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()) + + diff --git a/programmer/urls.py b/programmer/urls.py index 7c4dd8d..541f3b8 100644 --- a/programmer/urls.py +++ b/programmer/urls.py @@ -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//', show_post, name='post'), path('callback/', callback_request, name='callback'), path('admin/statistics/', statistics_view, name='statistics'), diff --git a/programmer/views.py b/programmer/views.py index 8bc4f0a..547f693 100644 --- a/programmer/views.py +++ b/programmer/views.py @@ -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'} ]