feat(blog): expand publishing and moderation APIs

This commit is contained in:
2026-06-11 21:20:44 +03:30
parent 4039be0187
commit 5045f8da47
7 changed files with 600 additions and 42 deletions

View File

@@ -4,22 +4,22 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from simplemde.widgets import SimpleMDEEditor from simplemde.widgets import SimpleMDEEditor
from apps.blog.models import Category, Tag, Post, PostAsset, Comment, Like, SavedPost from apps.blog.models import BlogBanner, Category, Tag, Post, PostAsset, Comment, Like, SavedPost
from apps.blog.resources import PostResource, CategoryResource from apps.blog.resources import PostResource, CategoryResource
from core.admin import SoftDeleteListFilter, BaseModelAdmin from core.admin import SoftDeleteListFilter, BaseModelAdmin
@admin.register(Category) @admin.register(Category)
class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin): class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = CategoryResource resource_class = CategoryResource
list_display = ('name', 'slug', 'created_at', 'is_deleted') list_display = ('name', 'parent', 'slug', 'created_at', 'is_deleted')
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter) list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
search_fields = ('name', 'description') search_fields = ('name', 'description', 'parent__name')
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at', 'deleted_at') readonly_fields = ('created_at', 'updated_at', 'deleted_at')
fieldsets = ( fieldsets = (
('Content', { ('Content', {
'fields': ('name', 'slug', 'description') 'fields': ('name', 'parent', 'slug', 'description')
}), }),
('Metadata', { ('Metadata', {
'fields': ('created_at', 'updated_at') 'fields': ('created_at', 'updated_at')
@@ -76,7 +76,7 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter) list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter)
search_fields = ('title', 'content', 'author__username') search_fields = ('title', 'content', 'author__username')
prepopulated_fields = {'slug': ('title',)} prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('tags',) filter_horizontal = ('tags', 'writers')
date_hierarchy = 'published_at' date_hierarchy = 'published_at'
fieldsets = ( fieldsets = (
@@ -87,7 +87,7 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
'fields': ('seo_title', 'seo_description', 'canonical_url', 'og_title', 'og_description', 'og_image', 'noindex', 'focus_keyword', 'reading_time') 'fields': ('seo_title', 'seo_description', 'canonical_url', 'og_title', 'og_description', 'og_image', 'noindex', 'focus_keyword', 'reading_time')
}), }),
('Metadata', { ('Metadata', {
'fields': ('author', 'category', 'tags', 'status', 'is_featured', 'submitted_at', 'reviewed_at', 'reviewed_by', 'review_note', 'published_at', 'published_by') 'fields': ('author', 'writers', 'category', 'tags', 'status', 'is_featured', 'submitted_at', 'reviewed_at', 'reviewed_by', 'review_note', 'published_at', 'published_by')
}), }),
('Soft Delete', { ('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'), 'fields': ('is_deleted', 'deleted_at'),
@@ -132,8 +132,8 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
@admin.register(Comment) @admin.register(Comment)
class CommentAdmin(BaseModelAdmin): class CommentAdmin(BaseModelAdmin):
list_display = ('author', 'post', 'content_preview', 'is_approved', 'created_at') list_display = ('author', 'post', 'content_preview', 'is_approved', 'is_hidden', 'is_deleted', 'created_at')
list_filter = ('is_approved', 'created_at', 'post', SoftDeleteListFilter) list_filter = ('is_approved', 'is_hidden', 'created_at', 'post', SoftDeleteListFilter)
search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title') search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title')
readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at') readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at')
@@ -142,28 +142,35 @@ class CommentAdmin(BaseModelAdmin):
'fields': ('post', 'author', 'content') 'fields': ('post', 'author', 'content')
}), }),
('Metadata', { ('Metadata', {
'fields': ('is_approved', 'hidden_by', 'hidden_at', 'moderation_note', 'created_at', 'updated_at') 'fields': ('is_approved', 'is_hidden', 'hidden_by', 'hidden_at', 'moderation_note', 'created_at', 'updated_at')
}), }),
('Soft Delete', { ('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'), 'fields': ('is_deleted', 'deleted_at', 'deleted_by', 'delete_note'),
'classes': ('collapse',) 'classes': ('collapse',)
}) })
) )
actions = BaseModelAdmin.actions + ['approve_comments', 'disapprove_comments'] actions = BaseModelAdmin.actions + ['approve_comments', 'hide_comments', 'unhide_comments']
def content_preview(self, obj): def content_preview(self, obj):
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
content_preview.short_description = 'Content Preview' content_preview.short_description = 'Content Preview'
def approve_comments(self, request, queryset): def approve_comments(self, request, queryset):
queryset.update(is_approved=True) queryset.update(is_approved=True, is_hidden=False, hidden_by=None, hidden_at=None, moderation_note='')
self.message_user(request, f"Approved {queryset.count()} comments.") self.message_user(request, f"Approved {queryset.count()} comments.")
approve_comments.short_description = "Approve selected comments" approve_comments.short_description = "Approve selected comments"
def disapprove_comments(self, request, queryset): def hide_comments(self, request, queryset):
queryset.update(is_approved=False) for comment in queryset:
self.message_user(request, f"Disapproved {queryset.count()} comments.") comment.hide(request.user)
disapprove_comments.short_description = "Disapprove selected comments" self.message_user(request, f"Hidden {queryset.count()} comments.")
hide_comments.short_description = "Hide selected comments"
def unhide_comments(self, request, queryset):
for comment in queryset:
comment.unhide()
self.message_user(request, f"Restored {queryset.count()} comments.")
unhide_comments.short_description = "Unhide selected comments"
@admin.register(Like) @admin.register(Like)
class LikeAdmin(admin.ModelAdmin): class LikeAdmin(admin.ModelAdmin):
@@ -185,3 +192,24 @@ class PostAssetAdmin(BaseModelAdmin):
list_filter = ('file_type', 'mime_type', 'created_at') list_filter = ('file_type', 'mime_type', 'created_at')
search_fields = ('title', 'caption', 'alt_text', 'post__title', 'uploaded_by__username') search_fields = ('title', 'caption', 'alt_text', 'post__title', 'uploaded_by__username')
readonly_fields = ('size', 'mime_type', 'created_at', 'updated_at', 'deleted_at') readonly_fields = ('size', 'mime_type', 'created_at', 'updated_at', 'deleted_at')
@admin.register(BlogBanner)
class BlogBannerAdmin(BaseModelAdmin):
list_display = ('title', 'url', 'is_active', 'sort_order', 'created_at', 'is_deleted')
list_filter = ('is_active', 'created_at', 'is_deleted', SoftDeleteListFilter)
search_fields = ('title', 'alt_text', 'url')
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
fieldsets = (
('Banner', {
'fields': ('title', 'alt_text', 'image', 'url', 'is_active', 'sort_order')
}),
('Metadata', {
'fields': ('created_at', 'updated_at')
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('collapse',)
}),
)

