feat(backend): add blog publishing platform
This commit is contained in:
@@ -1,22 +1,29 @@
|
||||
"""Blog API schemas."""
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from apps.blog.models import Category, Tag, Comment
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.blog.models import Category, Comment, PostAsset, Tag
|
||||
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||
|
||||
|
||||
class CategorySchema(ModelSchema):
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
model = Category
|
||||
model_fields = ['id', 'name', 'slug', 'description']
|
||||
model_fields = ["id", "name", "slug", "description", "created_at"]
|
||||
|
||||
|
||||
class TagSchema(ModelSchema):
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
model = Tag
|
||||
model_fields = ['id', 'name', 'slug']
|
||||
model_fields = ["id", "name", "slug", "created_at"]
|
||||
|
||||
|
||||
class AuthorSchema(Schema):
|
||||
id: int
|
||||
@@ -29,8 +36,8 @@ class AuthorSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture(obj, context):
|
||||
request = context['request']
|
||||
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
|
||||
request = context["request"]
|
||||
if obj.profile_picture and hasattr(obj.profile_picture, "url"):
|
||||
return request.build_absolute_uri(obj.profile_picture.url)
|
||||
return None
|
||||
|
||||
@@ -46,6 +53,64 @@ class AuthorSchema(Schema):
|
||||
url = derivative_url(obj.profile_picture, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
|
||||
class PostAssetSchema(ModelSchema):
|
||||
absolute_file_url: Optional[str] = None
|
||||
absolute_thumbnail_url: Optional[str] = None
|
||||
absolute_preview_url: Optional[str] = None
|
||||
absolute_blur_url: Optional[str] = None
|
||||
markdown_image: Optional[str] = None
|
||||
markdown_link: Optional[str] = None
|
||||
uploaded_by: AuthorSchema
|
||||
|
||||
class Config:
|
||||
model = PostAsset
|
||||
model_fields = [
|
||||
"id",
|
||||
"file_type",
|
||||
"title",
|
||||
"alt_text",
|
||||
"caption",
|
||||
"size",
|
||||
"mime_type",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_file_url(obj, context):
|
||||
request = context["request"]
|
||||
return request.build_absolute_uri(obj.file.url) if obj.file else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_thumbnail_url(obj, context):
|
||||
request = context["request"]
|
||||
return request.build_absolute_uri(obj.thumbnail_url) if obj.thumbnail_url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_preview_url(obj, context):
|
||||
request = context["request"]
|
||||
return request.build_absolute_uri(obj.preview_url) if obj.preview_url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_blur_url(obj, context):
|
||||
request = context["request"]
|
||||
return request.build_absolute_uri(obj.blur_url) if obj.blur_url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_markdown_image(obj, context):
|
||||
request = context["request"]
|
||||
if obj.file_type != PostAsset.FileType.IMAGE or not obj.file:
|
||||
return None
|
||||
return f"})"
|
||||
|
||||
@staticmethod
|
||||
def resolve_markdown_link(obj, context):
|
||||
request = context["request"]
|
||||
if not obj.file:
|
||||
return None
|
||||
return f"[{obj.title}]({request.build_absolute_uri(obj.file.url)})"
|
||||
|
||||
|
||||
class PostListSchema(Schema):
|
||||
id: int
|
||||
title: str
|
||||
@@ -62,7 +127,18 @@ class PostListSchema(Schema):
|
||||
tags: List[TagSchema]
|
||||
is_featured: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
reading_time: int
|
||||
seo_title: str
|
||||
seo_description: str
|
||||
canonical_url: str
|
||||
og_title: str
|
||||
og_description: str
|
||||
noindex: bool
|
||||
focus_keyword: str
|
||||
likes_count: int
|
||||
saves_count: int
|
||||
comments_count: int
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_url(obj, context):
|
||||
@@ -83,9 +159,32 @@ class PostListSchema(Schema):
|
||||
url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
|
||||
return request.build_absolute_uri(url) if url else None
|
||||
|
||||
@staticmethod
|
||||
def resolve_likes_count(obj):
|
||||
return getattr(obj, "likes_count", None) or obj.likes.count()
|
||||
|
||||
@staticmethod
|
||||
def resolve_saves_count(obj):
|
||||
return getattr(obj, "saves_count", None) or obj.saves.count()
|
||||
|
||||
@staticmethod
|
||||
def resolve_comments_count(obj):
|
||||
return getattr(obj, "comments_count", None) or obj.comments.filter(is_approved=True).count()
|
||||
|
||||
|
||||
class PostDetailSchema(PostListSchema):
|
||||
content: str
|
||||
content_html: str
|
||||
og_image_url: Optional[str] = None
|
||||
assets: List[PostAssetSchema] = []
|
||||
|
||||
@staticmethod
|
||||
def resolve_og_image_url(obj, context):
|
||||
request = context["request"]
|
||||
if obj.og_image and hasattr(obj.og_image, "url"):
|
||||
return request.build_absolute_uri(obj.og_image.url)
|
||||
return None
|
||||
|
||||
|
||||
class PostCreateSchema(Schema):
|
||||
title: str
|
||||
@@ -95,17 +194,37 @@ class PostCreateSchema(Schema):
|
||||
tag_ids: Optional[List[int]] = []
|
||||
status: str = "draft"
|
||||
is_featured: bool = False
|
||||
seo_title: Optional[str] = ""
|
||||
seo_description: Optional[str] = ""
|
||||
canonical_url: Optional[str] = ""
|
||||
og_title: Optional[str] = ""
|
||||
og_description: Optional[str] = ""
|
||||
noindex: Optional[bool] = False
|
||||
focus_keyword: Optional[str] = ""
|
||||
|
||||
|
||||
class PostReviewSchema(Schema):
|
||||
action: str
|
||||
note: Optional[str] = ""
|
||||
|
||||
|
||||
class PostAssetCreateSchema(Schema):
|
||||
title: Optional[str] = ""
|
||||
alt_text: Optional[str] = ""
|
||||
caption: Optional[str] = ""
|
||||
|
||||
|
||||
class CommentSchema(ModelSchema):
|
||||
author: AuthorSchema
|
||||
replies: List['CommentSchema'] = []
|
||||
replies: List["CommentSchema"] = []
|
||||
post_id: int
|
||||
post_title: str
|
||||
post_slug: str
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
model = Comment
|
||||
model_fields = ['id', 'content', 'created_at', 'is_approved']
|
||||
model_fields = ["id", "content", "created_at", "is_approved", "hidden_at"]
|
||||
|
||||
@staticmethod
|
||||
def resolve_post_id(obj):
|
||||
@@ -119,6 +238,30 @@ class CommentSchema(ModelSchema):
|
||||
def resolve_post_slug(obj):
|
||||
return obj.post.slug
|
||||
|
||||
@staticmethod
|
||||
def resolve_parent_id(obj):
|
||||
return obj.parent_id
|
||||
|
||||
|
||||
class CommentCreateSchema(Schema):
|
||||
content: str
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class CommentHideSchema(Schema):
|
||||
note: Optional[str] = ""
|
||||
|
||||
|
||||
class BlogInteractionSchema(Schema):
|
||||
liked: bool
|
||||
saved: bool
|
||||
likes_count: int
|
||||
saves_count: int
|
||||
comments_count: int
|
||||
|
||||
|
||||
class BlogProfileActivitySchema(Schema):
|
||||
liked_posts: List[PostListSchema]
|
||||
saved_posts: List[PostListSchema]
|
||||
comments: List[CommentSchema]
|
||||
replies: List[CommentSchema]
|
||||
|
||||
@@ -1,26 +1,298 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Prefetch
|
||||
from __future__ import annotations
|
||||
|
||||
from ninja import Router, Query
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from apps.users.models import User
|
||||
from django.conf import settings
|
||||
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 (
|
||||
BlogInteractionSchema,
|
||||
BlogProfileActivitySchema,
|
||||
CategorySchema,
|
||||
CommentCreateSchema,
|
||||
CommentHideSchema,
|
||||
CommentSchema,
|
||||
PostAssetCreateSchema,
|
||||
PostAssetSchema,
|
||||
PostCreateSchema,
|
||||
PostDetailSchema,
|
||||
PostListSchema,
|
||||
PostReviewSchema,
|
||||
TagSchema,
|
||||
)
|
||||
from apps.blog.models import Post, Category, Tag, Comment, Like
|
||||
from apps.blog.models import 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 core.api.schemas import ErrorSchema, MessageSchema
|
||||
from core.authentication import jwt_auth
|
||||
|
||||
|
||||
blog_router = Router()
|
||||
|
||||
# Post endpoints
|
||||
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", "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),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _published_queryset():
|
||||
return _post_queryset().filter(status=Post.StatusChoices.PUBLISHED)
|
||||
|
||||
|
||||
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 _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)
|
||||
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)
|
||||
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).count(),
|
||||
)
|
||||
|
||||
|
||||
@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}/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)
|
||||
.select_related("author", "post")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
return BlogProfileActivitySchema(
|
||||
liked_posts=list(_published_queryset().filter(likes__user=request.auth)[:20]),
|
||||
saved_posts=list(_published_queryset().filter(saves__user=request.auth)[:20]),
|
||||
comments=list([comment for comment in comments if comment.parent_id is None]),
|
||||
replies=list([comment for comment in comments if comment.parent_id is not None]),
|
||||
)
|
||||
|
||||
|
||||
@blog_router.get("/posts", response=List[PostListSchema])
|
||||
def list_posts(
|
||||
request,
|
||||
@@ -30,124 +302,116 @@ def list_posts(
|
||||
tag: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
featured: Optional[bool] = None,
|
||||
author: Optional[str] = None
|
||||
author: Optional[str] = None,
|
||||
):
|
||||
"""List published posts with filtering and pagination"""
|
||||
queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related(
|
||||
'author', 'category'
|
||||
).prefetch_related('tags')
|
||||
|
||||
# Apply filters
|
||||
queryset = _published_queryset()
|
||||
if category:
|
||||
queryset = queryset.filter(category__slug=category)
|
||||
|
||||
if tag:
|
||||
queryset = queryset.filter(tags__slug=tag)
|
||||
|
||||
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:
|
||||
queryset = queryset.filter(is_featured=featured)
|
||||
|
||||
if author:
|
||||
queryset = queryset.filter(author__username=author)
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * limit
|
||||
posts = queryset[offset:offset + limit]
|
||||
|
||||
return posts
|
||||
return list(queryset[offset : offset + limit])
|
||||
|
||||
|
||||
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
|
||||
def get_post(request, slug: str):
|
||||
"""Get single post by slug"""
|
||||
post = get_object_or_404(
|
||||
Post.objects.select_related('author', 'category').prefetch_related('tags'),
|
||||
slug=slug,
|
||||
status=Post.StatusChoices.PUBLISHED
|
||||
)
|
||||
return post
|
||||
return get_object_or_404(_published_queryset(), slug=slug)
|
||||
|
||||
@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def create_post(request, data: PostCreateSchema):
|
||||
"""Create a new post (committee members only)"""
|
||||
user = request.auth
|
||||
|
||||
if not (user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "Only committee members can create posts"}
|
||||
|
||||
try:
|
||||
post = Post.objects.create(
|
||||
title=data.title,
|
||||
content=data.content,
|
||||
excerpt=data.excerpt,
|
||||
author=user,
|
||||
category_id=data.category_id,
|
||||
status=data.status,
|
||||
is_featured=data.is_featured
|
||||
)
|
||||
|
||||
if data.tag_ids:
|
||||
post.tags.set(data.tag_ids)
|
||||
|
||||
return 201, post
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to create post", "details": str(e)}
|
||||
|
||||
@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def update_post(request, slug: str, data: PostCreateSchema):
|
||||
"""Update a post (author or committee only)"""
|
||||
user = request.auth
|
||||
@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)
|
||||
|
||||
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "You can only edit your own posts"}
|
||||
|
||||
try:
|
||||
for field, value in data.dict(exclude_unset=True).items():
|
||||
if field == 'tag_ids':
|
||||
if value:
|
||||
post.tags.set(value)
|
||||
elif field == 'category_id':
|
||||
post.category_id = value
|
||||
else:
|
||||
setattr(post, field, value)
|
||||
|
||||
post.save()
|
||||
return 200, post
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to update post", "details": str(e)}
|
||||
return update_admin_post(request, post.id, data)
|
||||
|
||||
@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
|
||||
@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||
def delete_post(request, slug: str):
|
||||
"""Soft delete a post owned by the requester or committee."""
|
||||
user = request.auth
|
||||
if not request.auth.is_superuser:
|
||||
return 403, {"error": "Only superusers can delete posts"}
|
||||
post = get_object_or_404(Post, slug=slug)
|
||||
|
||||
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "You can only delete your own posts"}
|
||||
|
||||
post.delete()
|
||||
return 200, {"message": "Post deleted successfully"}
|
||||
|
||||
@blog_router.get("/deleted/posts", response=List[PostListSchema], auth=jwt_auth)
|
||||
def list_deleted_posts(request):
|
||||
"""List all soft-deleted posts (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Post.deleted_objects.all().select_related('author', 'category').prefetch_related('tags')
|
||||
|
||||
@blog_router.post("deleted/posts/{post_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
@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"))
|
||||
)
|
||||
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)
|
||||
comment = Comment.objects.create(post=post, author=request.auth, content=data.content, parent=parent)
|
||||
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("/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):
|
||||
"""Restore a soft-deleted post (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
if not request.auth.is_superuser:
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
post = Post.deleted_objects.get(id=post_id)
|
||||
@@ -157,56 +421,16 @@ def restore_post(request, post_id: int):
|
||||
return 400, {"error": "Post not found or not soft-deleted."}
|
||||
|
||||
|
||||
|
||||
# Comment endpoints
|
||||
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
|
||||
def list_comments(request, slug: str):
|
||||
"""List approved comments for a post"""
|
||||
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').prefetch_related(
|
||||
Prefetch(
|
||||
'replies',
|
||||
queryset=Comment.objects.filter(is_approved=True).select_related('author')
|
||||
)
|
||||
)
|
||||
|
||||
return comments
|
||||
|
||||
@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def create_comment(request, slug: str, data: CommentCreateSchema):
|
||||
"""Create a comment on a post"""
|
||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||
user = request.auth
|
||||
|
||||
try:
|
||||
comment = Comment.objects.create(
|
||||
post=post,
|
||||
author=user,
|
||||
content=data.content,
|
||||
parent_id=data.parent_id
|
||||
)
|
||||
|
||||
return 201, comment
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to create comment", "details": str(e)}
|
||||
|
||||
@blog_router.get("/deleted/comments", response=List[CommentSchema], auth=jwt_auth)
|
||||
@blog_router.get("/deleted/comments", response={200: List[CommentSchema], 403: ErrorSchema}, auth=jwt_auth)
|
||||
def list_deleted_comments(request):
|
||||
"""List all soft-deleted comments (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
if not can_moderate_blog_comments(request.auth):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Comment.deleted_objects.all().select_related('author', 'post')
|
||||
return 200, Comment.deleted_objects.all().select_related("author", "post")
|
||||
|
||||
@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
|
||||
@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):
|
||||
"""Restore a soft-deleted comment (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
if not can_moderate_blog_comments(request.auth):
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
comment = Comment.deleted_objects.get(id=comment_id)
|
||||
@@ -216,53 +440,26 @@ def restore_comment(request, comment_id: int):
|
||||
return 400, {"error": "Comment not found or not soft-deleted."}
|
||||
|
||||
|
||||
|
||||
# Like endpoints
|
||||
@blog_router.post("/posts/{slug}/like", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def toggle_like(request, slug: str):
|
||||
"""Toggle like on a post"""
|
||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||
user = request.auth
|
||||
|
||||
like, created = Like.objects.get_or_create(post=post, user=user)
|
||||
|
||||
if not created:
|
||||
like.delete()
|
||||
return 200, {"message": "Post unliked"}
|
||||
|
||||
return 200, {"message": "Post liked"}
|
||||
|
||||
@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema})
|
||||
def get_likes_count(request, slug: str):
|
||||
"""Get likes count for a post"""
|
||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||
count = post.likes.count()
|
||||
return {"message": f"{count}"}
|
||||
|
||||
|
||||
|
||||
# Category endpoints
|
||||
@blog_router.get("/categories", response=List[CategorySchema])
|
||||
def list_categories(request):
|
||||
"""List all categories"""
|
||||
return Category.objects.all()
|
||||
|
||||
|
||||
@blog_router.get("/categories/{slug}", response=CategorySchema)
|
||||
def get_category(request, slug: str):
|
||||
"""Get single category by slug"""
|
||||
return get_object_or_404(Category, slug=slug)
|
||||
|
||||
@blog_router.get("/deleted/categories", response=List[CategorySchema], auth=jwt_auth)
|
||||
def list_deleted_categories(request):
|
||||
"""List all soft-deleted categories (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Category.deleted_objects.all()
|
||||
|
||||
@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
@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):
|
||||
"""Restore a soft-deleted category (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
if not request.auth.is_superuser:
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
category = Category.deleted_objects.get(id=category_id)
|
||||
@@ -272,29 +469,26 @@ def restore_category(request, category_id: int):
|
||||
return 400, {"error": "Category not found or not soft-deleted."}
|
||||
|
||||
|
||||
|
||||
# Tag endpoints
|
||||
@blog_router.get("/tags", response=List[TagSchema])
|
||||
def list_tags(request):
|
||||
"""List all tags"""
|
||||
return Tag.objects.all()
|
||||
|
||||
|
||||
@blog_router.get("/tags/{slug}", response=TagSchema)
|
||||
def get_tag(request, slug: str):
|
||||
"""Get single tag by slug"""
|
||||
return get_object_or_404(Tag, slug=slug)
|
||||
|
||||
@blog_router.get("/deleted/tags", response=List[TagSchema], auth=jwt_auth)
|
||||
def list_deleted_tags(request):
|
||||
"""List all soft-deleted tags (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Tag.all_objects.all()
|
||||
|
||||
@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
@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):
|
||||
"""Restore a soft-deleted tag (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
if not request.auth.is_superuser:
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
tag = Tag.deleted_objects.get(id=tag_id)
|
||||
|
||||
Reference in New Issue
Block a user