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_DEFAULT_PAGE_SIZE=20
NOTIFICATION_MAX_PAGE_SIZE=100 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 # Optional web-push settings kept for legacy admin flows
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=

View File

@@ -4,7 +4,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from simplemde.widgets import SimpleMDEEditor 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 apps.blog.resources import PostResource, CategoryResource
from core.admin import SoftDeleteListFilter, BaseModelAdmin from core.admin import SoftDeleteListFilter, BaseModelAdmin
@@ -72,7 +72,7 @@ class PostAdminForm(forms.ModelForm):
class PostAdmin(BaseModelAdmin, ImportExportModelAdmin): class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
form = PostAdminForm form = PostAdminForm
resource_class = PostResource 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) list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter)
search_fields = ('title', 'content', 'author__username') search_fields = ('title', 'content', 'author__username')
prepopulated_fields = {'slug': ('title',)} prepopulated_fields = {'slug': ('title',)}
@@ -83,8 +83,11 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
('Content', { ('Content', {
'fields': ('title', 'slug', 'content', 'excerpt', 'featured_image') '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', { ('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', { ('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'), '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): 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.") self.message_user(request, f"Published {queryset.count()} posts.")
make_published.short_description = "Mark selected posts as published" make_published.short_description = "Mark selected posts as published"
@@ -129,7 +142,7 @@ class CommentAdmin(BaseModelAdmin):
'fields': ('post', 'author', 'content') 'fields': ('post', 'author', 'content')
}), }),
('Metadata', { ('Metadata', {
'fields': ('is_approved', 'created_at', 'updated_at') 'fields': ('is_approved', 'hidden_by', 'hidden_at', 'moderation_note', 'created_at', 'updated_at')
}), }),
('Soft Delete', { ('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'), 'fields': ('is_deleted', 'deleted_at'),
@@ -153,7 +166,22 @@ class CommentAdmin(BaseModelAdmin):
disapprove_comments.short_description = "Disapprove selected comments" disapprove_comments.short_description = "Disapprove selected comments"
@admin.register(Like) @admin.register(Like)
class LikeAdmin(BaseModelAdmin): class LikeAdmin(admin.ModelAdmin):
list_display = ('user', 'post', 'created_at') list_display = ('user', 'post', 'created_at')
list_filter = ('created_at', 'post') list_filter = ('created_at', 'post')
search_fields = ('user__username', 'post__title') 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.""" """Blog API schemas."""
from ninja import Schema, ModelSchema
from typing import Optional, List
from datetime import datetime 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 from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
class CategorySchema(ModelSchema): class CategorySchema(ModelSchema):
created_at: Optional[datetime] = None
class Config: class Config:
model = Category model = Category
model_fields = ['id', 'name', 'slug', 'description'] model_fields = ["id", "name", "slug", "description", "created_at"]
class TagSchema(ModelSchema): class TagSchema(ModelSchema):
created_at: Optional[datetime] = None
class Config: class Config:
model = Tag model = Tag
model_fields = ['id', 'name', 'slug'] model_fields = ["id", "name", "slug", "created_at"]
class AuthorSchema(Schema): class AuthorSchema(Schema):
id: int id: int
@@ -29,8 +36,8 @@ class AuthorSchema(Schema):
@staticmethod @staticmethod
def resolve_profile_picture(obj, context): def resolve_profile_picture(obj, context):
request = context['request'] request = context["request"]
if obj.profile_picture and hasattr(obj.profile_picture, 'url'): if obj.profile_picture and hasattr(obj.profile_picture, "url"):
return request.build_absolute_uri(obj.profile_picture.url) return request.build_absolute_uri(obj.profile_picture.url)
return None return None
@@ -46,6 +53,64 @@ class AuthorSchema(Schema):
url = derivative_url(obj.profile_picture, PREVIEW_VARIANT) url = derivative_url(obj.profile_picture, PREVIEW_VARIANT)
return request.build_absolute_uri(url) if url else None 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): class PostListSchema(Schema):
id: int id: int
title: str title: str
@@ -62,7 +127,18 @@ class PostListSchema(Schema):
tags: List[TagSchema] tags: List[TagSchema]
is_featured: bool is_featured: bool
created_at: datetime created_at: datetime
updated_at: datetime
reading_time: int 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 @staticmethod
def resolve_absolute_featured_image_url(obj, context): def resolve_absolute_featured_image_url(obj, context):
@@ -83,9 +159,32 @@ class PostListSchema(Schema):
url = derivative_url(obj.featured_image, PREVIEW_VARIANT) url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
return request.build_absolute_uri(url) if url else None 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): class PostDetailSchema(PostListSchema):
content: str content: str
content_html: 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): class PostCreateSchema(Schema):
title: str title: str
@@ -95,17 +194,37 @@ class PostCreateSchema(Schema):
tag_ids: Optional[List[int]] = [] tag_ids: Optional[List[int]] = []
status: str = "draft" status: str = "draft"
is_featured: bool = False 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): class CommentSchema(ModelSchema):
author: AuthorSchema author: AuthorSchema
replies: List['CommentSchema'] = [] replies: List["CommentSchema"] = []
post_id: int post_id: int
post_title: str post_title: str
post_slug: str post_slug: str
parent_id: Optional[int] = None
class Config: class Config:
model = Comment model = Comment
model_fields = ['id', 'content', 'created_at', 'is_approved'] model_fields = ["id", "content", "created_at", "is_approved", "hidden_at"]
@staticmethod @staticmethod
def resolve_post_id(obj): def resolve_post_id(obj):
@@ -119,6 +238,30 @@ class CommentSchema(ModelSchema):
def resolve_post_slug(obj): def resolve_post_slug(obj):
return obj.post.slug return obj.post.slug
@staticmethod
def resolve_parent_id(obj):
return obj.parent_id
class CommentCreateSchema(Schema): class CommentCreateSchema(Schema):
content: str content: str
parent_id: Optional[int] = None 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 __future__ import annotations
from django.db.models import Q, Prefetch
from ninja import Router, Query import mimetypes
from pathlib import Path
from typing import List, Optional 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 ( from apps.blog.api.schemas import (
BlogInteractionSchema,
BlogProfileActivitySchema,
CategorySchema, CategorySchema,
CommentCreateSchema, CommentCreateSchema,
CommentHideSchema,
CommentSchema, CommentSchema,
PostAssetCreateSchema,
PostAssetSchema,
PostCreateSchema, PostCreateSchema,
PostDetailSchema, PostDetailSchema,
PostListSchema, PostListSchema,
PostReviewSchema,
TagSchema, 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.api.schemas import ErrorSchema, MessageSchema
from core.authentication import jwt_auth from core.authentication import jwt_auth
blog_router = Router() 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]) @blog_router.get("/posts", response=List[PostListSchema])
def list_posts( def list_posts(
request, request,
@@ -30,124 +302,116 @@ def list_posts(
tag: Optional[str] = None, tag: Optional[str] = None,
search: Optional[str] = None, search: Optional[str] = None,
featured: Optional[bool] = None, featured: Optional[bool] = None,
author: Optional[str] = None author: Optional[str] = None,
): ):
"""List published posts with filtering and pagination""" queryset = _published_queryset()
queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related(
'author', 'category'
).prefetch_related('tags')
# Apply filters
if category: if category:
queryset = queryset.filter(category__slug=category) queryset = queryset.filter(category__slug=category)
if tag: if tag:
queryset = queryset.filter(tags__slug=tag) queryset = queryset.filter(tags__slug=tag)
if search: if search:
queryset = queryset.filter( queryset = queryset.filter(Q(title__icontains=search) | Q(content__icontains=search) | Q(excerpt__icontains=search))
Q(title__icontains=search) |
Q(content__icontains=search) |
Q(excerpt__icontains=search)
)
if featured is not None: if featured is not None:
queryset = queryset.filter(is_featured=featured) queryset = queryset.filter(is_featured=featured)
if author: if author:
queryset = queryset.filter(author__username=author) queryset = queryset.filter(author__username=author)
# Pagination
offset = (page - 1) * limit offset = (page - 1) * limit
posts = queryset[offset:offset + limit] return list(queryset[offset : offset + limit])
return posts
@blog_router.get("/posts/{slug}", response=PostDetailSchema) @blog_router.get("/posts/{slug}", response=PostDetailSchema)
def get_post(request, slug: str): def get_post(request, slug: str):
"""Get single post by slug""" return get_object_or_404(_published_queryset(), slug=slug)
post = get_object_or_404(
Post.objects.select_related('author', 'category').prefetch_related('tags'),
slug=slug,
status=Post.StatusChoices.PUBLISHED
)
return post
@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) @blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
def update_post(request, slug: str, data: PostCreateSchema): def create_post_compat(request, data: PostCreateSchema):
"""Update a post (author or committee only)""" return create_admin_post(request, data)
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) 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): def delete_post(request, slug: str):
"""Soft delete a post owned by the requester or committee.""" if not request.auth.is_superuser:
user = request.auth return 403, {"error": "Only superusers can delete posts"}
post = get_object_or_404(Post, slug=slug) 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() post.delete()
return 200, {"message": "Post deleted successfully"} 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): def restore_post(request, post_id: int):
"""Restore a soft-deleted post (Admin/Committee only)""" if not request.auth.is_superuser:
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"} return 403, {"error": "Permission denied"}
try: try:
post = Post.deleted_objects.get(id=post_id) 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."} return 400, {"error": "Post not found or not soft-deleted."}
@blog_router.get("/deleted/comments", response={200: List[CommentSchema], 403: ErrorSchema}, auth=jwt_auth)
# 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)
def list_deleted_comments(request): def list_deleted_comments(request):
"""List all soft-deleted comments (Admin/Committee only)""" if not can_moderate_blog_comments(request.auth):
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"} 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): def restore_comment(request, comment_id: int):
"""Restore a soft-deleted comment (Admin/Committee only)""" if not can_moderate_blog_comments(request.auth):
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"} return 403, {"error": "Permission denied"}
try: try:
comment = Comment.deleted_objects.get(id=comment_id) 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."} 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]) @blog_router.get("/categories", response=List[CategorySchema])
def list_categories(request): def list_categories(request):
"""List all categories"""
return Category.objects.all() return Category.objects.all()
@blog_router.get("/categories/{slug}", response=CategorySchema) @blog_router.get("/categories/{slug}", response=CategorySchema)
def get_category(request, slug: str): def get_category(request, slug: str):
"""Get single category by slug"""
return get_object_or_404(Category, slug=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): def restore_category(request, category_id: int):
"""Restore a soft-deleted category (Admin/Committee only)""" if not request.auth.is_superuser:
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"} return 403, {"error": "Permission denied"}
try: try:
category = Category.deleted_objects.get(id=category_id) 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."} return 400, {"error": "Category not found or not soft-deleted."}
# Tag endpoints
@blog_router.get("/tags", response=List[TagSchema]) @blog_router.get("/tags", response=List[TagSchema])
def list_tags(request): def list_tags(request):
"""List all tags"""
return Tag.objects.all() return Tag.objects.all()
@blog_router.get("/tags/{slug}", response=TagSchema) @blog_router.get("/tags/{slug}", response=TagSchema)
def get_tag(request, slug: str): def get_tag(request, slug: str):
"""Get single tag by slug"""
return get_object_or_404(Tag, slug=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): def restore_tag(request, tag_id: int):
"""Restore a soft-deleted tag (Admin/Committee only)""" if not request.auth.is_superuser:
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "Permission denied"} return 403, {"error": "Permission denied"}
try: try:
tag = Tag.deleted_objects.get(id=tag_id) 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 __future__ import annotations
from django.conf import settings
from django.utils.text import slugify import mimetypes
from django.utils import timezone import re
from pathlib import Path
from uuid import uuid4
import markdown 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 ( from core.media import (
BLUR_VARIANT,
PREVIEW_VARIANT,
THUMBNAIL_VARIANT,
delete_image_derivatives_by_name, delete_image_derivatives_by_name,
derivative_url,
get_image_previous_name, get_image_previous_name,
safe_process_public_image, safe_process_public_image,
) )
from core.models import BaseModel 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): class Category(BaseModel):
name = models.CharField(max_length=100, unique=True) 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) description = models.TextField(blank=True)
class Meta: class Meta:
verbose_name_plural = "Categories" verbose_name_plural = "Categories"
ordering = ['name'] ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = _unique_slug_for(self, self.name)
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Tag(BaseModel): class Tag(BaseModel):
name = models.CharField(max_length=50, unique=True) 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: class Meta:
ordering = ['name'] ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.slug: if not self.slug:
self.slug = slugify(self.name) self.slug = _unique_slug_for(self, self.name)
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Post(BaseModel): class Post(BaseModel):
class StatusChoices(models.TextChoices): class StatusChoices(models.TextChoices):
DRAFT = 'draft', 'Draft' DRAFT = "draft", "Draft"
PUBLISHED = 'published', 'Published' SUBMITTED = "submitted", "Submitted for review"
CHANGES_REQUESTED = "changes_requested", "Changes requested"
PUBLISHED = "published", "Published"
ARCHIVED = "archived", "Archived"
title = models.CharField(max_length=200) 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 = models.TextField(help_text="Content in Markdown format")
content_html = models.TextField(blank=True, editable=False)
excerpt = models.TextField(max_length=300, blank=True) excerpt = models.TextField(max_length=300, blank=True)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts') 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) featured_image = models.ImageField(upload_to="blog/featured/", null=True, blank=True)
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT) status = models.CharField(max_length=24, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
published_at = models.DateTimeField(null=True, blank=True) published_at = models.DateTimeField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, 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') tags = models.ManyToManyField(Tag, blank=True, related_name="posts")
is_featured = models.BooleanField(default=False) 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: class Meta:
ordering = ['-created_at'] ordering = ["-created_at"]
indexes = [ indexes = [
models.Index(fields=['status', 'published_at']), models.Index(fields=["status", "published_at"]),
models.Index(fields=['is_featured']), 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): def __str__(self):
return self.title return self.title
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
previous_image_name = get_image_previous_name(self, "featured_image") previous_featured_name = get_image_previous_name(self, "featured_image")
current_image_name = self.featured_image.name if self.featured_image else None 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: 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: if not self.excerpt and self.content:
# Convert markdown to plain text for excerpt plain_text = _plain_text_from_markdown(self.content)
plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra']) self.excerpt = f"{plain_text[:297]}..." if len(plain_text) > 300 else plain_text
# Remove HTML tags and truncate
import re self.content_html = markdown.markdown(
plain_text = re.sub('<[^<]+?>', '', plain_text) self.content or "",
self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text 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: if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
self.published_at = timezone.now() self.published_at = timezone.now()
super().save(*args, **kwargs) 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( delete_image_derivatives_by_name(
self.featured_image.storage if self.featured_image else None, self.featured_image.storage if self.featured_image else None,
previous_image_name, previous_featured_name,
"blog_featured", "blog_featured",
delete_original=True, delete_original=True,
) )
if previous_featured_name != current_featured_name and self.featured_image:
if previous_image_name != current_image_name and self.featured_image:
safe_process_public_image(self.featured_image, "blog_featured") safe_process_public_image(self.featured_image, "blog_featured")
@property if previous_og_name != current_og_name and previous_og_name:
def content_html(self): delete_image_derivatives_by_name(
"""Convert markdown content to HTML""" self.og_image.storage if self.og_image else None,
return markdown.markdown( previous_og_name,
self.content, "blog_featured",
extensions=[ delete_original=True,
'markdown.extensions.extra', )
'markdown.extensions.codehilite', if previous_og_name != current_og_name and self.og_image:
'markdown.extensions.toc', 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): class PostAsset(BaseModel):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments') class FileType(models.TextChoices):
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments') IMAGE = "image", "Image"
content = models.TextField() VIDEO = "video", "Video"
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies') DOCUMENT = "document", "Document"
is_approved = models.BooleanField(default=True) 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: class Meta:
ordering = ['created_at'] ordering = ["-created_at"]
indexes = [ indexes = [
models.Index(fields=['post', 'is_approved']), models.Index(fields=["post", "file_type"]),
models.Index(fields=["uploaded_by", "created_at"]),
] ]
def __str__(self): 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 @property
def is_reply(self): def is_reply(self):
return self.parent is not None 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): class Like(models.Model):
post = models.ForeignKey(Post, 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') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="likes")
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
unique_together = ['post', 'user'] unique_together = ["post", "user"]
indexes = [ indexes = [
models.Index(fields=['post']), models.Index(fields=["post"]),
models.Index(fields=["user", "created_at"]),
] ]
def __str__(self): 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 ninja import ModelSchema, Schema
from apps.users.models import User 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 from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
@@ -97,6 +98,9 @@ class UserProfileSchema(ModelSchema):
mobile: Optional[str] = None mobile: Optional[str] = None
requires_mobile_verification: bool requires_mobile_verification: bool
has_google_link: bool has_google_link: bool
can_access_blog_admin: bool
can_write_blog_posts: bool
can_review_blog_posts: bool
class Meta: class Meta:
model = User model = User
@@ -138,6 +142,18 @@ class UserProfileSchema(ModelSchema):
def resolve_has_google_link(obj): def resolve_has_google_link(obj):
return obj.has_google_link 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 @staticmethod
def resolve_profile_picture(obj, context): def resolve_profile_picture(obj, context):
request = context["request"] 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_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) 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": if DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql":
DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql" DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql"

View File

@@ -28,6 +28,9 @@ class SoftDeleteListFilter(admin.SimpleListFilter):
class BaseModelAdmin(ModelAdmin): class BaseModelAdmin(ModelAdmin):
actions = ["hard_delete_selected", "restore_selected"] 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): def get_queryset(self, request):
return self.model.all_objects.all() 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), 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( "profile_picture": ImageFamilySpec(
key="profile_picture", key="profile_picture",
original_max_size=(1200, 1200), original_max_size=(1200, 1200),