Replace content publishing workflow with direct Unfold editing

This commit is contained in:
2026-09-27 11:09:25 +03:30
parent 148b7038a9
commit f6b4ef7dd8
14 changed files with 680 additions and 1232 deletions

View File

@@ -0,0 +1,208 @@
from django.conf import settings
from django.db import migrations, models
from django.db.models import Q
import django.db.models.deletion
import apps.content.current
def seo_fields():
return [
("seo_title", models.CharField(blank=True, max_length=120, verbose_name="SEO title")),
("seo_description", models.CharField(blank=True, max_length=300, verbose_name="SEO description")),
("seo_image", models.ImageField(blank=True, upload_to=apps.content.current.content_upload_path, verbose_name="Social sharing image")),
("seo_image_alt", models.CharField(blank=True, max_length=180, verbose_name="Social image description")),
]
def single_id():
return [("id", models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False))]
def strings(*names):
return [(name, models.CharField(blank=True, max_length=length)) for name, length in names]
def image(name):
return (name, models.ImageField(blank=True, upload_to=apps.content.current.content_upload_path))
def copy_image(target, name, asset_id, alt, assets):
asset = assets.get(asset_id)
if asset:
setattr(target, name, asset.file.name)
setattr(target, f"{name}_alt", alt or asset.alt)
def populate_current_content(apps, schema_editor):
db = schema_editor.connection.alias
SiteContent = apps.get_model("content", "SiteContent")
HomeContent = apps.get_model("content", "HomeContent")
AboutContent = apps.get_model("content", "AboutContent")
AboutItem = apps.get_model("content", "AboutItem")
ListingContent = apps.get_model("content", "ListingContent")
SiteIdentity = apps.get_model("content", "SiteIdentity")
EditorialPage = apps.get_model("content", "EditorialPage")
MediaAsset = apps.get_model("content", "MediaAsset")
assets = {asset.pk: asset for asset in MediaAsset.objects.using(db).all()}
site = SiteContent(id=1)
identity = SiteIdentity.objects.using(db).filter(pk=1).first()
revision = identity.published_revision if identity and identity.published_revision_id else None
if revision:
for field in ("official_name", "short_name", "faculty_subline", "description", "footer_copy", "seo_title", "seo_description"):
setattr(site, field, getattr(revision, field))
copy_image(site, "logo", revision.logo_id, revision.logo_alt, assets)
copy_image(site, "seo_image", revision.seo_image_id, "Social sharing image", assets)
for nav in revision.navigation:
if nav.get("route_id") in ("home", "about", "events", "blog"):
setattr(site, f"nav_{nav['route_id']}", nav.get("label", ""))
for contact in revision.contacts:
kind, url = contact.get("kind"), contact.get("url", "")
if kind == "email":
site.email = url.removeprefix("mailto:")
elif kind == "phone":
site.phone = url.removeprefix("tel:")
elif kind in ("telegram", "instagram", "website"):
setattr(site, f"{kind}_url", url)
site.save(using=db)
pages = {page.slug: page for page in EditorialPage.objects.using(db).all()}
for slug, Model in (("home", HomeContent), ("about", AboutContent), ("events", ListingContent), ("blog", ListingContent)):
page = pages.get(slug)
revision = page.published_revision if page and page.published_revision_id else None
record = Model(id=1) if slug in ("home", "about") else Model(slug=slug)
if revision:
for field in ("title", "intro", "seo_title", "seo_description"):
setattr(record, field, getattr(revision, field))
copy_image(record, "seo_image", revision.seo_image_id, "Social sharing image", assets)
if slug in ("home", "about"):
sections = list(revision.sections.using(db).all())
for section in sections:
prefix = section.key
if slug == "home":
if prefix in ("hero", "participation"):
setattr(record, f"{prefix}_heading", section.heading)
setattr(record, f"{prefix}_body", section.body)
setattr(record, f"{prefix}_action_label", (section.primary_action or {}).get("label", ""))
copy_image(record, f"{prefix}_image", section.image_id, section.image_alt, assets)
elif prefix in ("featured_events", "featured_posts"):
setattr(record, f"{prefix}_heading", section.heading)
setattr(record, f"{prefix}_intro", section.intro)
setattr(record, f"{prefix}_link_label", section.all_link_label)
elif prefix in ("identity", "mission", "activities", "values", "committees", "affiliations", "faq", "contact"):
setattr(record, f"{prefix}_heading", section.heading)
if hasattr(record, f"{prefix}_body"):
setattr(record, f"{prefix}_body", section.body)
record.save(using=db)
if revision and slug == "about":
for section in sections:
if section.key in ("mission", "activities", "values", "committees", "affiliations", "faq"):
for position, entry in enumerate(section.entries):
AboutItem.objects.using(db).create(about_id=1, kind=section.key, title=entry["title"], text=entry["text"], position=position)
def preserve_editor_access(apps, schema_editor):
db = schema_editor.connection.alias
ContentType = apps.get_model("contenttypes", "ContentType")
Permission = apps.get_model("auth", "Permission")
Group = apps.get_model("auth", "Group")
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
models_to_change = ("sitecontent", "homecontent", "aboutcontent", "aboutitem", "listingcontent")
new_permissions = []
for model in models_to_change:
content_type, _ = ContentType.objects.using(db).get_or_create(app_label="content", model=model)
permission, _ = Permission.objects.using(db).get_or_create(
content_type=content_type, codename=f"change_{model}", defaults={"name": f"Can change {model}"}
)
new_permissions.append(permission)
for group in Group.objects.using(db).filter(name__in=("content_editor", "content_publisher")):
group.permissions.add(*new_permissions)
old_permissions = Permission.objects.using(db).filter(
content_type__app_label="content", codename__in=("change_content_draft", "publish_content")
)
for user in User.objects.using(db).filter(user_permissions__in=old_permissions).distinct():
user.user_permissions.add(*new_permissions)
class Migration(migrations.Migration):
dependencies = [
("content", "0003_content_groups"),
("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="SiteContent",
fields=single_id() + seo_fields() + strings(
("official_name", 180), ("short_name", 80), ("faculty_subline", 140),
("logo_alt", 180), ("phone", 30), ("footer_copy", 300)
) + [
("description", models.TextField(blank=True)), image("logo"),
("nav_home", models.CharField(default="Home", max_length=80, verbose_name="Home navigation label")),
("nav_about", models.CharField(default="About", max_length=80, verbose_name="About navigation label")),
("nav_events", models.CharField(default="Events", max_length=80, verbose_name="Events navigation label")),
("nav_blog", models.CharField(default="Blog", max_length=80, verbose_name="Blog navigation label")),
("email", models.EmailField(blank=True, max_length=254)),
("telegram_url", models.URLField(blank=True)),
("instagram_url", models.URLField(blank=True)),
("website_url", models.URLField(blank=True)),
("address", models.TextField(blank=True)),
],
options={"verbose_name": "Site content"},
),
migrations.CreateModel(
name="HomeContent",
fields=single_id() + seo_fields() + strings(
("title", 180), ("hero_heading", 120), ("hero_image_alt", 180),
("hero_action_label", 40), ("featured_events_heading", 120),
("featured_events_intro", 300), ("featured_events_link_label", 40),
("featured_posts_heading", 120), ("featured_posts_intro", 300),
("featured_posts_link_label", 40), ("participation_heading", 120),
("participation_image_alt", 180), ("participation_action_label", 40)
) + [
("intro", models.TextField(blank=True)), ("hero_body", models.TextField(blank=True)),
image("hero_image"), ("participation_body", models.TextField(blank=True)),
image("participation_image"),
],
options={"verbose_name": "Home page content"},
),
migrations.CreateModel(
name="AboutContent",
fields=single_id() + seo_fields() + strings(
("title", 180), ("identity_heading", 120), ("mission_heading", 120),
("activities_heading", 120), ("values_heading", 120),
("committees_heading", 120), ("affiliations_heading", 120),
("contact_heading", 120)
) + [
("faq_heading", models.CharField(blank=True, max_length=120, verbose_name="FAQ heading")),
("intro", models.TextField(blank=True)),
*[(f"{name}_body", models.TextField(blank=True)) for name in ("identity", "mission", "activities", "values", "committees", "affiliations")],
],
options={"verbose_name": "About page content"},
),
migrations.CreateModel(
name="ListingContent",
fields=[("slug", models.CharField(choices=[("events", "Events"), ("blog", "Blog")], max_length=10, primary_key=True, serialize=False))]
+ seo_fields() + strings(("title", 180)) + [("intro", models.TextField(blank=True))],
options={"verbose_name": "Listing page content", "verbose_name_plural": "Listing page content"},
),
migrations.CreateModel(
name="AboutItem",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("kind", models.CharField(choices=[(key, key.title()) for key in ("mission", "activities", "values", "committees", "affiliations", "faq")], max_length=20)),
("title", models.CharField(max_length=120)), ("text", models.TextField()),
("position", models.PositiveSmallIntegerField(default=0)),
("about", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="items", to="content.aboutcontent")),
],
options={"verbose_name": "About page item", "ordering": ["kind", "position", "pk"]},
),
migrations.AddConstraint(model_name="sitecontent", constraint=models.CheckConstraint(condition=Q(id=1), name="content_current_single_site")),
migrations.AddConstraint(model_name="homecontent", constraint=models.CheckConstraint(condition=Q(id=1), name="content_current_single_home")),
migrations.AddConstraint(model_name="aboutcontent", constraint=models.CheckConstraint(condition=Q(id=1), name="content_current_single_about")),
migrations.RunPython(populate_current_content, migrations.RunPython.noop),
migrations.RunPython(preserve_editor_access, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,136 @@
"""Copy current static website copy into untouched direct-edit content records.
Sources: frontend src/views/{Home,AboutUs,Events,Blog}.tsx and
src/app/{page,about/page,events/page,blog/page,layout}.tsx as of 2026-09-27.
The migration deliberately leaves existing CMS edits and uploaded images alone.
"""
from django.db import migrations
HOME = {
"title": "انجمن علمی کامپیوتر دانشگاه گیلان",
"intro": "با ما همراه شوید و در دنیای مهندسی/علوم کامپیوتر و فناوری پیشرفت کنید. رویدادها، محتوای آموزشی و جامعه‌ای پویا برای رشد شما فراهم است.",
"hero_heading": "انجمن علمی کامپیوتر دانشگاه گیلان",
"hero_body": "با ما همراه شوید و در دنیای مهندسی/علوم کامپیوتر و فناوری پیشرفت کنید. رویدادها، محتوای آموزشی و جامعه‌ای پویا برای رشد شما فراهم است.",
"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_action_label": "ثبت‌نام کنید",
"seo_title": "انجمن علمی کامپیوتر دانشگاه گیلان",
"seo_description": "با ما همراه شوید و در دنیای مهندسی و علوم کامپیوتر پیشرفت کنید. رویدادها، محتوای آموزشی و جامعه‌ای پویا برای رشد شما فراهم است.",
}
ABOUT = {
"title": "انجمن‌های علمی کامپیوتر گیلان",
"intro": "پیوند دانشگاه و صنعت با برگزاری رویدادها، کارگاه‌ها و نشست‌های تخصصی — با محوریت رشد مهارت‌های فنی و مسیرهای شغلی.",
"identity_heading": "انجمن‌های علمی کامپیوتر گیلان",
"identity_body": "پیوند دانشگاه و صنعت با برگزاری رویدادها، کارگاه‌ها و نشست‌های تخصصی — با محوریت رشد مهارت‌های فنی و مسیرهای شغلی.",
"mission_heading": "ماموریت ما",
"mission_body": "توانمندسازی دانشجویان با مهارت‌های موردنیاز صنعت، ایجاد شبکه‌های حرفه‌ای و فراهم‌کردن مسیرهای یادگیری پروژه‌محور؛ از معرفی نقش‌ها و مسیرهای شغلی تا تمرین مهارت‌های نرم و فنی.",
"values_heading": "ارزش‌های ما",
"values_body": "یادگیری پیوسته و اشتراک دانش\nکیفیت، شفافیت و مسئولیت‌پذیری\nفرصت برابر برای مشارکت\nارتباط مؤثر با صنعت و جامعه",
"committees_heading": "کمیته‌ها و گروه‌های کاری",
"affiliations_heading": "انجمن‌ها",
"affiliations_body": "اطلاعات انجمن‌های زیرمجموعه/همکار",
"faq_heading": "پرسش‌های متداول",
"contact_heading": "راه‌های ارتباطی",
"seo_title": "درباره ما | انجمن علمی کامپیوتر شرق گیلان",
"seo_description": "آشنایی با تاریخچه، مأموریت‌ها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راه‌های مشارکت دانشجویان در برنامه‌های انجمن.",
}
EVENTS = {
"title": "رویدادها",
"intro": "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاه‌ها، مسابقات و برنامه‌های آموزشی.",
"seo_title": "رویدادها",
"seo_description": "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاه‌ها، مسابقات و برنامه‌های آموزشی.",
}
BLOG = {
"title": "بلاگ",
"intro": "نوشته‌های آموزشی، تجربه‌های دانشجویی و یادداشت‌های تخصصی اعضای انجمن علمی.",
"seo_title": "وبلاگ",
"seo_description": "مقالات، نوشته‌ها و محتوای آموزشی انجمن علمی کامپیوتر شرق گیلان.",
}
SITE = {
"official_name": "انجمن علمی مهندسی کامپیوتر دانشگاه گیلان (دانشکده‌ی فنی و مهندسی شرق گیلان)",
"short_name": "انجمن علمی مهندسی کامپیوتر",
"faculty_subline": "دانشکده‌ی فنی و مهندسی شرق گیلان",
"description": "ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکه‌ی دانشجویی و پیوند با صنعت.",
"nav_home": "خانه",
"nav_about": "درباره ما",
"nav_events": "رویدادها",
"nav_blog": "بلاگ",
"email": "eastguilanceassociation@gmail.com",
"phone": "۰۱۳-۴۲۶۸۸۴۴۷",
"telegram_url": "https://t.me/guilancea",
"instagram_url": "https://instagram.com/guilance.ir",
"website_url": "https://east-guilan-ce.ir",
"address": "گیلان، رودسر، واجارگاه، دانشکده‌ی فنی و مهندسی شرق گیلان\nکدپستی: ۴۴۹۱۸۹۸۵۶۶",
"footer_copy": "ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکه‌ی دانشجویی و پیوند با صنعت.",
"seo_title": "انجمن علمی کامپیوتر شرق گیلان",
"seo_description": "انجمن علمی کامپیوتر شرق گیلان، برگزارکننده رویدادها، کارگاه‌ها و محتوای آموزشی برای دانشجویان و علاقه‌مندان فناوری.",
}
# Each pair is (kind, title, text). These are the visible About page items.
ABOUT_ITEMS = (
("committees", "کمیته آموزش", "برنامه‌ریزی و اجرای دوره‌ها و کارگاه‌های مهارتی، هماهنگی با مدرسین و طراحی مسیرهای یادگیری."),
("committees", "کمیته صنعت و اشتغال", "تعامل با شرکت‌ها، دعوت از متخصصان صنعت، و شبکه‌سازی برای فرصت‌های کارآموزی و استخدام."),
("committees", "کمیته محتوای دیجیتال", "تولید محتوای آموزشی، خبرنامه، مدیریت شبکه‌های اجتماعی و پوشش رسانه‌ای رویدادها."),
("affiliations", "انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی شرق", "دانشگاه گیلان، رودسر؛ تأسیس: ۱۳۹۲. برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصت‌های شغلی و پژوهشی. حوزه‌ها: مهندسی نرم‌افزار، هوش مصنوعی و داده، طراحی محصول، DevOps/امنیت."),
("affiliations", "انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی", "دانشگاه گیلان، رشت؛ تأسیس: ۱۳۵۳. برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصت‌های شغلی و پژوهشی. حوزه‌ها: مهندسی نرم‌افزار، هوش مصنوعی و داده، DevOps."),
("affiliations", "انجمن علمی علوم کامپیوتر دانشکده‌ی علوم‌پایه", "دانشگاه گیلان، رشت؛ تأسیس: ۱۳۵۳. برگزاری رویدادهای آموزشی و صنعتی، منتورینگ دانشجویی، و اتصال دانشجویان به فرصت‌های شغلی و پژوهشی. حوزه‌ها: امنیت، سیستم‌عامل، سخت‌افزار."),
("faq", "چطور در رویدادها شرکت کنم؟", "برای ثبت‌نام در رویدادها ابتدا باید حساب‌کاربری با کددانشجویی خود ایجاد کنید و سپس از طریق صفحه‌ی ثبت‌نام در رویداد موردنظرتان شرکت کنید :)"),
("faq", "آیا امکان همکاری انجمن‌ها/شرکت‌ها هم وجود دارد؟", "بله، ما همیشه آماده‌ی همکاری با سازمان‌ها و انجمن‌های مختلف دانشگاه‌ها جهت توانمندسازی دانشجویان با مهارت‌های موردنیاز صنعت و ایجاد شبکه‌های حرفه‌ای هستیم."),
("faq", "برای سخنرانی/منتورینگ به چه چیزهایی نیاز است؟", "رزومه کوتاه، موضوع پیشنهادی و زمان‌های دسترس‌پذیرتان را ارسال کنید تا هماهنگ کنیم."),
)
def seed_if_untouched(record, values, marker_fields, db):
if any(getattr(record, name) for name in marker_fields):
return False
for name, value in values.items():
setattr(record, name, value)
record.save(using=db)
return True
def seed_current_copy(apps, schema_editor):
db = schema_editor.connection.alias
SiteContent = apps.get_model("content", "SiteContent")
HomeContent = apps.get_model("content", "HomeContent")
AboutContent = apps.get_model("content", "AboutContent")
AboutItem = apps.get_model("content", "AboutItem")
ListingContent = apps.get_model("content", "ListingContent")
site, _ = SiteContent.objects.using(db).get_or_create(pk=1)
seed_if_untouched(site, SITE, ("official_name", "short_name", "email", "seo_title"), db)
home, _ = HomeContent.objects.using(db).get_or_create(pk=1)
seed_if_untouched(home, HOME, ("title", "hero_heading", "seo_title"), db)
about, _ = AboutContent.objects.using(db).get_or_create(pk=1)
seeded_about = seed_if_untouched(about, ABOUT, ("title", "identity_heading", "seo_title"), db)
if seeded_about and not AboutItem.objects.using(db).filter(about_id=1).exists():
positions = {}
for kind, title, item_text in ABOUT_ITEMS:
position = positions.get(kind, 0)
AboutItem.objects.using(db).create(about_id=1, kind=kind, title=title, text=item_text, position=position)
positions[kind] = position + 1
for slug, values in (("events", EVENTS), ("blog", BLOG)):
listing, _ = ListingContent.objects.using(db).get_or_create(slug=slug)
seed_if_untouched(listing, values, ("title", "intro", "seo_title"), db)
class Migration(migrations.Migration):
dependencies = [("content", "0004_direct_edit_content")]
operations = [migrations.RunPython(seed_current_copy, migrations.RunPython.noop)]