diff --git a/apps/content/management/__init__.py b/apps/content/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/content/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/content/management/commands/__init__.py b/apps/content/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/content/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/content/management/commands/seed_mock_content.py b/apps/content/management/commands/seed_mock_content.py new file mode 100644 index 0000000..88417e6 --- /dev/null +++ b/apps/content/management/commands/seed_mock_content.py @@ -0,0 +1,182 @@ +from django.contrib.admin.models import CHANGE, LogEntry +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand +from django.db import DEFAULT_DB_ALIAS, connections, transaction + +from apps.content.current import AboutContent, AboutItem, HomeContent, ListingContent, SiteContent + + +SITE_MOCK = { + "official_name": "نام انجمن نمونه", + "short_name": "انجمن نمونه", + "faculty_subline": "دانشکده نمونه", + "description": "این متن نمونه است و پس از ورود محتوای اصلی از پنل مدیریت جایگزین می‌شود.", + "logo": "", + "logo_alt": "", + "nav_home": "خانه", + "nav_about": "درباره ما", + "nav_events": "رویدادها", + "nav_blog": "بلاگ", + "email": "contact@example.invalid", + "phone": "0000000000", + "telegram_url": "https://example.invalid/telegram", + "instagram_url": "https://example.invalid/instagram", + "website_url": "https://example.invalid", + "address": "نشانی نمونه", + "footer_copy": "متن نمونه پابرگ؛ محتوای نهایی را از پنل مدیریت وارد کنید.", + "seo_title": "عنوان نمونه سایت", + "seo_description": "توضیحات نمونه برای موتورهای جست‌وجو.", + "seo_image": "", + "seo_image_alt": "", +} + +HOME_MOCK = { + "title": "عنوان نمونه صفحه اصلی", + "intro": "این متن نمونه صفحه اصلی است و پس از ورود محتوای اصلی جایگزین می‌شود.", + "hero_heading": "عنوان نمونه صفحه اصلی", + "hero_body": "توضیحات نمونه برای معرفی انجمن و فعالیت‌های آن.", + "hero_image": "", + "hero_image_alt": "", + "hero_action_label": "مشاهده نمونه‌ها", + "featured_events_heading": "رویدادهای نمونه", + "featured_events_intro": "توضیحات نمونه برای بخش رویدادها.", + "featured_events_link_label": "همه رویدادها", + "featured_posts_heading": "نوشته‌های نمونه", + "featured_posts_intro": "توضیحات نمونه برای بخش نوشته‌ها.", + "featured_posts_link_label": "همه نوشته‌ها", + "participation_heading": "به ما بپیوندید", + "participation_body": "این متن نمونه راه‌های مشارکت را توضیح می‌دهد.", + "participation_image": "", + "participation_image_alt": "", + "participation_action_label": "اطلاعات بیشتر", + "seo_title": "عنوان نمونه صفحه اصلی", + "seo_description": "توضیحات نمونه صفحه اصلی برای موتورهای جست‌وجو.", + "seo_image": "", + "seo_image_alt": "", +} + +ABOUT_MOCK = { + "title": "عنوان نمونه صفحه درباره ما", + "intro": "این متن نمونه معرفی انجمن است و بعداً از پنل مدیریت قابل ویرایش است.", + "identity_heading": "معرفی نمونه", + "identity_body": "اطلاعات نمونه درباره هویت انجمن.", + "mission_heading": "ماموریت نمونه", + "mission_body": "توضیحات نمونه درباره ماموریت انجمن.", + "activities_heading": "فعالیت‌های نمونه", + "activities_body": "توضیحات نمونه درباره فعالیت‌ها.", + "values_heading": "ارزش‌های نمونه", + "values_body": "یادگیری، همکاری و مسئولیت‌پذیری نمونه.", + "committees_heading": "کمیته‌های نمونه", + "committees_body": "اطلاعات نمونه درباره کمیته‌ها.", + "affiliations_heading": "همکاری‌های نمونه", + "affiliations_body": "اطلاعات نمونه درباره گروه‌های همکار.", + "faq_heading": "پرسش‌های نمونه", + "contact_heading": "راه‌های ارتباطی نمونه", + "seo_title": "عنوان نمونه صفحه درباره ما", + "seo_description": "توضیحات نمونه صفحه درباره ما برای موتورهای جست‌وجو.", + "seo_image": "", + "seo_image_alt": "", +} + +LISTING_MOCKS = { + "events": { + "title": "عنوان نمونه رویدادها", + "intro": "این متن نمونه برای معرفی فهرست رویدادهاست.", + "seo_title": "عنوان نمونه رویدادها", + "seo_description": "توضیحات نمونه صفحه رویدادها برای موتورهای جست‌وجو.", + "seo_image": "", + "seo_image_alt": "", + }, + "blog": { + "title": "عنوان نمونه بلاگ", + "intro": "این متن نمونه برای معرفی نوشته‌های بلاگ است.", + "seo_title": "عنوان نمونه بلاگ", + "seo_description": "توضیحات نمونه صفحه بلاگ برای موتورهای جست‌وجو.", + "seo_image": "", + "seo_image_alt": "", + }, +} + +ABOUT_ITEM_MOCKS = ( + ("mission", "ماموریت نمونه", "این متن نمونه برای توضیح ماموریت انجمن است."), + ("activities", "فعالیت نمونه", "این متن نمونه یکی از فعالیت‌های انجمن را معرفی می‌کند."), + ("values", "ارزش نمونه", "این متن نمونه یکی از ارزش‌های انجمن را توضیح می‌دهد."), + ("committees", "کمیته نمونه", "این متن نمونه برای معرفی یک کمیته است."), + ("affiliations", "همکاری نمونه", "این متن نمونه برای معرفی یک گروه همکار است."), + ("faq", "پرسش نمونه", "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید."), +) + +RECORDS = ( + ("site", SiteContent, {"pk": 1}, SITE_MOCK), + ("home", HomeContent, {"pk": 1}, HOME_MOCK), + ("about", AboutContent, {"pk": 1}, ABOUT_MOCK), + ("events", ListingContent, {"pk": "events"}, LISTING_MOCKS["events"]), + ("blog", ListingContent, {"pk": "blog"}, LISTING_MOCKS["blog"]), +) + + +def has_admin_change(model, object_id, using): + content_type = ContentType.objects.db_manager(using).get_for_model(model) + return LogEntry.objects.using(using).filter( + content_type=content_type, + object_id=str(object_id), + action_flag=CHANGE, + ).exists() + + +class Command(BaseCommand): + help = "Fill untouched site content records with obvious sample copy." + + def add_arguments(self, parser): + parser.add_argument( + "--database", + default=DEFAULT_DB_ALIAS, + choices=tuple(connections), + help="Database alias to update.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report intended changes without writing to the database.", + ) + + def handle(self, *args, database, dry_run, **options): + updated = 0 + skipped = 0 + prefix = "Dry run: " if dry_run else "" + + with transaction.atomic(using=database): + for label, model, lookup, values in RECORDS: + record = model.objects.using(database).select_for_update().filter(**lookup).first() + object_id = lookup["pk"] + if record is not None and has_admin_change(model, object_id, database): + self.stdout.write(f"Skipped {label}: it has a Django admin change entry.") + skipped += 1 + continue + + if dry_run: + action = "create" if record is None else "replace" + self.stdout.write(f"Dry run: would {action} {label} with mock content.") + updated += 1 + continue + + if record is None: + record = model(**lookup) + for field, value in values.items(): + setattr(record, field, value) + record.save(using=database) + updated += 1 + self.stdout.write(f"Loaded mock content for {label}.") + + if label == "about": + AboutItem.objects.using(database).filter(about_id=record.pk).delete() + AboutItem.objects.using(database).bulk_create( + [ + AboutItem(about_id=record.pk, kind=kind, title=title, text=text, position=position) + for position, (kind, title, text) in enumerate(ABOUT_ITEM_MOCKS) + ] + ) + + action = "would be updated" if dry_run else "updated" + summary = f"{updated} content records {action}; {skipped} admin-edited records skipped." + self.stdout.write(f"{prefix}{summary}") diff --git a/apps/content/tests/integration/test_seed_mock_content_command.py b/apps/content/tests/integration/test_seed_mock_content_command.py new file mode 100644 index 0000000..85650b8 --- /dev/null +++ b/apps/content/tests/integration/test_seed_mock_content_command.py @@ -0,0 +1,65 @@ +from io import StringIO + +import pytest +from django.contrib.admin.models import CHANGE, LogEntry +from django.contrib.contenttypes.models import ContentType +from django.core.management import call_command + +from apps.content.current import AboutContent, HomeContent, ListingContent +from apps.users.models import User + + +pytestmark = [pytest.mark.django_db, pytest.mark.integration] + + +def test_command_replaces_unedited_migration_copy_with_mock_content_idempotently(): + call_command("seed_mock_content", stdout=StringIO()) + + home = HomeContent.objects.get(pk=1) + about = AboutContent.objects.get(pk=1) + events = ListingContent.objects.get(pk="events") + blog = ListingContent.objects.get(pk="blog") + + assert home.hero_heading == "عنوان نمونه صفحه اصلی" + assert about.title == "عنوان نمونه صفحه درباره ما" + assert about.items.filter(title="کمیته نمونه").exists() + assert not about.items.filter(title="انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی").exists() + assert events.title == "عنوان نمونه رویدادها" + assert blog.title == "عنوان نمونه بلاگ" + + call_command("seed_mock_content", stdout=StringIO()) + assert HomeContent.objects.get(pk=1).hero_heading == "عنوان نمونه صفحه اصلی" + + +def test_command_preserves_content_with_an_admin_change_log(): + editor = User.objects.create_user( + username="mock-editor", + mobile="09120000001", + password="TestPass123!", + is_staff=True, + ) + home = HomeContent.objects.get(pk=1) + home.hero_heading = "Admin's real heading" + home.save(update_fields=["hero_heading"]) + LogEntry.objects.create( + user=editor, + content_type=ContentType.objects.get_for_model(HomeContent), + object_id="1", + object_repr=str(home), + action_flag=CHANGE, + change_message="Changed through admin", + ) + + call_command("seed_mock_content", stdout=StringIO()) + + assert HomeContent.objects.get(pk=1).hero_heading == "Admin's real heading" + assert ListingContent.objects.get(slug="events").title == "عنوان نمونه رویدادها" + + +def test_dry_run_does_not_change_seeded_content(): + output = StringIO() + + call_command("seed_mock_content", dry_run=True, stdout=output) + + assert HomeContent.objects.get(pk=1).hero_heading == "انجمن علمی کامپیوتر دانشگاه گیلان" + assert "dry run" in output.getvalue().lower()