feat(backend): add blog publishing platform
This commit is contained in:
@@ -74,6 +74,10 @@ NOTIFICATION_RETENTION_DAYS=30
|
||||
NOTIFICATION_DEFAULT_PAGE_SIZE=20
|
||||
NOTIFICATION_MAX_PAGE_SIZE=100
|
||||
|
||||
# Blog uploads
|
||||
BLOG_ASSET_MAX_SIZE_MB=50
|
||||
BLOG_IMAGE_ASSET_MAX_SIZE_MB=10
|
||||
|
||||
# Optional web-push settings kept for legacy admin flows
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.contrib import admin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from simplemde.widgets import SimpleMDEEditor
|
||||
|
||||
from apps.blog.models import Category, Tag, Post, Comment, Like
|
||||
from apps.blog.models import Category, Tag, Post, PostAsset, Comment, Like, SavedPost
|
||||
from apps.blog.resources import PostResource, CategoryResource
|
||||
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
|
||||
@@ -72,7 +72,7 @@ class PostAdminForm(forms.ModelForm):
|
||||
class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
form = PostAdminForm
|
||||
resource_class = PostResource
|
||||
list_display = ('title', 'author', 'status', 'category', 'is_featured', 'published_at', 'created_at')
|
||||
list_display = ('title', 'author', 'status', 'category', 'is_featured', 'submitted_at', 'published_at', 'created_at')
|
||||
list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter)
|
||||
search_fields = ('title', 'content', 'author__username')
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
@@ -83,8 +83,11 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
('Content', {
|
||||
'fields': ('title', 'slug', 'content', 'excerpt', 'featured_image')
|
||||
}),
|
||||
('SEO', {
|
||||
'fields': ('seo_title', 'seo_description', 'canonical_url', 'og_title', 'og_description', 'og_image', 'noindex', 'focus_keyword', 'reading_time')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('author', 'category', 'tags', 'status', 'is_featured', 'published_at')
|
||||
'fields': ('author', 'category', 'tags', 'status', 'is_featured', 'submitted_at', 'reviewed_at', 'reviewed_by', 'review_note', 'published_at', 'published_by')
|
||||
}),
|
||||
('Soft Delete', {
|
||||
'fields': ('is_deleted', 'deleted_at'),
|
||||
@@ -92,12 +95,22 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = ('deleted_at',)
|
||||
readonly_fields = ('deleted_at', 'content_html', 'reading_time')
|
||||
|
||||
actions = BaseModelAdmin.actions + ['make_published', 'make_draft', 'make_featured', 'restore_posts']
|
||||
actions = BaseModelAdmin.actions + ['submit_for_review', 'request_changes', 'make_published', 'make_draft', 'make_featured', 'restore_posts']
|
||||
|
||||
def submit_for_review(self, request, queryset):
|
||||
queryset.update(status='submitted')
|
||||
self.message_user(request, f"Submitted {queryset.count()} posts for review.")
|
||||
submit_for_review.short_description = "Submit selected posts for review"
|
||||
|
||||
def request_changes(self, request, queryset):
|
||||
queryset.update(status='changes_requested', reviewed_by=request.user)
|
||||
self.message_user(request, f"Requested changes on {queryset.count()} posts.")
|
||||
request_changes.short_description = "Request changes on selected posts"
|
||||
|
||||
def make_published(self, request, queryset):
|
||||
queryset.update(status='published')
|
||||
queryset.update(status='published', published_by=request.user)
|
||||
self.message_user(request, f"Published {queryset.count()} posts.")
|
||||
make_published.short_description = "Mark selected posts as published"
|
||||
|
||||
@@ -129,7 +142,7 @@ class CommentAdmin(BaseModelAdmin):
|
||||
'fields': ('post', 'author', 'content')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('is_approved', 'created_at', 'updated_at')
|
||||
'fields': ('is_approved', 'hidden_by', 'hidden_at', 'moderation_note', 'created_at', 'updated_at')
|
||||
}),
|
||||
('Soft Delete', {
|
||||
'fields': ('is_deleted', 'deleted_at'),
|
||||
@@ -153,7 +166,22 @@ class CommentAdmin(BaseModelAdmin):
|
||||
disapprove_comments.short_description = "Disapprove selected comments"
|
||||
|
||||
@admin.register(Like)
|
||||
class LikeAdmin(BaseModelAdmin):
|
||||
class LikeAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'post', 'created_at')
|
||||
list_filter = ('created_at', 'post')
|
||||
search_fields = ('user__username', 'post__title')
|
||||
|
||||
|
||||
@admin.register(SavedPost)
|
||||
class SavedPostAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'post', 'created_at')
|
||||
list_filter = ('created_at', 'post')
|
||||
search_fields = ('user__username', 'post__title')
|
||||
|
||||
|
||||
@admin.register(PostAsset)
|
||||
class PostAssetAdmin(BaseModelAdmin):
|
||||
list_display = ('title', 'post', 'file_type', 'mime_type', 'size', 'uploaded_by', 'created_at')
|
||||
list_filter = ('file_type', 'mime_type', 'created_at')
|
||||
search_fields = ('title', 'caption', 'alt_text', 'post__title', 'uploaded_by__username')
|
||||
readonly_fields = ('size', 'mime_type', 'created_at', 'updated_at', 'deleted_at')
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
apps/blog/management/__init__.py
Normal file
1
apps/blog/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Blog management commands."""
|
||||
1
apps/blog/management/commands/__init__.py
Normal file
1
apps/blog/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Blog command package."""
|
||||
72
apps/blog/management/commands/sync_blog_roles.py
Normal file
72
apps/blog/management/commands/sync_blog_roles.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.blog.models import Category, Post, Tag
|
||||
from apps.blog.permissions import (
|
||||
ASSOCIATION_ADMIN_GROUP,
|
||||
BLOG_EDITOR_GROUP,
|
||||
BLOG_SUPERVISOR_GROUP,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create or refresh blog role groups and their permissions."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
post_ct = ContentType.objects.get_for_model(Post)
|
||||
category_ct = ContentType.objects.get_for_model(Category)
|
||||
tag_ct = ContentType.objects.get_for_model(Tag)
|
||||
|
||||
specs = [
|
||||
(post_ct, "add_post", "Can add post"),
|
||||
(post_ct, "change_post", "Can change post"),
|
||||
(post_ct, "access_blog_admin", "Can access blog admin"),
|
||||
(post_ct, "upload_blog_asset", "Can upload blog assets"),
|
||||
(post_ct, "review_blog_post", "Can review blog posts"),
|
||||
(post_ct, "publish_blog_post", "Can publish blog posts"),
|
||||
(post_ct, "moderate_blog_comment", "Can moderate blog comments"),
|
||||
(category_ct, "add_category", "Can add category"),
|
||||
(category_ct, "change_category", "Can change category"),
|
||||
(tag_ct, "add_tag", "Can add tag"),
|
||||
(tag_ct, "change_tag", "Can change tag"),
|
||||
]
|
||||
|
||||
permissions = {}
|
||||
for content_type, codename, name in specs:
|
||||
permission, _ = Permission.objects.get_or_create(
|
||||
content_type=content_type,
|
||||
codename=codename,
|
||||
defaults={"name": name},
|
||||
)
|
||||
permissions[codename] = permission
|
||||
|
||||
editor, _ = Group.objects.get_or_create(name=BLOG_EDITOR_GROUP)
|
||||
editor.permissions.set(
|
||||
[
|
||||
permissions["add_post"],
|
||||
permissions["change_post"],
|
||||
permissions["access_blog_admin"],
|
||||
permissions["upload_blog_asset"],
|
||||
]
|
||||
)
|
||||
|
||||
supervisor, _ = Group.objects.get_or_create(name=BLOG_SUPERVISOR_GROUP)
|
||||
supervisor.permissions.set(
|
||||
[
|
||||
permissions["add_post"],
|
||||
permissions["change_post"],
|
||||
permissions["access_blog_admin"],
|
||||
permissions["upload_blog_asset"],
|
||||
permissions["review_blog_post"],
|
||||
permissions["publish_blog_post"],
|
||||
permissions["moderate_blog_comment"],
|
||||
permissions["add_category"],
|
||||
permissions["change_category"],
|
||||
permissions["add_tag"],
|
||||
permissions["change_tag"],
|
||||
]
|
||||
)
|
||||
|
||||
Group.objects.get_or_create(name=ASSOCIATION_ADMIN_GROUP)
|
||||
self.stdout.write(self.style.SUCCESS("Blog role groups synchronized."))
|
||||
297
apps/blog/migrations/0003_blog_platform.py
Normal file
297
apps/blog/migrations/0003_blog_platform.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# Generated by Django 5.2.5 on 2026-06-08 17:29
|
||||
|
||||
import apps.blog.models
|
||||
import django.db.models.deletion
|
||||
import markdown
|
||||
import re
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_post_render_fields(apps, schema_editor):
|
||||
Post = apps.get_model('blog', 'Post')
|
||||
for post in Post.objects.all():
|
||||
html = markdown.markdown(
|
||||
post.content or '',
|
||||
extensions=[
|
||||
'markdown.extensions.extra',
|
||||
'markdown.extensions.codehilite',
|
||||
'markdown.extensions.toc',
|
||||
],
|
||||
)
|
||||
plain_text = re.sub(r'<[^<]+?>', ' ', html).replace('\n', ' ').strip()
|
||||
post.content_html = html
|
||||
word_count = len((post.content or '').split())
|
||||
post.reading_time = max(1, (word_count + 199) // 200)
|
||||
if not post.excerpt and plain_text:
|
||||
post.excerpt = f'{plain_text[:297]}...' if len(plain_text) > 300 else plain_text
|
||||
post.save(update_fields=['content_html', 'reading_time', 'excerpt'])
|
||||
|
||||
|
||||
def seed_blog_role_groups(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
Permission = apps.get_model('auth', 'Permission')
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
|
||||
post_ct, _ = ContentType.objects.get_or_create(app_label='blog', model='post')
|
||||
category_ct, _ = ContentType.objects.get_or_create(app_label='blog', model='category')
|
||||
tag_ct, _ = ContentType.objects.get_or_create(app_label='blog', model='tag')
|
||||
|
||||
permission_specs = [
|
||||
(post_ct, 'access_blog_admin', 'Can access blog admin'),
|
||||
(post_ct, 'review_blog_post', 'Can review blog posts'),
|
||||
(post_ct, 'publish_blog_post', 'Can publish blog posts'),
|
||||
(post_ct, 'moderate_blog_comment', 'Can moderate blog comments'),
|
||||
(post_ct, 'upload_blog_asset', 'Can upload blog assets'),
|
||||
(post_ct, 'add_post', 'Can add post'),
|
||||
(post_ct, 'change_post', 'Can change post'),
|
||||
(category_ct, 'add_category', 'Can add category'),
|
||||
(category_ct, 'change_category', 'Can change category'),
|
||||
(tag_ct, 'add_tag', 'Can add tag'),
|
||||
(tag_ct, 'change_tag', 'Can change tag'),
|
||||
]
|
||||
permissions = {}
|
||||
for content_type, codename, name in permission_specs:
|
||||
permission, _ = Permission.objects.get_or_create(
|
||||
content_type=content_type,
|
||||
codename=codename,
|
||||
defaults={'name': name},
|
||||
)
|
||||
permissions[codename] = permission
|
||||
|
||||
editor, _ = Group.objects.get_or_create(name='blog_editor')
|
||||
editor.permissions.add(
|
||||
permissions['add_post'],
|
||||
permissions['change_post'],
|
||||
permissions['access_blog_admin'],
|
||||
permissions['upload_blog_asset'],
|
||||
)
|
||||
|
||||
supervisor, _ = Group.objects.get_or_create(name='blog_supervisor')
|
||||
supervisor.permissions.add(
|
||||
permissions['add_post'],
|
||||
permissions['change_post'],
|
||||
permissions['access_blog_admin'],
|
||||
permissions['upload_blog_asset'],
|
||||
permissions['review_blog_post'],
|
||||
permissions['publish_blog_post'],
|
||||
permissions['moderate_blog_comment'],
|
||||
permissions['add_category'],
|
||||
permissions['change_category'],
|
||||
permissions['add_tag'],
|
||||
permissions['change_tag'],
|
||||
)
|
||||
|
||||
Group.objects.get_or_create(name='association_admin')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('blog', '0002_initial'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PostAsset',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('file', models.FileField(upload_to=apps.blog.models.post_asset_upload_to)),
|
||||
('file_type', models.CharField(choices=[('image', 'Image'), ('video', 'Video'), ('document', 'Document'), ('archive', 'Archive'), ('other', 'Other')], default='other', max_length=16)),
|
||||
('title', models.CharField(blank=True, max_length=200)),
|
||||
('alt_text', models.CharField(blank=True, max_length=200)),
|
||||
('caption', models.TextField(blank=True)),
|
||||
('size', models.PositiveBigIntegerField(default=0)),
|
||||
('mime_type', models.CharField(blank=True, max_length=120)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SavedPost',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='post',
|
||||
options={'ordering': ['-created_at'], 'permissions': [('access_blog_admin', 'Can access blog admin'), ('review_blog_post', 'Can review blog posts'), ('publish_blog_post', 'Can publish blog posts'), ('moderate_blog_comment', 'Can moderate blog comments'), ('upload_blog_asset', 'Can upload blog assets')]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='comment',
|
||||
name='hidden_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='comment',
|
||||
name='hidden_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hidden_blog_comments', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='comment',
|
||||
name='moderation_note',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='canonical_url',
|
||||
field=models.URLField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='content_html',
|
||||
field=models.TextField(blank=True, editable=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='focus_keyword',
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='noindex',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='og_description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='og_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='blog/og/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='og_title',
|
||||
field=models.CharField(blank=True, max_length=95),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='published_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='published_blog_posts', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='reading_time',
|
||||
field=models.PositiveIntegerField(default=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='review_note',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='reviewed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='reviewed_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_blog_posts', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='seo_description',
|
||||
field=models.CharField(blank=True, max_length=170),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='seo_title',
|
||||
field=models.CharField(blank=True, max_length=70),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='post',
|
||||
name='submitted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='category',
|
||||
name='slug',
|
||||
field=models.SlugField(allow_unicode=True, blank=True, max_length=100, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='slug',
|
||||
field=models.SlugField(allow_unicode=True, blank=True, max_length=200, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('draft', 'Draft'), ('submitted', 'Submitted for review'), ('changes_requested', 'Changes requested'), ('published', 'Published'), ('archived', 'Archived')], default='draft', max_length=24),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tag',
|
||||
name='slug',
|
||||
field=models.SlugField(allow_unicode=True, blank=True, unique=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='comment',
|
||||
index=models.Index(fields=['author', 'created_at'], name='blog_commen_author__9faedb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='like',
|
||||
index=models.Index(fields=['user', 'created_at'], name='blog_like_user_id_7a46aa_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='post',
|
||||
index=models.Index(fields=['author', 'status'], name='blog_post_author__95cbf7_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='post',
|
||||
index=models.Index(fields=['slug', 'status'], name='blog_post_slug_714acb_idx'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='postasset',
|
||||
name='post',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='blog.post'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='postasset',
|
||||
name='uploaded_by',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blog_assets', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='savedpost',
|
||||
name='post',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saves', to='blog.post'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='savedpost',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_posts', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='postasset',
|
||||
index=models.Index(fields=['post', 'file_type'], name='blog_postas_post_id_d81393_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='postasset',
|
||||
index=models.Index(fields=['uploaded_by', 'created_at'], name='blog_postas_uploade_c579a7_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='savedpost',
|
||||
index=models.Index(fields=['post'], name='blog_savedp_post_id_62b622_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='savedpost',
|
||||
index=models.Index(fields=['user', 'created_at'], name='blog_savedp_user_id_c04172_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='savedpost',
|
||||
unique_together={('post', 'user')},
|
||||
),
|
||||
migrations.RunPython(backfill_post_render_fields, migrations.RunPython.noop),
|
||||
migrations.RunPython(seed_blog_role_groups, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1,156 +1,347 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import markdown
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from core.media import (
|
||||
BLUR_VARIANT,
|
||||
PREVIEW_VARIANT,
|
||||
THUMBNAIL_VARIANT,
|
||||
delete_image_derivatives_by_name,
|
||||
derivative_url,
|
||||
get_image_previous_name,
|
||||
safe_process_public_image,
|
||||
)
|
||||
from core.models import BaseModel
|
||||
|
||||
|
||||
def _plain_text_from_markdown(value: str) -> str:
|
||||
html = markdown.markdown(value or "", extensions=["markdown.extensions.extra"])
|
||||
return re.sub(r"<[^<]+?>", " ", html).replace("\n", " ").strip()
|
||||
|
||||
|
||||
def _unique_slug_for(instance, value: str) -> str:
|
||||
base = slugify(value, allow_unicode=True) or uuid4().hex[:10]
|
||||
slug = base[:180]
|
||||
counter = 2
|
||||
manager = getattr(instance.__class__, "all_objects", instance.__class__._default_manager)
|
||||
queryset = manager.filter(slug=slug)
|
||||
if instance.pk:
|
||||
queryset = queryset.exclude(pk=instance.pk)
|
||||
|
||||
while queryset.exists():
|
||||
suffix = f"-{counter}"
|
||||
slug = f"{base[: 200 - len(suffix)]}{suffix}"
|
||||
queryset = manager.filter(slug=slug)
|
||||
if instance.pk:
|
||||
queryset = queryset.exclude(pk=instance.pk)
|
||||
counter += 1
|
||||
|
||||
return slug
|
||||
|
||||
|
||||
def post_asset_upload_to(instance: "PostAsset", filename: str) -> str:
|
||||
suffix = Path(filename).suffix.lower()
|
||||
post_part = instance.post_id or "draft"
|
||||
return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}"
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
slug = models.SlugField(max_length=100, unique=True, blank=True)
|
||||
slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Categories"
|
||||
ordering = ['name']
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
self.slug = _unique_slug_for(self, self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Tag(BaseModel):
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(max_length=50, unique=True, blank=True)
|
||||
slug = models.SlugField(max_length=50, unique=True, blank=True, allow_unicode=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
self.slug = _unique_slug_for(self, self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Post(BaseModel):
|
||||
class StatusChoices(models.TextChoices):
|
||||
DRAFT = 'draft', 'Draft'
|
||||
PUBLISHED = 'published', 'Published'
|
||||
DRAFT = "draft", "Draft"
|
||||
SUBMITTED = "submitted", "Submitted for review"
|
||||
CHANGES_REQUESTED = "changes_requested", "Changes requested"
|
||||
PUBLISHED = "published", "Published"
|
||||
ARCHIVED = "archived", "Archived"
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=200, unique=True, blank=True)
|
||||
slug = models.SlugField(max_length=200, unique=True, blank=True, allow_unicode=True)
|
||||
content = models.TextField(help_text="Content in Markdown format")
|
||||
content_html = models.TextField(blank=True, editable=False)
|
||||
excerpt = models.TextField(max_length=300, blank=True)
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts')
|
||||
featured_image = models.ImageField(upload_to='blog/featured/', null=True, blank=True)
|
||||
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts")
|
||||
featured_image = models.ImageField(upload_to="blog/featured/", null=True, blank=True)
|
||||
status = models.CharField(max_length=24, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts')
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts")
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name="posts")
|
||||
is_featured = models.BooleanField(default=False)
|
||||
|
||||
seo_title = models.CharField(max_length=70, blank=True)
|
||||
seo_description = models.CharField(max_length=170, blank=True)
|
||||
canonical_url = models.URLField(blank=True)
|
||||
og_title = models.CharField(max_length=95, blank=True)
|
||||
og_description = models.CharField(max_length=200, blank=True)
|
||||
og_image = models.ImageField(upload_to="blog/og/", null=True, blank=True)
|
||||
noindex = models.BooleanField(default=False)
|
||||
focus_keyword = models.CharField(max_length=120, blank=True)
|
||||
reading_time = models.PositiveIntegerField(default=1)
|
||||
|
||||
submitted_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="reviewed_blog_posts",
|
||||
)
|
||||
review_note = models.TextField(blank=True)
|
||||
published_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="published_blog_posts",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'published_at']),
|
||||
models.Index(fields=['is_featured']),
|
||||
models.Index(fields=["status", "published_at"]),
|
||||
models.Index(fields=["is_featured"]),
|
||||
models.Index(fields=["author", "status"]),
|
||||
models.Index(fields=["slug", "status"]),
|
||||
]
|
||||
permissions = [
|
||||
("access_blog_admin", "Can access blog admin"),
|
||||
("review_blog_post", "Can review blog posts"),
|
||||
("publish_blog_post", "Can publish blog posts"),
|
||||
("moderate_blog_comment", "Can moderate blog comments"),
|
||||
("upload_blog_asset", "Can upload blog assets"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_image_name = get_image_previous_name(self, "featured_image")
|
||||
current_image_name = self.featured_image.name if self.featured_image else None
|
||||
previous_featured_name = get_image_previous_name(self, "featured_image")
|
||||
current_featured_name = self.featured_image.name if self.featured_image else None
|
||||
previous_og_name = get_image_previous_name(self, "og_image")
|
||||
current_og_name = self.og_image.name if self.og_image else None
|
||||
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
|
||||
# Auto-generate excerpt if not provided
|
||||
self.slug = _unique_slug_for(self, self.title)
|
||||
|
||||
if not self.excerpt and self.content:
|
||||
# Convert markdown to plain text for excerpt
|
||||
plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra'])
|
||||
# Remove HTML tags and truncate
|
||||
import re
|
||||
plain_text = re.sub('<[^<]+?>', '', plain_text)
|
||||
self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text
|
||||
plain_text = _plain_text_from_markdown(self.content)
|
||||
self.excerpt = f"{plain_text[:297]}..." if len(plain_text) > 300 else plain_text
|
||||
|
||||
self.content_html = markdown.markdown(
|
||||
self.content or "",
|
||||
extensions=[
|
||||
"markdown.extensions.extra",
|
||||
"markdown.extensions.codehilite",
|
||||
"markdown.extensions.toc",
|
||||
],
|
||||
)
|
||||
word_count = len((self.content or "").split())
|
||||
self.reading_time = max(1, (word_count + 199) // 200)
|
||||
|
||||
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
|
||||
self.published_at = timezone.now()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if previous_image_name != current_image_name and previous_image_name:
|
||||
if previous_featured_name != current_featured_name and previous_featured_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.featured_image.storage if self.featured_image else None,
|
||||
previous_image_name,
|
||||
previous_featured_name,
|
||||
"blog_featured",
|
||||
delete_original=True,
|
||||
)
|
||||
|
||||
if previous_image_name != current_image_name and self.featured_image:
|
||||
if previous_featured_name != current_featured_name and self.featured_image:
|
||||
safe_process_public_image(self.featured_image, "blog_featured")
|
||||
|
||||
@property
|
||||
def content_html(self):
|
||||
"""Convert markdown content to HTML"""
|
||||
return markdown.markdown(
|
||||
self.content,
|
||||
extensions=[
|
||||
'markdown.extensions.extra',
|
||||
'markdown.extensions.codehilite',
|
||||
'markdown.extensions.toc',
|
||||
]
|
||||
)
|
||||
if previous_og_name != current_og_name and previous_og_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.og_image.storage if self.og_image else None,
|
||||
previous_og_name,
|
||||
"blog_featured",
|
||||
delete_original=True,
|
||||
)
|
||||
if previous_og_name != current_og_name and self.og_image:
|
||||
safe_process_public_image(self.og_image, "blog_featured")
|
||||
|
||||
@property
|
||||
def reading_time(self):
|
||||
"""Estimate reading time in minutes assuming 200 words per minute."""
|
||||
word_count = len(self.content.split())
|
||||
return max(1, word_count // 200)
|
||||
|
||||
class Comment(BaseModel):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments')
|
||||
content = models.TextField()
|
||||
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies')
|
||||
is_approved = models.BooleanField(default=True)
|
||||
class PostAsset(BaseModel):
|
||||
class FileType(models.TextChoices):
|
||||
IMAGE = "image", "Image"
|
||||
VIDEO = "video", "Video"
|
||||
DOCUMENT = "document", "Document"
|
||||
ARCHIVE = "archive", "Archive"
|
||||
OTHER = "other", "Other"
|
||||
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="assets")
|
||||
file = models.FileField(upload_to=post_asset_upload_to)
|
||||
file_type = models.CharField(max_length=16, choices=FileType.choices, default=FileType.OTHER)
|
||||
title = models.CharField(max_length=200, blank=True)
|
||||
alt_text = models.CharField(max_length=200, blank=True)
|
||||
caption = models.TextField(blank=True)
|
||||
size = models.PositiveBigIntegerField(default=0)
|
||||
mime_type = models.CharField(max_length=120, blank=True)
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="blog_assets",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=['post', 'is_approved']),
|
||||
models.Index(fields=["post", "file_type"]),
|
||||
models.Index(fields=["uploaded_by", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'Comment by {self.author.username} on {self.post.title}'
|
||||
return self.title or Path(self.file.name).name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
previous_file_name = get_image_previous_name(self, "file")
|
||||
current_file_name = self.file.name if self.file else None
|
||||
if self.file:
|
||||
self.size = self.file.size or self.size
|
||||
if not self.title:
|
||||
self.title = Path(self.file.name).name
|
||||
if not self.mime_type:
|
||||
self.mime_type = mimetypes.guess_type(self.file.name)[0] or ""
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if previous_file_name != current_file_name and previous_file_name:
|
||||
delete_image_derivatives_by_name(
|
||||
self.file.storage if self.file else None,
|
||||
previous_file_name,
|
||||
"blog_asset",
|
||||
delete_original=True,
|
||||
)
|
||||
if previous_file_name != current_file_name and self.file_type == self.FileType.IMAGE and self.file:
|
||||
safe_process_public_image(self.file, "blog_asset")
|
||||
|
||||
@property
|
||||
def file_url(self):
|
||||
return self.file.url if self.file else None
|
||||
|
||||
@property
|
||||
def thumbnail_url(self):
|
||||
return derivative_url(self.file, THUMBNAIL_VARIANT) if self.file_type == self.FileType.IMAGE else None
|
||||
|
||||
@property
|
||||
def preview_url(self):
|
||||
return derivative_url(self.file, PREVIEW_VARIANT) if self.file_type == self.FileType.IMAGE else None
|
||||
|
||||
@property
|
||||
def blur_url(self):
|
||||
return derivative_url(self.file, BLUR_VARIANT) if self.file_type == self.FileType.IMAGE else None
|
||||
|
||||
|
||||
class Comment(BaseModel):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
|
||||
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments")
|
||||
content = models.TextField()
|
||||
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies")
|
||||
is_approved = models.BooleanField(default=True)
|
||||
hidden_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="hidden_blog_comments",
|
||||
)
|
||||
hidden_at = models.DateTimeField(null=True, blank=True)
|
||||
moderation_note = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["post", "is_approved"]),
|
||||
models.Index(fields=["author", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Comment by {self.author.username} on {self.post.title}"
|
||||
|
||||
@property
|
||||
def is_reply(self):
|
||||
return self.parent is not None
|
||||
|
||||
def hide(self, user, note: str = ""):
|
||||
self.is_approved = False
|
||||
self.hidden_by = user
|
||||
self.hidden_at = timezone.now()
|
||||
self.moderation_note = note
|
||||
self.save(update_fields=["is_approved", "hidden_by", "hidden_at", "moderation_note", "updated_at"])
|
||||
|
||||
|
||||
class Like(models.Model):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='likes')
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="likes")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="likes")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['post', 'user']
|
||||
unique_together = ["post", "user"]
|
||||
indexes = [
|
||||
models.Index(fields=['post']),
|
||||
models.Index(fields=["post"]),
|
||||
models.Index(fields=["user", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user.username} likes {self.post.title}'
|
||||
return f"{self.user.username} likes {self.post.title}"
|
||||
|
||||
|
||||
class SavedPost(models.Model):
|
||||
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="saves")
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="saved_posts")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["post", "user"]
|
||||
indexes = [
|
||||
models.Index(fields=["post"]),
|
||||
models.Index(fields=["user", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} saved {self.post.title}"
|
||||
|
||||
93
apps/blog/permissions.py
Normal file
93
apps/blog/permissions.py
Normal file
@@ -0,0 +1,93 @@
|
||||
BLOG_EDITOR_GROUP = "blog_editor"
|
||||
BLOG_SUPERVISOR_GROUP = "blog_supervisor"
|
||||
ASSOCIATION_ADMIN_GROUP = "association_admin"
|
||||
|
||||
BLOG_EDITOR_PERMISSIONS = {
|
||||
"blog.add_post",
|
||||
"blog.change_post",
|
||||
"blog.access_blog_admin",
|
||||
"blog.upload_blog_asset",
|
||||
}
|
||||
|
||||
BLOG_SUPERVISOR_PERMISSIONS = BLOG_EDITOR_PERMISSIONS | {
|
||||
"blog.review_blog_post",
|
||||
"blog.publish_blog_post",
|
||||
"blog.moderate_blog_comment",
|
||||
"blog.add_category",
|
||||
"blog.change_category",
|
||||
"blog.add_tag",
|
||||
"blog.change_tag",
|
||||
}
|
||||
|
||||
|
||||
def _has_any_perm(user, permissions: set[str]) -> bool:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return any(user.has_perm(permission) for permission in permissions)
|
||||
|
||||
|
||||
def can_access_blog_admin(user) -> bool:
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and (
|
||||
user.is_superuser
|
||||
or user.is_staff
|
||||
or user.has_perm("blog.access_blog_admin")
|
||||
or user.has_perm("blog.add_post")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_write_blog_posts(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_post")
|
||||
or user.has_perm("blog.change_post")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_review_blog_posts(user) -> bool:
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and (
|
||||
user.is_superuser
|
||||
or user.is_staff
|
||||
or user.has_perm("blog.review_blog_post")
|
||||
or user.has_perm("blog.publish_blog_post")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_moderate_blog_comments(user) -> bool:
|
||||
return bool(
|
||||
user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and (
|
||||
user.is_superuser
|
||||
or user.is_staff
|
||||
or user.has_perm("blog.moderate_blog_comment")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_edit_post(user, post) -> bool:
|
||||
if not user or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if user.is_superuser or user.is_staff or can_review_blog_posts(user):
|
||||
return True
|
||||
return bool(post.author_id == user.id and can_write_blog_posts(user) and post.status != "archived")
|
||||
|
||||
|
||||
def can_manage_post_assets(user, post) -> bool:
|
||||
if not can_edit_post(user, post):
|
||||
return False
|
||||
return bool(user.is_superuser or user.is_staff or user.has_perm("blog.upload_blog_asset"))
|
||||
@@ -6,6 +6,7 @@ from typing import Optional
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.blog.permissions import can_access_blog_admin, can_review_blog_posts, can_write_blog_posts
|
||||
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||
|
||||
|
||||
@@ -97,6 +98,9 @@ class UserProfileSchema(ModelSchema):
|
||||
mobile: Optional[str] = None
|
||||
requires_mobile_verification: bool
|
||||
has_google_link: bool
|
||||
can_access_blog_admin: bool
|
||||
can_write_blog_posts: bool
|
||||
can_review_blog_posts: bool
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -138,6 +142,18 @@ class UserProfileSchema(ModelSchema):
|
||||
def resolve_has_google_link(obj):
|
||||
return obj.has_google_link
|
||||
|
||||
@staticmethod
|
||||
def resolve_can_access_blog_admin(obj):
|
||||
return can_access_blog_admin(obj)
|
||||
|
||||
@staticmethod
|
||||
def resolve_can_write_blog_posts(obj):
|
||||
return can_write_blog_posts(obj)
|
||||
|
||||
@staticmethod
|
||||
def resolve_can_review_blog_posts(obj):
|
||||
return can_review_blog_posts(obj)
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture(obj, context):
|
||||
request = context["request"]
|
||||
|
||||
@@ -251,6 +251,9 @@ NOTIFICATION_RETENTION_DAYS = config('NOTIFICATION_RETENTION_DAYS', default=30,
|
||||
NOTIFICATION_DEFAULT_PAGE_SIZE = config('NOTIFICATION_DEFAULT_PAGE_SIZE', default=20, cast=int)
|
||||
NOTIFICATION_MAX_PAGE_SIZE = config('NOTIFICATION_MAX_PAGE_SIZE', default=100, cast=int)
|
||||
|
||||
BLOG_ASSET_MAX_SIZE_MB = config('BLOG_ASSET_MAX_SIZE_MB', default=50, cast=int)
|
||||
BLOG_IMAGE_ASSET_MAX_SIZE_MB = config('BLOG_IMAGE_ASSET_MAX_SIZE_MB', default=10, cast=int)
|
||||
|
||||
if DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql":
|
||||
DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql"
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ class SoftDeleteListFilter(admin.SimpleListFilter):
|
||||
class BaseModelAdmin(ModelAdmin):
|
||||
actions = ["hard_delete_selected", "restore_selected"]
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return bool(request.user and request.user.is_superuser)
|
||||
|
||||
def get_queryset(self, request):
|
||||
return self.model.all_objects.all()
|
||||
|
||||
|
||||
@@ -64,6 +64,16 @@ IMAGE_FAMILIES: dict[str, ImageFamilySpec] = {
|
||||
ImageVariantSpec(BLUR_VARIANT, (48, 48), quality=36, blur_radius=10.0),
|
||||
),
|
||||
),
|
||||
"blog_asset": ImageFamilySpec(
|
||||
key="blog_asset",
|
||||
original_max_size=(2560, 2560),
|
||||
original_quality=84,
|
||||
variants=(
|
||||
ImageVariantSpec(THUMBNAIL_VARIANT, (640, 640), quality=72),
|
||||
ImageVariantSpec(PREVIEW_VARIANT, (1600, 1600), quality=82),
|
||||
ImageVariantSpec(BLUR_VARIANT, (48, 48), quality=36, blur_radius=10.0),
|
||||
),
|
||||
),
|
||||
"profile_picture": ImageFamilySpec(
|
||||
key="profile_picture",
|
||||
original_max_size=(1200, 1200),
|
||||
|
||||
Reference in New Issue
Block a user