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

@@ -5,17 +5,37 @@ from typing import List, Optional
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
class CategorySchema(ModelSchema):
created_at: Optional[datetime] = None
parent_id: Optional[int] = None
class Config:
model = Category
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):
created_at: Optional[datetime] = None
@@ -25,11 +45,19 @@ class TagSchema(ModelSchema):
model_fields = ["id", "name", "slug", "created_at"]
class TagFilterSchema(Schema):
id: int
name: str
slug: str
post_count: int = 0
class AuthorSchema(Schema):
id: int
username: str
first_name: str
last_name: str
bio: Optional[str] = None
profile_picture: Optional[str] = None
profile_picture_thumbnail_url: Optional[str] = None
profile_picture_preview_url: Optional[str] = None
@@ -124,6 +152,8 @@ class PostListSchema(Schema):
status: str
published_at: Optional[datetime] = None
category: Optional[CategorySchema] = None
category_path: List[CategoryPathSchema] = []
writers: List[AuthorSchema] = []
tags: List[TagSchema]
is_featured: bool
created_at: datetime
@@ -159,6 +189,17 @@ class PostListSchema(Schema):
url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
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
def resolve_likes_count(obj):
return getattr(obj, "likes_count", None) or obj.likes.count()
@@ -169,7 +210,11 @@ class PostListSchema(Schema):
@staticmethod
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):
@@ -192,6 +237,7 @@ class PostCreateSchema(Schema):
excerpt: Optional[str] = None
category_id: Optional[int] = None
tag_ids: Optional[List[int]] = []
writer_ids: Optional[List[int]] = None
status: str = "draft"
is_featured: bool = False
seo_title: Optional[str] = ""
@@ -221,10 +267,14 @@ class CommentSchema(ModelSchema):
post_title: str
post_slug: str
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:
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
def resolve_post_id(obj):
@@ -242,12 +292,22 @@ class CommentSchema(ModelSchema):
def resolve_parent_id(obj):
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):
content: str
parent_id: Optional[int] = None
class CommentUpdateSchema(Schema):
content: str
class CommentHideSchema(Schema):
note: Optional[str] = ""
@@ -265,3 +325,30 @@ class BlogProfileActivitySchema(Schema):
saved_posts: List[PostListSchema]
comments: 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 django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Count, Prefetch, Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from ninja import File, Form, Query, Router, UploadedFile
from apps.blog.api.schemas import (
BlogBannerSchema,
BlogFiltersSchema,
BlogInteractionSchema,
BlogProfileActivitySchema,
CategorySchema,
CommentCreateSchema,
CommentHideSchema,
CommentSchema,
CommentUpdateSchema,
AuthorSchema,
PostAssetCreateSchema,
PostAssetSchema,
PostCreateSchema,
@@ -25,7 +30,7 @@ from apps.blog.api.schemas import (
PostReviewSchema,
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 (
can_access_blog_admin,
can_edit_post,
@@ -39,6 +44,7 @@ from core.authentication import jwt_auth
blog_router = Router()
User = get_user_model()
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"}
@@ -49,11 +55,15 @@ ARCHIVE_EXTENSIONS = {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"}
def _post_queryset():
return (
Post.objects.select_related("author", "category", "reviewed_by", "published_by")
.prefetch_related("tags", "assets__uploaded_by")
.prefetch_related("tags", "writers", "assets__uploaded_by")
.annotate(
likes_count=Count("likes", 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)
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:
suffix = Path(file.name).suffix.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:
payload = data.dict(exclude_unset=True)
tag_ids = payload.pop("tag_ids", None)
writer_ids = payload.pop("writer_ids", None)
category_id = payload.pop("category_id", None)
requested_status = payload.pop("status", None)
@@ -126,6 +219,14 @@ def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_statu
post.save()
if tag_ids is not None:
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
@@ -135,7 +236,24 @@ def _interaction_payload(post: Post, user) -> BlogInteractionSchema:
saved=SavedPost.objects.filter(post=post, user=user).exists(),
likes_count=post.likes.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])
def list_posts(
request,
@@ -344,17 +510,20 @@ def list_posts(
):
queryset = _published_queryset()
if category:
queryset = queryset.filter(category__slug=category)
if tag:
queryset = queryset.filter(tags__slug=tag)
category_ids = _category_and_descendant_ids(category)
queryset = queryset.filter(category_id__in=category_ids) if category_ids else queryset.none()
tags = _query_values(request, "tag", tag)
if tags:
queryset = queryset.filter(tags__slug__in=tags)
if search:
queryset = queryset.filter(Q(title__icontains=search) | Q(content__icontains=search) | Q(excerpt__icontains=search))
if featured is not None:
queryset = queryset.filter(is_featured=featured)
if author:
queryset = queryset.filter(author__username=author)
authors = _query_values(request, "author", author)
if authors:
queryset = queryset.filter(Q(author__username__in=authors) | Q(writers__username__in=authors))
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])
@@ -426,8 +595,19 @@ def delete_post(request, slug: str):
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
def list_comments(request, slug: str):
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(
Prefetch("replies", queryset=Comment.objects.filter(is_approved=True).select_related("author", "post"))
user = _optional_auth_user(request)
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)
@@ -437,7 +617,7 @@ def create_comment(request, slug: str, data: CommentCreateSchema):
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
parent = None
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)
return 201, comment
@@ -451,6 +631,42 @@ def hide_comment(request, comment_id: int, data: CommentHideSchema):
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)
def toggle_like(request, slug: str):
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)