Compare commits

...

3 Commits

Author SHA1 Message Date
41f9be4c7e fix(users): require mobile for superuser creation
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-11 21:20:59 +03:30
13ea129d3a feat(blog): add mock data seed command 2026-06-11 21:20:51 +03:30
5045f8da47 feat(blog): expand publishing and moderation APIs 2026-06-11 21:20:44 +03:30
11 changed files with 987 additions and 49 deletions

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ from core.admin import SoftDeleteListFilter, BaseModelAdmin
class UserAdminForm(forms.ModelForm):
mobile = forms.CharField(required=True)
bio = forms.CharField(widget=SimpleMDEEditor(), required=False)
student_id = forms.CharField(required=False)
@@ -25,13 +26,13 @@ class UserAdminForm(forms.ModelForm):
class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
form = UserAdminForm
resource_class = UserResource
list_display = ('email', 'username', 'university', 'is_email_verified', 'date_joined')
list_filter = ('is_email_verified', 'is_staff', 'year_of_study', SoftDeleteListFilter)
search_fields = ('email', 'username', 'student_id', 'first_name', 'last_name')
list_display = ('email', 'mobile', 'username', 'university', 'is_email_verified', 'is_mobile_verified', 'date_joined')
list_filter = ('is_email_verified', 'is_mobile_verified', 'is_staff', 'year_of_study', SoftDeleteListFilter)
search_fields = ('email', 'mobile', 'username', 'student_id', 'first_name', 'last_name')
ordering = ('-date_joined',)
fieldsets = (
('Auth Credentials', {'fields': ('username', 'email', 'password')}),
('Auth Credentials', {'fields': ('username', 'email', 'mobile', 'password')}),
('Personal info', {
'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', {
'fields': ('is_email_verified', 'email_verification_token', 'email_verification_sent_at')
}),
('Mobile Verification', {
'fields': ('is_mobile_verified',)
}),
('Password Reset', {
'fields': ('password_reset_token', 'password_reset_token_expires_at'),
'classes': ('collapse',)
@@ -57,7 +61,7 @@ class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin):
'Step 1',
{
'classes': ('wide',),
'fields': ('email', 'student_id', 'password1', 'password2', 'usable_password'),
'fields': ('email', 'mobile', 'student_id', 'password1', 'password2', 'usable_password'),
},
),
)

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

View File

@@ -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.db import models
@@ -14,6 +14,24 @@ from core.models import BaseModel
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):
code = models.CharField(max_length=64, unique=True)
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)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = []
REQUIRED_FIELDS = ['mobile']
objects = UserManager()
class Meta:
db_table = 'users'