diff --git a/.env.example b/.env.example index 30fd6bf..0577e9e 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/blog/admin.py b/apps/blog/admin.py index 84647a2..26badc2 100644 --- a/apps/blog/admin.py +++ b/apps/blog/admin.py @@ -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') diff --git a/apps/blog/api/schemas.py b/apps/blog/api/schemas.py index 7034248..957c713 100644 --- a/apps/blog/api/schemas.py +++ b/apps/blog/api/schemas.py @@ -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] diff --git a/apps/blog/api/views.py b/apps/blog/api/views.py index 0b7152a..fd9ace0 100644 --- a/apps/blog/api/views.py +++ b/apps/blog/api/views.py @@ -1,26 +1,298 @@ -from django.shortcuts import get_object_or_404 -from django.db.models import Q, Prefetch +from __future__ import annotations -from ninja import Router, Query +import mimetypes +from pathlib import Path from typing import List, Optional -from apps.users.models import User +from django.conf import settings +from django.db.models import Count, Prefetch, Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from ninja import File, Form, Query, Router, UploadedFile + from apps.blog.api.schemas import ( + BlogInteractionSchema, + BlogProfileActivitySchema, CategorySchema, CommentCreateSchema, + CommentHideSchema, CommentSchema, + PostAssetCreateSchema, + PostAssetSchema, PostCreateSchema, PostDetailSchema, PostListSchema, + PostReviewSchema, TagSchema, ) -from apps.blog.models import Post, Category, Tag, Comment, Like +from apps.blog.models import Category, Comment, Like, Post, PostAsset, SavedPost, Tag +from apps.blog.permissions import ( + can_access_blog_admin, + can_edit_post, + can_manage_post_assets, + can_moderate_blog_comments, + can_review_blog_posts, + can_write_blog_posts, +) from core.api.schemas import ErrorSchema, MessageSchema from core.authentication import jwt_auth + blog_router = Router() -# Post endpoints +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"} +VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"} +DOCUMENT_EXTENSIONS = {".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".md", ".csv"} +ARCHIVE_EXTENSIONS = {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"} + + +def _post_queryset(): + return ( + Post.objects.select_related("author", "category", "reviewed_by", "published_by") + .prefetch_related("tags", "assets__uploaded_by") + .annotate( + likes_count=Count("likes", distinct=True), + saves_count=Count("saves", distinct=True), + comments_count=Count("comments", filter=Q(comments__is_approved=True), distinct=True), + ) + ) + + +def _published_queryset(): + return _post_queryset().filter(status=Post.StatusChoices.PUBLISHED) + + +def _asset_file_type(file: UploadedFile) -> str: + suffix = Path(file.name).suffix.lower() + content_type = (file.content_type or mimetypes.guess_type(file.name)[0] or "").lower() + if content_type.startswith("image/") or suffix in IMAGE_EXTENSIONS: + return PostAsset.FileType.IMAGE + if content_type.startswith("video/") or suffix in VIDEO_EXTENSIONS: + return PostAsset.FileType.VIDEO + if suffix in DOCUMENT_EXTENSIONS: + return PostAsset.FileType.DOCUMENT + if suffix in ARCHIVE_EXTENSIONS: + return PostAsset.FileType.ARCHIVE + return PostAsset.FileType.OTHER + + +def _validate_asset_file(file: UploadedFile) -> str | None: + file_type = _asset_file_type(file) + suffix = Path(file.name).suffix.lower() + if file_type == PostAsset.FileType.OTHER: + return "Unsupported file type." + + max_size_mb = getattr(settings, "BLOG_ASSET_MAX_SIZE_MB", 50) + image_max_size_mb = getattr(settings, "BLOG_IMAGE_ASSET_MAX_SIZE_MB", 10) + limit_mb = image_max_size_mb if file_type == PostAsset.FileType.IMAGE else max_size_mb + if file.size > limit_mb * 1024 * 1024: + return f"File size must be less than {limit_mb}MB." + + allowed = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS | DOCUMENT_EXTENSIONS | ARCHIVE_EXTENSIONS + if suffix and suffix not in allowed: + return "Unsupported file extension." + return None + + +def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_status: bool = False) -> Post: + payload = data.dict(exclude_unset=True) + tag_ids = payload.pop("tag_ids", None) + category_id = payload.pop("category_id", None) + requested_status = payload.pop("status", None) + + if category_id is not None: + post.category = Category.objects.filter(id=category_id, is_deleted=False).first() if category_id else None + + if requested_status and allow_status: + post.status = requested_status + if requested_status == Post.StatusChoices.PUBLISHED: + post.published_by = user + + for field, value in payload.items(): + setattr(post, field, value if value is not None else "") + + post.save() + if tag_ids is not None: + post.tags.set(tag_ids) + return post + + +def _interaction_payload(post: Post, user) -> BlogInteractionSchema: + return BlogInteractionSchema( + liked=Like.objects.filter(post=post, user=user).exists(), + saved=SavedPost.objects.filter(post=post, user=user).exists(), + likes_count=post.likes.count(), + saves_count=post.saves.count(), + comments_count=post.comments.filter(is_approved=True).count(), + ) + + +@blog_router.get("/admin/posts", response={200: List[PostListSchema], 403: ErrorSchema}, auth=jwt_auth) +def list_admin_posts( + request, + status: Optional[str] = Query(None), + search: Optional[str] = Query(None), + mine: bool = Query(False), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + user = request.auth + if not can_access_blog_admin(user): + return 403, {"error": "Permission denied"} + + queryset = _post_queryset().order_by("-updated_at", "-created_at") + if not (user.is_superuser or user.is_staff or can_review_blog_posts(user)) or mine: + queryset = queryset.filter(author=user) + if status: + queryset = queryset.filter(status=status) + if search: + queryset = queryset.filter(Q(title__icontains=search) | Q(content__icontains=search) | Q(excerpt__icontains=search)) + return list(queryset[offset : offset + limit]) + + +@blog_router.post("/admin/posts", response={201: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def create_admin_post(request, data: PostCreateSchema): + user = request.auth + if not can_write_blog_posts(user): + return 403, {"error": "Permission denied"} + + try: + post = Post(author=user) + _apply_post_payload(post, data, user=user, allow_status=can_review_blog_posts(user)) + if not can_review_blog_posts(user) and post.status == Post.StatusChoices.PUBLISHED: + post.status = Post.StatusChoices.DRAFT + post.published_at = None + post.published_by = None + post.save(update_fields=["status", "published_at", "published_by", "updated_at"]) + return 201, _post_queryset().get(pk=post.pk) + except Exception as exc: + return 400, {"error": "Failed to create post", "details": str(exc)} + + +@blog_router.get("/admin/posts/{post_id}", response={200: PostDetailSchema, 403: ErrorSchema, 404: ErrorSchema}, auth=jwt_auth) +def get_admin_post(request, post_id: int): + post = get_object_or_404(_post_queryset(), id=post_id) + if not can_edit_post(request.auth, post): + return 403, {"error": "Permission denied"} + return 200, post + + +@blog_router.put("/admin/posts/{post_id}", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def update_admin_post(request, post_id: int, data: PostCreateSchema): + post = get_object_or_404(Post, id=post_id) + if not can_edit_post(request.auth, post): + return 403, {"error": "Permission denied"} + + try: + _apply_post_payload(post, data, user=request.auth, allow_status=can_review_blog_posts(request.auth)) + return 200, _post_queryset().get(pk=post.pk) + except Exception as exc: + return 400, {"error": "Failed to update post", "details": str(exc)} + + +@blog_router.post("/admin/posts/{post_id}/submit", response={200: PostDetailSchema, 403: ErrorSchema}, auth=jwt_auth) +def submit_post_for_review(request, post_id: int): + post = get_object_or_404(Post, id=post_id) + if not can_edit_post(request.auth, post): + return 403, {"error": "Permission denied"} + post.status = Post.StatusChoices.SUBMITTED + post.submitted_at = timezone.now() + post.review_note = "" + post.save(update_fields=["status", "submitted_at", "review_note", "updated_at"]) + return 200, _post_queryset().get(pk=post.pk) + + +@blog_router.post("/admin/posts/{post_id}/review", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def review_post(request, post_id: int, data: PostReviewSchema): + user = request.auth + if not can_review_blog_posts(user): + return 403, {"error": "Permission denied"} + + post = get_object_or_404(Post, id=post_id) + action = data.action.strip().lower() + if action in {"publish", "approve"}: + post.status = Post.StatusChoices.PUBLISHED + post.published_at = post.published_at or timezone.now() + post.published_by = user + elif action in {"request_changes", "changes_requested"}: + post.status = Post.StatusChoices.CHANGES_REQUESTED + elif action == "archive": + post.status = Post.StatusChoices.ARCHIVED + else: + return 400, {"error": "Unsupported review action"} + + post.reviewed_by = user + post.reviewed_at = timezone.now() + post.review_note = data.note or "" + post.save() + return 200, _post_queryset().get(pk=post.pk) + + +@blog_router.get("/admin/posts/{post_id}/assets", response={200: List[PostAssetSchema], 403: ErrorSchema}, auth=jwt_auth) +def list_post_assets(request, post_id: int): + post = get_object_or_404(Post, id=post_id) + if not can_edit_post(request.auth, post): + return 403, {"error": "Permission denied"} + return list(post.assets.select_related("uploaded_by")) + + +@blog_router.post("/admin/posts/{post_id}/assets", response={201: PostAssetSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def upload_post_asset( + request, + post_id: int, + file: UploadedFile = File(...), + data: PostAssetCreateSchema = Form(...), +): + post = get_object_or_404(Post, id=post_id) + if not can_manage_post_assets(request.auth, post): + return 403, {"error": "Permission denied"} + + error = _validate_asset_file(file) + if error: + return 400, {"error": error} + + try: + asset = PostAsset.objects.create( + post=post, + file=file, + file_type=_asset_file_type(file), + title=(data.title if data else "") or file.name, + alt_text=data.alt_text if data else "", + caption=data.caption if data else "", + size=file.size, + mime_type=file.content_type or mimetypes.guess_type(file.name)[0] or "", + uploaded_by=request.auth, + ) + return 201, asset + except Exception as exc: + return 400, {"error": "Failed to upload asset", "details": str(exc)} + + +@blog_router.delete("/admin/posts/{post_id}/assets/{asset_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) +def delete_post_asset(request, post_id: int, asset_id: int): + post = get_object_or_404(Post, id=post_id) + asset = get_object_or_404(PostAsset, id=asset_id, post=post) + if not can_manage_post_assets(request.auth, post): + return 403, {"error": "Permission denied"} + asset.delete() + return 200, {"message": "Asset deleted successfully"} + + +@blog_router.get("/me/activity", response=BlogProfileActivitySchema, auth=jwt_auth) +def my_blog_activity(request): + comments = ( + Comment.objects.filter(author=request.auth) + .select_related("author", "post") + .order_by("-created_at")[:20] + ) + return BlogProfileActivitySchema( + liked_posts=list(_published_queryset().filter(likes__user=request.auth)[:20]), + saved_posts=list(_published_queryset().filter(saves__user=request.auth)[:20]), + comments=list([comment for comment in comments if comment.parent_id is None]), + replies=list([comment for comment in comments if comment.parent_id is not None]), + ) + + @blog_router.get("/posts", response=List[PostListSchema]) def list_posts( request, @@ -30,124 +302,116 @@ def list_posts( tag: Optional[str] = None, search: Optional[str] = None, featured: Optional[bool] = None, - author: Optional[str] = None + author: Optional[str] = None, ): - """List published posts with filtering and pagination""" - queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related( - 'author', 'category' - ).prefetch_related('tags') - - # Apply filters + queryset = _published_queryset() if category: queryset = queryset.filter(category__slug=category) - if tag: queryset = queryset.filter(tags__slug=tag) - if search: - queryset = queryset.filter( - Q(title__icontains=search) | - Q(content__icontains=search) | - Q(excerpt__icontains=search) - ) - + queryset = queryset.filter(Q(title__icontains=search) | Q(content__icontains=search) | Q(excerpt__icontains=search)) if featured is not None: queryset = queryset.filter(is_featured=featured) - if author: queryset = queryset.filter(author__username=author) - - # Pagination offset = (page - 1) * limit - posts = queryset[offset:offset + limit] - - return posts + return list(queryset[offset : offset + limit]) + @blog_router.get("/posts/{slug}", response=PostDetailSchema) def get_post(request, slug: str): - """Get single post by slug""" - post = get_object_or_404( - Post.objects.select_related('author', 'category').prefetch_related('tags'), - slug=slug, - status=Post.StatusChoices.PUBLISHED - ) - return post + return get_object_or_404(_published_queryset(), slug=slug) -@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth) -def create_post(request, data: PostCreateSchema): - """Create a new post (committee members only)""" - user = request.auth - - if not (user.is_superuser or user.is_staff): - return 400, {"error": "Only committee members can create posts"} - - try: - post = Post.objects.create( - title=data.title, - content=data.content, - excerpt=data.excerpt, - author=user, - category_id=data.category_id, - status=data.status, - is_featured=data.is_featured - ) - - if data.tag_ids: - post.tags.set(data.tag_ids) - - return 201, post - - except Exception as e: - return 400, {"error": "Failed to create post", "details": str(e)} -@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth) -def update_post(request, slug: str, data: PostCreateSchema): - """Update a post (author or committee only)""" - user = request.auth +@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def create_post_compat(request, data: PostCreateSchema): + return create_admin_post(request, data) + + +@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def update_post_compat(request, slug: str, data: PostCreateSchema): post = get_object_or_404(Post, slug=slug) - - if not (post.author == user or user.is_superuser or user.is_staff): - return 400, {"error": "You can only edit your own posts"} - - try: - for field, value in data.dict(exclude_unset=True).items(): - if field == 'tag_ids': - if value: - post.tags.set(value) - elif field == 'category_id': - post.category_id = value - else: - setattr(post, field, value) - - post.save() - return 200, post - - except Exception as e: - return 400, {"error": "Failed to update post", "details": str(e)} + return update_admin_post(request, post.id, data) -@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) + +@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) def delete_post(request, slug: str): - """Soft delete a post owned by the requester or committee.""" - user = request.auth + if not request.auth.is_superuser: + return 403, {"error": "Only superusers can delete posts"} post = get_object_or_404(Post, slug=slug) - - if not (post.author == user or user.is_superuser or user.is_staff): - return 400, {"error": "You can only delete your own posts"} - post.delete() return 200, {"message": "Post deleted successfully"} -@blog_router.get("/deleted/posts", response=List[PostListSchema], auth=jwt_auth) -def list_deleted_posts(request): - """List all soft-deleted posts (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): - return 403, {"error": "Permission denied"} - return Post.deleted_objects.all().select_related('author', 'category').prefetch_related('tags') -@blog_router.post("deleted/posts/{post_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema]) +def list_comments(request, slug: str): + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + comments = Comment.objects.filter(post=post, is_approved=True, parent=None).select_related("author", "post").prefetch_related( + Prefetch("replies", queryset=Comment.objects.filter(is_approved=True).select_related("author", "post")) + ) + return list(comments) + + +@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth) +def create_comment(request, slug: str, data: CommentCreateSchema): + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + parent = None + if data.parent_id: + parent = get_object_or_404(Comment, id=data.parent_id, post=post, is_approved=True) + comment = Comment.objects.create(post=post, author=request.auth, content=data.content, parent=parent) + return 201, comment + + +@blog_router.post("/comments/{comment_id}/hide", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) +def hide_comment(request, comment_id: int, data: CommentHideSchema): + if not can_moderate_blog_comments(request.auth): + return 403, {"error": "Permission denied"} + comment = get_object_or_404(Comment, id=comment_id) + comment.hide(request.auth, data.note or "") + return 200, {"message": "Comment hidden successfully"} + + +@blog_router.post("/posts/{slug}/like", response={200: BlogInteractionSchema}, auth=jwt_auth) +def toggle_like(request, slug: str): + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + like, created = Like.objects.get_or_create(post=post, user=request.auth) + if not created: + like.delete() + return 200, _interaction_payload(post, request.auth) + + +@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema}) +def get_likes_count(request, slug: str): + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + return 200, {"message": str(post.likes.count())} + + +@blog_router.post("/posts/{slug}/save", response={200: BlogInteractionSchema}, auth=jwt_auth) +def toggle_save(request, slug: str): + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + saved, created = SavedPost.objects.get_or_create(post=post, user=request.auth) + if not created: + saved.delete() + return 200, _interaction_payload(post, request.auth) + + +@blog_router.get("/posts/{slug}/interaction", response={200: BlogInteractionSchema}, auth=jwt_auth) +def get_interaction(request, slug: str): + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + return 200, _interaction_payload(post, request.auth) + + +@blog_router.get("/deleted/posts", response={200: List[PostListSchema], 403: ErrorSchema}, auth=jwt_auth) +def list_deleted_posts(request): + if not request.auth.is_superuser: + return 403, {"error": "Permission denied"} + return 200, Post.deleted_objects.all().select_related("author", "category").prefetch_related("tags") + + +@blog_router.post("/deleted/posts/{post_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def restore_post(request, post_id: int): - """Restore a soft-deleted post (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): + if not request.auth.is_superuser: return 403, {"error": "Permission denied"} try: post = Post.deleted_objects.get(id=post_id) @@ -157,56 +421,16 @@ def restore_post(request, post_id: int): return 400, {"error": "Post not found or not soft-deleted."} - -# Comment endpoints -@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema]) -def list_comments(request, slug: str): - """List approved comments for a post""" - post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) - - comments = Comment.objects.filter( - post=post, - is_approved=True, - parent=None - ).select_related('author').prefetch_related( - Prefetch( - 'replies', - queryset=Comment.objects.filter(is_approved=True).select_related('author') - ) - ) - - return comments - -@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth) -def create_comment(request, slug: str, data: CommentCreateSchema): - """Create a comment on a post""" - post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) - user = request.auth - - try: - comment = Comment.objects.create( - post=post, - author=user, - content=data.content, - parent_id=data.parent_id - ) - - return 201, comment - - except Exception as e: - return 400, {"error": "Failed to create comment", "details": str(e)} - -@blog_router.get("/deleted/comments", response=List[CommentSchema], auth=jwt_auth) +@blog_router.get("/deleted/comments", response={200: List[CommentSchema], 403: ErrorSchema}, auth=jwt_auth) def list_deleted_comments(request): - """List all soft-deleted comments (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): + if not can_moderate_blog_comments(request.auth): return 403, {"error": "Permission denied"} - return Comment.deleted_objects.all().select_related('author', 'post') + return 200, Comment.deleted_objects.all().select_related("author", "post") -@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) + +@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def restore_comment(request, comment_id: int): - """Restore a soft-deleted comment (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): + if not can_moderate_blog_comments(request.auth): return 403, {"error": "Permission denied"} try: comment = Comment.deleted_objects.get(id=comment_id) @@ -216,53 +440,26 @@ def restore_comment(request, comment_id: int): return 400, {"error": "Comment not found or not soft-deleted."} - -# Like endpoints -@blog_router.post("/posts/{slug}/like", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) -def toggle_like(request, slug: str): - """Toggle like on a post""" - post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) - user = request.auth - - like, created = Like.objects.get_or_create(post=post, user=user) - - if not created: - like.delete() - return 200, {"message": "Post unliked"} - - return 200, {"message": "Post liked"} - -@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema}) -def get_likes_count(request, slug: str): - """Get likes count for a post""" - post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) - count = post.likes.count() - return {"message": f"{count}"} - - - -# Category endpoints @blog_router.get("/categories", response=List[CategorySchema]) def list_categories(request): - """List all categories""" return Category.objects.all() + @blog_router.get("/categories/{slug}", response=CategorySchema) def get_category(request, slug: str): - """Get single category by slug""" return get_object_or_404(Category, slug=slug) -@blog_router.get("/deleted/categories", response=List[CategorySchema], auth=jwt_auth) -def list_deleted_categories(request): - """List all soft-deleted categories (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): - return 403, {"error": "Permission denied"} - return Category.deleted_objects.all() -@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +@blog_router.get("/deleted/categories", response={200: List[CategorySchema], 403: ErrorSchema}, auth=jwt_auth) +def list_deleted_categories(request): + if not request.auth.is_superuser: + return 403, {"error": "Permission denied"} + return 200, Category.deleted_objects.all() + + +@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def restore_category(request, category_id: int): - """Restore a soft-deleted category (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): + if not request.auth.is_superuser: return 403, {"error": "Permission denied"} try: category = Category.deleted_objects.get(id=category_id) @@ -272,29 +469,26 @@ def restore_category(request, category_id: int): return 400, {"error": "Category not found or not soft-deleted."} - -# Tag endpoints @blog_router.get("/tags", response=List[TagSchema]) def list_tags(request): - """List all tags""" return Tag.objects.all() + @blog_router.get("/tags/{slug}", response=TagSchema) def get_tag(request, slug: str): - """Get single tag by slug""" return get_object_or_404(Tag, slug=slug) -@blog_router.get("/deleted/tags", response=List[TagSchema], auth=jwt_auth) -def list_deleted_tags(request): - """List all soft-deleted tags (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): - return 403, {"error": "Permission denied"} - return Tag.all_objects.all() -@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +@blog_router.get("/deleted/tags", response={200: List[TagSchema], 403: ErrorSchema}, auth=jwt_auth) +def list_deleted_tags(request): + if not request.auth.is_superuser: + return 403, {"error": "Permission denied"} + return 200, Tag.deleted_objects.all() + + +@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def restore_tag(request, tag_id: int): - """Restore a soft-deleted tag (Admin/Committee only)""" - if not (request.auth.is_staff or request.auth.is_superuser): + if not request.auth.is_superuser: return 403, {"error": "Permission denied"} try: tag = Tag.deleted_objects.get(id=tag_id) diff --git a/apps/blog/management/__init__.py b/apps/blog/management/__init__.py new file mode 100644 index 0000000..f92a486 --- /dev/null +++ b/apps/blog/management/__init__.py @@ -0,0 +1 @@ +"""Blog management commands.""" diff --git a/apps/blog/management/commands/__init__.py b/apps/blog/management/commands/__init__.py new file mode 100644 index 0000000..00ab0dc --- /dev/null +++ b/apps/blog/management/commands/__init__.py @@ -0,0 +1 @@ +"""Blog command package.""" diff --git a/apps/blog/management/commands/sync_blog_roles.py b/apps/blog/management/commands/sync_blog_roles.py new file mode 100644 index 0000000..47eaf10 --- /dev/null +++ b/apps/blog/management/commands/sync_blog_roles.py @@ -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.")) diff --git a/apps/blog/migrations/0003_blog_platform.py b/apps/blog/migrations/0003_blog_platform.py new file mode 100644 index 0000000..3c659ef --- /dev/null +++ b/apps/blog/migrations/0003_blog_platform.py @@ -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), + ] diff --git a/apps/blog/models.py b/apps/blog/models.py index 747881c..9241672 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -1,156 +1,347 @@ -from django.db import models -from django.conf import settings -from django.utils.text import slugify -from django.utils import timezone +from __future__ import annotations + +import mimetypes +import re +from pathlib import Path +from uuid import uuid4 import markdown +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.text import slugify from core.media import ( + BLUR_VARIANT, + PREVIEW_VARIANT, + THUMBNAIL_VARIANT, delete_image_derivatives_by_name, + derivative_url, get_image_previous_name, safe_process_public_image, ) from core.models import BaseModel + +def _plain_text_from_markdown(value: str) -> str: + html = markdown.markdown(value or "", extensions=["markdown.extensions.extra"]) + return re.sub(r"<[^<]+?>", " ", html).replace("\n", " ").strip() + + +def _unique_slug_for(instance, value: str) -> str: + base = slugify(value, allow_unicode=True) or uuid4().hex[:10] + slug = base[:180] + counter = 2 + manager = getattr(instance.__class__, "all_objects", instance.__class__._default_manager) + queryset = manager.filter(slug=slug) + if instance.pk: + queryset = queryset.exclude(pk=instance.pk) + + while queryset.exists(): + suffix = f"-{counter}" + slug = f"{base[: 200 - len(suffix)]}{suffix}" + queryset = manager.filter(slug=slug) + if instance.pk: + queryset = queryset.exclude(pk=instance.pk) + counter += 1 + + return slug + + +def post_asset_upload_to(instance: "PostAsset", filename: str) -> str: + suffix = Path(filename).suffix.lower() + post_part = instance.post_id or "draft" + return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}" + + class Category(BaseModel): name = models.CharField(max_length=100, unique=True) - slug = models.SlugField(max_length=100, unique=True, blank=True) + slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True) description = models.TextField(blank=True) class Meta: verbose_name_plural = "Categories" - ordering = ['name'] + ordering = ["name"] def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(self.name) + self.slug = _unique_slug_for(self, self.name) super().save(*args, **kwargs) + class Tag(BaseModel): name = models.CharField(max_length=50, unique=True) - slug = models.SlugField(max_length=50, unique=True, blank=True) + slug = models.SlugField(max_length=50, unique=True, blank=True, allow_unicode=True) class Meta: - ordering = ['name'] + ordering = ["name"] def __str__(self): return self.name def save(self, *args, **kwargs): if not self.slug: - self.slug = slugify(self.name) + self.slug = _unique_slug_for(self, self.name) super().save(*args, **kwargs) + class Post(BaseModel): class StatusChoices(models.TextChoices): - DRAFT = 'draft', 'Draft' - PUBLISHED = 'published', 'Published' + DRAFT = "draft", "Draft" + SUBMITTED = "submitted", "Submitted for review" + CHANGES_REQUESTED = "changes_requested", "Changes requested" + PUBLISHED = "published", "Published" + ARCHIVED = "archived", "Archived" title = models.CharField(max_length=200) - slug = models.SlugField(max_length=200, unique=True, blank=True) + slug = models.SlugField(max_length=200, unique=True, blank=True, allow_unicode=True) content = models.TextField(help_text="Content in Markdown format") + content_html = models.TextField(blank=True, editable=False) excerpt = models.TextField(max_length=300, blank=True) - author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts') - featured_image = models.ImageField(upload_to='blog/featured/', null=True, blank=True) - status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT) + author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts") + featured_image = models.ImageField(upload_to="blog/featured/", null=True, blank=True) + status = models.CharField(max_length=24, choices=StatusChoices.choices, default=StatusChoices.DRAFT) published_at = models.DateTimeField(null=True, blank=True) - category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts') - tags = models.ManyToManyField(Tag, blank=True, related_name='posts') + category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts") + tags = models.ManyToManyField(Tag, blank=True, related_name="posts") is_featured = models.BooleanField(default=False) + seo_title = models.CharField(max_length=70, blank=True) + seo_description = models.CharField(max_length=170, blank=True) + canonical_url = models.URLField(blank=True) + og_title = models.CharField(max_length=95, blank=True) + og_description = models.CharField(max_length=200, blank=True) + og_image = models.ImageField(upload_to="blog/og/", null=True, blank=True) + noindex = models.BooleanField(default=False) + focus_keyword = models.CharField(max_length=120, blank=True) + reading_time = models.PositiveIntegerField(default=1) + + submitted_at = models.DateTimeField(null=True, blank=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="reviewed_blog_posts", + ) + review_note = models.TextField(blank=True) + published_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="published_blog_posts", + ) + class Meta: - ordering = ['-created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['status', 'published_at']), - models.Index(fields=['is_featured']), + models.Index(fields=["status", "published_at"]), + models.Index(fields=["is_featured"]), + models.Index(fields=["author", "status"]), + models.Index(fields=["slug", "status"]), + ] + permissions = [ + ("access_blog_admin", "Can access blog admin"), + ("review_blog_post", "Can review blog posts"), + ("publish_blog_post", "Can publish blog posts"), + ("moderate_blog_comment", "Can moderate blog comments"), + ("upload_blog_asset", "Can upload blog assets"), ] def __str__(self): return self.title def save(self, *args, **kwargs): - previous_image_name = get_image_previous_name(self, "featured_image") - current_image_name = self.featured_image.name if self.featured_image else None + previous_featured_name = get_image_previous_name(self, "featured_image") + current_featured_name = self.featured_image.name if self.featured_image else None + previous_og_name = get_image_previous_name(self, "og_image") + current_og_name = self.og_image.name if self.og_image else None if not self.slug: - self.slug = slugify(self.title) - - # Auto-generate excerpt if not provided + self.slug = _unique_slug_for(self, self.title) + if not self.excerpt and self.content: - # Convert markdown to plain text for excerpt - plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra']) - # Remove HTML tags and truncate - import re - plain_text = re.sub('<[^<]+?>', '', plain_text) - self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text + plain_text = _plain_text_from_markdown(self.content) + self.excerpt = f"{plain_text[:297]}..." if len(plain_text) > 300 else plain_text + + self.content_html = markdown.markdown( + self.content or "", + extensions=[ + "markdown.extensions.extra", + "markdown.extensions.codehilite", + "markdown.extensions.toc", + ], + ) + word_count = len((self.content or "").split()) + self.reading_time = max(1, (word_count + 199) // 200) if self.status == Post.StatusChoices.PUBLISHED and not self.published_at: self.published_at = timezone.now() super().save(*args, **kwargs) - if previous_image_name != current_image_name and previous_image_name: + if previous_featured_name != current_featured_name and previous_featured_name: delete_image_derivatives_by_name( self.featured_image.storage if self.featured_image else None, - previous_image_name, + previous_featured_name, "blog_featured", delete_original=True, ) - - if previous_image_name != current_image_name and self.featured_image: + if previous_featured_name != current_featured_name and self.featured_image: safe_process_public_image(self.featured_image, "blog_featured") - @property - def content_html(self): - """Convert markdown content to HTML""" - return markdown.markdown( - self.content, - extensions=[ - 'markdown.extensions.extra', - 'markdown.extensions.codehilite', - 'markdown.extensions.toc', - ] - ) + if previous_og_name != current_og_name and previous_og_name: + delete_image_derivatives_by_name( + self.og_image.storage if self.og_image else None, + previous_og_name, + "blog_featured", + delete_original=True, + ) + if previous_og_name != current_og_name and self.og_image: + safe_process_public_image(self.og_image, "blog_featured") - @property - def reading_time(self): - """Estimate reading time in minutes assuming 200 words per minute.""" - word_count = len(self.content.split()) - return max(1, word_count // 200) -class Comment(BaseModel): - post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments') - author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments') - content = models.TextField() - parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies') - is_approved = models.BooleanField(default=True) +class PostAsset(BaseModel): + class FileType(models.TextChoices): + IMAGE = "image", "Image" + VIDEO = "video", "Video" + DOCUMENT = "document", "Document" + ARCHIVE = "archive", "Archive" + OTHER = "other", "Other" + + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="assets") + file = models.FileField(upload_to=post_asset_upload_to) + file_type = models.CharField(max_length=16, choices=FileType.choices, default=FileType.OTHER) + title = models.CharField(max_length=200, blank=True) + alt_text = models.CharField(max_length=200, blank=True) + caption = models.TextField(blank=True) + size = models.PositiveBigIntegerField(default=0) + mime_type = models.CharField(max_length=120, blank=True) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="blog_assets", + ) class Meta: - ordering = ['created_at'] + ordering = ["-created_at"] indexes = [ - models.Index(fields=['post', 'is_approved']), + models.Index(fields=["post", "file_type"]), + models.Index(fields=["uploaded_by", "created_at"]), ] def __str__(self): - return f'Comment by {self.author.username} on {self.post.title}' + return self.title or Path(self.file.name).name + + def save(self, *args, **kwargs): + previous_file_name = get_image_previous_name(self, "file") + current_file_name = self.file.name if self.file else None + if self.file: + self.size = self.file.size or self.size + if not self.title: + self.title = Path(self.file.name).name + if not self.mime_type: + self.mime_type = mimetypes.guess_type(self.file.name)[0] or "" + + super().save(*args, **kwargs) + + if previous_file_name != current_file_name and previous_file_name: + delete_image_derivatives_by_name( + self.file.storage if self.file else None, + previous_file_name, + "blog_asset", + delete_original=True, + ) + if previous_file_name != current_file_name and self.file_type == self.FileType.IMAGE and self.file: + safe_process_public_image(self.file, "blog_asset") + + @property + def file_url(self): + return self.file.url if self.file else None + + @property + def thumbnail_url(self): + return derivative_url(self.file, THUMBNAIL_VARIANT) if self.file_type == self.FileType.IMAGE else None + + @property + def preview_url(self): + return derivative_url(self.file, PREVIEW_VARIANT) if self.file_type == self.FileType.IMAGE else None + + @property + def blur_url(self): + return derivative_url(self.file, BLUR_VARIANT) if self.file_type == self.FileType.IMAGE else None + + +class Comment(BaseModel): + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments") + author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="comments") + content = models.TextField() + parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies") + is_approved = models.BooleanField(default=True) + hidden_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="hidden_blog_comments", + ) + hidden_at = models.DateTimeField(null=True, blank=True) + moderation_note = models.TextField(blank=True) + + class Meta: + ordering = ["created_at"] + indexes = [ + models.Index(fields=["post", "is_approved"]), + models.Index(fields=["author", "created_at"]), + ] + + def __str__(self): + return f"Comment by {self.author.username} on {self.post.title}" @property def is_reply(self): return self.parent is not None + def hide(self, user, note: str = ""): + self.is_approved = False + self.hidden_by = user + self.hidden_at = timezone.now() + self.moderation_note = note + self.save(update_fields=["is_approved", "hidden_by", "hidden_at", "moderation_note", "updated_at"]) + + class Like(models.Model): - post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes') - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='likes') + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="likes") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="likes") created_at = models.DateTimeField(auto_now_add=True) class Meta: - unique_together = ['post', 'user'] + unique_together = ["post", "user"] indexes = [ - models.Index(fields=['post']), + models.Index(fields=["post"]), + models.Index(fields=["user", "created_at"]), ] def __str__(self): - return f'{self.user.username} likes {self.post.title}' + return f"{self.user.username} likes {self.post.title}" + + +class SavedPost(models.Model): + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="saves") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="saved_posts") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ["post", "user"] + indexes = [ + models.Index(fields=["post"]), + models.Index(fields=["user", "created_at"]), + ] + + def __str__(self): + return f"{self.user.username} saved {self.post.title}" diff --git a/apps/blog/permissions.py b/apps/blog/permissions.py new file mode 100644 index 0000000..b94758e --- /dev/null +++ b/apps/blog/permissions.py @@ -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")) diff --git a/apps/users/api/schemas.py b/apps/users/api/schemas.py index 135315b..d01bff6 100644 --- a/apps/users/api/schemas.py +++ b/apps/users/api/schemas.py @@ -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"] diff --git a/config/settings/base.py b/config/settings/base.py index f0948d6..3693655 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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" diff --git a/core/admin.py b/core/admin.py index 36387e0..064314d 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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() diff --git a/core/media.py b/core/media.py index a16fa11..d76c1a1 100644 --- a/core/media.py +++ b/core/media.py @@ -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),