Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added app/services/blog/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions app/services/blog/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django.contrib import admin

from .models import BlogCategory, BlogPost, Tag


@admin.register(BlogPost)
class BlogPostAdmin(admin.ModelAdmin):
list_display = ("id", "title", "author", "category", "created_at",
"is_published")
list_filter = ("author", "category", "title", "tags")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

list_filter = ("author", "category", "title", "tags") — фильтрация по заголовку ("title") часто не очень полезна и может раздувать список значений; обычно title отправляем в search_fields.

search_fields = ("title",)
readonly_fields = ("created_at", "updated_at")

def get_tags(self, obj):
return ", ".join([tag.name for tag in obj.tags.all()])

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

get_tags сейчас делает obj.tags.all() и собирает имена. На списке админки это может дать проблему N+1. Опционально: переопределить get_queryset() и сделать prefetch_related('tags'), а в get_tags использовать values_list.

get_tags.short_description = "Tags"


@admin.register(BlogCategory)
class BlogCategoryAdmin(admin.ModelAdmin):
list_display = ("name", "created_at", "updated_at")
search_fields = ("name",)
list_filter = ("created_at", "updated_at")
readonly_fields = ("created_at", "updated_at")


@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ("name", "created_at", "updated_at")
search_fields = ("name",)
list_filter = ("created_at", "updated_at")
readonly_fields = ("created_at", "updated_at")
6 changes: 6 additions & 0 deletions app/services/blog/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BlogConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'app.services.blog'
64 changes: 64 additions & 0 deletions app/services/blog/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Generated by Django 5.2.7 on 2025-11-13 15:17

import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='BlogCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Name')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
],
options={
'verbose_name': 'Blog Category',
'verbose_name_plural': 'Blog Categories',
},
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Name')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
],
options={
'verbose_name': 'Blog Tag',
'verbose_name_plural': 'Blog Tags',
},
),
migrations.CreateModel(
name='BlogPost',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=150, unique=True, verbose_name='Title')),
('content_short', models.CharField(max_length=250, verbose_name='Short Content')),
('content_full', models.TextField(verbose_name='Full Content')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
('is_published', models.BooleanField(default=True, verbose_name='Published')),
('duration_minutes', models.PositiveIntegerField(help_text='Specify the number of minutes', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(60)], verbose_name='Duration (minutes)')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Author')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='post_categories', to='blog.blogcategory', verbose_name='Blogs_categories')),
('tags', models.ManyToManyField(blank=True, related_name='post_tags', to='blog.tag', verbose_name='Tags')),
],
options={
'verbose_name': 'Blog Post',
'verbose_name_plural': 'Blog Posts',
},
),
]
Empty file.
125 changes: 125 additions & 0 deletions app/services/blog/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _

User = get_user_model()


class BlogCategory(models.Model):
name = models.CharField(
max_length=100,
null=False,
unique=True,
verbose_name=_("Name")
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Created at"))
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_("Updated at"))

message_valid = _("Cannot delete category because it is in use.")

class Meta:
verbose_name = _("Blog Category")
verbose_name_plural = _("Blog Categories")

def __str__(self) -> str:
return f"{self.name}"

def can_delete(self):
return not self.post_categories.exists()

def delete(self, *args, **kwargs):
if not self.can_delete():
raise ValidationError(self.message_valid)
super().delete(*args, **kwargs)


class Tag(models.Model):
name = models.CharField(
max_length=100,
unique=True,
verbose_name=_("Name"))
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Created at"))
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_("Updated at"))
message_valid = _("Tag is in use and cannot be deleted.")

class Meta:
verbose_name = _("Blog Tag")
verbose_name_plural = _("Blog Tags")

def __str__(self):
return f"{self.name}"

def can_delete(self):
return not self.post_tags.exists()

def delete(self, *args, **kwargs):
if not self.can_delete():
raise ValidationError(self.message_valid)
super().delete(*args, **kwargs)


class BlogPost(models.Model):
title = models.CharField(
max_length=150,
unique=True,
verbose_name=_("Title")
)
content_short = models.CharField(
max_length=250,
verbose_name=_("Short Content")
)
content_full = models.TextField(verbose_name=_("Full Content"))
category = models.ForeignKey(
BlogCategory,
on_delete=models.PROTECT,
related_name="post_categories",
verbose_name=_("Blogs_categories")
)
tags = models.ManyToManyField(
Tag,
related_name="post_tags",
verbose_name=_("Tags"),
blank=True
)
author = models.ForeignKey(
User,
on_delete=models.PROTECT,
verbose_name=_("Author")
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Created at")
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name=_("Updated at")
)
is_published = models.BooleanField(
default=True,
verbose_name=_("Published")
)
duration_minutes = models.PositiveIntegerField(
verbose_name=_("Duration (minutes)"),
help_text=_("Specify the number of minutes"),
validators=[
MinValueValidator(1),
MaxValueValidator(60)
]
)

