feat: add direct-save CMS and About archive

This commit is contained in:
2026-09-28 20:29:00 +03:30
parent 94b32310af
commit 0f6220f72c
12 changed files with 957 additions and 41 deletions

View File

@@ -1,7 +1,21 @@
from django.contrib import admin
from django.utils import timezone
from unfold.admin import ModelAdmin, TabularInline
from .current import AboutContent, AboutItem, HomeContent, ListingContent, SiteContent
from apps.events.models import Event
from .current import (
AboutAudience,
AboutContent,
AboutGalleryImage,
AboutItem,
AboutMember,
AboutMilestone,
AboutYear,
HomeContent,
ListingContent,
SiteContent,
)
class FixedContentAdmin(ModelAdmin):
@@ -22,6 +36,7 @@ class SiteContentAdmin(FixedContentAdmin):
("Association", {"fields": ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt")}),
("Navigation labels", {"fields": ("nav_home", "nav_about", "nav_events", "nav_blog")}),
("Contact and footer", {"fields": ("email", "phone", "telegram_url", "instagram_url", "website_url", "address", "footer_copy")}),
("eNAMAD certificate", {"fields": ("enamad_image", "enamad_image_alt", "enamad_url"), "description": "The badge stays unlinked until you add its verification URL."}),
("SEO defaults", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
)
@@ -30,10 +45,21 @@ class SiteContentAdmin(FixedContentAdmin):
class HomeContentAdmin(FixedContentAdmin):
fieldsets = (
("Page introduction", {"fields": ("title", "intro")}),
("Hero", {"fields": ("hero_heading", "hero_body", "hero_image", "hero_image_alt", "hero_action_label")}),
("Featured events", {"fields": ("featured_events_heading", "featured_events_intro", "featured_events_link_label")}),
("Featured blog posts", {"fields": ("featured_posts_heading", "featured_posts_intro", "featured_posts_link_label")}),
("Participation", {"fields": ("participation_heading", "participation_body", "participation_image", "participation_image_alt", "participation_action_label")}),
("Hero", {"fields": (
"hero_heading", "hero_body", "hero_image", "hero_image_alt",
"hero_action_label", "hero_action_url", "hero_secondary_action_label", "hero_secondary_action_url",
), "description": "Destinations accept an internal path such as /events or a secure https URL."}),
("Featured events", {"fields": (
"featured_events_heading", "featured_events_intro", "featured_events_link_label", "featured_events_url", "featured_event_action_label",
), "description": "Destinations accept an internal path such as /events or a secure https URL."}),
("Featured blog posts", {"fields": (
"featured_posts_heading", "featured_posts_intro", "featured_posts_link_label", "featured_posts_url", "featured_post_action_label",
), "description": "Destinations accept an internal path such as /blog or a secure https URL."}),
("Participation", {"fields": (
"participation_heading", "participation_body", "participation_image", "participation_image_alt",
"participation_action_label", "participation_action_url",
"participation_secondary_action_label", "participation_secondary_action_url",
), "description": "Destinations accept an internal path such as /auth or a secure https URL."}),
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
)
@@ -43,6 +69,100 @@ class AboutItemInline(TabularInline):
extra = 0
fields = ("kind", "title", "text", "position")
def get_queryset(self, request):
return super().get_queryset(request).filter(kind="faq")
def formfield_for_choice_field(self, db_field, request, **kwargs):
field = super().formfield_for_choice_field(db_field, request, **kwargs)
if db_field.name == "kind":
field.choices = tuple(choice for choice in field.choices if choice[0] == "faq")
return field
def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_change_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_delete_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
class AboutAudienceInline(TabularInline):
model = AboutAudience
extra = 0
fields = ("title", "text", "icon", "position")
ordering = ("position",)
def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_change_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_delete_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
class AboutMilestoneInline(TabularInline):
model = AboutMilestone
extra = 0
fields = ("year_label", "title", "description", "position")
ordering = ("position",)
def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_change_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_delete_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
class AboutMemberInline(TabularInline):
model = AboutMember
fk_name = "year_group"
extra = 0
fields = ("name", "role", "bio", "photo", "photo_alt", "position")
ordering = ("position",)
def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_change_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_delete_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
class LegacyAboutMemberInline(TabularInline):
model = AboutMember
fk_name = "about"
extra = 0
fields = ("name", "role", "entry_year", "bio", "photo", "photo_alt", "position")
ordering = ("entry_year", "position")
def get_queryset(self, request):
return super().get_queryset(request).filter(year_group__isnull=True)
def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_change_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
def has_delete_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
class AboutGalleryInline(TabularInline):
model = AboutGalleryImage
extra = 0
fields = ("image", "image_alt", "caption", "position")
ordering = ("position",)
def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent")
@@ -55,19 +175,76 @@ class AboutItemInline(TabularInline):
@admin.register(AboutContent)
class AboutContentAdmin(FixedContentAdmin):
inlines = (AboutItemInline,)
inlines = (
AboutItemInline,
AboutAudienceInline,
AboutMilestoneInline,
LegacyAboutMemberInline,
AboutGalleryInline,
)
fieldsets = (
("Page introduction", {"fields": ("title", "intro", "identity_heading", "identity_body")}),
("Mission", {"fields": ("mission_heading", "mission_body")}),
("Mission, vision, and values", {"fields": (
"mission_heading", "mission_body", "vision_heading", "vision_body", "values_heading", "values_body",
)}),
("Activities", {"fields": ("activities_heading", "activities_body")}),
("Values", {"fields": ("values_heading", "values_body")}),
("Committees", {"fields": ("committees_heading", "committees_body")}),
("Affiliations", {"fields": ("affiliations_heading", "affiliations_body")}),
("FAQ and contact", {"fields": ("faq_heading", "contact_heading")}),
("Association history", {"fields": ("history_heading",)}),
("Who the association serves", {"fields": ("audience_heading",)}),
("Annual archive", {"fields": ("members_heading", "members_intro", "year_events_heading")}),
("Memories gallery", {"fields": ("gallery_heading", "gallery_intro")}),
("FAQ and call to action", {"fields": (
"faq_heading", "cta_heading", "cta_body", "cta_action_label", "cta_action_url",
), "description": "The CTA destination stays empty until you set an internal path or secure https URL."}),
("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
)
@admin.register(AboutYear)
class AboutYearAdmin(ModelAdmin):
list_display = ("year_label", "position")
list_editable = ("position",)
ordering = ("position", "pk")
search_fields = ("year_label",)
fieldsets = (
("Academic year", {"fields": ("year_label", "position", "events")}),
)
filter_horizontal = ("events",)
inlines = (AboutMemberInline,)
def has_module_permission(self, request):
return request.user.has_perm("content.change_aboutcontent")
def has_view_permission(self, request, obj=None):
return self.has_module_permission(request)
def has_add_permission(self, request):
return self.has_module_permission(request)
def has_change_permission(self, request, obj=None):
return self.has_module_permission(request)
def has_delete_permission(self, request, obj=None):
return self.has_module_permission(request)
def get_queryset(self, request):
return super().get_queryset(request).filter(about_id=1)
def save_model(self, request, obj, form, change):
if not obj.about_id:
obj.about = AboutContent.objects.get(pk=1)
super().save_model(request, obj, form, change)
def formfield_for_manytomany(self, db_field, request, **kwargs):
field = super().formfield_for_manytomany(db_field, request, **kwargs)
if db_field.name == "events":
field.queryset = Event.objects.filter(
is_deleted=False,
status__in=(Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED),
start_time__lte=timezone.now(),
).order_by("-start_time", "title")
return field
@admin.register(ListingContent)
class ListingContentAdmin(FixedContentAdmin):
list_display = ("slug", "title")

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Literal, Optional, Union
from ninja import Schema
@@ -24,6 +25,8 @@ class SiteContentOut(Schema):
contacts: dict[str, str]
address: str
footer_copy: str
enamad_image: Optional[MediaOut] = None
enamad_url: str
seo: SeoOut
@@ -40,16 +43,26 @@ class HomePageOut(PageBaseOut):
hero_body: str
hero_image: Optional[MediaOut] = None
hero_action_label: str
hero_action_url: str
hero_secondary_action_label: str
hero_secondary_action_url: str
featured_events_heading: str
featured_events_intro: str
featured_events_link_label: str
featured_events_url: str
featured_event_action_label: str
featured_posts_heading: str
featured_posts_intro: str
featured_posts_link_label: str
featured_posts_url: str
featured_post_action_label: str
participation_heading: str
participation_body: str
participation_image: Optional[MediaOut] = None
participation_action_label: str
participation_action_url: str
participation_secondary_action_label: str
participation_secondary_action_url: str
class AboutItemOut(Schema):
@@ -59,23 +72,82 @@ class AboutItemOut(Schema):
position: int
class AboutMemberOut(Schema):
name: str
role: str
entry_year: str
bio: str
photo: Optional[MediaOut] = None
position: int
class AboutGalleryImageOut(Schema):
image: MediaOut
caption: str
position: int
class AboutAudienceOut(Schema):
title: str
text: str
icon: str
position: int
class AboutMilestoneOut(Schema):
year_label: str
title: str
description: str
position: int
class AboutSelectedEventOut(Schema):
id: int
title: str
slug: str
start_time: datetime
featured_image: Optional[MediaOut] = None
class AboutYearOut(Schema):
year_label: str
position: int
members: list[AboutMemberOut]
selected_events: list[AboutSelectedEventOut]
class AboutPageOut(PageBaseOut):
slug: Literal["about"]
identity_heading: str
identity_body: str
mission_heading: str
mission_body: str
vision_heading: str
vision_body: str
activities_heading: str
activities_body: str
values_heading: str
values_body: str
committees_heading: str
committees_body: str
affiliations_heading: str
affiliations_body: str
faq_heading: str
contact_heading: str
members_heading: str
members_intro: str
gallery_heading: str
gallery_intro: str
past_events_heading: str
history_heading: str
audience_heading: str
year_events_heading: str
cta_heading: str
cta_body: str
cta_action_label: str
cta_action_url: str
items: list[AboutItemOut]
members: list[AboutMemberOut]
gallery_images: list[AboutGalleryImageOut]
audiences: list[AboutAudienceOut]
milestones: list[AboutMilestoneOut]
years: list[AboutYearOut]
class ListingPageOut(PageBaseOut):

View File

@@ -1,7 +1,9 @@
from django.http import HttpResponse
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
from apps.events.models import Event
from apps.content.current import AboutContent, HomeContent, ListingContent, SiteContent
from apps.content.api.schemas import AboutPageOut, HomePageOut, ListingPageOut, PageContentOut, SiteContentOut
@@ -23,6 +25,27 @@ def _seo(request, obj):
}
def _about_member_out(request, member, year_label=None):
return {
"name": member.name,
"role": member.role,
"entry_year": year_label or member.entry_year,
"bio": member.bio,
"photo": _media(request, member.photo, member.photo_alt),
"position": member.position,
}
def _about_event_out(request, event):
return {
"id": event.pk,
"title": event.title,
"slug": event.slug,
"start_time": event.start_time,
"featured_image": _media(request, event.featured_image, f"پوستر {event.title}"),
}
@content_router.get("/site", response=SiteContentOut)
def get_site_content(request, response: HttpResponse):
site = SiteContent.objects.filter(pk=1).first()
@@ -50,6 +73,8 @@ def get_site_content(request, response: HttpResponse):
},
"address": site.address,
"footer_copy": site.footer_copy,
"enamad_image": _media(request, site.enamad_image, site.enamad_image_alt),
"enamad_url": site.enamad_url,
"seo": _seo(request, site),
}
@@ -66,32 +91,105 @@ def get_page_content(request, response: HttpResponse, slug: str):
"hero_heading": page.hero_heading, "hero_body": page.hero_body,
"hero_image": _media(request, page.hero_image, page.hero_image_alt),
"hero_action_label": page.hero_action_label,
"hero_action_url": page.hero_action_url,
"hero_secondary_action_label": page.hero_secondary_action_label,
"hero_secondary_action_url": page.hero_secondary_action_url,
"featured_events_heading": page.featured_events_heading,
"featured_events_intro": page.featured_events_intro,
"featured_events_link_label": page.featured_events_link_label,
"featured_events_url": page.featured_events_url,
"featured_event_action_label": page.featured_event_action_label,
"featured_posts_heading": page.featured_posts_heading,
"featured_posts_intro": page.featured_posts_intro,
"featured_posts_link_label": page.featured_posts_link_label,
"featured_posts_url": page.featured_posts_url,
"featured_post_action_label": page.featured_post_action_label,
"participation_heading": page.participation_heading,
"participation_body": page.participation_body,
"participation_image": _media(request, page.participation_image, page.participation_image_alt),
"participation_action_label": page.participation_action_label,
"participation_action_url": page.participation_action_url,
"participation_secondary_action_label": page.participation_secondary_action_label,
"participation_secondary_action_url": page.participation_secondary_action_url,
}
if slug == "about":
page = AboutContent.objects.filter(pk=1).first()
if page is None:
raise HttpError(404, "Page content not found")
event_statuses = (Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED)
years_by_label = {}
for year in page.years.prefetch_related("members", "events").all():
label = year.year_label.strip() or "سال نامشخص"
events = year.events.filter(
is_deleted=False,
status__in=event_statuses,
start_time__lte=timezone.now(),
).order_by("-start_time", "-pk")
years_by_label[label] = {
"year_label": label,
"position": year.position,
"_pk": year.pk,
"members": [
_about_member_out(request, member, label)
for member in year.members.all()
],
"selected_events": [_about_event_out(request, event) for event in events],
}
legacy_members = page.members.filter(year_group__isnull=True).order_by("entry_year", "position", "pk")
for member in legacy_members:
label = member.entry_year.strip() or "سال نامشخص"
year_data = years_by_label.setdefault(
label,
{
"year_label": label,
"position": 0,
"_pk": 0,
"members": [],
"selected_events": [],
},
)
year_data["members"].append(_about_member_out(request, member, label))
years = sorted(
years_by_label.values(),
key=lambda year: (year["position"], year["_pk"], year["year_label"]),
)
all_members = [member for year in years for member in year["members"]]
return {
"slug": "about", "title": page.title, "intro": page.intro, "seo": _seo(request, page),
"identity_heading": page.identity_heading, "identity_body": page.identity_body,
"mission_heading": page.mission_heading, "mission_body": page.mission_body,
"vision_heading": page.vision_heading, "vision_body": page.vision_body,
"activities_heading": page.activities_heading, "activities_body": page.activities_body,
"values_heading": page.values_heading, "values_body": page.values_body,
"committees_heading": page.committees_heading, "committees_body": page.committees_body,
"affiliations_heading": page.affiliations_heading, "affiliations_body": page.affiliations_body,
"faq_heading": page.faq_heading, "contact_heading": page.contact_heading,
"items": list(page.items.values("kind", "title", "text", "position")),
"members_heading": page.members_heading, "members_intro": page.members_intro,
"gallery_heading": page.gallery_heading, "gallery_intro": page.gallery_intro,
"past_events_heading": page.past_events_heading,
"history_heading": page.history_heading,
"audience_heading": page.audience_heading,
"year_events_heading": page.year_events_heading,
"cta_heading": page.cta_heading,
"cta_body": page.cta_body,
"cta_action_label": page.cta_action_label,
"cta_action_url": page.cta_action_url,
"items": list(page.items.exclude(kind__in=("committees", "affiliations")).values("kind", "title", "text", "position")),
"members": all_members,
"gallery_images": [
{
"image": _media(request, item.image, item.image_alt),
"caption": item.caption, "position": item.position,
}
for item in page.gallery_images.all()
],
"audiences": list(page.audiences.values("title", "text", "icon", "position")),
"milestones": list(page.milestones.values("year_label", "title", "description", "position")),
"years": [
{key: value for key, value in year.items() if not key.startswith("_")}
for year in years
],
}
if slug in ("events", "blog"):

