Compare commits
3 Commits
4039be0187
...
41f9be4c7e
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f9be4c7e | |||
| 13ea129d3a | |||
| 5045f8da47 |
@@ -4,22 +4,22 @@ from django.contrib import admin
|
|||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
from simplemde.widgets import SimpleMDEEditor
|
from simplemde.widgets import SimpleMDEEditor
|
||||||
|
|
||||||
from apps.blog.models import Category, Tag, Post, 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 apps.blog.resources import PostResource, CategoryResource
|
||||||
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
|
||||||
@admin.register(Category)
|
@admin.register(Category)
|
||||||
class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
resource_class = CategoryResource
|
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)
|
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
|
||||||
search_fields = ('name', 'description')
|
search_fields = ('name', 'description', 'parent__name')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
|
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Content', {
|
('Content', {
|
||||||
'fields': ('name', 'slug', 'description')
|
'fields': ('name', 'parent', 'slug', 'description')
|
||||||
}),
|
}),
|
||||||
('Metadata', {
|
('Metadata', {
|
||||||
'fields': ('created_at', 'updated_at')
|
'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)
|
list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter)
|
||||||
search_fields = ('title', 'content', 'author__username')
|
search_fields = ('title', 'content', 'author__username')
|
||||||
prepopulated_fields = {'slug': ('title',)}
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
filter_horizontal = ('tags',)
|
filter_horizontal = ('tags', 'writers')
|
||||||
date_hierarchy = 'published_at'
|
date_hierarchy = 'published_at'
|
||||||
|
|
||||||
fieldsets = (
|
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')
|
'fields': ('seo_title', 'seo_description', 'canonical_url', 'og_title', 'og_description', 'og_image', 'noindex', 'focus_keyword', 'reading_time')
|
||||||
}),
|
}),
|
||||||
('Metadata', {
|
('Metadata', {
|
||||||
'fields': ('author', 'category', 'tags', 'status', 'is_featured', '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', {
|
('Soft Delete', {
|
||||||
'fields': ('is_deleted', 'deleted_at'),
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
@@ -132,8 +132,8 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Comment)
|
@admin.register(Comment)
|
||||||
class CommentAdmin(BaseModelAdmin):
|
class CommentAdmin(BaseModelAdmin):
|
||||||
list_display = ('author', 'post', 'content_preview', 'is_approved', 'created_at')
|
list_display = ('author', 'post', 'content_preview', 'is_approved', 'is_hidden', 'is_deleted', 'created_at')
|
||||||
list_filter = ('is_approved', 'created_at', 'post', SoftDeleteListFilter)
|
list_filter = ('is_approved', 'is_hidden', 'created_at', 'post', SoftDeleteListFilter)
|
||||||
search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title')
|
search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title')
|
||||||
readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at')
|
readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
@@ -142,28 +142,35 @@ class CommentAdmin(BaseModelAdmin):
|
|||||||
'fields': ('post', 'author', 'content')
|
'fields': ('post', 'author', 'content')
|
||||||
}),
|
}),
|
||||||
('Metadata', {
|
('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', {
|
('Soft Delete', {
|
||||||
'fields': ('is_deleted', 'deleted_at'),
|
'fields': ('is_deleted', 'deleted_at', 'deleted_by', 'delete_note'),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
actions = BaseModelAdmin.actions + ['approve_comments', 'disapprove_comments']
|
actions = BaseModelAdmin.actions + ['approve_comments', 'hide_comments', 'unhide_comments']
|
||||||
|
|
||||||
def content_preview(self, obj):
|
def content_preview(self, obj):
|
||||||
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
||||||
content_preview.short_description = 'Content Preview'
|
content_preview.short_description = 'Content Preview'
|
||||||
|
|
||||||
def approve_comments(self, request, queryset):
|
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.")
|
self.message_user(request, f"Approved {queryset.count()} comments.")
|
||||||
approve_comments.short_description = "Approve selected comments"
|
approve_comments.short_description = "Approve selected comments"
|
||||||
|
|
||||||
def disapprove_comments(self, request, queryset):
|
def hide_comments(self, request, queryset):
|
||||||
queryset.update(is_approved=False)
|
for comment in queryset:
|
||||||
self.message_user(request, f"Disapproved {queryset.count()} comments.")
|
comment.hide(request.user)
|
||||||
disapprove_comments.short_description = "Disapprove selected comments"
|
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)
|
@admin.register(Like)
|
||||||
class LikeAdmin(admin.ModelAdmin):
|
class LikeAdmin(admin.ModelAdmin):
|
||||||
@@ -185,3 +192,24 @@ class PostAssetAdmin(BaseModelAdmin):
|
|||||||
list_filter = ('file_type', 'mime_type', 'created_at')
|
list_filter = ('file_type', 'mime_type', 'created_at')
|
||||||
search_fields = ('title', 'caption', 'alt_text', 'post__title', 'uploaded_by__username')
|
search_fields = ('title', 'caption', 'alt_text', 'post__title', 'uploaded_by__username')
|
||||||
readonly_fields = ('size', 'mime_type', 'created_at', 'updated_at', 'deleted_at')
|
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',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,17 +5,37 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from ninja import ModelSchema, Schema
|
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
|
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
|
||||||
|
|
||||||
|
|
||||||
class CategorySchema(ModelSchema):
|
class CategorySchema(ModelSchema):
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
model = Category
|
model = Category
|
||||||
model_fields = ["id", "name", "slug", "description", "created_at"]
|
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):
|
class TagSchema(ModelSchema):
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@@ -25,11 +45,19 @@ class TagSchema(ModelSchema):
|
|||||||
model_fields = ["id", "name", "slug", "created_at"]
|
model_fields = ["id", "name", "slug", "created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class TagFilterSchema(Schema):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
post_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class AuthorSchema(Schema):
|
class AuthorSchema(Schema):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
first_name: str
|
first_name: str
|
||||||
last_name: str
|
last_name: str
|
||||||
|
bio: Optional[str] = None
|
||||||
profile_picture: Optional[str] = None
|
profile_picture: Optional[str] = None
|
||||||
profile_picture_thumbnail_url: Optional[str] = None
|
profile_picture_thumbnail_url: Optional[str] = None
|
||||||
profile_picture_preview_url: Optional[str] = None
|
profile_picture_preview_url: Optional[str] = None
|
||||||
@@ -124,6 +152,8 @@ class PostListSchema(Schema):
|
|||||||
status: str
|
status: str
|
||||||
published_at: Optional[datetime] = None
|
published_at: Optional[datetime] = None
|
||||||
category: Optional[CategorySchema] = None
|
category: Optional[CategorySchema] = None
|
||||||
|
category_path: List[CategoryPathSchema] = []
|
||||||
|
writers: List[AuthorSchema] = []
|
||||||
tags: List[TagSchema]
|
tags: List[TagSchema]
|
||||||
is_featured: bool
|
is_featured: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
@@ -159,6 +189,17 @@ class PostListSchema(Schema):
|
|||||||
url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
|
url = derivative_url(obj.featured_image, PREVIEW_VARIANT)
|
||||||
return request.build_absolute_uri(url) if url else None
|
return request.build_absolute_uri(url) if url else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_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
|
@staticmethod
|
||||||
def resolve_likes_count(obj):
|
def resolve_likes_count(obj):
|
||||||
return getattr(obj, "likes_count", None) or obj.likes.count()
|
return getattr(obj, "likes_count", None) or obj.likes.count()
|
||||||
@@ -169,7 +210,11 @@ class PostListSchema(Schema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_comments_count(obj):
|
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):
|
class PostDetailSchema(PostListSchema):
|
||||||
@@ -192,6 +237,7 @@ class PostCreateSchema(Schema):
|
|||||||
excerpt: Optional[str] = None
|
excerpt: Optional[str] = None
|
||||||
category_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
tag_ids: Optional[List[int]] = []
|
tag_ids: Optional[List[int]] = []
|
||||||
|
writer_ids: Optional[List[int]] = None
|
||||||
status: str = "draft"
|
status: str = "draft"
|
||||||
is_featured: bool = False
|
is_featured: bool = False
|
||||||
seo_title: Optional[str] = ""
|
seo_title: Optional[str] = ""
|
||||||
@@ -221,10 +267,14 @@ class CommentSchema(ModelSchema):
|
|||||||
post_title: str
|
post_title: str
|
||||||
post_slug: str
|
post_slug: str
|
||||||
parent_id: Optional[int] = None
|
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:
|
class Config:
|
||||||
model = Comment
|
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
|
@staticmethod
|
||||||
def resolve_post_id(obj):
|
def resolve_post_id(obj):
|
||||||
@@ -242,12 +292,22 @@ class CommentSchema(ModelSchema):
|
|||||||
def resolve_parent_id(obj):
|
def resolve_parent_id(obj):
|
||||||
return obj.parent_id
|
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):
|
class CommentCreateSchema(Schema):
|
||||||
content: str
|
content: str
|
||||||
parent_id: Optional[int] = None
|
parent_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentUpdateSchema(Schema):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class CommentHideSchema(Schema):
|
class CommentHideSchema(Schema):
|
||||||
note: Optional[str] = ""
|
note: Optional[str] = ""
|
||||||
|
|
||||||
@@ -265,3 +325,30 @@ class BlogProfileActivitySchema(Schema):
|
|||||||
saved_posts: List[PostListSchema]
|
saved_posts: List[PostListSchema]
|
||||||
comments: List[CommentSchema]
|
comments: List[CommentSchema]
|
||||||
replies: 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]
|
||||||
|
|||||||
@@ -5,18 +5,23 @@ from pathlib import Path
|
|||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.models import Count, Prefetch, Q
|
from django.db.models import Count, Prefetch, Q
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from ninja import File, Form, Query, Router, UploadedFile
|
from ninja import File, Form, Query, Router, UploadedFile
|
||||||
|
|
||||||
from apps.blog.api.schemas import (
|
from apps.blog.api.schemas import (
|
||||||
|
BlogBannerSchema,
|
||||||
|
BlogFiltersSchema,
|
||||||
BlogInteractionSchema,
|
BlogInteractionSchema,
|
||||||
BlogProfileActivitySchema,
|
BlogProfileActivitySchema,
|
||||||
CategorySchema,
|
CategorySchema,
|
||||||
CommentCreateSchema,
|
CommentCreateSchema,
|
||||||
CommentHideSchema,
|
CommentHideSchema,
|
||||||
CommentSchema,
|
CommentSchema,
|
||||||
|
CommentUpdateSchema,
|
||||||
|
AuthorSchema,
|
||||||
PostAssetCreateSchema,
|
PostAssetCreateSchema,
|
||||||
PostAssetSchema,
|
PostAssetSchema,
|
||||||
PostCreateSchema,
|
PostCreateSchema,
|
||||||
@@ -25,7 +30,7 @@ from apps.blog.api.schemas import (
|
|||||||
PostReviewSchema,
|
PostReviewSchema,
|
||||||
TagSchema,
|
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 (
|
from apps.blog.permissions import (
|
||||||
can_access_blog_admin,
|
can_access_blog_admin,
|
||||||
can_edit_post,
|
can_edit_post,
|
||||||
@@ -39,6 +44,7 @@ from core.authentication import jwt_auth
|
|||||||
|
|
||||||
|
|
||||||
blog_router = Router()
|
blog_router = Router()
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"}
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff", ".svg"}
|
||||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"}
|
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi"}
|
||||||
@@ -49,11 +55,15 @@ ARCHIVE_EXTENSIONS = {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"}
|
|||||||
def _post_queryset():
|
def _post_queryset():
|
||||||
return (
|
return (
|
||||||
Post.objects.select_related("author", "category", "reviewed_by", "published_by")
|
Post.objects.select_related("author", "category", "reviewed_by", "published_by")
|
||||||
.prefetch_related("tags", "assets__uploaded_by")
|
.prefetch_related("tags", "writers", "assets__uploaded_by")
|
||||||
.annotate(
|
.annotate(
|
||||||
likes_count=Count("likes", distinct=True),
|
likes_count=Count("likes", distinct=True),
|
||||||
saves_count=Count("saves", 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)
|
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:
|
def _asset_file_type(file: UploadedFile) -> str:
|
||||||
suffix = Path(file.name).suffix.lower()
|
suffix = Path(file.name).suffix.lower()
|
||||||
content_type = (file.content_type or mimetypes.guess_type(file.name)[0] or "").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:
|
def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_status: bool = False) -> Post:
|
||||||
payload = data.dict(exclude_unset=True)
|
payload = data.dict(exclude_unset=True)
|
||||||
tag_ids = payload.pop("tag_ids", None)
|
tag_ids = payload.pop("tag_ids", None)
|
||||||
|
writer_ids = payload.pop("writer_ids", None)
|
||||||
category_id = payload.pop("category_id", None)
|
category_id = payload.pop("category_id", None)
|
||||||
requested_status = payload.pop("status", None)
|
requested_status = payload.pop("status", None)
|
||||||
|
|
||||||
@@ -126,6 +219,14 @@ def _apply_post_payload(post: Post, data: PostCreateSchema, *, user, allow_statu
|
|||||||
post.save()
|
post.save()
|
||||||
if tag_ids is not None:
|
if tag_ids is not None:
|
||||||
post.tags.set(tag_ids)
|
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
|
return post
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +236,24 @@ def _interaction_payload(post: Post, user) -> BlogInteractionSchema:
|
|||||||
saved=SavedPost.objects.filter(post=post, user=user).exists(),
|
saved=SavedPost.objects.filter(post=post, user=user).exists(),
|
||||||
likes_count=post.likes.count(),
|
likes_count=post.likes.count(),
|
||||||
saves_count=post.saves.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])
|
@blog_router.get("/posts", response=List[PostListSchema])
|
||||||
def list_posts(
|
def list_posts(
|
||||||
request,
|
request,
|
||||||
@@ -344,17 +510,20 @@ def list_posts(
|
|||||||
):
|
):
|
||||||
queryset = _published_queryset()
|
queryset = _published_queryset()
|
||||||
if category:
|
if category:
|
||||||
queryset = queryset.filter(category__slug=category)
|
category_ids = _category_and_descendant_ids(category)
|
||||||
if tag:
|
queryset = queryset.filter(category_id__in=category_ids) if category_ids else queryset.none()
|
||||||
queryset = queryset.filter(tags__slug=tag)
|
tags = _query_values(request, "tag", tag)
|
||||||
|
if tags:
|
||||||
|
queryset = queryset.filter(tags__slug__in=tags)
|
||||||
if search:
|
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:
|
if featured is not None:
|
||||||
queryset = queryset.filter(is_featured=featured)
|
queryset = queryset.filter(is_featured=featured)
|
||||||
if author:
|
authors = _query_values(request, "author", author)
|
||||||
queryset = queryset.filter(author__username=author)
|
if authors:
|
||||||
|
queryset = queryset.filter(Q(author__username__in=authors) | Q(writers__username__in=authors))
|
||||||
offset = (page - 1) * limit
|
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])
|
@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])
|
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
|
||||||
def list_comments(request, slug: str):
|
def list_comments(request, slug: str):
|
||||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
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(
|
user = _optional_auth_user(request)
|
||||||
Prefetch("replies", queryset=Comment.objects.filter(is_approved=True).select_related("author", "post"))
|
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)
|
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)
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
parent = None
|
parent = None
|
||||||
if data.parent_id:
|
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)
|
comment = Comment.objects.create(post=post, author=request.auth, content=data.content, parent=parent)
|
||||||
return 201, comment
|
return 201, comment
|
||||||
|
|
||||||
@@ -451,6 +631,42 @@ def hide_comment(request, comment_id: int, data: CommentHideSchema):
|
|||||||
return 200, {"message": "Comment hidden successfully"}
|
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)
|
@blog_router.post("/posts/{slug}/like", response={200: BlogInteractionSchema}, auth=jwt_auth)
|
||||||
def toggle_like(request, slug: str):
|
def toggle_like(request, slug: str):
|
||||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
|||||||
338
apps/blog/management/commands/seed_blog_mock_data.py
Normal file
338
apps/blog/management/commands/seed_blog_mock_data.py
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.blog.models import BlogBanner, Category, Comment, Like, Post, SavedPost, Tag
|
||||||
|
from apps.blog.permissions import BLOG_EDITOR_GROUP
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
except ImportError: # pragma: no cover - command gracefully explains missing optional dependency.
|
||||||
|
Image = None
|
||||||
|
ImageDraw = None
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
WRITERS = [
|
||||||
|
{
|
||||||
|
"username": "mock-blog-writer-ali",
|
||||||
|
"first_name": "علی",
|
||||||
|
"last_name": "کریمی",
|
||||||
|
"bio": "دانشجوی مهندسی کامپیوتر و علاقهمند به معماری نرمافزار، لینوکس و تجربههای واقعی تیمی.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "mock-blog-writer-sara",
|
||||||
|
"first_name": "سارا",
|
||||||
|
"last_name": "احمدی",
|
||||||
|
"bio": "نویسنده حوزه تجربه کاربری، فرانتاند و یادگیری کاربردی برای دانشجویان تازهوارد.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "mock-blog-writer-nima",
|
||||||
|
"first_name": "نیما",
|
||||||
|
"last_name": "رضایی",
|
||||||
|
"bio": "علاقهمند به الگوریتم، بکاند و انتقال تجربههای مسابقهای به پروژههای واقعی.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
TAG_NAMES = [
|
||||||
|
"پایتون",
|
||||||
|
"فرانتاند",
|
||||||
|
"بکاند",
|
||||||
|
"الگوریتم",
|
||||||
|
"هوش مصنوعی",
|
||||||
|
"تجربه دانشجویی",
|
||||||
|
"مسیر شغلی",
|
||||||
|
"لینوکس",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
POSTS = [
|
||||||
|
{
|
||||||
|
"title": "چطور یک پروژه دانشجویی را مثل محصول واقعی جلو ببریم؟",
|
||||||
|
"slug": "mock-پروژه-دانشجویی-محصول-واقعی",
|
||||||
|
"category": "توسعه نرمافزار",
|
||||||
|
"tags": ["بکاند", "فرانتاند", "تجربه دانشجویی"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "راهنمای شروع پایتون برای دانشجویان مهندسی کامپیوتر",
|
||||||
|
"slug": "mock-شروع-پایتون-برای-دانشجویان",
|
||||||
|
"category": "برنامهنویسی",
|
||||||
|
"tags": ["پایتون", "مسیر شغلی"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "الگوریتمها را چطور کاربردی یاد بگیریم؟",
|
||||||
|
"slug": "mock-یادگیری-کاربردی-الگوریتم",
|
||||||
|
"category": "علوم کامپیوتر",
|
||||||
|
"tags": ["الگوریتم", "تجربه دانشجویی"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "از ترمینال نترسیم: لینوکس برای زندگی روزمره دانشجویی",
|
||||||
|
"slug": "mock-لینوکس-برای-دانشجویان",
|
||||||
|
"category": "ابزارها",
|
||||||
|
"tags": ["لینوکس", "مسیر شغلی"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "هوش مصنوعی در پروژههای کوچک دانشجویی",
|
||||||
|
"slug": "mock-هوش-مصنوعی-پروژه-دانشجویی",
|
||||||
|
"category": "هوش مصنوعی",
|
||||||
|
"tags": ["هوش مصنوعی", "پایتون"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_markdown(title: str) -> str:
|
||||||
|
return f"""# {title}
|
||||||
|
|
||||||
|
این نوشته برای تست نمای واقعی بلاگ ساخته شده است. متن عمداً چند بخش دارد تا فهرست محتوا، خوانایی، کدبلاک و کامنتها در صفحه جزئیات بهتر دیده شوند.
|
||||||
|
|
||||||
|
## مسئله از کجا شروع میشود؟
|
||||||
|
|
||||||
|
وقتی یک تیم دانشجویی روی پروژه کار میکند، معمولاً تمرکز اصلی روی تمام کردن سریع کار است. اما اگر کمی ساختار داشته باشیم، خروجی هم قابل ارائهتر میشود و هم بعداً قابل توسعه خواهد بود.
|
||||||
|
|
||||||
|
## یک نمونه کد کوتاه
|
||||||
|
|
||||||
|
```python
|
||||||
|
def normalize_title(title: str) -> str:
|
||||||
|
return "-".join(title.strip().lower().split())
|
||||||
|
|
||||||
|
print(normalize_title("Guilan ACE Blog"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## پیشنهاد عملی
|
||||||
|
|
||||||
|
- ابتدا مسئله را واضح بنویسید.
|
||||||
|
- کارها را کوچک و قابل بررسی کنید.
|
||||||
|
- خروجی هر مرحله را مستند کنید.
|
||||||
|
- بازخورد گرفتن را به آخر کار موکول نکنید.
|
||||||
|
|
||||||
|
### نکته تکمیلی
|
||||||
|
|
||||||
|
اگر نوشته شامل تصویر، کد یا لینک است، بهتر است ساختار آن از ابتدا با تیترهای واضح جدا شود تا کاربر بتواند سریعتر بخش موردنظرش را پیدا کند.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def make_image_bytes(label: str, width: int, height: int, color: tuple[int, int, int]) -> bytes:
|
||||||
|
if Image is None or ImageDraw is None:
|
||||||
|
raise RuntimeError("Pillow is required to generate mock images.")
|
||||||
|
|
||||||
|
image = Image.new("RGB", (width, height), color)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
for index in range(0, width, 48):
|
||||||
|
draw.line((index, 0, index - height, height), fill=(255, 255, 255), width=2)
|
||||||
|
draw.rectangle((32, height - 112, width - 32, height - 32), fill=(20, 24, 38))
|
||||||
|
draw.text((52, height - 84), label[:70], fill=(255, 255, 255))
|
||||||
|
output = BytesIO()
|
||||||
|
image.save(output, format="JPEG", quality=88)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def set_image_field(instance, field_name: str, path: str, label: str, width: int, height: int, color: tuple[int, int, int]):
|
||||||
|
field = getattr(instance, field_name)
|
||||||
|
if field:
|
||||||
|
return
|
||||||
|
image_bytes = make_image_bytes(label, width, height, color)
|
||||||
|
field.save(path, ContentFile(image_bytes), save=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Seed rich mock blog data for local visual QA."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--reset", action="store_true", help="Delete previous mock blog data before seeding.")
|
||||||
|
parser.add_argument("--password", default="MockPass12345!", help="Password for generated writer users.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if Image is None:
|
||||||
|
raise RuntimeError("Pillow is required. Install project requirements before running this command.")
|
||||||
|
|
||||||
|
random.seed(42)
|
||||||
|
if options["reset"]:
|
||||||
|
self._reset_mock_data()
|
||||||
|
|
||||||
|
editor_group, _ = Group.objects.get_or_create(name=BLOG_EDITOR_GROUP)
|
||||||
|
writers = self._seed_writers(editor_group, options["password"])
|
||||||
|
categories = self._seed_categories()
|
||||||
|
tags = self._seed_tags()
|
||||||
|
self._seed_banners()
|
||||||
|
posts = self._seed_posts(writers, categories, tags)
|
||||||
|
self._seed_comments_and_reactions(posts, writers)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Mock blog data seeded successfully."))
|
||||||
|
self.stdout.write("Writer login usernames:")
|
||||||
|
for writer in writers:
|
||||||
|
self.stdout.write(f" - {writer.username} / {options['password']}")
|
||||||
|
|
||||||
|
def _reset_mock_data(self):
|
||||||
|
Post.all_objects.filter(slug__startswith="mock-").delete()
|
||||||
|
BlogBanner.all_objects.filter(title__startswith="Mock ").delete()
|
||||||
|
Category.all_objects.filter(slug__startswith="mock-").delete()
|
||||||
|
Tag.all_objects.filter(slug__startswith="mock-").delete()
|
||||||
|
User.objects.filter(username__startswith="mock-blog-writer-").delete()
|
||||||
|
|
||||||
|
def _seed_writers(self, editor_group: Group, password: str):
|
||||||
|
writers = []
|
||||||
|
for index, spec in enumerate(WRITERS, start=1):
|
||||||
|
user, created = User.objects.get_or_create(
|
||||||
|
username=spec["username"],
|
||||||
|
defaults={
|
||||||
|
"first_name": spec["first_name"],
|
||||||
|
"last_name": spec["last_name"],
|
||||||
|
"email": f"{spec['username']}@example.local",
|
||||||
|
"mobile": f"09199000{index:03d}",
|
||||||
|
"bio": spec["bio"],
|
||||||
|
"is_active": True,
|
||||||
|
"is_mobile_verified": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
user.first_name = spec["first_name"]
|
||||||
|
user.last_name = spec["last_name"]
|
||||||
|
user.bio = spec["bio"]
|
||||||
|
user.is_active = True
|
||||||
|
user.is_mobile_verified = True
|
||||||
|
if created:
|
||||||
|
user.set_password(password)
|
||||||
|
set_image_field(
|
||||||
|
user,
|
||||||
|
"profile_picture",
|
||||||
|
f"profile_pictures/mock-writer-{index}.jpg",
|
||||||
|
spec["first_name"],
|
||||||
|
512,
|
||||||
|
512,
|
||||||
|
(42 + index * 30, 95 + index * 20, 130 + index * 15),
|
||||||
|
)
|
||||||
|
user.save()
|
||||||
|
user.groups.add(editor_group)
|
||||||
|
writers.append(user)
|
||||||
|
return writers
|
||||||
|
|
||||||
|
def _seed_categories(self):
|
||||||
|
root, _ = Category.objects.get_or_create(
|
||||||
|
slug="mock-بلاگ-انجمن",
|
||||||
|
defaults={"name": "بلاگ انجمن", "description": "دسته اصلی محتوای تستی بلاگ"},
|
||||||
|
)
|
||||||
|
names = ["برنامهنویسی", "علوم کامپیوتر", "توسعه نرمافزار", "ابزارها", "هوش مصنوعی"]
|
||||||
|
categories = {"بلاگ انجمن": root}
|
||||||
|
for name in names:
|
||||||
|
category, _ = Category.objects.get_or_create(
|
||||||
|
slug=f"mock-{name}",
|
||||||
|
defaults={"name": name, "parent": root, "description": f"مطالب تستی درباره {name}"},
|
||||||
|
)
|
||||||
|
category.name = name
|
||||||
|
category.parent = root
|
||||||
|
category.save()
|
||||||
|
categories[name] = category
|
||||||
|
return categories
|
||||||
|
|
||||||
|
def _seed_tags(self):
|
||||||
|
tags = {}
|
||||||
|
for name in TAG_NAMES:
|
||||||
|
tag, _ = Tag.objects.get_or_create(slug=f"mock-{name}", defaults={"name": name})
|
||||||
|
tag.name = name
|
||||||
|
tag.save()
|
||||||
|
tags[name] = tag
|
||||||
|
return tags
|
||||||
|
|
||||||
|
def _seed_banners(self):
|
||||||
|
colors = [(9, 80, 90), (120, 64, 24), (38, 70, 83)]
|
||||||
|
for index in range(1, 4):
|
||||||
|
banner, _ = BlogBanner.objects.get_or_create(
|
||||||
|
title=f"Mock Blog Banner {index}",
|
||||||
|
defaults={
|
||||||
|
"url": f"https://east-guilan-ce.ir/blog?mock-banner={index}",
|
||||||
|
"alt_text": f"بنر تستی بلاگ {index}",
|
||||||
|
"sort_order": index,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
banner.url = f"https://east-guilan-ce.ir/blog?mock-banner={index}"
|
||||||
|
banner.alt_text = f"بنر تستی بلاگ {index}"
|
||||||
|
banner.sort_order = index
|
||||||
|
banner.is_active = True
|
||||||
|
set_image_field(
|
||||||
|
banner,
|
||||||
|
"image",
|
||||||
|
f"blog/banners/mock-banner-{index}.jpg",
|
||||||
|
f"Mock Banner {index}",
|
||||||
|
1440,
|
||||||
|
320,
|
||||||
|
colors[index - 1],
|
||||||
|
)
|
||||||
|
banner.save()
|
||||||
|
|
||||||
|
def _seed_posts(self, writers, categories, tags):
|
||||||
|
posts = []
|
||||||
|
for index, spec in enumerate(POSTS, start=1):
|
||||||
|
writer_pool = writers[: 1 + (index % len(writers))]
|
||||||
|
post, _ = Post.all_objects.get_or_create(
|
||||||
|
slug=spec["slug"],
|
||||||
|
defaults={
|
||||||
|
"title": spec["title"],
|
||||||
|
"author": writer_pool[0],
|
||||||
|
"content": make_markdown(spec["title"]),
|
||||||
|
"excerpt": f"خلاصه تستی برای نوشته «{spec['title']}» که برای بررسی کارتها و سئوی بلاگ استفاده میشود.",
|
||||||
|
"status": Post.StatusChoices.PUBLISHED,
|
||||||
|
"category": categories[spec["category"]],
|
||||||
|
"is_featured": index <= 2,
|
||||||
|
"seo_title": spec["title"][:70],
|
||||||
|
"seo_description": f"توضیح سئوی تستی برای {spec['title']}",
|
||||||
|
"og_title": spec["title"][:95],
|
||||||
|
"og_description": f"متن شبکههای اجتماعی برای {spec['title']}",
|
||||||
|
"focus_keyword": spec["tags"][0],
|
||||||
|
"published_at": timezone.now() - timezone.timedelta(days=index * 3),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
post.title = spec["title"]
|
||||||
|
post.author = writer_pool[0]
|
||||||
|
post.content = make_markdown(spec["title"])
|
||||||
|
post.excerpt = f"خلاصه تستی برای نوشته «{spec['title']}» که برای بررسی کارتها و سئوی بلاگ استفاده میشود."
|
||||||
|
post.status = Post.StatusChoices.PUBLISHED
|
||||||
|
post.category = categories[spec["category"]]
|
||||||
|
post.is_featured = index <= 2
|
||||||
|
post.published_at = post.published_at or timezone.now() - timezone.timedelta(days=index * 3)
|
||||||
|
set_image_field(
|
||||||
|
post,
|
||||||
|
"featured_image",
|
||||||
|
f"blog/featured/mock-post-{index}.jpg",
|
||||||
|
spec["title"],
|
||||||
|
1280,
|
||||||
|
720,
|
||||||
|
(25 + index * 28, 90 + index * 18, 120 + index * 12),
|
||||||
|
)
|
||||||
|
post.save()
|
||||||
|
post.tags.set([tags[name] for name in spec["tags"]])
|
||||||
|
post.writers.set(writer_pool)
|
||||||
|
posts.append(post)
|
||||||
|
return posts
|
||||||
|
|
||||||
|
def _seed_comments_and_reactions(self, posts, writers):
|
||||||
|
for post in posts:
|
||||||
|
for index, writer in enumerate(writers, start=1):
|
||||||
|
if writer == post.author:
|
||||||
|
continue
|
||||||
|
comment, _ = Comment.objects.get_or_create(
|
||||||
|
post=post,
|
||||||
|
author=writer,
|
||||||
|
parent=None,
|
||||||
|
defaults={"content": f"کامنت تستی {index}: این بخش برای بررسی ظاهر کامنتها و پاسخها ساخته شده است."},
|
||||||
|
)
|
||||||
|
Comment.objects.get_or_create(
|
||||||
|
post=post,
|
||||||
|
author=post.author,
|
||||||
|
parent=comment,
|
||||||
|
defaults={"content": "پاسخ تستی نویسنده برای بررسی حالت nested در کامنتها."},
|
||||||
|
)
|
||||||
|
Like.objects.get_or_create(post=post, user=writer)
|
||||||
|
if index % 2 == 0:
|
||||||
|
SavedPost.objects.get_or_create(post=post, user=writer)
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
58
apps/blog/migrations/0005_comment_hide_delete_state.py
Normal file
58
apps/blog/migrations/0005_comment_hide_delete_state.py
Normal file
@@ -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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -54,10 +54,22 @@ def post_asset_upload_to(instance: "PostAsset", filename: str) -> str:
|
|||||||
return f"blog/posts/{post_part}/assets/{uuid4().hex}{suffix}"
|
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):
|
class Category(BaseModel):
|
||||||
name = models.CharField(max_length=100, unique=True)
|
name = models.CharField(max_length=100, unique=True)
|
||||||
slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True)
|
slug = models.SlugField(max_length=100, unique=True, blank=True, allow_unicode=True)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
"self",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="children",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = "Categories"
|
verbose_name_plural = "Categories"
|
||||||
@@ -71,6 +83,17 @@ class Category(BaseModel):
|
|||||||
self.slug = _unique_slug_for(self, self.name)
|
self.slug = _unique_slug_for(self, self.name)
|
||||||
super().save(*args, **kwargs)
|
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):
|
class Tag(BaseModel):
|
||||||
name = models.CharField(max_length=50, unique=True)
|
name = models.CharField(max_length=50, unique=True)
|
||||||
@@ -136,6 +159,11 @@ class Post(BaseModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name="published_blog_posts",
|
related_name="published_blog_posts",
|
||||||
)
|
)
|
||||||
|
writers = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
blank=True,
|
||||||
|
related_name="written_blog_posts",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
@@ -177,8 +205,8 @@ class Post(BaseModel):
|
|||||||
"markdown.extensions.toc",
|
"markdown.extensions.toc",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
word_count = len((self.content or "").split())
|
character_count = len(_plain_text_from_markdown(self.content or ""))
|
||||||
self.reading_time = max(1, (word_count + 199) // 200)
|
self.reading_time = max(1, (character_count + 999) // 1000)
|
||||||
|
|
||||||
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
|
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
|
||||||
self.published_at = timezone.now()
|
self.published_at = timezone.now()
|
||||||
@@ -206,6 +234,24 @@ class Post(BaseModel):
|
|||||||
safe_process_public_image(self.og_image, "blog_featured")
|
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 PostAsset(BaseModel):
|
||||||
class FileType(models.TextChoices):
|
class FileType(models.TextChoices):
|
||||||
IMAGE = "image", "Image"
|
IMAGE = "image", "Image"
|
||||||
@@ -283,6 +329,7 @@ class Comment(BaseModel):
|
|||||||
content = models.TextField()
|
content = models.TextField()
|
||||||
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies")
|
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="replies")
|
||||||
is_approved = models.BooleanField(default=True)
|
is_approved = models.BooleanField(default=True)
|
||||||
|
is_hidden = models.BooleanField(default=False)
|
||||||
hidden_by = models.ForeignKey(
|
hidden_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@@ -292,12 +339,21 @@ class Comment(BaseModel):
|
|||||||
)
|
)
|
||||||
hidden_at = models.DateTimeField(null=True, blank=True)
|
hidden_at = models.DateTimeField(null=True, blank=True)
|
||||||
moderation_note = models.TextField(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:
|
class Meta:
|
||||||
ordering = ["created_at"]
|
ordering = ["created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["post", "is_approved"]),
|
models.Index(fields=["post", "is_approved", "is_hidden"]),
|
||||||
models.Index(fields=["author", "created_at"]),
|
models.Index(fields=["author", "created_at"]),
|
||||||
|
models.Index(fields=["parent", "is_deleted", "is_hidden"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -308,11 +364,50 @@ class Comment(BaseModel):
|
|||||||
return self.parent is not None
|
return self.parent is not None
|
||||||
|
|
||||||
def hide(self, user, note: str = ""):
|
def hide(self, user, note: str = ""):
|
||||||
self.is_approved = False
|
now = timezone.now()
|
||||||
self.hidden_by = user
|
ids = [self.id, *self.descendant_ids()]
|
||||||
self.hidden_at = timezone.now()
|
self.__class__.all_objects.filter(id__in=ids, is_deleted=False).update(
|
||||||
self.moderation_note = note
|
is_hidden=True,
|
||||||
self.save(update_fields=["is_approved", "hidden_by", "hidden_at", "moderation_note", "updated_at"])
|
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):
|
class Like(models.Model):
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from apps.blog.models import Post, Category, Tag
|
|||||||
class CategoryResource(resources.ModelResource):
|
class CategoryResource(resources.ModelResource):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
fields = ('id', 'name', 'slug', 'description', 'created_at')
|
fields = ('id', 'name', 'parent', 'slug', 'description', 'created_at')
|
||||||
|
|
||||||
class PostResource(resources.ModelResource):
|
class PostResource(resources.ModelResource):
|
||||||
author = fields.Field(
|
author = fields.Field(
|
||||||
@@ -25,8 +25,13 @@ class PostResource(resources.ModelResource):
|
|||||||
attribute='tags',
|
attribute='tags',
|
||||||
widget=ManyToManyWidget(Tag, field='name', separator='|')
|
widget=ManyToManyWidget(Tag, field='name', separator='|')
|
||||||
)
|
)
|
||||||
|
writers = fields.Field(
|
||||||
|
column_name='writers',
|
||||||
|
attribute='writers',
|
||||||
|
widget=ManyToManyWidget(User, field='username', separator='|')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Post
|
model = Post
|
||||||
fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author',
|
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')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
|||||||
|
|
||||||
|
|
||||||
class UserAdminForm(forms.ModelForm):
|
class UserAdminForm(forms.ModelForm):
|
||||||
|
mobile = forms.CharField(required=True)
|
||||||
bio = forms.CharField(widget=SimpleMDEEditor(), required=False)
|
bio = forms.CharField(widget=SimpleMDEEditor(), required=False)
|
||||||
student_id = forms.CharField(required=False)
|
student_id = forms.CharField(required=False)
|
||||||
|
|
||||||
@@ -25,13 +26,13 @@ class UserAdminForm(forms.ModelForm):
|
|||||||
class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
|
class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
|
||||||
form = UserAdminForm
|
form = UserAdminForm
|
||||||
resource_class = UserResource
|
resource_class = UserResource
|
||||||
list_display = ('email', 'username', 'university', 'is_email_verified', 'date_joined')
|
list_display = ('email', 'mobile', 'username', 'university', 'is_email_verified', 'is_mobile_verified', 'date_joined')
|
||||||
list_filter = ('is_email_verified', 'is_staff', 'year_of_study', SoftDeleteListFilter)
|
list_filter = ('is_email_verified', 'is_mobile_verified', 'is_staff', 'year_of_study', SoftDeleteListFilter)
|
||||||
search_fields = ('email', 'username', 'student_id', 'first_name', 'last_name')
|
search_fields = ('email', 'mobile', 'username', 'student_id', 'first_name', 'last_name')
|
||||||
ordering = ('-date_joined',)
|
ordering = ('-date_joined',)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Auth Credentials', {'fields': ('username', 'email', 'password')}),
|
('Auth Credentials', {'fields': ('username', 'email', 'mobile', 'password')}),
|
||||||
('Personal info', {
|
('Personal info', {
|
||||||
'fields': ('first_name', 'last_name', 'student_id', 'university', 'year_of_study', 'major', 'bio', 'profile_picture')
|
'fields': ('first_name', 'last_name', 'student_id', 'university', 'year_of_study', 'major', 'bio', 'profile_picture')
|
||||||
}),
|
}),
|
||||||
@@ -43,6 +44,9 @@ class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
('Email Verification', {
|
('Email Verification', {
|
||||||
'fields': ('is_email_verified', 'email_verification_token', 'email_verification_sent_at')
|
'fields': ('is_email_verified', 'email_verification_token', 'email_verification_sent_at')
|
||||||
}),
|
}),
|
||||||
|
('Mobile Verification', {
|
||||||
|
'fields': ('is_mobile_verified',)
|
||||||
|
}),
|
||||||
('Password Reset', {
|
('Password Reset', {
|
||||||
'fields': ('password_reset_token', 'password_reset_token_expires_at'),
|
'fields': ('password_reset_token', 'password_reset_token_expires_at'),
|
||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
@@ -57,7 +61,7 @@ class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
'Step 1',
|
'Step 1',
|
||||||
{
|
{
|
||||||
'classes': ('wide',),
|
'classes': ('wide',),
|
||||||
'fields': ('email', 'student_id', 'password1', 'password2', 'usable_password'),
|
'fields': ('email', 'mobile', 'student_id', 'password1', 'password2', 'usable_password'),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
18
apps/users/migrations/0008_alter_user_managers.py
Normal file
18
apps/users/migrations/0008_alter_user_managers.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import apps.users.models
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0007_user_is_mobile_verified_user_mobile_alter_user_email_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name="user",
|
||||||
|
managers=[
|
||||||
|
("objects", apps.users.models.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser, UserManager as DjangoUserManager
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
@@ -14,6 +14,24 @@ from core.models import BaseModel
|
|||||||
from apps.users.email_identity import normalize_email_identity, normalize_mobile_number
|
from apps.users.email_identity import normalize_email_identity, normalize_mobile_number
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(DjangoUserManager):
|
||||||
|
def _normalize_required_mobile(self, mobile):
|
||||||
|
normalized = normalize_mobile_number(mobile)
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("The mobile number must be set")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def create_user(self, username, email=None, password=None, **extra_fields):
|
||||||
|
extra_fields["mobile"] = self._normalize_required_mobile(extra_fields.get("mobile"))
|
||||||
|
return super().create_user(username, email=email, password=password, **extra_fields)
|
||||||
|
|
||||||
|
def create_superuser(self, username, email=None, password=None, **extra_fields):
|
||||||
|
extra_fields["mobile"] = self._normalize_required_mobile(extra_fields.get("mobile"))
|
||||||
|
extra_fields.setdefault("is_active", True)
|
||||||
|
extra_fields.setdefault("is_mobile_verified", True)
|
||||||
|
return super().create_superuser(username, email=email, password=password, **extra_fields)
|
||||||
|
|
||||||
|
|
||||||
class University(BaseModel):
|
class University(BaseModel):
|
||||||
code = models.CharField(max_length=64, unique=True)
|
code = models.CharField(max_length=64, unique=True)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@@ -69,7 +87,9 @@ class User(AbstractUser, BaseModel):
|
|||||||
password_reset_token_expires_at = models.DateTimeField(null=True, blank=True)
|
password_reset_token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
USERNAME_FIELD = 'username'
|
USERNAME_FIELD = 'username'
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = ['mobile']
|
||||||
|
|
||||||
|
objects = UserManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'users'
|
db_table = 'users'
|
||||||
|
|||||||
Reference in New Issue
Block a user