View File

@@ -5,17 +5,37 @@ from typing import List, Optional
from ninja import ModelSchema, Schema from ninja import ModelSchema, Schema
from apps.blog.models import Category, Comment, PostAsset, Tag from apps.blog.models import BlogBanner, Category, Comment, PostAsset, Tag
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
class CategorySchema(ModelSchema): class CategorySchema(ModelSchema):
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
parent_id: Optional[int] = None
class Config: class Config:
model = Category model = Category
model_fields = ["id", "name", "slug", "description", "created_at"] model_fields = ["id", "name", "slug", "description", "created_at"]
@staticmethod
def resolve_parent_id(obj):
return obj.parent_id
class CategoryPathSchema(Schema):
id: int
name: str
slug: str
class CategoryFilterSchema(Schema):
id: int
name: str
slug: str
parent_id: Optional[int] = None
post_count: int = 0
children: List["CategoryFilterSchema"] = []
class TagSchema(ModelSchema): class TagSchema(ModelSchema):
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
@@ -25,11 +45,19 @@ class TagSchema(ModelSchema):
model_fields = ["id", "name", "slug", "created_at"] model_fields = ["id", "name", "slug", "created_at"]
class TagFilterSchema(Schema):
id: int
name: str
slug: str
post_count: int = 0
class AuthorSchema(Schema): class AuthorSchema(Schema):
id: int id: int
username: str username: str
first_name: str first_name: str
last_name: str last_name: str
bio: Optional[str] = None
profile_picture: Optional[str] = None profile_picture: Optional[str] = None
profile_picture_thumbnail_url: Optional[str] = None profile_picture_thumbnail_url: Optional[str] = None
profile_picture_preview_url: Optional[str] = None profile_picture_preview_url: Optional[str] = None
@@ -124,6 +152,8 @@ class PostListSchema(Schema):
status: str status: str
published_at: Optional[datetime] = None published_at: Optional[datetime] = None
category: Optional[CategorySchema] = None category: Optional[CategorySchema] = None
category_path: List[CategoryPathSchema] = []
writers: List[AuthorSchema] = []
tags: List[TagSchema] tags: List[TagSchema]
is_featured: bool is_featured: bool
created_at: datetime created_at: datetime
@@ -159,6 +189,17 @@ class PostListSchema(Schema):
url = derivative_url(obj.featured_image, PREVIEW_VARIANT) url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
return request.build_absolute_uri(url) if url else None return request.build_absolute_uri(url) if url else None
@staticmethod
def resolve_category_path(obj):
if not obj.category_id:
return []
return obj.category.path
@staticmethod
def resolve_writers(obj):
writers = list(obj.writers.all())
return writers or [obj.author]
@staticmethod @staticmethod
def resolve_likes_count(obj): def resolve_likes_count(obj):
return getattr(obj, "likes_count", None) or obj.likes.count() return getattr(obj, "likes_count", None) or obj.likes.count()
@@ -169,7 +210,11 @@ class PostListSchema(Schema):
@staticmethod @staticmethod
def resolve_comments_count(obj): def resolve_comments_count(obj):
return getattr(obj, "comments_count", None) or obj.comments.filter(is_approved=True).count() return getattr(obj, "comments_count", None) or obj.comments.filter(
is_approved=True,
is_hidden=False,
is_deleted=False,
).count()
class PostDetailSchema(PostListSchema): class PostDetailSchema(PostListSchema):
@@ -192,6 +237,7 @@ class PostCreateSchema(Schema):
excerpt: Optional[str] = None excerpt: Optional[str] = None
category_id: Optional[int] = None category_id: Optional[int] = None
tag_ids: Optional[List[int]] = [] tag_ids: Optional[List[int]] = []
writer_ids: Optional[List[int]] = None
status: str = "draft" status: str = "draft"
is_featured: bool = False is_featured: bool = False
seo_title: Optional[str] = "" seo_title: Optional[str] = ""
@@ -221,10 +267,14 @@ class CommentSchema(ModelSchema):
post_title: str post_title: str
post_slug: str post_slug: str
parent_id: Optional[int] = None parent_id: Optional[int] = None
is_hidden: bool = False
is_deleted: bool = False
deleted_at: Optional[datetime] = None
hidden_replies_count: int = 0
class Config: class Config:
model = Comment model = Comment
model_fields = ["id", "content", "created_at", "is_approved", "hidden_at"] model_fields = ["id", "content", "created_at", "updated_at", "is_approved", "hidden_at"]
@staticmethod @staticmethod
def resolve_post_id(obj): def resolve_post_id(obj):
@@ -242,12 +292,22 @@ class CommentSchema(ModelSchema):
def resolve_parent_id(obj): def resolve_parent_id(obj):
return obj.parent_id return obj.parent_id
@staticmethod
def resolve_hidden_replies_count(obj):
if not getattr(obj, "replies", None):
return 0
return sum(len(reply.replies.all()) for reply in obj.replies.all())
class CommentCreateSchema(Schema): class CommentCreateSchema(Schema):
content: str content: str
parent_id: Optional[int] = None parent_id: Optional[int] = None
class CommentUpdateSchema(Schema):
content: str
class CommentHideSchema(Schema): class CommentHideSchema(Schema):
note: Optional[str] = "" note: Optional[str] = ""
@@ -265,3 +325,30 @@ class BlogProfileActivitySchema(Schema):
saved_posts: List[PostListSchema] saved_posts: List[PostListSchema]
comments: List[CommentSchema] comments: List[CommentSchema]
replies: List[CommentSchema] replies: List[CommentSchema]
class BlogBannerSchema(ModelSchema):
image_url: str
class Config:
model = BlogBanner
model_fields = ["id", "title", "alt_text", "url", "sort_order"]
@staticmethod
def resolve_image_url(obj, context):
request = context["request"]
return request.build_absolute_uri(obj.image.url) if obj.image else ""
class BlogFilterAuthorSchema(Schema):
id: int
username: str
first_name: str
last_name: str
post_count: int = 0
class BlogFiltersSchema(Schema):
categories: List[CategoryFilterSchema]
tags: List[TagFilterSchema]
authors: List[BlogFilterAuthorSchema]