View File

@@ -56,7 +56,7 @@ class EditableContent(models.Model):
validators.text(getattr(self, field.name), field.max_length or 5000)
except ValidationError as exc:
errors[field.name] = exc
for image_name in ("seo_image", "logo", "hero_image", "participation_image"):
for image_name in ("seo_image", "logo", "hero_image", "participation_image", "enamad_image"):
if not hasattr(self, image_name):
continue
image = getattr(self, image_name)
@@ -102,6 +102,9 @@ class SiteContent(SingleRecordContent):
website_url = models.URLField(blank=True)
address = models.TextField(blank=True)
footer_copy = models.CharField(max_length=300, blank=True)
enamad_image = models.ImageField("eNAMAD certificate image", upload_to=content_upload_path, blank=True)
enamad_image_alt = models.CharField("eNAMAD image description", max_length=180, blank=True)
enamad_url = models.URLField("eNAMAD verification URL", blank=True)
class Meta:
verbose_name = "Site content"
@@ -110,6 +113,11 @@ class SiteContent(SingleRecordContent):
def __str__(self):
return "Site content"
def clean(self):
super().clean()
if self.enamad_url:
validators.external_https_url(self.enamad_url)
class HomeContent(SingleRecordContent):
title = models.CharField(max_length=180, blank=True)
@@ -119,17 +127,27 @@ class HomeContent(SingleRecordContent):
hero_image = models.ImageField(upload_to=content_upload_path, blank=True)
hero_image_alt = models.CharField(max_length=180, blank=True)
hero_action_label = models.CharField(max_length=40, blank=True)
hero_action_url = models.CharField(max_length=500, blank=True)
hero_secondary_action_label = models.CharField(max_length=40, blank=True)
hero_secondary_action_url = models.CharField(max_length=500, blank=True)
featured_events_heading = models.CharField(max_length=120, blank=True)
featured_events_intro = models.CharField(max_length=300, blank=True)
featured_events_link_label = models.CharField(max_length=40, blank=True)
featured_events_url = models.CharField(max_length=500, blank=True)
featured_event_action_label = models.CharField(max_length=40, blank=True)
featured_posts_heading = models.CharField(max_length=120, blank=True)
featured_posts_intro = models.CharField(max_length=300, blank=True)
featured_posts_link_label = models.CharField(max_length=40, blank=True)
featured_posts_url = models.CharField(max_length=500, blank=True)
featured_post_action_label = models.CharField(max_length=40, blank=True)
participation_heading = models.CharField(max_length=120, blank=True)
participation_body = models.TextField(blank=True)
participation_image = models.ImageField(upload_to=content_upload_path, blank=True)
participation_image_alt = models.CharField(max_length=180, blank=True)
participation_action_label = models.CharField(max_length=40, blank=True)
participation_action_url = models.CharField(max_length=500, blank=True)
participation_secondary_action_label = models.CharField(max_length=40, blank=True)
participation_secondary_action_url = models.CharField(max_length=500, blank=True)
class Meta:
verbose_name = "Home page content"
@@ -138,6 +156,21 @@ class HomeContent(SingleRecordContent):
def __str__(self):
return "Home page"
def clean(self):
super().clean()
errors = {}
for name in (
"hero_action_url", "hero_secondary_action_url",
"featured_events_url", "featured_posts_url",
"participation_action_url", "participation_secondary_action_url",
):
try:
validators.action_url(getattr(self, name))
except ValidationError as exc:
errors[name] = exc
if errors:
raise ValidationError(errors)
class AboutContent(SingleRecordContent):
title = models.CharField(max_length=180, blank=True)
@@ -146,6 +179,8 @@ class AboutContent(SingleRecordContent):
identity_body = models.TextField(blank=True)
mission_heading = models.CharField(max_length=120, blank=True)
mission_body = models.TextField(blank=True)
vision_heading = models.CharField(max_length=120, blank=True)
vision_body = models.TextField(blank=True)
activities_heading = models.CharField(max_length=120, blank=True)
activities_body = models.TextField(blank=True)
values_heading = models.CharField(max_length=120, blank=True)
@@ -156,6 +191,18 @@ class AboutContent(SingleRecordContent):
affiliations_body = models.TextField(blank=True)
faq_heading = models.CharField("FAQ heading", max_length=120, blank=True)
contact_heading = models.CharField(max_length=120, blank=True)
members_heading = models.CharField(max_length=120, blank=True)
members_intro = models.CharField(max_length=300, blank=True)
gallery_heading = models.CharField(max_length=120, blank=True)
gallery_intro = models.CharField(max_length=300, blank=True)
past_events_heading = models.CharField(max_length=120, blank=True)
history_heading = models.CharField(max_length=120, blank=True)
audience_heading = models.CharField(max_length=120, blank=True)
year_events_heading = models.CharField(max_length=120, blank=True)
cta_heading = models.CharField(max_length=120, blank=True)
cta_body = models.CharField(max_length=300, blank=True)
cta_action_label = models.CharField(max_length=40, blank=True)
cta_action_url = models.CharField(max_length=500, blank=True)
class Meta:
verbose_name = "About page content"
@@ -164,6 +211,192 @@ class AboutContent(SingleRecordContent):
def __str__(self):
return "About page"
def clean(self):
super().clean()
validators.action_url(self.cta_action_url)
class AboutYear(models.Model):
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="years")
year_label = models.CharField("Academic year", max_length=32)
position = models.PositiveSmallIntegerField(
default=0,
help_text="Order years from oldest to newest; the last year is selected by default on the page.",
)
events = models.ManyToManyField(
"events.Event",
blank=True,
related_name="about_year_highlights",
help_text="Choose past, published or completed events. Draft, cancelled, and upcoming events are not shown.",
)
class Meta:
ordering = ["position", "pk"]
constraints = [
models.UniqueConstraint(fields=("about", "year_label"), name="content_about_year_label_unique"),
]
verbose_name = "Association year"
verbose_name_plural = "Association years"
def __str__(self):
return self.year_label
def clean(self):
super().clean()
validators.text(self.year_label, 32, required=True)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.members.exclude(about_id=self.about_id, entry_year=self.year_label).update(
about_id=self.about_id,
entry_year=self.year_label,
)
class AboutMember(models.Model):
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="members")
entry_year = models.CharField("Academic year", max_length=32)
year_group = models.ForeignKey(
AboutYear,
on_delete=models.CASCADE,
related_name="members",
blank=True,
null=True,
)
name = models.CharField(max_length=120)
role = models.CharField(max_length=120)
bio = models.CharField(max_length=500, blank=True)
photo = models.ImageField(upload_to=content_upload_path, blank=True)
photo_alt = models.CharField(max_length=180, blank=True)
position = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ["position", "pk"]
verbose_name = "Association member for this year"
verbose_name_plural = "Association members for this year"
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if self.year_group_id:
self.about_id = self.year_group.about_id
self.entry_year = self.year_group.year_label
super().save(*args, **kwargs)
def clean(self):
super().clean()
errors = {}
for name, limit in (("name", 120), ("role", 120), ("entry_year", 32), ("bio", 500), ("photo_alt", 180)):
try:
required = name in {"name", "role"} or (name == "entry_year" and not self.year_group_id)
validators.text(getattr(self, name), limit, required=required)
except ValidationError as exc:
errors[name] = exc
if self.year_group_id and self.about_id and self.year_group.about_id != self.about_id:
errors["year_group"] = "Select an academic year from the same About page."
if self.photo and not self.photo_alt.strip():
errors["photo_alt"] = "Describe the image for screen readers."
if not self.photo and self.photo_alt:
errors["photo_alt"] = "Remove the description or upload an image."
try:
check_image(self.photo)
except ValidationError as exc:
errors["photo"] = exc
if errors:
raise ValidationError(errors)
class AboutAudience(models.Model):
class IconChoices(models.TextChoices):
STUDENTS = "students", "Students"
ALUMNI = "alumni", "Alumni"
FACULTY = "faculty", "Faculty and researchers"
PARTNERS = "partners", "Industry and community partners"
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="audiences")
title = models.CharField(max_length=120)
text = models.CharField(max_length=300)
icon = models.CharField(max_length=20, choices=IconChoices.choices, default=IconChoices.STUDENTS)
position = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ["position", "pk"]
verbose_name = "About page audience"
verbose_name_plural = "About page audiences"
def __str__(self):
return self.title
def clean(self):
super().clean()
errors = {}
for name, limit in (("title", 120), ("text", 300)):
try:
validators.text(getattr(self, name), limit, required=True)
except ValidationError as exc:
errors[name] = exc
if errors:
raise ValidationError(errors)
class AboutMilestone(models.Model):
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="milestones")
year_label = models.CharField("Year", max_length=32)
title = models.CharField(max_length=120)
description = models.CharField(max_length=500)
position = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ["position", "pk"]
verbose_name = "Association history milestone"
verbose_name_plural = "Association history milestones"
def __str__(self):
return f"{self.year_label}: {self.title}"
def clean(self):
super().clean()
errors = {}
for name, limit in (("year_label", 32), ("title", 120), ("description", 500)):
try:
validators.text(getattr(self, name), limit, required=True)
except ValidationError as exc:
errors[name] = exc
if errors:
raise ValidationError(errors)
class AboutGalleryImage(models.Model):
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="gallery_images")
image = models.ImageField(upload_to=content_upload_path)
image_alt = models.CharField(max_length=180)
caption = models.CharField(max_length=180, blank=True)
position = models.PositiveSmallIntegerField(default=0)
class Meta:
ordering = ["position", "pk"]
verbose_name = "About page gallery image"
verbose_name_plural = "About page gallery images"
def __str__(self):
return self.caption or self.image_alt
def clean(self):
super().clean()
errors = {}
for name, limit, required in (("image_alt", 180, True), ("caption", 180, False)):
try:
validators.text(getattr(self, name), limit, required=required)
except ValidationError as exc:
errors[name] = exc
try:
check_image(self.image)
except ValidationError as exc:
errors["image"] = exc
if errors:
raise ValidationError(errors)
class AboutItem(models.Model):
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="items")

