diff --git a/apps/blog/management/commands/seed_blog_mock_data.py b/apps/blog/management/commands/seed_blog_mock_data.py new file mode 100644 index 0000000..67ca46a --- /dev/null +++ b/apps/blog/management/commands/seed_blog_mock_data.py @@ -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)