View File

@@ -5,18 +5,23 @@ from pathlib import Path
from typing import List, Optional from typing import List, Optional
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Count, Prefetch, Q from django.db.models import Count, Prefetch, Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from ninja import File, Form, Query, Router, UploadedFile from ninja import File, Form, Query, Router, UploadedFile
from apps.blog.api.schemas import ( from apps.blog.api.schemas import (
BlogBannerSchema,
BlogFiltersSchema,
BlogInteractionSchema, BlogInteractionSchema,
BlogProfileActivitySchema, BlogProfileActivitySchema,
CategorySchema, CategorySchema,
CommentCreateSchema, CommentCreateSchema,
CommentHideSchema, CommentHideSchema,
CommentSchema, CommentSchema,
CommentUpdateSchema,
AuthorSchema,
PostAssetCreateSchema, PostAssetCreateSchema,
PostAssetSchema, PostAssetSchema,
PostCreateSchema, PostCreateSchema,
@@ -25,7 +30,7 @@ from apps.blog.api.schemas import (
PostReviewSchema, PostReviewSchema,
TagSchema, TagSchema,
) )
from apps.blog.models import Category, Comment, Like, Post, PostAsset, SavedPost, Tag from apps.blog.models import BlogBanner, Category, Comment, Like, Post, PostAsset, SavedPost, Tag
from apps.blog.permissions import ( from apps.blog.permissions import (
can_access_blog_admin, can_access_blog_admin,
can_edit_post, can_edit_post,
@@ -39,6 +44,7 @@ from core.authentication import jwt_auth
blog_router = Router() blog_router = Router()
User = get_user_model()
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"} IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"} VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"}
@@ -49,11 +55,15 @@ ARCHIVE_EXTENSIONS = {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"}
def _post_queryset(): def _post_queryset():
return ( return (
Post.objects.select_related("author", "category", "reviewed_by", "published_by") Post.objects.select_related("author", "category", "reviewed_by", "published_by")
.prefetch_related("tags", "assets__uploaded_by") .prefetch_related("tags", "writers", "assets__uploaded_by")
.annotate( .annotate(
likes_count=Count("likes", distinct=True), likes_count=Count("likes", distinct=True),
saves_count=Count("saves", distinct=True), saves_count=Count("saves", distinct=True),
comments_count=Count("comments", filter=Q(comments__is_approved=True), distinct=True), comments_count=Count(
"comments",
filter=Q(comments__is_approved=True, comments__is_hidden=False, comments__is_deleted=False),
distinct=True,
),
) )
) )
@@ -62,6 +72,88 @@ def _published_queryset():
return _post_queryset().filter(status=Post.StatusChoices.PUBLISHED) return _post_queryset().filter(status=Post.StatusChoices.PUBLISHED)
def _optional_auth_user(request):
auth_header = request.headers.get("Authorization", "")
if not auth_header.lower().startswith("bearer "):
return None
token = auth_header.split(" ", 1)[1].strip()
return jwt_auth.authenticate(request, token)
def _query_values(request, key: str, fallback: Optional[str] = None) -> list[str]:
values = request.GET.getlist(key)
if fallback and fallback not in values:
values.append(fallback)
cleaned: list[str] = []
for value in values:
for item in str(value).split(","):
item = item.strip()
if item and item not in cleaned:
cleaned.append(item)
return cleaned
def _category_and_descendant_ids(slug: str) -> list[int]:
categories = list(Category.objects.values("id", "parent_id", "slug"))
target = next((category for category in categories if category["slug"] == slug), None)
if not target:
return []
children_by_parent: dict[int, list[int]] = {}
for category in categories:
parent_id = category["parent_id"]
if parent_id:
children_by_parent.setdefault(parent_id, []).append(category["id"])
selected = [target["id"]]
pending = [target["id"]]
while pending:
next_pending: list[int] = []
for parent_id in pending:
next_pending.extend(children_by_parent.get(parent_id, []))
selected.extend(next_pending)
pending = next_pending
return selected
def _comment_visibility_filter(user=None) -> Q:
if user and can_moderate_blog_comments(user):
return Q(is_deleted=False) & (Q(is_approved=True, is_hidden=False) | Q(is_hidden=True))
return Q(is_deleted=False, is_approved=True, is_hidden=False)
def _build_category_filter_tree():
categories = list(
Category.objects.annotate(
post_count=Count(
"posts",
filter=Q(posts__status=Post.StatusChoices.PUBLISHED, posts__is_deleted=False),
distinct=True,
)
).order_by("name")
)
nodes = {
category.id: {
"id": category.id,
"name": category.name,
"slug": category.slug,
"parent_id": category.parent_id,
"post_count": category.post_count,
"children": [],
}
for category in categories
}
roots = []
for category in categories:
node = nodes[category.id]
if category.parent_id and category.parent_id in nodes:
nodes[category.parent_id]["children"].append(node)
else:
roots.append(node)
return roots
def _asset_file_type(file: UploadedFile) -> str: def _asset_file_type(file: UploadedFile) -> str:
suffix = Path(file.name).suffix.lower() suffix = Path(file.name).suffix.lower()
content_type = (file.content_type or mimetypes.guess_type(file.name)[0] or "").lower() content_type = (file.content_type or mimetypes.guess_type(file.name)[0] or "").lower()
@@ -109,6 +201,7 @@ def _validate_featured_image(file: UploadedFile) -> str | None:
def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_status: bool = False) -> Post: def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_status: bool = False) -> Post:
payload = data.dict(exclude_unset=True) payload = data.dict(exclude_unset=True)
tag_ids = payload.pop("tag_ids", None) tag_ids = payload.pop("tag_ids", None)
writer_ids = payload.pop("writer_ids", None)
category_id = payload.pop("category_id", None) category_id = payload.pop("category_id", None)
requested_status = payload.pop("status", None) requested_status = payload.pop("status", None)
@@ -126,6 +219,14 @@ def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_statu
post.save() post.save()
if tag_ids is not None: if tag_ids is not None:
post.tags.set(tag_ids) post.tags.set(tag_ids)
if writer_ids is not None:
if user.is_superuser or user.is_staff or can_review_blog_posts(user):
writers = list(post.writers.model.objects.filter(id__in=writer_ids, is_active=True))
post.writers.set(writers or [post.author])
else:
post.writers.set([user])
elif not post.writers.exists():
post.writers.set([post.author])
return post return post
@@ -135,7 +236,24 @@ def _interaction_payload(post: Post, user) -> BlogInteractionSchema:
saved=SavedPost.objects.filter(post=post, user=user).exists(), saved=SavedPost.objects.filter(post=post, user=user).exists(),
likes_count=post.likes.count(), likes_count=post.likes.count(),
saves_count=post.saves.count(), saves_count=post.saves.count(),
comments_count=post.comments.filter(is_approved=True).count(), comments_count=post.comments.filter(is_approved=True, is_hidden=False, is_deleted=False).count(),
)
@blog_router.get("/admin/writers", response={200: List[AuthorSchema], 403: ErrorSchema}, auth=jwt_auth)
def list_blog_writers(request):
if not (request.auth.is_superuser or request.auth.is_staff or can_review_blog_posts(request.auth)):
return 403, {"error": "Permission denied"}
return (
User.objects.filter(is_active=True)
.filter(
Q(is_staff=True)
| Q(is_superuser=True)
| Q(groups__permissions__content_type__app_label="blog", groups__permissions__codename__in=["add_post", "change_post"])
| Q(user_permissions__content_type__app_label="blog", user_permissions__codename__in=["add_post", "change_post"])
)
.distinct()
.order_by("first_name", "last_name", "username")
) )
@@ -331,6 +449,54 @@ def my_blog_activity(request):
) )
@blog_router.get("/banners", response=List[BlogBannerSchema])
def list_banners(request):
return BlogBanner.objects.filter(is_active=True, is_deleted=False).order_by("sort_order", "-created_at")
@blog_router.get("/filters", response=BlogFiltersSchema)
def blog_filters(request):
tag_rows = (
Tag.objects.annotate(
post_count=Count(
"posts",
filter=Q(posts__status=Post.StatusChoices.PUBLISHED, posts__is_deleted=False),
distinct=True,
)
)
.filter(post_count__gt=0)
.order_by("name")
)
author_post_ids: dict[int, set[int]] = {}
published_posts = Post.objects.filter(status=Post.StatusChoices.PUBLISHED, is_deleted=False)
for row in published_posts.values("id", "author_id"):
author_post_ids.setdefault(row["author_id"], set()).add(row["id"])
for row in Post.writers.through.objects.filter(post__status=Post.StatusChoices.PUBLISHED, post__is_deleted=False).values("post_id", "user_id"):
author_post_ids.setdefault(row["user_id"], set()).add(row["post_id"])
users = User.objects.filter(id__in=author_post_ids.keys(), is_active=True).order_by("first_name", "last_name", "username")
authors = [
{
"id": user.id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"post_count": len(author_post_ids.get(user.id, set())),
}
for user in users
]
return {
"categories": _build_category_filter_tree(),
"tags": [
{"id": tag.id, "name": tag.name, "slug": tag.slug, "post_count": tag.post_count}
for tag in tag_rows
],
"authors": authors,
}
@blog_router.get("/posts", response=List[PostListSchema]) @blog_router.get("/posts", response=List[PostListSchema])
def list_posts( def list_posts(
request, request,
@@ -344,17 +510,20 @@ def list_posts(
): ):
queryset = _published_queryset() queryset = _published_queryset()
if category: if category:
queryset = queryset.filter(category__slug=category) category_ids = _category_and_descendant_ids(category)
if tag: queryset = queryset.filter(category_id__in=category_ids) if category_ids else queryset.none()
queryset = queryset.filter(tags__slug=tag) tags = _query_values(request, "tag", tag)
if tags:
queryset = queryset.filter(tags__slug__in=tags)
if search: if search:
queryset = queryset.filter(Q(title__icontains=search) | Q(content__icontains=search) | Q(excerpt__icontains=search)) queryset = queryset.filter(Q(title__icontains=search) | Q(content__icontains=search) | Q(excerpt__icontains=search))
if featured is not None: if featured is not None:
queryset = queryset.filter(is_featured=featured) queryset = queryset.filter(is_featured=featured)
if author: authors = _query_values(request, "author", author)
queryset = queryset.filter(author__username=author) if authors:
queryset = queryset.filter(Q(author__username__in=authors) | Q(writers__username__in=authors))
offset = (page - 1) * limit offset = (page - 1) * limit
return list(queryset[offset : offset + limit]) return list(queryset.distinct()[offset : offset + limit])
@blog_router.get("/posts/{slug}/recommended", response=List[PostListSchema]) @blog_router.get("/posts/{slug}/recommended", response=List[PostListSchema])
@@ -426,8 +595,19 @@ def delete_post(request, slug: str):
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema]) @blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
def list_comments(request, slug: str): def list_comments(request, slug: str):
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
comments = Comment.objects.filter(post=post, is_approved=True, parent=None).select_related("author", "post").prefetch_related( user = _optional_auth_user(request)
Prefetch("replies", queryset=Comment.objects.filter(is_approved=True).select_related("author", "post")) visibility = _comment_visibility_filter(user)
replies = (
Comment.objects.filter(visibility)
.select_related("author", "post")
.prefetch_related(Prefetch("replies", queryset=Comment.objects.filter(visibility).select_related("author", "post").order_by("-created_at")))
.order_by("-created_at")
)
comments = (
Comment.objects.filter(visibility, post=post, parent=None)
.select_related("author", "post")
.prefetch_related(Prefetch("replies", queryset=replies))
.order_by("-created_at")
) )
return list(comments) return list(comments)
@@ -437,7 +617,7 @@ def create_comment(request, slug: str, data: CommentCreateSchema):
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
parent = None parent = None
if data.parent_id: if data.parent_id:
parent = get_object_or_404(Comment, id=data.parent_id, post=post, is_approved=True) parent = get_object_or_404(Comment, id=data.parent_id, post=post, is_approved=True, is_hidden=False, is_deleted=False)
comment = Comment.objects.create(post=post, author=request.auth, content=data.content, parent=parent) comment = Comment.objects.create(post=post, author=request.auth, content=data.content, parent=parent)
return 201, comment return 201, comment
@@ -451,6 +631,42 @@ def hide_comment(request, comment_id: int, data: CommentHideSchema):
return 200, {"message": "Comment hidden successfully"} return 200, {"message": "Comment hidden successfully"}
@blog_router.post("/comments/{comment_id}/unhide", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
def unhide_comment(request, comment_id: int):
if not can_moderate_blog_comments(request.auth):
return 403, {"error": "Permission denied"}
comment = get_object_or_404(Comment, id=comment_id)
comment.unhide()
return 200, {"message": "Comment restored successfully"}
@blog_router.post("/comments/{comment_id}/delete", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
def soft_delete_comment_tree(request, comment_id: int, data: CommentHideSchema):
if not can_moderate_blog_comments(request.auth):
return 403, {"error": "Permission denied"}
comment = get_object_or_404(Comment.all_objects, id=comment_id, is_deleted=False)
deleted_count = 1 + len(comment.descendant_ids())
comment.soft_delete_tree(request.auth, data.note or "")
return 200, {"message": f"{deleted_count} comment(s) deleted successfully"}
@blog_router.put("/comments/{comment_id}", response={200: CommentSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
def update_comment(request, comment_id: int, data: CommentUpdateSchema):
comment = get_object_or_404(Comment.objects.select_related("author", "post"), id=comment_id)
if comment.author_id != request.auth.id:
return 403, {"error": "Permission denied"}
if comment.is_deleted or comment.is_hidden or not comment.is_approved or comment.hidden_at:
return 403, {"error": "Hidden comments cannot be edited"}
content = data.content.strip()
if not content:
return 400, {"error": "Comment content is required"}
comment.content = content
comment.save(update_fields=["content", "updated_at"])
return 200, comment
@blog_router.post("/posts/{slug}/like", response={200: BlogInteractionSchema}, auth=jwt_auth) @blog_router.post("/posts/{slug}/like", response={200: BlogInteractionSchema}, auth=jwt_auth)
def toggle_like(request, slug: str): def toggle_like(request, slug: str):
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)

View File

@@ -0,0 +1,69 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import apps.blog.models
def backfill_post_writers(apps, schema_editor):
Post = apps.get_model("blog", "Post")
for post in Post.objects.exclude(author_id__isnull=True).iterator():
post.writers.add(post.author_id)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("blog", "0003_blog_platform"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="BlogBanner",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("is_deleted", models.BooleanField(default=False)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
("title", models.CharField(blank=True, max_length=160)),
("alt_text", models.CharField(blank=True, max_length=200)),
("image", models.ImageField(upload_to=apps.blog.models.blog_banner_upload_to)),
("url", models.URLField()),
("is_active", models.BooleanField(default=True)),
("sort_order", models.PositiveIntegerField(default=0)),
],
options={
"ordering": ["sort_order", "-created_at"],
},
),
migrations.AddField(
model_name="category",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="children",
to="blog.category",
),
),
migrations.AddField(
model_name="post",
name="writers",
field=models.ManyToManyField(
blank=True,
related_name="written_blog_posts",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddIndex(
model_name="blogbanner",
index=models.Index(fields=["is_active", "sort_order"], name="blog_blogba_is_acti_c11b3c_idx"),
),
migrations.RunPython(backfill_post_writers, noop_reverse),
]

View File

@@ -0,0 +1,58 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def mark_legacy_hidden_comments(apps, schema_editor):
Comment = apps.get_model("blog", "Comment")
Comment.objects.filter(is_approved=False, is_deleted=False).update(is_hidden=True)
def unmark_legacy_hidden_comments(apps, schema_editor):
Comment = apps.get_model("blog", "Comment")
Comment.objects.filter(is_hidden=True, is_deleted=False).update(is_hidden=False)
class Migration(migrations.Migration):
dependencies = [
("blog", "0004_blog_banner_nested_categories_post_writers"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="comment",
name="delete_note",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="comment",
name="deleted_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="deleted_blog_comments",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="comment",
name="is_hidden",
field=models.BooleanField(default=False),
),
migrations.RunPython(mark_legacy_hidden_comments, unmark_legacy_hidden_comments),
migrations.RemoveIndex(
model_name="comment",
name="blog_commen_post_id_7710b1_idx",
),
migrations.AddIndex(
model_name="comment",
index=models.Index(fields=["post", "is_approved", "is_hidden"], name="blog_commen_post_id_760827_idx"),
),
migrations.AddIndex(
model_name="comment",
index=models.Index(fields=["parent", "is_deleted", "is_hidden"], name="blog_commen_parent__2abfc7_idx"),
),
]

View File

@@ -54,10 +54,22 @@ def post_asset_upload_to(instance: "PostAsset", filename: str) -> str:
return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}" return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}"
def blog_banner_upload_to(instance: "BlogBanner", filename: str) -> str:
suffix = Path(filename).suffix.lower()
return f"blog/banners/{uuid4().hex}{suffix}"
class Category(BaseModel): class Category(BaseModel):
name = models.CharField(max_length=100, unique=True) name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True) slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
parent = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="children",
)
class Meta: class Meta:
verbose_name_plural = "Categories" verbose_name_plural = "Categories"
@@ -71,6 +83,17 @@ class Category(BaseModel):
self.slug = _unique_slug_for(self, self.name) self.slug = _unique_slug_for(self, self.name)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@property
def path(self):
path = []
current = self
seen = set()
while current and current.pk not in seen:
seen.add(current.pk)
path.append(current)
current = current.parent
return list(reversed(path))
class Tag(BaseModel): class Tag(BaseModel):
name = models.CharField(max_length=50, unique=True) name = models.CharField(max_length=50, unique=True)
@@ -136,6 +159,11 @@ class Post(BaseModel):
blank=True, blank=True,
related_name="published_blog_posts", related_name="published_blog_posts",
) )
writers = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="written_blog_posts",
)
class Meta: class Meta:
ordering = ["-created_at"] ordering = ["-created_at"]
@@ -177,8 +205,8 @@ class Post(BaseModel):
"markdown.extensions.toc", "markdown.extensions.toc",
], ],
) )
word_count = len((self.content or "").split()) character_count = len(_plain_text_from_markdown(self.content or ""))
self.reading_time = max(1, (word_count + 199) // 200) self.reading_time = max(1, (character_count + 999) // 1000)
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at: if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
self.published_at = timezone.now() self.published_at = timezone.now()
@@ -206,6 +234,24 @@ class Post(BaseModel):
safe_process_public_image(self.og_image, "blog_featured") safe_process_public_image(self.og_image, "blog_featured")
class BlogBanner(BaseModel):
title = models.CharField(max_length=160, blank=True)
alt_text = models.CharField(max_length=200, blank=True)
image = models.ImageField(upload_to=blog_banner_upload_to)
url = models.URLField()
is_active = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "-created_at"]
indexes = [
models.Index(fields=["is_active", "sort_order"]),
]
def __str__(self):
return self.title or self.url
class PostAsset(BaseModel): class PostAsset(BaseModel):
class FileType(models.TextChoices): class FileType(models.TextChoices):
IMAGE = "image", "Image" IMAGE = "image", "Image"
@@ -283,6 +329,7 @@ class Comment(BaseModel):
content = models.TextField() content = models.TextField()
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies") parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies")
is_approved = models.BooleanField(default=True) is_approved = models.BooleanField(default=True)
is_hidden = models.BooleanField(default=False)
hidden_by = models.ForeignKey( hidden_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -292,12 +339,21 @@ class Comment(BaseModel):
) )
hidden_at = models.DateTimeField(null=True, blank=True) hidden_at = models.DateTimeField(null=True, blank=True)
moderation_note = models.TextField(blank=True) moderation_note = models.TextField(blank=True)
deleted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="deleted_blog_comments",
)
delete_note = models.TextField(blank=True)
class Meta: class Meta:
ordering = ["created_at"] ordering = ["created_at"]
indexes = [ indexes = [
models.Index(fields=["post", "is_approved"]), models.Index(fields=["post", "is_approved", "is_hidden"]),
models.Index(fields=["author", "created_at"]), models.Index(fields=["author", "created_at"]),
models.Index(fields=["parent", "is_deleted", "is_hidden"]),
] ]
def __str__(self): def __str__(self):
@@ -308,11 +364,50 @@ class Comment(BaseModel):
return self.parent is not None return self.parent is not None
def hide(self, user, note: str = ""): def hide(self, user, note: str = ""):
self.is_approved = False now = timezone.now()
self.hidden_by = user ids = [self.id, *self.descendant_ids()]
self.hidden_at = timezone.now() self.__class__.all_objects.filter(id__in=ids, is_deleted=False).update(
self.moderation_note = note is_hidden=True,
self.save(update_fields=["is_approved", "hidden_by", "hidden_at", "moderation_note", "updated_at"]) is_approved=False,
hidden_by=user,
hidden_at=now,
moderation_note=note,
updated_at=now,
)
def unhide(self):
now = timezone.now()
ids = [self.id, *self.descendant_ids()]
self.__class__.all_objects.filter(id__in=ids, is_deleted=False).update(
is_hidden=False,
is_approved=True,
hidden_by=None,
hidden_at=None,
moderation_note="",
updated_at=now,
)
def descendant_ids(self) -> list[int]:
pending = [self.id]
descendants: list[int] = []
while pending:
child_ids = list(
self.__class__.all_objects.filter(parent_id__in=pending).values_list("id", flat=True)
)
descendants.extend(child_ids)
pending = child_ids
return descendants
def soft_delete_tree(self, user, note: str = ""):
now = timezone.now()
ids = [self.id, *self.descendant_ids()]
self.__class__.all_objects.filter(id__in=ids).update(
is_deleted=True,
deleted_at=now,
deleted_by=user,
delete_note=note,
updated_at=now,
)
class Like(models.Model): class Like(models.Model):

View File

@@ -7,7 +7,7 @@ from apps.blog.models import Post, Category, Tag
class CategoryResource(resources.ModelResource): class CategoryResource(resources.ModelResource):
class Meta: class Meta:
model = Category model = Category
fields = ('id', 'name', 'slug', 'description', 'created_at') fields = ('id', 'name', 'parent', 'slug', 'description', 'created_at')
class PostResource(resources.ModelResource): class PostResource(resources.ModelResource):
author = fields.Field( author = fields.Field(
@@ -25,8 +25,13 @@ class PostResource(resources.ModelResource):
attribute='tags', attribute='tags',
widget=ManyToManyWidget(Tag, field='name', separator='|') widget=ManyToManyWidget(Tag, field='name', separator='|')
) )
writers = fields.Field(
column_name='writers',
attribute='writers',
widget=ManyToManyWidget(User, field='username', separator='|')
)
class Meta: class Meta:
model = Post model = Post
fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author', fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author',
'category', 'tags', 'status', 'is_featured', 'published_at', 'created_at') 'category', 'tags', 'writers', 'status', 'is_featured', 'published_at', 'created_at')