View File

@@ -24,6 +24,9 @@ SITE_MOCK = {
"website_url": "https://example.invalid",
"address": "نشانی نمونه",
"footer_copy": "متن نمونه پابرگ؛ محتوای نهایی را از پنل مدیریت وارد کنید.",
"enamad_image": "",
"enamad_image_alt": "",
"enamad_url": "",
"seo_title": "عنوان نمونه سایت",
"seo_description": "توضیحات نمونه برای موتورهای جست‌وجو.",
"seo_image": "",
@@ -37,18 +40,28 @@ HOME_MOCK = {
"hero_body": "توضیحات نمونه برای معرفی انجمن و فعالیت‌های آن.",
"hero_image": "",
"hero_image_alt": "",
"hero_action_label": "مشاهده نمونه‌ها",
"hero_action_label": "مشاهده رویدادها",
"hero_action_url": "/events",
"hero_secondary_action_label": "مطالب بلاگ",
"hero_secondary_action_url": "/blog",
"featured_events_heading": "رویدادهای نمونه",
"featured_events_intro": "توضیحات نمونه برای بخش رویدادها.",
"featured_events_link_label": "همه رویدادها",
"featured_events_url": "/events",
"featured_event_action_label": "مشاهده رویداد",
"featured_posts_heading": "نوشته‌های نمونه",
"featured_posts_intro": "توضیحات نمونه برای بخش نوشته‌ها.",
"featured_posts_link_label": "همه نوشته‌ها",
"featured_posts_url": "/blog",
"featured_post_action_label": "خواندن مطلب",
"participation_heading": "به ما بپیوندید",
"participation_body": "این متن نمونه راه‌های مشارکت را توضیح می‌دهد.",
"participation_image": "",
"participation_image_alt": "",
"participation_action_label": "اطلاعات بیشتر",
"participation_action_url": "/auth",
"participation_secondary_action_label": "درباره انجمن",
"participation_secondary_action_url": "/about",
"seo_title": "عنوان نمونه صفحه اصلی",
"seo_description": "توضیحات نمونه صفحه اصلی برای موتورهای جست‌وجو.",
"seo_image": "",
@@ -62,16 +75,26 @@ ABOUT_MOCK = {
"identity_body": "اطلاعات نمونه درباره هویت انجمن.",
"mission_heading": "ماموریت نمونه",
"mission_body": "توضیحات نمونه درباره ماموریت انجمن.",
"vision_heading": "چشم‌انداز نمونه",
"vision_body": "جامعه‌ای برای یادگیری، تجربه‌کردن و رشد در کنار یکدیگر.",
"activities_heading": "فعالیت‌های نمونه",
"activities_body": "توضیحات نمونه درباره فعالیت‌ها.",
"values_heading": "ارزش‌های نمونه",
"values_body": "یادگیری، همکاری و مسئولیت‌پذیری نمونه.",
"committees_heading": "کمیته‌های نمونه",
"committees_body": "اطلاعات نمونه درباره کمیته‌ها.",
"affiliations_heading": "همکاری‌های نمونه",
"affiliations_body": "اطلاعات نمونه درباره گروه‌های همکار.",
"faq_heading": "پرسش‌های نمونه",
"contact_heading": "راه‌های ارتباطی نمونه",
"members_heading": "اعضای انجمن",
"members_intro": "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.",
"gallery_heading": "خاطرات انجمن",
"gallery_intro": "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.",
"past_events_heading": "رویدادهای برگزارشده",
"history_heading": "مسیر انجمن",
"audience_heading": "برای چه کسانی؟",
"year_events_heading": "لحظه‌های هر سال",
"cta_heading": "به جمع ما بپیوندید",
"cta_body": "اگر دوست دارید یاد بگیرید و در ساختن تجربه‌های تازه سهیم شوید، با ما همراه باشید.",
"cta_action_label": "راه‌های پیوستن",
"cta_action_url": "",
"seo_title": "عنوان نمونه صفحه درباره ما",
"seo_description": "توضیحات نمونه صفحه درباره ما برای موتورهای جست‌وجو.",
"seo_image": "",
@@ -101,8 +124,6 @@ ABOUT_ITEM_MOCKS = (
("mission", "ماموریت نمونه", "این متن نمونه برای توضیح ماموریت انجمن است."),
("activities", "فعالیت نمونه", "این متن نمونه یکی از فعالیت‌های انجمن را معرفی می‌کند."),
("values", "ارزش نمونه", "این متن نمونه یکی از ارزش‌های انجمن را توضیح می‌دهد."),
("committees", "کمیته نمونه", "این متن نمونه برای معرفی یک کمیته است."),
("affiliations", "همکاری نمونه", "این متن نمونه برای معرفی یک گروه همکار است."),
("faq", "پرسش نمونه", "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید."),
)

View File

@@ -0,0 +1,78 @@
"""Add editable Home actions, About team/gallery content, and the footer badge."""
from django.db import migrations, models
import django.db.models.deletion
import apps.content.current
class Migration(migrations.Migration):
dependencies = [("content", "0005_seed_current_website_copy")]
operations = [
migrations.AddField(
model_name="sitecontent",
name="enamad_image",
field=models.ImageField(blank=True, upload_to=apps.content.current.content_upload_path, verbose_name="eNAMAD certificate image"),
),
migrations.AddField(
model_name="sitecontent",
name="enamad_image_alt",
field=models.CharField(blank=True, max_length=180, verbose_name="eNAMAD image description"),
),
migrations.AddField(
model_name="sitecontent",
name="enamad_url",
field=models.URLField(blank=True, verbose_name="eNAMAD verification URL"),
),
migrations.AddField(model_name="homecontent", name="hero_action_url", field=models.CharField(blank=True, max_length=500)),
migrations.AddField(model_name="homecontent", name="hero_secondary_action_label", field=models.CharField(blank=True, max_length=40)),
migrations.AddField(model_name="homecontent", name="hero_secondary_action_url", field=models.CharField(blank=True, max_length=500)),
migrations.AddField(model_name="homecontent", name="featured_events_url", field=models.CharField(blank=True, max_length=500)),
migrations.AddField(model_name="homecontent", name="featured_event_action_label", field=models.CharField(blank=True, max_length=40)),
migrations.AddField(model_name="homecontent", name="featured_posts_url", field=models.CharField(blank=True, max_length=500)),
migrations.AddField(model_name="homecontent", name="featured_post_action_label", field=models.CharField(blank=True, max_length=40)),
migrations.AddField(model_name="homecontent", name="participation_action_url", field=models.CharField(blank=True, max_length=500)),
migrations.AddField(model_name="homecontent", name="participation_secondary_action_label", field=models.CharField(blank=True, max_length=40)),
migrations.AddField(model_name="homecontent", name="participation_secondary_action_url", field=models.CharField(blank=True, max_length=500)),
migrations.AddField(model_name="aboutcontent", name="members_heading", field=models.CharField(blank=True, max_length=120)),
migrations.AddField(model_name="aboutcontent", name="members_intro", field=models.CharField(blank=True, max_length=300)),
migrations.AddField(model_name="aboutcontent", name="gallery_heading", field=models.CharField(blank=True, max_length=120)),
migrations.AddField(model_name="aboutcontent", name="gallery_intro", field=models.CharField(blank=True, max_length=300)),
migrations.AddField(model_name="aboutcontent", name="past_events_heading", field=models.CharField(blank=True, max_length=120)),
migrations.CreateModel(
name="AboutMember",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=120)),
("role", models.CharField(max_length=120)),
("entry_year", models.CharField(max_length=16, verbose_name="Year joined the association")),
("bio", models.CharField(blank=True, max_length=500)),
("photo", models.ImageField(blank=True, upload_to=apps.content.current.content_upload_path)),
("photo_alt", models.CharField(blank=True, max_length=180)),
("position", models.PositiveSmallIntegerField(default=0)),
("about", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="members", to="content.aboutcontent")),
],
options={
"verbose_name": "Association member",
"verbose_name_plural": "Association members",
"ordering": ["position", "pk"],
},
),
migrations.CreateModel(
name="AboutGalleryImage",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("image", models.ImageField(upload_to=apps.content.current.content_upload_path)),
("image_alt", models.CharField(max_length=180)),
("caption", models.CharField(blank=True, max_length=180)),
("position", models.PositiveSmallIntegerField(default=0)),
("about", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="gallery_images", to="content.aboutcontent")),
],
options={
"verbose_name": "About page gallery image",
"verbose_name_plural": "About page gallery images",
"ordering": ["position", "pk"],
},
),
]

