Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
1047 lines
40 KiB
Python
1047 lines
40 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import mimetypes
|
|
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 import IntegrityError
|
|
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 (
|
|
AdminCategorySchema,
|
|
AdminTagSchema,
|
|
BlogBannerSchema,
|
|
BlogFiltersSchema,
|
|
BlogInteractionSchema,
|
|
BlogProfileActivitySchema,
|
|
CategorySchema,
|
|
CategoryWriteSchema,
|
|
CommentCreateSchema,
|
|
CommentHideSchema,
|
|
CommentSchema,
|
|
CommentUpdateSchema,
|
|
AuthorSchema,
|
|
PostAssetCreateSchema,
|
|
PostAssetSchema,
|
|
PostCreateSchema,
|
|
PostDetailSchema,
|
|
PostListSchema,
|
|
PostReviewSchema,
|
|
TagSchema,
|
|
TagWriteSchema,
|
|
)
|
|
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,
|
|
can_manage_post_assets,
|
|
can_moderate_blog_comments,
|
|
can_review_blog_posts,
|
|
can_write_blog_posts,
|
|
)
|
|
from apps.notifications.services import notify_user
|
|
from core.api.schemas import ErrorSchema, MessageSchema
|
|
from core.authentication import jwt_auth
|
|
|
|
|
|
blog_router = Router()
|
|
User = get_user_model()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"}
|
|
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"}
|
|
DOCUMENT_EXTENSIONS = {".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md", ".csv"}
|
|
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", "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, comments__is_hidden=False, comments__is_deleted=False),
|
|
distinct=True,
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
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()
|
|
if content_type.startswith("image/") or suffix in IMAGE_EXTENSIONS:
|
|
return PostAsset.FileType.IMAGE
|
|
if content_type.startswith("video/") or suffix in VIDEO_EXTENSIONS:
|
|
return PostAsset.FileType.VIDEO
|
|
if suffix in DOCUMENT_EXTENSIONS:
|
|
return PostAsset.FileType.DOCUMENT
|
|
if suffix in ARCHIVE_EXTENSIONS:
|
|
return PostAsset.FileType.ARCHIVE
|
|
return PostAsset.FileType.OTHER
|
|
|
|
|
|
def _validate_asset_file(file: UploadedFile) -> str | None:
|
|
file_type = _asset_file_type(file)
|
|
suffix = Path(file.name).suffix.lower()
|
|
if file_type == PostAsset.FileType.OTHER:
|
|
return "Unsupported file type."
|
|
|
|
max_size_mb = getattr(settings, "BLOG_ASSET_MAX_SIZE_MB", 50)
|
|
image_max_size_mb = getattr(settings, "BLOG_IMAGE_ASSET_MAX_SIZE_MB", 10)
|
|
limit_mb = image_max_size_mb if file_type == PostAsset.FileType.IMAGE else max_size_mb
|
|
if file.size > limit_mb * 1024 * 1024:
|
|
return f"File size must be less than {limit_mb}MB."
|
|
|
|
allowed = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS | DOCUMENT_EXTENSIONS | ARCHIVE_EXTENSIONS
|
|
if suffix and suffix not in allowed:
|
|
return "Unsupported file extension."
|
|
return None
|
|
|
|
|
|
def _validate_featured_image(file: UploadedFile) -> str | None:
|
|
suffix = Path(file.name).suffix.lower()
|
|
content_type = (file.content_type or mimetypes.guess_type(file.name)[0] or "").lower()
|
|
if not content_type.startswith("image/") and suffix not in IMAGE_EXTENSIONS:
|
|
return "Featured image must be an image file."
|
|
|
|
image_max_size_mb = getattr(settings, "BLOG_IMAGE_ASSET_MAX_SIZE_MB", 10)
|
|
if file.size > image_max_size_mb * 1024 * 1024:
|
|
return f"Featured image size must be less than {image_max_size_mb}MB."
|
|
return 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)
|
|
|
|
if category_id is not None:
|
|
post.category = Category.objects.filter(id=category_id, is_deleted=False).first() if category_id else None
|
|
|
|
if requested_status and allow_status:
|
|
post.status = requested_status
|
|
if requested_status == Post.StatusChoices.PUBLISHED:
|
|
post.published_by = user
|
|
|
|
for field, value in payload.items():
|
|
setattr(post, field, value if value is not None else "")
|
|
|
|
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
|
|
|
|
|
|
def _interaction_payload(post: Post, user) -> BlogInteractionSchema:
|
|
return BlogInteractionSchema(
|
|
liked=Like.objects.filter(post=post, user=user).exists(),
|
|
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, is_hidden=False, is_deleted=False).count(),
|
|
)
|
|
|
|
|
|
def _frontend_blog_url(post: Post) -> str:
|
|
root = getattr(settings, "FRONTEND_ROOT", "/") or "/"
|
|
if not root.endswith("/"):
|
|
root = f"{root}/"
|
|
return f"{root}blog/{post.slug}"
|
|
|
|
|
|
def _blog_moderator_ids() -> set[int]:
|
|
return set(
|
|
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="moderate_blog_comment")
|
|
| Q(user_permissions__content_type__app_label="blog", user_permissions__codename="moderate_blog_comment")
|
|
)
|
|
.distinct()
|
|
.values_list("id", flat=True)
|
|
)
|
|
|
|
|
|
def _post_author_ids(post: Post) -> set[int]:
|
|
author_ids = set(post.writers.values_list("id", flat=True))
|
|
if post.author_id:
|
|
author_ids.add(post.author_id)
|
|
return author_ids
|
|
|
|
|
|
def _notify_blog_comment(comment: Comment) -> None:
|
|
post = comment.post
|
|
actor_id = comment.author_id
|
|
action_url = _frontend_blog_url(post)
|
|
excluded_ids = {actor_id}
|
|
|
|
if comment.parent_id and comment.parent.author_id != actor_id:
|
|
notify_user(
|
|
comment.parent.author_id,
|
|
{
|
|
"type": "blog_reply",
|
|
"title": f"پاسخ جدید به دیدگاه شما درباره «{post.title}»",
|
|
"message": f"به کامنت شما در «{post.title}» پاسخ داده شد.",
|
|
"level": "info",
|
|
"action_url": action_url,
|
|
"entity_type": "blog_comment",
|
|
"entity_id": comment.id,
|
|
"meta": {"post_id": post.id, "post_slug": post.slug, "parent_id": comment.parent_id},
|
|
},
|
|
)
|
|
excluded_ids.add(comment.parent.author_id)
|
|
|
|
recipient_ids = (_blog_moderator_ids() | _post_author_ids(post)) - excluded_ids
|
|
for user_id in recipient_ids:
|
|
notify_user(
|
|
user_id,
|
|
{
|
|
"type": "blog_comment",
|
|
"title": f"دیدگاه جدید برای «{post.title}»",
|
|
"message": f"برای «{post.title}» کامنت جدید ثبت شد.",
|
|
"level": "info",
|
|
"action_url": action_url,
|
|
"entity_type": "blog_post",
|
|
"entity_id": post.id,
|
|
"meta": {"post_id": post.id, "post_slug": post.slug, "comment_id": comment.id},
|
|
},
|
|
)
|
|
|
|
|
|
def _can_manage_blog_taxonomy(user) -> bool:
|
|
return bool(
|
|
user
|
|
and getattr(user, "is_authenticated", False)
|
|
and (
|
|
user.is_superuser
|
|
or user.is_staff
|
|
or user.has_perm("blog.add_category")
|
|
or user.has_perm("blog.change_category")
|
|
or user.has_perm("blog.add_tag")
|
|
or user.has_perm("blog.change_tag")
|
|
)
|
|
)
|
|
|
|
|
|
def _category_queryset_with_counts():
|
|
return Category.objects.annotate(post_count=Count("posts", filter=Q(posts__is_deleted=False), distinct=True))
|
|
|
|
|
|
def _tag_queryset_with_counts():
|
|
return Tag.objects.annotate(post_count=Count("posts", filter=Q(posts__is_deleted=False), distinct=True))
|
|
|
|
|
|
def _validate_category_parent(category_id: int | None, parent_id: int | None) -> tuple[Category | None, str | None]:
|
|
if not parent_id:
|
|
return None, None
|
|
if category_id and parent_id == category_id:
|
|
return None, "A category cannot be its own parent."
|
|
if category_id and Category.objects.filter(parent_id=category_id).exists():
|
|
return None, "A category with child categories must remain a root category."
|
|
|
|
parent = Category.objects.filter(id=parent_id).first()
|
|
if not parent:
|
|
return None, "Parent category not found."
|
|
if parent.parent_id:
|
|
return None, "Only root categories can be selected as a parent."
|
|
|
|
current = parent
|
|
seen: set[int] = set()
|
|
while current:
|
|
if current.id in seen:
|
|
return None, "Invalid category hierarchy."
|
|
seen.add(current.id)
|
|
if category_id and current.id == category_id:
|
|
return None, "Category parent would create a cycle."
|
|
current = current.parent
|
|
return parent, None
|
|
|
|
|
|
def _apply_category_payload(category: Category, data: CategoryWriteSchema) -> tuple[Category | None, str | None]:
|
|
name = (data.name or "").strip()
|
|
if not name:
|
|
return None, "Category name is required."
|
|
|
|
parent, error = _validate_category_parent(category.id, data.parent_id)
|
|
if error:
|
|
return None, error
|
|
|
|
category.name = name
|
|
if data.slug is not None:
|
|
category.slug = data.slug.strip()
|
|
category.description = data.description or ""
|
|
category.parent = parent
|
|
return category, None
|
|
|
|
|
|
def _apply_tag_payload(tag: Tag, data: TagWriteSchema) -> tuple[Tag | None, str | None]:
|
|
name = (data.name or "").strip()
|
|
if not name:
|
|
return None, "Tag name is required."
|
|
|
|
tag.name = name
|
|
if data.slug is not None:
|
|
tag.slug = data.slug.strip()
|
|
return tag, None
|
|
|
|
|
|
@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")
|
|
)
|
|
|
|
|
|
@blog_router.get("/admin/posts", response={200: List[PostListSchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_admin_posts(
|
|
request,
|
|
status: Optional[str] = Query(None),
|
|
search: Optional[str] = Query(None),
|
|
mine: bool = Query(False),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
user = request.auth
|
|
if not can_access_blog_admin(user):
|
|
return 403, {"error": "Permission denied"}
|
|
|
|
queryset = _post_queryset().order_by("-updated_at", "-created_at")
|
|
if not (user.is_superuser or user.is_staff or can_review_blog_posts(user)) or mine:
|
|
queryset = queryset.filter(author=user)
|
|
if status:
|
|
queryset = queryset.filter(status=status)
|
|
if search:
|
|
queryset = queryset.filter(Q(title__icontains=search) | Q(content__icontains=search) | Q(excerpt__icontains=search))
|
|
return list(queryset[offset : offset + limit])
|
|
|
|
|
|
@blog_router.post("/admin/posts", response={201: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def create_admin_post(request, data: PostCreateSchema):
|
|
user = request.auth
|
|
if not can_write_blog_posts(user):
|
|
return 403, {"error": "Permission denied"}
|
|
|
|
try:
|
|
post = Post(author=user)
|
|
_apply_post_payload(post, data, user=user, allow_status=can_review_blog_posts(user))
|
|
if not can_review_blog_posts(user) and post.status == Post.StatusChoices.PUBLISHED:
|
|
post.status = Post.StatusChoices.DRAFT
|
|
post.published_at = None
|
|
post.published_by = None
|
|
post.save(update_fields=["status", "published_at", "published_by", "updated_at"])
|
|
return 201, _post_queryset().get(pk=post.pk)
|
|
except Exception as exc:
|
|
return 400, {"error": "Failed to create post", "details": str(exc)}
|
|
|
|
|
|
@blog_router.get("/admin/posts/{post_id}", response={200: PostDetailSchema, 403: ErrorSchema, 404: ErrorSchema}, auth=jwt_auth)
|
|
def get_admin_post(request, post_id: int):
|
|
post = get_object_or_404(_post_queryset(), id=post_id)
|
|
if not can_edit_post(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
return 200, post
|
|
|
|
|
|
@blog_router.put("/admin/posts/{post_id}", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def update_admin_post(request, post_id: int, data: PostCreateSchema):
|
|
post = get_object_or_404(Post, id=post_id)
|
|
if not can_edit_post(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
|
|
try:
|
|
_apply_post_payload(post, data, user=request.auth, allow_status=can_review_blog_posts(request.auth))
|
|
return 200, _post_queryset().get(pk=post.pk)
|
|
except Exception as exc:
|
|
return 400, {"error": "Failed to update post", "details": str(exc)}
|
|
|
|
|
|
@blog_router.post("/admin/posts/{post_id}/featured-image", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def upload_post_featured_image(request, post_id: int, file: UploadedFile = File(...)):
|
|
post = get_object_or_404(Post, id=post_id)
|
|
if not can_edit_post(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
|
|
error = _validate_featured_image(file)
|
|
if error:
|
|
return 400, {"error": error}
|
|
|
|
post.featured_image = file
|
|
post.save(update_fields=["featured_image", "updated_at"])
|
|
return 200, _post_queryset().get(pk=post.pk)
|
|
|
|
|
|
@blog_router.delete("/admin/posts/{post_id}/featured-image", response={200: PostDetailSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def delete_post_featured_image(request, post_id: int):
|
|
post = get_object_or_404(Post, id=post_id)
|
|
if not can_edit_post(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
|
|
post.featured_image = None
|
|
post.save(update_fields=["featured_image", "updated_at"])
|
|
return 200, _post_queryset().get(pk=post.pk)
|
|
|
|
|
|
@blog_router.post("/admin/posts/{post_id}/submit", response={200: PostDetailSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def submit_post_for_review(request, post_id: int):
|
|
post = get_object_or_404(Post, id=post_id)
|
|
if not can_edit_post(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
post.status = Post.StatusChoices.SUBMITTED
|
|
post.submitted_at = timezone.now()
|
|
post.review_note = ""
|
|
post.save(update_fields=["status", "submitted_at", "review_note", "updated_at"])
|
|
return 200, _post_queryset().get(pk=post.pk)
|
|
|
|
|
|
@blog_router.post("/admin/posts/{post_id}/review", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def review_post(request, post_id: int, data: PostReviewSchema):
|
|
user = request.auth
|
|
if not can_review_blog_posts(user):
|
|
return 403, {"error": "Permission denied"}
|
|
|
|
post = get_object_or_404(Post, id=post_id)
|
|
action = data.action.strip().lower()
|
|
if action in {"publish", "approve"}:
|
|
post.status = Post.StatusChoices.PUBLISHED
|
|
post.published_at = post.published_at or timezone.now()
|
|
post.published_by = user
|
|
elif action in {"request_changes", "changes_requested"}:
|
|
post.status = Post.StatusChoices.CHANGES_REQUESTED
|
|
elif action == "archive":
|
|
post.status = Post.StatusChoices.ARCHIVED
|
|
else:
|
|
return 400, {"error": "Unsupported review action"}
|
|
|
|
post.reviewed_by = user
|
|
post.reviewed_at = timezone.now()
|
|
post.review_note = data.note or ""
|
|
post.save()
|
|
return 200, _post_queryset().get(pk=post.pk)
|
|
|
|
|
|
@blog_router.get("/admin/posts/{post_id}/assets", response={200: List[PostAssetSchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_post_assets(request, post_id: int):
|
|
post = get_object_or_404(Post, id=post_id)
|
|
if not can_edit_post(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
return list(post.assets.select_related("uploaded_by"))
|
|
|
|
|
|
@blog_router.post("/admin/posts/{post_id}/assets", response={201: PostAssetSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def upload_post_asset(
|
|
request,
|
|
post_id: int,
|
|
file: UploadedFile = File(...),
|
|
data: PostAssetCreateSchema = Form(...),
|
|
):
|
|
post = get_object_or_404(Post, id=post_id)
|
|
if not can_manage_post_assets(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
|
|
error = _validate_asset_file(file)
|
|
if error:
|
|
return 400, {"error": error}
|
|
|
|
try:
|
|
asset = PostAsset.objects.create(
|
|
post=post,
|
|
file=file,
|
|
file_type=_asset_file_type(file),
|
|
title=(data.title if data else "") or file.name,
|
|
alt_text=data.alt_text if data else "",
|
|
caption=data.caption if data else "",
|
|
size=file.size,
|
|
mime_type=file.content_type or mimetypes.guess_type(file.name)[0] or "",
|
|
uploaded_by=request.auth,
|
|
)
|
|
return 201, asset
|
|
except Exception as exc:
|
|
return 400, {"error": "Failed to upload asset", "details": str(exc)}
|
|
|
|
|
|
@blog_router.delete("/admin/posts/{post_id}/assets/{asset_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def delete_post_asset(request, post_id: int, asset_id: int):
|
|
post = get_object_or_404(Post, id=post_id)
|
|
asset = get_object_or_404(PostAsset, id=asset_id, post=post)
|
|
if not can_manage_post_assets(request.auth, post):
|
|
return 403, {"error": "Permission denied"}
|
|
asset.delete()
|
|
return 200, {"message": "Asset deleted successfully"}
|
|
|
|
|
|
@blog_router.get("/me/activity", response=BlogProfileActivitySchema, auth=jwt_auth)
|
|
def my_blog_activity(request):
|
|
comments = (
|
|
Comment.objects.filter(
|
|
author=request.auth,
|
|
is_approved=True,
|
|
is_hidden=False,
|
|
is_deleted=False,
|
|
post__status=Post.StatusChoices.PUBLISHED,
|
|
post__is_deleted=False,
|
|
)
|
|
.select_related("author", "post")
|
|
.order_by("-created_at")[:20]
|
|
)
|
|
return {
|
|
"liked_posts": list(_published_queryset().filter(likes__user=request.auth)[:20]),
|
|
"saved_posts": list(_published_queryset().filter(saves__user=request.auth)[:20]),
|
|
"comments": [comment for comment in comments if comment.parent_id is None],
|
|
"replies": [comment for comment in comments if comment.parent_id is not None],
|
|
}
|
|
|
|
|
|
@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,
|
|
page: int = Query(1, ge=1),
|
|
limit: int = Query(10, ge=1, le=50),
|
|
category: Optional[str] = None,
|
|
tag: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
featured: Optional[bool] = None,
|
|
author: Optional[str] = None,
|
|
):
|
|
queryset = _published_queryset()
|
|
if category:
|
|
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)
|
|
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.distinct()[offset : offset + limit])
|
|
|
|
|
|
@blog_router.get("/posts/{slug}/recommended", response=List[PostListSchema])
|
|
def recommended_posts(request, slug: str, limit: int = Query(3, ge=1, le=12)):
|
|
post = get_object_or_404(
|
|
Post.objects.select_related("category").prefetch_related("tags"),
|
|
slug=slug,
|
|
status=Post.StatusChoices.PUBLISHED,
|
|
)
|
|
tag_ids = list(post.tags.values_list("id", flat=True))
|
|
selected = []
|
|
seen_ids = {post.id}
|
|
|
|
def append_posts(queryset):
|
|
remaining = limit - len(selected)
|
|
if remaining <= 0:
|
|
return
|
|
for candidate in queryset.exclude(id__in=seen_ids)[:remaining]:
|
|
selected.append(candidate)
|
|
seen_ids.add(candidate.id)
|
|
|
|
if tag_ids:
|
|
append_posts(
|
|
_published_queryset()
|
|
.filter(tags__id__in=tag_ids)
|
|
.annotate(shared_tags=Count("tags", filter=Q(tags__id__in=tag_ids), distinct=True))
|
|
.order_by("-shared_tags", "-published_at", "-created_at")
|
|
.distinct()
|
|
)
|
|
|
|
if len(selected) < limit and post.category_id:
|
|
append_posts(
|
|
_published_queryset()
|
|
.filter(category_id=post.category_id)
|
|
.order_by("-published_at", "-created_at")
|
|
)
|
|
|
|
if len(selected) < limit:
|
|
append_posts(_published_queryset().order_by("-published_at", "-created_at"))
|
|
|
|
return selected
|
|
|
|
|
|
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
|
|
def get_post(request, slug: str):
|
|
return get_object_or_404(_published_queryset(), slug=slug)
|
|
|
|
|
|
@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def create_post_compat(request, data: PostCreateSchema):
|
|
return create_admin_post(request, data)
|
|
|
|
|
|
@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def update_post_compat(request, slug: str, data: PostCreateSchema):
|
|
post = get_object_or_404(Post, slug=slug)
|
|
return update_admin_post(request, post.id, data)
|
|
|
|
|
|
@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def delete_post(request, slug: str):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Only superusers can delete posts"}
|
|
post = get_object_or_404(Post, slug=slug)
|
|
post.delete()
|
|
return 200, {"message": "Post deleted successfully"}
|
|
|
|
|
|
@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)
|
|
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)
|
|
|
|
|
|
@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
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, is_hidden=False, is_deleted=False)
|
|
comment = Comment.objects.create(post=post, author=request.auth, content=data.content, parent=parent)
|
|
try:
|
|
_notify_blog_comment(comment)
|
|
except Exception:
|
|
logger.exception("Failed to send blog comment notifications for comment=%s", comment.id)
|
|
return 201, comment
|
|
|
|
|
|
@blog_router.post("/comments/{comment_id}/hide", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def hide_comment(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, id=comment_id)
|
|
comment.hide(request.auth, data.note or "")
|
|
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)
|
|
like, created = Like.objects.get_or_create(post=post, user=request.auth)
|
|
if not created:
|
|
like.delete()
|
|
return 200, _interaction_payload(post, request.auth)
|
|
|
|
|
|
@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema})
|
|
def get_likes_count(request, slug: str):
|
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
|
return 200, {"message": str(post.likes.count())}
|
|
|
|
|
|
@blog_router.post("/posts/{slug}/save", response={200: BlogInteractionSchema}, auth=jwt_auth)
|
|
def toggle_save(request, slug: str):
|
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
|
saved, created = SavedPost.objects.get_or_create(post=post, user=request.auth)
|
|
if not created:
|
|
saved.delete()
|
|
return 200, _interaction_payload(post, request.auth)
|
|
|
|
|
|
@blog_router.get("/posts/{slug}/interaction", response={200: BlogInteractionSchema}, auth=jwt_auth)
|
|
def get_interaction(request, slug: str):
|
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
|
return 200, _interaction_payload(post, request.auth)
|
|
|
|
|
|
@blog_router.get("/deleted/posts", response={200: List[PostListSchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_deleted_posts(request):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
return 200, Post.deleted_objects.all().select_related("author", "category").prefetch_related("tags")
|
|
|
|
|
|
@blog_router.post("/deleted/posts/{post_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def restore_post(request, post_id: int):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
try:
|
|
post = Post.deleted_objects.get(id=post_id)
|
|
post.restore()
|
|
return 200, {"message": f"Post '{post.title}' restored successfully."}
|
|
except Post.DoesNotExist:
|
|
return 400, {"error": "Post not found or not soft-deleted."}
|
|
|
|
|
|
@blog_router.get("/deleted/comments", response={200: List[CommentSchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_deleted_comments(request):
|
|
if not can_moderate_blog_comments(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
return 200, Comment.deleted_objects.all().select_related("author", "post")
|
|
|
|
|
|
@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def restore_comment(request, comment_id: int):
|
|
if not can_moderate_blog_comments(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
try:
|
|
comment = Comment.deleted_objects.get(id=comment_id)
|
|
comment.restore()
|
|
return 200, {"message": f"Comment by {comment.author.username} restored successfully."}
|
|
except Comment.DoesNotExist:
|
|
return 400, {"error": "Comment not found or not soft-deleted."}
|
|
|
|
|
|
@blog_router.get("/admin/categories", response={200: List[AdminCategorySchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_admin_categories(request):
|
|
if not _can_manage_blog_taxonomy(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
return 200, _category_queryset_with_counts().order_by("name")
|
|
|
|
|
|
@blog_router.post("/admin/categories", response={201: AdminCategorySchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def create_admin_category(request, data: CategoryWriteSchema):
|
|
if not _can_manage_blog_taxonomy(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
category, error = _apply_category_payload(Category(), data)
|
|
if error:
|
|
return 400, {"error": error}
|
|
try:
|
|
category.save()
|
|
except IntegrityError:
|
|
return 400, {"error": "Category name or slug already exists."}
|
|
return 201, _category_queryset_with_counts().get(id=category.id)
|
|
|
|
|
|
@blog_router.put("/admin/categories/{category_id}", response={200: AdminCategorySchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def update_admin_category(request, category_id: int, data: CategoryWriteSchema):
|
|
if not _can_manage_blog_taxonomy(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
category = get_object_or_404(Category, id=category_id)
|
|
category, error = _apply_category_payload(category, data)
|
|
if error:
|
|
return 400, {"error": error}
|
|
try:
|
|
category.save()
|
|
except IntegrityError:
|
|
return 400, {"error": "Category name or slug already exists."}
|
|
return 200, _category_queryset_with_counts().get(id=category.id)
|
|
|
|
|
|
@blog_router.delete("/admin/categories/{category_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def delete_admin_category(request, category_id: int):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
category = get_object_or_404(Category, id=category_id)
|
|
category.delete()
|
|
return 200, {"message": f"Category '{category.name}' deleted successfully."}
|
|
|
|
|
|
@blog_router.get("/admin/tags", response={200: List[AdminTagSchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_admin_tags(request):
|
|
if not _can_manage_blog_taxonomy(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
return 200, _tag_queryset_with_counts().order_by("name")
|
|
|
|
|
|
@blog_router.post("/admin/tags", response={201: AdminTagSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def create_admin_tag(request, data: TagWriteSchema):
|
|
if not _can_manage_blog_taxonomy(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
tag, error = _apply_tag_payload(Tag(), data)
|
|
if error:
|
|
return 400, {"error": error}
|
|
try:
|
|
tag.save()
|
|
except IntegrityError:
|
|
return 400, {"error": "Tag name or slug already exists."}
|
|
return 201, _tag_queryset_with_counts().get(id=tag.id)
|
|
|
|
|
|
@blog_router.put("/admin/tags/{tag_id}", response={200: AdminTagSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def update_admin_tag(request, tag_id: int, data: TagWriteSchema):
|
|
if not _can_manage_blog_taxonomy(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
tag = get_object_or_404(Tag, id=tag_id)
|
|
tag, error = _apply_tag_payload(tag, data)
|
|
if error:
|
|
return 400, {"error": error}
|
|
try:
|
|
tag.save()
|
|
except IntegrityError:
|
|
return 400, {"error": "Tag name or slug already exists."}
|
|
return 200, _tag_queryset_with_counts().get(id=tag.id)
|
|
|
|
|
|
@blog_router.delete("/admin/tags/{tag_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def delete_admin_tag(request, tag_id: int):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
tag = get_object_or_404(Tag, id=tag_id)
|
|
tag.delete()
|
|
return 200, {"message": f"Tag '{tag.name}' deleted successfully."}
|
|
|
|
|
|
@blog_router.get("/categories", response=List[CategorySchema])
|
|
def list_categories(request):
|
|
return Category.objects.all()
|
|
|
|
|
|
@blog_router.get("/categories/{slug}", response=CategorySchema)
|
|
def get_category(request, slug: str):
|
|
return get_object_or_404(Category, slug=slug)
|
|
|
|
|
|
@blog_router.get("/deleted/categories", response={200: List[CategorySchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_deleted_categories(request):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
return 200, Category.deleted_objects.all()
|
|
|
|
|
|
@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def restore_category(request, category_id: int):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
try:
|
|
category = Category.deleted_objects.get(id=category_id)
|
|
category.restore()
|
|
return 200, {"message": f"Category '{category.name}' restored successfully."}
|
|
except Category.DoesNotExist:
|
|
return 400, {"error": "Category not found or not soft-deleted."}
|
|
|
|
|
|
@blog_router.get("/tags", response=List[TagSchema])
|
|
def list_tags(request):
|
|
return Tag.objects.all()
|
|
|
|
|
|
@blog_router.get("/tags/{slug}", response=TagSchema)
|
|
def get_tag(request, slug: str):
|
|
return get_object_or_404(Tag, slug=slug)
|
|
|
|
|
|
@blog_router.get("/deleted/tags", response={200: List[TagSchema], 403: ErrorSchema}, auth=jwt_auth)
|
|
def list_deleted_tags(request):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
return 200, Tag.deleted_objects.all()
|
|
|
|
|
|
@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def restore_tag(request, tag_id: int):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Permission denied"}
|
|
try:
|
|
tag = Tag.deleted_objects.get(id=tag_id)
|
|
tag.restore()
|
|
return 200, {"message": f"Tag '{tag.name}' restored successfully."}
|
|
except Tag.DoesNotExist:
|
|
return 400, {"error": "Tag not found or not soft-deleted."}
|