diff --git a/apps/blog/admin.py b/apps/blog/admin.py index 26badc2..2efc5ec 100644 --- a/apps/blog/admin.py +++ b/apps/blog/admin.py @@ -4,22 +4,22 @@ from django.contrib import admin from import_export.admin import ImportExportModelAdmin from simplemde.widgets import SimpleMDEEditor -from apps.blog.models import Category, Tag, Post, PostAsset, Comment, Like, SavedPost +from apps.blog.models import BlogBanner, Category, Tag, Post, PostAsset, Comment, Like, SavedPost from apps.blog.resources import PostResource, CategoryResource from core.admin import SoftDeleteListFilter, BaseModelAdmin @admin.register(Category) class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin): resource_class = CategoryResource - list_display = ('name', 'slug', 'created_at', 'is_deleted') + list_display = ('name', 'parent', 'slug', 'created_at', 'is_deleted') list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter) - search_fields = ('name', 'description') + search_fields = ('name', 'description', 'parent__name') prepopulated_fields = {'slug': ('name',)} readonly_fields = ('created_at', 'updated_at', 'deleted_at') fieldsets = ( ('Content', { - 'fields': ('name', 'slug', 'description') + 'fields': ('name', 'parent', 'slug', 'description') }), ('Metadata', { 'fields': ('created_at', 'updated_at') @@ -76,7 +76,7 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin): list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter) search_fields = ('title', 'content', 'author__username') prepopulated_fields = {'slug': ('title',)} - filter_horizontal = ('tags',) + filter_horizontal = ('tags', 'writers') date_hierarchy = 'published_at' fieldsets = ( @@ -87,7 +87,7 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin): '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', 'submitted_at', 'reviewed_at', 'reviewed_by', 'review_note', 'published_at', 'published_by') + 'fields': ('author', 'writers', 'category', 'tags', 'status', 'is_featured', 'submitted_at', 'reviewed_at', 'reviewed_by', 'review_note', 'published_at', 'published_by') }), ('Soft Delete', { 'fields': ('is_deleted', 'deleted_at'), @@ -132,8 +132,8 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin): @admin.register(Comment) class CommentAdmin(BaseModelAdmin): - list_display = ('author', 'post', 'content_preview', 'is_approved', 'created_at') - list_filter = ('is_approved', 'created_at', 'post', SoftDeleteListFilter) + list_display = ('author', 'post', 'content_preview', 'is_approved', 'is_hidden', 'is_deleted', 'created_at') + list_filter = ('is_approved', 'is_hidden', 'created_at', 'post', SoftDeleteListFilter) search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title') readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at') @@ -142,28 +142,35 @@ class CommentAdmin(BaseModelAdmin): 'fields': ('post', 'author', 'content') }), ('Metadata', { - 'fields': ('is_approved', 'hidden_by', 'hidden_at', 'moderation_note', 'created_at', 'updated_at') + 'fields': ('is_approved', 'is_hidden', 'hidden_by', 'hidden_at', 'moderation_note', 'created_at', 'updated_at') }), ('Soft Delete', { - 'fields': ('is_deleted', 'deleted_at'), + 'fields': ('is_deleted', 'deleted_at', 'deleted_by', 'delete_note'), 'classes': ('collapse',) }) ) - actions = BaseModelAdmin.actions + ['approve_comments', 'disapprove_comments'] + actions = BaseModelAdmin.actions + ['approve_comments', 'hide_comments', 'unhide_comments'] def content_preview(self, obj): return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content content_preview.short_description = 'Content Preview' def approve_comments(self, request, queryset): - queryset.update(is_approved=True) + queryset.update(is_approved=True, is_hidden=False, hidden_by=None, hidden_at=None, moderation_note='') self.message_user(request, f"Approved {queryset.count()} comments.") approve_comments.short_description = "Approve selected comments" - def disapprove_comments(self, request, queryset): - queryset.update(is_approved=False) - self.message_user(request, f"Disapproved {queryset.count()} comments.") - disapprove_comments.short_description = "Disapprove selected comments" + def hide_comments(self, request, queryset): + for comment in queryset: + comment.hide(request.user) + self.message_user(request, f"Hidden {queryset.count()} comments.") + hide_comments.short_description = "Hide selected comments" + + def unhide_comments(self, request, queryset): + for comment in queryset: + comment.unhide() + self.message_user(request, f"Restored {queryset.count()} comments.") + unhide_comments.short_description = "Unhide selected comments" @admin.register(Like) class LikeAdmin(admin.ModelAdmin): @@ -185,3 +192,24 @@ class PostAssetAdmin(BaseModelAdmin): 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') + + +@admin.register(BlogBanner) +class BlogBannerAdmin(BaseModelAdmin): + list_display = ('title', 'url', 'is_active', 'sort_order', 'created_at', 'is_deleted') + list_filter = ('is_active', 'created_at', 'is_deleted', SoftDeleteListFilter) + search_fields = ('title', 'alt_text', 'url') + readonly_fields = ('created_at', 'updated_at', 'deleted_at') + + fieldsets = ( + ('Banner', { + 'fields': ('title', 'alt_text', 'image', 'url', 'is_active', 'sort_order') + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at') + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) diff --git a/apps/blog/api/schemas.py b/apps/blog/api/schemas.py index 957c713..0a87e8b 100644 --- a/apps/blog/api/schemas.py +++ b/apps/blog/api/schemas.py @@ -5,17 +5,37 @@ from typing import List, Optional from ninja import ModelSchema, Schema -from apps.blog.models import Category, Comment, PostAsset, Tag +from apps.blog.models import BlogBanner, Category, Comment, PostAsset, Tag from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url class CategorySchema(ModelSchema): created_at: Optional[datetime] = None + parent_id: Optional[int] = None class Config: model = Category model_fields = ["id", "name", "slug", "description", "created_at"] + @staticmethod + def resolve_parent_id(obj): + return obj.parent_id + + +class CategoryPathSchema(Schema): + id: int + name: str + slug: str + + +class CategoryFilterSchema(Schema): + id: int + name: str + slug: str + parent_id: Optional[int] = None + post_count: int = 0 + children: List["CategoryFilterSchema"] = [] + class TagSchema(ModelSchema): created_at: Optional[datetime] = None @@ -25,11 +45,19 @@ class TagSchema(ModelSchema): model_fields = ["id", "name", "slug", "created_at"] +class TagFilterSchema(Schema): + id: int + name: str + slug: str + post_count: int = 0 + + class AuthorSchema(Schema): id: int username: str first_name: str last_name: str + bio: Optional[str] = None profile_picture: Optional[str] = None profile_picture_thumbnail_url: Optional[str] = None profile_picture_preview_url: Optional[str] = None @@ -124,6 +152,8 @@ class PostListSchema(Schema): status: str published_at: Optional[datetime] = None category: Optional[CategorySchema] = None + category_path: List[CategoryPathSchema] = [] + writers: List[AuthorSchema] = [] tags: List[TagSchema] is_featured: bool created_at: datetime @@ -159,6 +189,17 @@ class PostListSchema(Schema): url = derivative_url(obj.featured_image, PREVIEW_VARIANT) return request.build_absolute_uri(url) if url else None + @staticmethod + def resolve_category_path(obj): + if not obj.category_id: + return [] + return obj.category.path + + @staticmethod + def resolve_writers(obj): + writers = list(obj.writers.all()) + return writers or [obj.author] + @staticmethod def resolve_likes_count(obj): return getattr(obj, "likes_count", None) or obj.likes.count() @@ -169,7 +210,11 @@ class PostListSchema(Schema): @staticmethod def resolve_comments_count(obj): - return getattr(obj, "comments_count", None) or obj.comments.filter(is_approved=True).count() + return getattr(obj, "comments_count", None) or obj.comments.filter( + is_approved=True, + is_hidden=False, + is_deleted=False, + ).count() class PostDetailSchema(PostListSchema): @@ -192,6 +237,7 @@ class PostCreateSchema(Schema): excerpt: Optional[str] = None category_id: Optional[int] = None tag_ids: Optional[List[int]] = [] + writer_ids: Optional[List[int]] = None status: str = "draft" is_featured: bool = False seo_title: Optional[str] = "" @@ -221,10 +267,14 @@ class CommentSchema(ModelSchema): post_title: str post_slug: str parent_id: Optional[int] = None + is_hidden: bool = False + is_deleted: bool = False + deleted_at: Optional[datetime] = None + hidden_replies_count: int = 0 class Config: model = Comment - model_fields = ["id", "content", "created_at", "is_approved", "hidden_at"] + model_fields = ["id", "content", "created_at", "updated_at", "is_approved", "hidden_at"] @staticmethod def resolve_post_id(obj): @@ -242,12 +292,22 @@ class CommentSchema(ModelSchema): def resolve_parent_id(obj): return obj.parent_id + @staticmethod + def resolve_hidden_replies_count(obj): + if not getattr(obj, "replies", None): + return 0 + return sum(len(reply.replies.all()) for reply in obj.replies.all()) + class CommentCreateSchema(Schema): content: str parent_id: Optional[int] = None +class CommentUpdateSchema(Schema): + content: str + + class CommentHideSchema(Schema): note: Optional[str] = "" @@ -265,3 +325,30 @@ class BlogProfileActivitySchema(Schema): saved_posts: List[PostListSchema] comments: List[CommentSchema] replies: List[CommentSchema] + + +class BlogBannerSchema(ModelSchema): + image_url: str + + class Config: + model = BlogBanner + model_fields = ["id", "title", "alt_text", "url", "sort_order"] + + @staticmethod + def resolve_image_url(obj, context): + request = context["request"] + return request.build_absolute_uri(obj.image.url) if obj.image else "" + + +class BlogFilterAuthorSchema(Schema): + id: int + username: str + first_name: str + last_name: str + post_count: int = 0 + + +class BlogFiltersSchema(Schema): + categories: List[CategoryFilterSchema] + tags: List[TagFilterSchema] + authors: List[BlogFilterAuthorSchema] diff --git a/apps/blog/api/views.py b/apps/blog/api/views.py index ef74dbd..34a1e8a 100644 --- a/apps/blog/api/views.py +++ b/apps/blog/api/views.py @@ -5,18 +5,23 @@ from pathlib import Path from typing import List, Optional from django.conf import settings +from django.contrib.auth import get_user_model 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 ( + BlogBannerSchema, + BlogFiltersSchema, BlogInteractionSchema, BlogProfileActivitySchema, CategorySchema, CommentCreateSchema, CommentHideSchema, CommentSchema, + CommentUpdateSchema, + AuthorSchema, PostAssetCreateSchema, PostAssetSchema, PostCreateSchema, @@ -25,7 +30,7 @@ from apps.blog.api.schemas import ( PostReviewSchema, TagSchema, ) -from apps.blog.models import Category, Comment, Like, Post, PostAsset, SavedPost, Tag +from apps.blog.models import BlogBanner, Category, Comment, Like, Post, PostAsset, SavedPost, Tag from apps.blog.permissions import ( can_access_blog_admin, can_edit_post, @@ -39,6 +44,7 @@ from core.authentication import jwt_auth blog_router = Router() +User = get_user_model() IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"} VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"} @@ -49,11 +55,15 @@ 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") + .prefetch_related("tags", "writers", "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), + comments_count=Count( + "comments", + filter=Q(comments__is_approved=True, comments__is_hidden=False, comments__is_deleted=False), + distinct=True, + ), ) ) @@ -62,6 +72,88 @@ def _published_queryset(): return _post_queryset().filter(status=Post.StatusChoices.PUBLISHED) +def _optional_auth_user(request): + auth_header = request.headers.get("Authorization", "") + if not auth_header.lower().startswith("bearer "): + return None + token = auth_header.split(" ", 1)[1].strip() + return jwt_auth.authenticate(request, token) + + +def _query_values(request, key: str, fallback: Optional[str] = None) -> list[str]: + values = request.GET.getlist(key) + if fallback and fallback not in values: + values.append(fallback) + + cleaned: list[str] = [] + for value in values: + for item in str(value).split(","): + item = item.strip() + if item and item not in cleaned: + cleaned.append(item) + return cleaned + + +def _category_and_descendant_ids(slug: str) -> list[int]: + categories = list(Category.objects.values("id", "parent_id", "slug")) + target = next((category for category in categories if category["slug"] == slug), None) + if not target: + return [] + + children_by_parent: dict[int, list[int]] = {} + for category in categories: + parent_id = category["parent_id"] + if parent_id: + children_by_parent.setdefault(parent_id, []).append(category["id"]) + + selected = [target["id"]] + pending = [target["id"]] + while pending: + next_pending: list[int] = [] + for parent_id in pending: + next_pending.extend(children_by_parent.get(parent_id, [])) + selected.extend(next_pending) + pending = next_pending + return selected + + +def _comment_visibility_filter(user=None) -> Q: + if user and can_moderate_blog_comments(user): + return Q(is_deleted=False) & (Q(is_approved=True, is_hidden=False) | Q(is_hidden=True)) + return Q(is_deleted=False, is_approved=True, is_hidden=False) + + +def _build_category_filter_tree(): + categories = list( + Category.objects.annotate( + post_count=Count( + "posts", + filter=Q(posts__status=Post.StatusChoices.PUBLISHED, posts__is_deleted=False), + distinct=True, + ) + ).order_by("name") + ) + nodes = { + category.id: { + "id": category.id, + "name": category.name, + "slug": category.slug, + "parent_id": category.parent_id, + "post_count": category.post_count, + "children": [], + } + for category in categories + } + roots = [] + for category in categories: + node = nodes[category.id] + if category.parent_id and category.parent_id in nodes: + nodes[category.parent_id]["children"].append(node) + else: + roots.append(node) + return roots + + 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() @@ -109,6 +201,7 @@ def _validate_featured_image(file: UploadedFile) -> str | 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) + writer_ids = payload.pop("writer_ids", None) category_id = payload.pop("category_id", None) requested_status = payload.pop("status", None) @@ -126,6 +219,14 @@ def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_statu post.save() if tag_ids is not None: post.tags.set(tag_ids) + if writer_ids is not None: + if user.is_superuser or user.is_staff or can_review_blog_posts(user): + writers = list(post.writers.model.objects.filter(id__in=writer_ids, is_active=True)) + post.writers.set(writers or [post.author]) + else: + post.writers.set([user]) + elif not post.writers.exists(): + post.writers.set([post.author]) return post @@ -135,7 +236,24 @@ def _interaction_payload(post: Post, user) -> BlogInteractionSchema: 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(), + comments_count=post.comments.filter(is_approved=True, is_hidden=False, is_deleted=False).count(), + ) + + +@blog_router.get("/admin/writers", response={200: List[AuthorSchema], 403: ErrorSchema}, auth=jwt_auth) +def list_blog_writers(request): + if not (request.auth.is_superuser or request.auth.is_staff or can_review_blog_posts(request.auth)): + return 403, {"error": "Permission denied"} + return ( + User.objects.filter(is_active=True) + .filter( + Q(is_staff=True) + | Q(is_superuser=True) + | Q(groups__permissions__content_type__app_label="blog", groups__permissions__codename__in=["add_post", "change_post"]) + | Q(user_permissions__content_type__app_label="blog", user_permissions__codename__in=["add_post", "change_post"]) + ) + .distinct() + .order_by("first_name", "last_name", "username") ) @@ -331,6 +449,54 @@ def my_blog_activity(request): ) +@blog_router.get("/banners", response=List[BlogBannerSchema]) +def list_banners(request): + return BlogBanner.objects.filter(is_active=True, is_deleted=False).order_by("sort_order", "-created_at") + + +@blog_router.get("/filters", response=BlogFiltersSchema) +def blog_filters(request): + tag_rows = ( + Tag.objects.annotate( + post_count=Count( + "posts", + filter=Q(posts__status=Post.StatusChoices.PUBLISHED, posts__is_deleted=False), + distinct=True, + ) + ) + .filter(post_count__gt=0) + .order_by("name") + ) + + author_post_ids: dict[int, set[int]] = {} + published_posts = Post.objects.filter(status=Post.StatusChoices.PUBLISHED, is_deleted=False) + for row in published_posts.values("id", "author_id"): + author_post_ids.setdefault(row["author_id"], set()).add(row["id"]) + for row in Post.writers.through.objects.filter(post__status=Post.StatusChoices.PUBLISHED, post__is_deleted=False).values("post_id", "user_id"): + author_post_ids.setdefault(row["user_id"], set()).add(row["post_id"]) + + users = User.objects.filter(id__in=author_post_ids.keys(), is_active=True).order_by("first_name", "last_name", "username") + authors = [ + { + "id": user.id, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "post_count": len(author_post_ids.get(user.id, set())), + } + for user in users + ] + + return { + "categories": _build_category_filter_tree(), + "tags": [ + {"id": tag.id, "name": tag.name, "slug": tag.slug, "post_count": tag.post_count} + for tag in tag_rows + ], + "authors": authors, + } + + @blog_router.get("/posts", response=List[PostListSchema]) def list_posts( request, @@ -344,17 +510,20 @@ def list_posts( ): queryset = _published_queryset() if category: - queryset = queryset.filter(category__slug=category) - if tag: - queryset = queryset.filter(tags__slug=tag) + category_ids = _category_and_descendant_ids(category) + queryset = queryset.filter(category_id__in=category_ids) if category_ids else queryset.none() + tags = _query_values(request, "tag", tag) + if tags: + queryset = queryset.filter(tags__slug__in=tags) if 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) + authors = _query_values(request, "author", author) + if authors: + queryset = queryset.filter(Q(author__username__in=authors) | Q(writers__username__in=authors)) offset = (page - 1) * limit - return list(queryset[offset : offset + limit]) + return list(queryset.distinct()[offset : offset + limit]) @blog_router.get("/posts/{slug}/recommended", response=List[PostListSchema]) @@ -426,8 +595,19 @@ def delete_post(request, slug: str): @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")) + user = _optional_auth_user(request) + visibility = _comment_visibility_filter(user) + replies = ( + Comment.objects.filter(visibility) + .select_related("author", "post") + .prefetch_related(Prefetch("replies", queryset=Comment.objects.filter(visibility).select_related("author", "post").order_by("-created_at"))) + .order_by("-created_at") + ) + comments = ( + Comment.objects.filter(visibility, post=post, parent=None) + .select_related("author", "post") + .prefetch_related(Prefetch("replies", queryset=replies)) + .order_by("-created_at") ) return list(comments) @@ -437,7 +617,7 @@ 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) + parent = get_object_or_404(Comment, id=data.parent_id, post=post, is_approved=True, is_hidden=False, is_deleted=False) comment = Comment.objects.create(post=post, author=request.auth, content=data.content, parent=parent) return 201, comment @@ -451,6 +631,42 @@ def hide_comment(request, comment_id: int, data: CommentHideSchema): return 200, {"message": "Comment hidden successfully"} +@blog_router.post("/comments/{comment_id}/unhide", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) +def unhide_comment(request, comment_id: int): + if not can_moderate_blog_comments(request.auth): + return 403, {"error": "Permission denied"} + comment = get_object_or_404(Comment, id=comment_id) + comment.unhide() + return 200, {"message": "Comment restored successfully"} + + +@blog_router.post("/comments/{comment_id}/delete", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) +def soft_delete_comment_tree(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.all_objects, id=comment_id, is_deleted=False) + deleted_count = 1 + len(comment.descendant_ids()) + comment.soft_delete_tree(request.auth, data.note or "") + return 200, {"message": f"{deleted_count} comment(s) deleted successfully"} + + +@blog_router.put("/comments/{comment_id}", response={200: CommentSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def update_comment(request, comment_id: int, data: CommentUpdateSchema): + comment = get_object_or_404(Comment.objects.select_related("author", "post"), id=comment_id) + if comment.author_id != request.auth.id: + return 403, {"error": "Permission denied"} + if comment.is_deleted or comment.is_hidden or not comment.is_approved or comment.hidden_at: + return 403, {"error": "Hidden comments cannot be edited"} + + content = data.content.strip() + if not content: + return 400, {"error": "Comment content is required"} + + comment.content = content + comment.save(update_fields=["content", "updated_at"]) + return 200, comment + + @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) diff --git a/apps/blog/migrations/0004_blog_banner_nested_categories_post_writers.py b/apps/blog/migrations/0004_blog_banner_nested_categories_post_writers.py new file mode 100644 index 0000000..b93b06e --- /dev/null +++ b/apps/blog/migrations/0004_blog_banner_nested_categories_post_writers.py @@ -0,0 +1,69 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import apps.blog.models + + +def backfill_post_writers(apps, schema_editor): + Post = apps.get_model("blog", "Post") + for post in Post.objects.exclude(author_id__isnull=True).iterator(): + post.writers.add(post.author_id) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0003_blog_platform"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="BlogBanner", + 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)), + ("title", models.CharField(blank=True, max_length=160)), + ("alt_text", models.CharField(blank=True, max_length=200)), + ("image", models.ImageField(upload_to=apps.blog.models.blog_banner_upload_to)), + ("url", models.URLField()), + ("is_active", models.BooleanField(default=True)), + ("sort_order", models.PositiveIntegerField(default=0)), + ], + options={ + "ordering": ["sort_order", "-created_at"], + }, + ), + migrations.AddField( + model_name="category", + name="parent", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="children", + to="blog.category", + ), + ), + migrations.AddField( + model_name="post", + name="writers", + field=models.ManyToManyField( + blank=True, + related_name="written_blog_posts", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddIndex( + model_name="blogbanner", + index=models.Index(fields=["is_active", "sort_order"], name="blog_blogba_is_acti_c11b3c_idx"), + ), + migrations.RunPython(backfill_post_writers, noop_reverse), + ] diff --git a/apps/blog/migrations/0005_comment_hide_delete_state.py b/apps/blog/migrations/0005_comment_hide_delete_state.py new file mode 100644 index 0000000..9871946 --- /dev/null +++ b/apps/blog/migrations/0005_comment_hide_delete_state.py @@ -0,0 +1,58 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +def mark_legacy_hidden_comments(apps, schema_editor): + Comment = apps.get_model("blog", "Comment") + Comment.objects.filter(is_approved=False, is_deleted=False).update(is_hidden=True) + + +def unmark_legacy_hidden_comments(apps, schema_editor): + Comment = apps.get_model("blog", "Comment") + Comment.objects.filter(is_hidden=True, is_deleted=False).update(is_hidden=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ("blog", "0004_blog_banner_nested_categories_post_writers"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="delete_note", + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name="comment", + name="deleted_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="deleted_blog_comments", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="comment", + name="is_hidden", + field=models.BooleanField(default=False), + ), + migrations.RunPython(mark_legacy_hidden_comments, unmark_legacy_hidden_comments), + migrations.RemoveIndex( + model_name="comment", + name="blog_commen_post_id_7710b1_idx", + ), + migrations.AddIndex( + model_name="comment", + index=models.Index(fields=["post", "is_approved", "is_hidden"], name="blog_commen_post_id_760827_idx"), + ), + migrations.AddIndex( + model_name="comment", + index=models.Index(fields=["parent", "is_deleted", "is_hidden"], name="blog_commen_parent__2abfc7_idx"), + ), + ] diff --git a/apps/blog/models.py b/apps/blog/models.py index 9241672..2315e76 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -54,10 +54,22 @@ def post_asset_upload_to(instance: "PostAsset", filename: str) -> str: return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}" +def blog_banner_upload_to(instance: "BlogBanner", filename: str) -> str: + suffix = Path(filename).suffix.lower() + return f"blog/banners/{uuid4().hex}{suffix}" + + class Category(BaseModel): name = models.CharField(max_length=100, unique=True) slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True) description = models.TextField(blank=True) + parent = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="children", + ) class Meta: verbose_name_plural = "Categories" @@ -71,6 +83,17 @@ class Category(BaseModel): self.slug = _unique_slug_for(self, self.name) super().save(*args, **kwargs) + @property + def path(self): + path = [] + current = self + seen = set() + while current and current.pk not in seen: + seen.add(current.pk) + path.append(current) + current = current.parent + return list(reversed(path)) + class Tag(BaseModel): name = models.CharField(max_length=50, unique=True) @@ -136,6 +159,11 @@ class Post(BaseModel): blank=True, related_name="published_blog_posts", ) + writers = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="written_blog_posts", + ) class Meta: ordering = ["-created_at"] @@ -177,8 +205,8 @@ class Post(BaseModel): "markdown.extensions.toc", ], ) - word_count = len((self.content or "").split()) - self.reading_time = max(1, (word_count + 199) // 200) + character_count = len(_plain_text_from_markdown(self.content or "")) + self.reading_time = max(1, (character_count + 999) // 1000) if self.status == Post.StatusChoices.PUBLISHED and not self.published_at: self.published_at = timezone.now() @@ -206,6 +234,24 @@ class Post(BaseModel): safe_process_public_image(self.og_image, "blog_featured") +class BlogBanner(BaseModel): + title = models.CharField(max_length=160, blank=True) + alt_text = models.CharField(max_length=200, blank=True) + image = models.ImageField(upload_to=blog_banner_upload_to) + url = models.URLField() + is_active = models.BooleanField(default=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "-created_at"] + indexes = [ + models.Index(fields=["is_active", "sort_order"]), + ] + + def __str__(self): + return self.title or self.url + + class PostAsset(BaseModel): class FileType(models.TextChoices): IMAGE = "image", "Image" @@ -283,6 +329,7 @@ class Comment(BaseModel): content = models.TextField() parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies") is_approved = models.BooleanField(default=True) + is_hidden = models.BooleanField(default=False) hidden_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -292,12 +339,21 @@ class Comment(BaseModel): ) hidden_at = models.DateTimeField(null=True, blank=True) moderation_note = models.TextField(blank=True) + deleted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="deleted_blog_comments", + ) + delete_note = models.TextField(blank=True) class Meta: ordering = ["created_at"] indexes = [ - models.Index(fields=["post", "is_approved"]), + models.Index(fields=["post", "is_approved", "is_hidden"]), models.Index(fields=["author", "created_at"]), + models.Index(fields=["parent", "is_deleted", "is_hidden"]), ] def __str__(self): @@ -308,11 +364,50 @@ class Comment(BaseModel): 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"]) + now = timezone.now() + ids = [self.id, *self.descendant_ids()] + self.__class__.all_objects.filter(id__in=ids, is_deleted=False).update( + is_hidden=True, + is_approved=False, + hidden_by=user, + hidden_at=now, + moderation_note=note, + updated_at=now, + ) + + def unhide(self): + now = timezone.now() + ids = [self.id, *self.descendant_ids()] + self.__class__.all_objects.filter(id__in=ids, is_deleted=False).update( + is_hidden=False, + is_approved=True, + hidden_by=None, + hidden_at=None, + moderation_note="", + updated_at=now, + ) + + def descendant_ids(self) -> list[int]: + pending = [self.id] + descendants: list[int] = [] + while pending: + child_ids = list( + self.__class__.all_objects.filter(parent_id__in=pending).values_list("id", flat=True) + ) + descendants.extend(child_ids) + pending = child_ids + return descendants + + def soft_delete_tree(self, user, note: str = ""): + now = timezone.now() + ids = [self.id, *self.descendant_ids()] + self.__class__.all_objects.filter(id__in=ids).update( + is_deleted=True, + deleted_at=now, + deleted_by=user, + delete_note=note, + updated_at=now, + ) class Like(models.Model): diff --git a/apps/blog/resources.py b/apps/blog/resources.py index 6c88ddd..e620a66 100644 --- a/apps/blog/resources.py +++ b/apps/blog/resources.py @@ -7,7 +7,7 @@ from apps.blog.models import Post, Category, Tag class CategoryResource(resources.ModelResource): class Meta: model = Category - fields = ('id', 'name', 'slug', 'description', 'created_at') + fields = ('id', 'name', 'parent', 'slug', 'description', 'created_at') class PostResource(resources.ModelResource): author = fields.Field( @@ -25,8 +25,13 @@ class PostResource(resources.ModelResource): attribute='tags', widget=ManyToManyWidget(Tag, field='name', separator='|') ) + writers = fields.Field( + column_name='writers', + attribute='writers', + widget=ManyToManyWidget(User, field='username', separator='|') + ) class Meta: model = Post fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author', - 'category', 'tags', 'status', 'is_featured', 'published_at', 'created_at') + 'category', 'tags', 'writers', 'status', 'is_featured', 'published_at', 'created_at')