View File

@@ -0,0 +1,113 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("content", "0006_home_actions_about_members_gallery_enamad"),
("events", "0014_remove_registration_cancellation_email_sent_at_and_more"),
]
operations = [
migrations.AddField(
model_name="aboutcontent",
name="vision_heading",
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name="aboutcontent",
name="vision_body",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="aboutcontent",
name="history_heading",
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name="aboutcontent",
name="audience_heading",
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name="aboutcontent",
name="year_events_heading",
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name="aboutcontent",
name="cta_heading",
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name="aboutcontent",
name="cta_body",
field=models.CharField(blank=True, max_length=300),
),
migrations.AddField(
model_name="aboutcontent",
name="cta_action_label",
field=models.CharField(blank=True, max_length=40),
),
migrations.AddField(
model_name="aboutcontent",
name="cta_action_url",
field=models.CharField(blank=True, max_length=500),
),
migrations.CreateModel(
name="AboutAudience",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=120)),
("text", models.CharField(max_length=300)),
("icon", models.CharField(choices=[("students", "Students"), ("alumni", "Alumni"), ("faculty", "Faculty and researchers"), ("partners", "Industry and community partners")], default="students", max_length=20)),
("position", models.PositiveSmallIntegerField(default=0)),
("about", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="audiences", to="content.aboutcontent")),
],
options={
"verbose_name": "About page audience",
"verbose_name_plural": "About page audiences",
"ordering": ["position", "pk"],
},
),
migrations.CreateModel(
name="AboutMilestone",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("year_label", models.CharField(max_length=32, verbose_name="Year")),
("title", models.CharField(max_length=120)),
("description", models.CharField(max_length=500)),
("position", models.PositiveSmallIntegerField(default=0)),
("about", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="milestones", to="content.aboutcontent")),
],
options={
"verbose_name": "Association history milestone",
"verbose_name_plural": "Association history milestones",
"ordering": ["position", "pk"],
},
),
migrations.CreateModel(
name="AboutYear",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("year_label", models.CharField(max_length=32, verbose_name="Academic year")),
("position", models.PositiveSmallIntegerField(default=0, help_text="Order years from oldest to newest; the last year is selected by default on the page.")),
("about", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="years", to="content.aboutcontent")),
("events", models.ManyToManyField(blank=True, help_text="Choose past, published or completed events. Draft, cancelled, and upcoming events are not shown.", related_name="about_year_highlights", to="events.event")),
],
options={
"verbose_name": "Association year",
"verbose_name_plural": "Association years",
"ordering": ["position", "pk"],
},
),
migrations.AddConstraint(
model_name="aboutyear",
constraint=models.UniqueConstraint(fields=("about", "year_label"), name="content_about_year_label_unique"),
),
migrations.AddField(
model_name="aboutmember",
name="year_group",
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="members", to="content.aboutyear"),
),
]

