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 %} +
+ Категория: {{ article.category.name }} | + {{ article.time_create|date:"d.m.Y" }} | + Просмотров: {{ article.views_count }} +
+{{ comment.author_name }} ({{ comment.time_create|date:"d.m.Y H:i" }})
+{{ comment.content|linebreaks }}
+ {% if not comment.is_moderated %} +Комментарий ожидает модерации
+ {% endif %} +Пока нет комментариев. Будьте первым!
+{% endfor %} + + +Полезные статьи и заметки
+{{ article.content|truncatewords:30 }}
+ +Статей пока нет.
+ {% endfor %} +