feat: add direct-save CMS and About archive
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", "پرسش نمونه", "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید."),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
113
apps/content/migrations/0007_about_story_archive.py
Normal file
113
apps/content/migrations/0007_about_story_archive.py
Normal 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"),
|
||||
),
|
||||
]
|
||||
@@ -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 == "عنوان نمونه بلاگ"
|
||||
|
||||
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user