View File

@@ -22,7 +22,9 @@ def test_command_replaces_unedited_migration_copy_with_mock_content_idempotently
assert home.hero_heading == "عنوان نمونه صفحه اصلی"
assert about.title == "عنوان نمونه صفحه درباره ما"
assert about.items.filter(title="کمیته نمونه").exists()
assert about.items.filter(kind="mission").exists()
assert about.items.filter(kind="faq").exists()
assert not about.items.filter(kind__in=["committees", "affiliations"]).exists()
assert not about.items.filter(title="انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی").exists()
assert events.title == "عنوان نمونه رویدادها"
assert blog.title == "عنوان نمونه بلاگ"

View File

@@ -80,3 +80,32 @@ def action(value):
text(value["label"], 40, required=True)
if not isinstance(value["route_id"], str) or value["route_id"] not in ROUTES - {"home"}:
raise ValidationError("Action route is not approved.")
def action_url(value):
if not value:
return
if not isinstance(value, str) or value != value.strip() or "\\" in value or any(ord(char) < 32 for char in value):
raise ValidationError("Use a root-relative path or a secure https URL.")
try:
parsed = urlsplit(value)
except ValueError as exc:
raise ValidationError("Invalid action URL.") from exc
if value.startswith("/"):
if value.startswith("//") or parsed.scheme or parsed.netloc:
raise ValidationError("Internal links must be root-relative paths.")
return
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ValidationError("External action URLs must use https and cannot include credentials.")
external_https_url(value)
def external_https_url(value):
try:
parsed = urlsplit(value)
hostname = parsed.hostname
parsed.port
except (AttributeError, ValueError) as exc:
raise ValidationError("Enter a valid https URL without credentials.") from exc
if parsed.scheme != "https" or not hostname or parsed.username or parsed.password:
raise ValidationError("Enter a valid https URL without credentials.")