feat(backend): add blog publishing platform
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-08 21:31:06 +03:30
parent b7b21a6cc6
commit 954e78d0cb
14 changed files with 1334 additions and 278 deletions

View File

@@ -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=

View File

@@ -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')

View File

@@ -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"![{obj.alt_text or obj.title}]({request.build_absolute_uri(obj.file.url)})"
@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]

View File

@@ -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 list(queryset[offset : offset + limit])
return posts
@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"}
@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)
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.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
def update_post_compat(request, slug: str, data: PostCreateSchema):
post = get_object_or_404(Post, slug=slug)
return update_admin_post(request, post.id, data)
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)}
@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)

View File

@@ -0,0 +1 @@
"""Blog management commands."""

View File

@@ -0,0 +1 @@
"""Blog command package."""

View 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."))

View 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),
]

View File

@@ -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)
self.slug = _unique_slug_for(self, self.title)
# Auto-generate excerpt if not provided
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")
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",
)
@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 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
View 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"))

View File

@@ -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"]

View File

@@ -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"

View File

@@ -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()

View File

@@ -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),