class Meta:
verbose_name = _("Blog Post")
verbose_name_plural = _("Blog Posts")

def __str__(self):
return f"{self.title}"
1 change: 1 addition & 0 deletions app/services/blog/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Create your tests here.
8 changes: 8 additions & 0 deletions app/services/blog/urls.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Блокирующий синтаксис → чинить до мёрджа

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from django.urls import path

from .views import BlogDetailView, BlogListView

urlpatterns = [
path("", BlogListView .as_view(), name="blog_list"),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

BlogListView .as_view() — пробел перед точкой делает выражение синтаксически невалидным в Python (будет SyntaxError). Нужно BlogListView.as_view().

path("<int:pk>/", BlogDetailView.as_view(), name="blog_detail"),
]
79 changes: 79 additions & 0 deletions app/services/blog/views.py

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

в списке и деталке нет фильтра по is_published а это значит, что потенциально можно показывать черновики и скрытые посты. Обычно делаем так: qs = qs.filter(is_published=True) и в detail: get_object_or_404(BlogPost, pk=pk, is_published=True).

Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from django.contrib.auth import get_user_model
from django.core.paginator import Paginator
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.views import View
from inertia import render as inertia_render

from .models import BlogCategory, BlogPost, Tag

User = get_user_model()


class BlogListView(View):
def get(self, request):
qs = BlogPost.objects.select_related(
'category', 'author').prefetch_related('tags')

category_id = request.GET.get('category')
if category_id:
qs = qs.filter(category_id=category_id)

q = request.GET.get('q')
if q:
qs = qs.filter(
Q(title__icontains=q) | Q(content_full__icontains=q)
)

paginator = Paginator(qs, 6)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

стоит добавить стабильную сортировку перед пагинацией (order_by('-created_at')), иначе порядок страниц может «прыгать».

page_number = request.GET.get('page')
page_obj = paginator.get_page(page_number)

posts = list(page_obj.object_list.values(
'id', 'title', 'content_short', 'content_full',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

в списке отдаётся content_full для каждого поста — это может быть жирно по трафику. Для списка обычно достаточно content_short, а content_full — только в детальном представлении.

'category__name', 'author__first_name', 'created_at',
'is_published', 'duration_minutes'
))

categories = list(BlogCategory.objects.values('id', 'name'))
users = list(User.objects.values('id', 'first_name'))
tags = list(Tag.objects.values('id', 'name'))

return inertia_render(
request,
"Blog",
props={
'posts': posts,
'categories': categories,
'users': users,
'tags': tags,
'pagination': {
'has_next': page_obj.has_next(),
'has_previous': page_obj.has_previous(),
'current_page': page_obj.number,
'num_pages': paginator.num_pages,
},
'filter': {
'category': category_id,
'q': q,
}
}
)


class BlogDetailView(View):
def get(self, request, pk):
post = get_object_or_404(BlogPost, pk=pk)
post_data = {
'id': post.id,
'title': post.title,
'content_short': post.content_short,
'content_full': post.content_full,
'created_at': post.created_at,
'is_published': post.is_published,
'duration_minutes': post.duration_minutes,
'category': post.category.name,
'author': post.author.email,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

BlogDetailView отдаётся post.author.email. Это утечка персональных данных (публичная страница блога не должна раздавать email). Лучше отдавать first_name/username/display_name.

'tags': list(post.tags.values_list('name', flat=True)),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

можно оптимизировать: select_related('category','author').prefetch_related('tags'), чтобы не ловить лишние запросы на связанных данных.

}
return inertia_render(request, "BlogPost", props={'post': post_data})
2 changes: 2 additions & 0 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_filters",
"inertia",
"app.services.hh.hh_parser",
"app.services.telegram.telegram_parser",
Expand All @@ -52,6 +53,7 @@
"app.services.account",
"django_vite",
"app.services.auth.tinkoff_id",
"app.services.blog",
]

AUTH_USER_MODEL = "users.User"
Expand Down
1 change: 1 addition & 0 deletions app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
path("telegram/", include("app.services.telegram.telegram_channels.urls")),
path("auth/", include("app.services.auth.users.urls")),
path("account/", include("app.services.account.urls")),
path("blog/", include("app.services.blog.urls")),
]

handler500 = views.custom_server_error
Expand Down