-
Notifications
You must be signed in to change notification settings - Fork 29
Feat/issue 117 backend blog page #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
67dcb69
646af8a
b20d209
6548e7a
3c923f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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") | ||
| search_fields = ("title",) | ||
| readonly_fields = ("created_at", "updated_at") | ||
|
|
||
| def get_tags(self, obj): | ||
| return ", ".join([tag.name for tag in obj.tags.all()]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tip
|
||
| 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") | ||
| 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' |
| 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', | ||
| }, | ||
| ), | ||
| ] |
| 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}" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # Create your tests here. |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Caution
|
||
| path("<int:pk>/", BlogDetailView.as_view(), name="blog_detail"), | ||
| ] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning в списке и деталке нет фильтра по |
| 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tip стоит добавить стабильную сортировку перед пагинацией ( |
||
| 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', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tip в списке отдаётся |
||
| '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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Warning
|
||
| 'tags': list(post.tags.values_list('name', flat=True)), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tip можно оптимизировать: |
||
| } | ||
| return inertia_render(request, "BlogPost", props={'post': post_data}) | ||
There was a problem hiding this comment.
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.