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

35
.env.development.example Normal file
View File

@@ -0,0 +1,35 @@
# Copy to .env for local development. Keep .env out of Git.
DJANGO_SETTINGS_MODULE=config.settings.development
SECRET_KEY=replace-with-a-local-random-secret
JWT_SECRET_KEY=replace-with-a-second-local-random-secret
DEBUG=True
ALLOWED_HOSTS=127.0.0.1,localhost
DJANGO_HOST=http://127.0.0.1:8000
SITE_URL=http://127.0.0.1:8000
DB_ENGINE=django.db.backends.postgresql
DB_NAME=guilan_ace_local
DB_USER=postgres
DB_PASSWORD=replace-with-your-local-postgres-password
DB_HOST=127.0.0.1
DB_PORT=5432
REDIS_URL=redis://127.0.0.1:6379/0
CELERY_BROKER_URL=redis://127.0.0.1:6379/0
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/1
# Copy these from a trusted backup or your SMS.ir account.
SMS_APIKEY=
SMS_AUTH_OTP_TEMPLATE_ID=
SMS_EVENT_CANCELLATION_TEMPLATE_ID=
SMS_EVENT_RESCHEDULE_TEMPLATE_ID=
SMS_PAYMENT_STATUS_TEMPLATE_ID=
FRONTEND_ROOT=http://localhost:8080
FRONTEND_CALLBACK_URL=http://localhost:8080/payments/result
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:8000/api/auth/oauth/google/callback
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:8080/auth/google/callback
ZARINPAL_MERCHANT_ID=replace-with-your-merchant-id
ZARINPAL_USE_SANDBOX=True
ZARINPAL_CALLBACK_URL=http://127.0.0.1:8000/api/payments/callback

View File

@@ -23,16 +23,54 @@ guilan-ace-backend/
manage.py manage.py
``` ```
## Local setup ## Local development on Windows (no Compose)
```bash
python -m venv .venv The backend, frontend, Celery worker, and Celery beat run as separate processes. PostgreSQL must listen on `127.0.0.1:5432` and Redis on `127.0.0.1:6379`. Redis can be a standalone container with its port published; no other service needs Docker.
.venv\\Scripts\\activate
pip install -r requirements.txt Use Python 3.13 for the pinned dependencies. From this directory in PowerShell:
cp .env.example .env
python manage.py migrate ```powershell
python manage.py runserver py -3.13 -m venv .venv
python -m pip install -r requirements.txt
Copy-Item .env.development.example .env
``` ```
Fill in `SECRET_KEY`, `JWT_SECRET_KEY`, `DB_PASSWORD`, `SMS_APIKEY`, the SMS template IDs you use, and `ZARINPAL_MERCHANT_ID` in the ignored `.env`. Leave `ZARINPAL_USE_SANDBOX=True`. The frontend's `.env` should contain `NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000` and `NEXT_PUBLIC_SITE_URL=http://localhost:8080`. Copy its `.env.sample` if needed.
Create the local database once, then apply migrations. Use the PostgreSQL `createdb.exe` from your installation if it is not on `PATH`:
```powershell
$env:PGPASSWORD = '<your local PostgreSQL password>'
createdb -h 127.0.0.1 -p 5432 -U postgres guilan_ace_local
python manage.py migrate
```
Open four PowerShell terminals. Run the first three in this backend directory:
```powershell
python manage.py runserver 127.0.0.1:8000
```
```powershell
celery -A config.services.celery worker --pool=solo --loglevel=INFO
```
```powershell
New-Item -ItemType Directory -Force (Join-Path $env:TEMP 'guilan-ace-local') | Out-Null
celery -A config.services.celery beat --loglevel=INFO --schedule (Join-Path $env:TEMP 'guilan-ace-local\celerybeat-schedule')
```
Run the frontend in the sibling `guilan-ace-frontend` directory:
```powershell
npm.cmd ci
npm.cmd run dev
```
Open `http://localhost:8080` for the site and `http://127.0.0.1:8000/api/docs` for the API. Django serves local media under `/media/`. The first Next.js page request may take a while to compile.
The June 12, 2026 server backup was restored locally into `guilan_ace_local` and this repository's ignored `media/` directory. The backup also supplied the local SMS.ir OTP credentials. Its other SMS template IDs were empty. The local `.env` points at localhost and Zarinpal's sandbox; never replace it directly with the backup's production environment file. The previous local `.env` is saved as ignored `.env.bak`.
## Tests ## Tests
Install test dependencies, then run the two pytest suites: Install test dependencies, then run the two pytest suites:

View File

@@ -1,7 +1,21 @@
from django.contrib import admin from django.contrib import admin
from django.utils import timezone
from unfold.admin import ModelAdmin, TabularInline 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): class FixedContentAdmin(ModelAdmin):
@@ -22,6 +36,7 @@ class SiteContentAdmin(FixedContentAdmin):
("Association", {"fields": ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt")}), ("Association", {"fields": ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt")}),
("Navigation labels", {"fields": ("nav_home", "nav_about", "nav_events", "nav_blog")}), ("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")}), ("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")}), ("SEO defaults", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
) )
@@ -30,10 +45,21 @@ class SiteContentAdmin(FixedContentAdmin):
class HomeContentAdmin(FixedContentAdmin): class HomeContentAdmin(FixedContentAdmin):
fieldsets = ( fieldsets = (
("Page introduction", {"fields": ("title", "intro")}), ("Page introduction", {"fields": ("title", "intro")}),
("Hero", {"fields": ("hero_heading", "hero_body", "hero_image", "hero_image_alt", "hero_action_label")}), ("Hero", {"fields": (
("Featured events", {"fields": ("featured_events_heading", "featured_events_intro", "featured_events_link_label")}), "hero_heading", "hero_body", "hero_image", "hero_image_alt",
("Featured blog posts", {"fields": ("featured_posts_heading", "featured_posts_intro", "featured_posts_link_label")}), "hero_action_label", "hero_action_url", "hero_secondary_action_label", "hero_secondary_action_url",
("Participation", {"fields": ("participation_heading", "participation_body", "participation_image", "participation_image_alt", "participation_action_label")}), ), "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")}), ("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}),
) )
@@ -43,6 +69,100 @@ class AboutItemInline(TabularInline):
extra = 0 extra = 0
fields = ("kind", "title", "text", "position") 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): def has_add_permission(self, request, obj=None):
return request.user.has_perm("content.change_aboutcontent") return request.user.has_perm("content.change_aboutcontent")
@@ -55,19 +175,76 @@ class AboutItemInline(TabularInline):
@admin.register(AboutContent) @admin.register(AboutContent)
class AboutContentAdmin(FixedContentAdmin): class AboutContentAdmin(FixedContentAdmin):
inlines = (AboutItemInline,) inlines = (
AboutItemInline,
AboutAudienceInline,
AboutMilestoneInline,
LegacyAboutMemberInline,
AboutGalleryInline,
)
fieldsets = ( fieldsets = (
("Page introduction", {"fields": ("title", "intro", "identity_heading", "identity_body")}), ("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")}), ("Activities", {"fields": ("activities_heading", "activities_body")}),
("Values", {"fields": ("values_heading", "values_body")}), ("Association history", {"fields": ("history_heading",)}),
("Committees", {"fields": ("committees_heading", "committees_body")}), ("Who the association serves", {"fields": ("audience_heading",)}),
("Affiliations", {"fields": ("affiliations_heading", "affiliations_body")}), ("Annual archive", {"fields": ("members_heading", "members_intro", "year_events_heading")}),
("FAQ and contact", {"fields": ("faq_heading", "contact_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")}), ("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) @admin.register(ListingContent)
class ListingContentAdmin(FixedContentAdmin): class ListingContentAdmin(FixedContentAdmin):
list_display = ("slug", "title") list_display = ("slug", "title")

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Literal, Optional, Union from typing import Literal, Optional, Union
from ninja import Schema from ninja import Schema
@@ -24,6 +25,8 @@ class SiteContentOut(Schema):
contacts: dict[str, str] contacts: dict[str, str]
address: str address: str
footer_copy: str footer_copy: str
enamad_image: Optional[MediaOut] = None
enamad_url: str
seo: SeoOut seo: SeoOut
@@ -40,16 +43,26 @@ class HomePageOut(PageBaseOut):
hero_body: str hero_body: str
hero_image: Optional[MediaOut] = None hero_image: Optional[MediaOut] = None
hero_action_label: str hero_action_label: str
hero_action_url: str
hero_secondary_action_label: str
hero_secondary_action_url: str
featured_events_heading: str featured_events_heading: str
featured_events_intro: str featured_events_intro: str
featured_events_link_label: str featured_events_link_label: str
featured_events_url: str
featured_event_action_label: str
featured_posts_heading: str featured_posts_heading: str
featured_posts_intro: str featured_posts_intro: str
featured_posts_link_label: str featured_posts_link_label: str
featured_posts_url: str
featured_post_action_label: str
participation_heading: str participation_heading: str
participation_body: str participation_body: str
participation_image: Optional[MediaOut] = None participation_image: Optional[MediaOut] = None
participation_action_label: str participation_action_label: str
participation_action_url: str
participation_secondary_action_label: str
participation_secondary_action_url: str
class AboutItemOut(Schema): class AboutItemOut(Schema):
@@ -59,23 +72,82 @@ class AboutItemOut(Schema):
position: int 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): class AboutPageOut(PageBaseOut):
slug: Literal["about"] slug: Literal["about"]
identity_heading: str identity_heading: str
identity_body: str identity_body: str
mission_heading: str mission_heading: str
mission_body: str mission_body: str
vision_heading: str
vision_body: str
activities_heading: str activities_heading: str
activities_body: str activities_body: str
values_heading: str values_heading: str
values_body: str values_body: str
committees_heading: str
committees_body: str
affiliations_heading: str
affiliations_body: str
faq_heading: str faq_heading: str
contact_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] items: list[AboutItemOut]
members: list[AboutMemberOut]
gallery_images: list[AboutGalleryImageOut]
audiences: list[AboutAudienceOut]
milestones: list[AboutMilestoneOut]
years: list[AboutYearOut]
class ListingPageOut(PageBaseOut): class ListingPageOut(PageBaseOut):

View File

@@ -1,7 +1,9 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.utils import timezone
from ninja import Router from ninja import Router
from ninja.errors import HttpError from ninja.errors import HttpError
from apps.events.models import Event
from apps.content.current import AboutContent, HomeContent, ListingContent, SiteContent from apps.content.current import AboutContent, HomeContent, ListingContent, SiteContent
from apps.content.api.schemas import AboutPageOut, HomePageOut, ListingPageOut, PageContentOut, SiteContentOut 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) @content_router.get("/site", response=SiteContentOut)
def get_site_content(request, response: HttpResponse): def get_site_content(request, response: HttpResponse):
site = SiteContent.objects.filter(pk=1).first() site = SiteContent.objects.filter(pk=1).first()
@@ -50,6 +73,8 @@ def get_site_content(request, response: HttpResponse):
}, },
"address": site.address, "address": site.address,
"footer_copy": site.footer_copy, "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), "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_heading": page.hero_heading, "hero_body": page.hero_body,
"hero_image": _media(request, page.hero_image, page.hero_image_alt), "hero_image": _media(request, page.hero_image, page.hero_image_alt),
"hero_action_label": page.hero_action_label, "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_heading": page.featured_events_heading,
"featured_events_intro": page.featured_events_intro, "featured_events_intro": page.featured_events_intro,
"featured_events_link_label": page.featured_events_link_label, "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_heading": page.featured_posts_heading,
"featured_posts_intro": page.featured_posts_intro, "featured_posts_intro": page.featured_posts_intro,
"featured_posts_link_label": page.featured_posts_link_label, "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_heading": page.participation_heading,
"participation_body": page.participation_body, "participation_body": page.participation_body,
"participation_image": _media(request, page.participation_image, page.participation_image_alt), "participation_image": _media(request, page.participation_image, page.participation_image_alt),
"participation_action_label": page.participation_action_label, "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": if slug == "about":
page = AboutContent.objects.filter(pk=1).first() page = AboutContent.objects.filter(pk=1).first()
if page is None: if page is None:
raise HttpError(404, "Page content not found") 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 { return {
"slug": "about", "title": page.title, "intro": page.intro, "seo": _seo(request, page), "slug": "about", "title": page.title, "intro": page.intro, "seo": _seo(request, page),
"identity_heading": page.identity_heading, "identity_body": page.identity_body, "identity_heading": page.identity_heading, "identity_body": page.identity_body,
"mission_heading": page.mission_heading, "mission_body": page.mission_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, "activities_heading": page.activities_heading, "activities_body": page.activities_body,
"values_heading": page.values_heading, "values_body": page.values_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, "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"): 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) validators.text(getattr(self, field.name), field.max_length or 5000)
except ValidationError as exc: except ValidationError as exc:
errors[field.name] = 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): if not hasattr(self, image_name):
continue continue
image = getattr(self, image_name) image = getattr(self, image_name)
@@ -102,6 +102,9 @@ class SiteContent(SingleRecordContent):
website_url = models.URLField(blank=True) website_url = models.URLField(blank=True)
address = models.TextField(blank=True) address = models.TextField(blank=True)
footer_copy = models.CharField(max_length=300, 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: class Meta:
verbose_name = "Site content" verbose_name = "Site content"
@@ -110,6 +113,11 @@ class SiteContent(SingleRecordContent):
def __str__(self): def __str__(self):
return "Site content" return "Site content"
def clean(self):
super().clean()
if self.enamad_url:
validators.external_https_url(self.enamad_url)
class HomeContent(SingleRecordContent): class HomeContent(SingleRecordContent):
title = models.CharField(max_length=180, blank=True) 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 = models.ImageField(upload_to=content_upload_path, blank=True)
hero_image_alt = models.CharField(max_length=180, blank=True) hero_image_alt = models.CharField(max_length=180, blank=True)
hero_action_label = models.CharField(max_length=40, 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_heading = models.CharField(max_length=120, blank=True)
featured_events_intro = models.CharField(max_length=300, 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_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_heading = models.CharField(max_length=120, blank=True)
featured_posts_intro = models.CharField(max_length=300, 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_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_heading = models.CharField(max_length=120, blank=True)
participation_body = models.TextField(blank=True) participation_body = models.TextField(blank=True)
participation_image = models.ImageField(upload_to=content_upload_path, blank=True) participation_image = models.ImageField(upload_to=content_upload_path, blank=True)
participation_image_alt = models.CharField(max_length=180, blank=True) participation_image_alt = models.CharField(max_length=180, blank=True)
participation_action_label = models.CharField(max_length=40, 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: class Meta:
verbose_name = "Home page content" verbose_name = "Home page content"
@@ -138,6 +156,21 @@ class HomeContent(SingleRecordContent):
def __str__(self): def __str__(self):
return "Home page" 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): class AboutContent(SingleRecordContent):
title = models.CharField(max_length=180, blank=True) title = models.CharField(max_length=180, blank=True)
@@ -146,6 +179,8 @@ class AboutContent(SingleRecordContent):
identity_body = models.TextField(blank=True) identity_body = models.TextField(blank=True)
mission_heading = models.CharField(max_length=120, blank=True) mission_heading = models.CharField(max_length=120, blank=True)
mission_body = models.TextField(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_heading = models.CharField(max_length=120, blank=True)
activities_body = models.TextField(blank=True) activities_body = models.TextField(blank=True)
values_heading = models.CharField(max_length=120, blank=True) values_heading = models.CharField(max_length=120, blank=True)
@@ -156,6 +191,18 @@ class AboutContent(SingleRecordContent):
affiliations_body = models.TextField(blank=True) affiliations_body = models.TextField(blank=True)
faq_heading = models.CharField("FAQ heading", max_length=120, blank=True) faq_heading = models.CharField("FAQ heading", max_length=120, blank=True)
contact_heading = models.CharField(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: class Meta:
verbose_name = "About page content" verbose_name = "About page content"
@@ -164,6 +211,192 @@ class AboutContent(SingleRecordContent):
def __str__(self): def __str__(self):
return "About page" 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): class AboutItem(models.Model):
about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="items") about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="items")

View File

@@ -24,6 +24,9 @@ SITE_MOCK = {
"website_url": "https://example.invalid", "website_url": "https://example.invalid",
"address": "نشانی نمونه", "address": "نشانی نمونه",
"footer_copy": "متن نمونه پابرگ؛ محتوای نهایی را از پنل مدیریت وارد کنید.", "footer_copy": "متن نمونه پابرگ؛ محتوای نهایی را از پنل مدیریت وارد کنید.",
"enamad_image": "",
"enamad_image_alt": "",
"enamad_url": "",
"seo_title": "عنوان نمونه سایت", "seo_title": "عنوان نمونه سایت",
"seo_description": "توضیحات نمونه برای موتورهای جست‌وجو.", "seo_description": "توضیحات نمونه برای موتورهای جست‌وجو.",
"seo_image": "", "seo_image": "",
@@ -37,18 +40,28 @@ HOME_MOCK = {
"hero_body": "توضیحات نمونه برای معرفی انجمن و فعالیت‌های آن.", "hero_body": "توضیحات نمونه برای معرفی انجمن و فعالیت‌های آن.",
"hero_image": "", "hero_image": "",
"hero_image_alt": "", "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_heading": "رویدادهای نمونه",
"featured_events_intro": "توضیحات نمونه برای بخش رویدادها.", "featured_events_intro": "توضیحات نمونه برای بخش رویدادها.",
"featured_events_link_label": "همه رویدادها", "featured_events_link_label": "همه رویدادها",
"featured_events_url": "/events",
"featured_event_action_label": "مشاهده رویداد",
"featured_posts_heading": "نوشته‌های نمونه", "featured_posts_heading": "نوشته‌های نمونه",
"featured_posts_intro": "توضیحات نمونه برای بخش نوشته‌ها.", "featured_posts_intro": "توضیحات نمونه برای بخش نوشته‌ها.",
"featured_posts_link_label": "همه نوشته‌ها", "featured_posts_link_label": "همه نوشته‌ها",
"featured_posts_url": "/blog",
"featured_post_action_label": "خواندن مطلب",
"participation_heading": "به ما بپیوندید", "participation_heading": "به ما بپیوندید",
"participation_body": "این متن نمونه راه‌های مشارکت را توضیح می‌دهد.", "participation_body": "این متن نمونه راه‌های مشارکت را توضیح می‌دهد.",
"participation_image": "", "participation_image": "",
"participation_image_alt": "", "participation_image_alt": "",
"participation_action_label": "اطلاعات بیشتر", "participation_action_label": "اطلاعات بیشتر",
"participation_action_url": "/auth",
"participation_secondary_action_label": "درباره انجمن",
"participation_secondary_action_url": "/about",
"seo_title": "عنوان نمونه صفحه اصلی", "seo_title": "عنوان نمونه صفحه اصلی",
"seo_description": "توضیحات نمونه صفحه اصلی برای موتورهای جست‌وجو.", "seo_description": "توضیحات نمونه صفحه اصلی برای موتورهای جست‌وجو.",
"seo_image": "", "seo_image": "",
@@ -62,16 +75,26 @@ ABOUT_MOCK = {
"identity_body": "اطلاعات نمونه درباره هویت انجمن.", "identity_body": "اطلاعات نمونه درباره هویت انجمن.",
"mission_heading": "ماموریت نمونه", "mission_heading": "ماموریت نمونه",
"mission_body": "توضیحات نمونه درباره ماموریت انجمن.", "mission_body": "توضیحات نمونه درباره ماموریت انجمن.",
"vision_heading": "چشم‌انداز نمونه",
"vision_body": "جامعه‌ای برای یادگیری، تجربه‌کردن و رشد در کنار یکدیگر.",
"activities_heading": "فعالیت‌های نمونه", "activities_heading": "فعالیت‌های نمونه",
"activities_body": "توضیحات نمونه درباره فعالیت‌ها.", "activities_body": "توضیحات نمونه درباره فعالیت‌ها.",
"values_heading": "ارزش‌های نمونه", "values_heading": "ارزش‌های نمونه",
"values_body": "یادگیری، همکاری و مسئولیت‌پذیری نمونه.", "values_body": "یادگیری، همکاری و مسئولیت‌پذیری نمونه.",
"committees_heading": "کمیته‌های نمونه",
"committees_body": "اطلاعات نمونه درباره کمیته‌ها.",
"affiliations_heading": "همکاری‌های نمونه",
"affiliations_body": "اطلاعات نمونه درباره گروه‌های همکار.",
"faq_heading": "پرسش‌های نمونه", "faq_heading": "پرسش‌های نمونه",
"contact_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_title": "عنوان نمونه صفحه درباره ما",
"seo_description": "توضیحات نمونه صفحه درباره ما برای موتورهای جست‌وجو.", "seo_description": "توضیحات نمونه صفحه درباره ما برای موتورهای جست‌وجو.",
"seo_image": "", "seo_image": "",
@@ -101,8 +124,6 @@ ABOUT_ITEM_MOCKS = (
("mission", "ماموریت نمونه", "این متن نمونه برای توضیح ماموریت انجمن است."), ("mission", "ماموریت نمونه", "این متن نمونه برای توضیح ماموریت انجمن است."),
("activities", "فعالیت نمونه", "این متن نمونه یکی از فعالیت‌های انجمن را معرفی می‌کند."), ("activities", "فعالیت نمونه", "این متن نمونه یکی از فعالیت‌های انجمن را معرفی می‌کند."),
("values", "ارزش نمونه", "این متن نمونه یکی از ارزش‌های انجمن را توضیح می‌دهد."), ("values", "ارزش نمونه", "این متن نمونه یکی از ارزش‌های انجمن را توضیح می‌دهد."),
("committees", "کمیته نمونه", "این متن نمونه برای معرفی یک کمیته است."),
("affiliations", "همکاری نمونه", "این متن نمونه برای معرفی یک گروه همکار است."),
("faq", "پرسش نمونه", "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید."), ("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 home.hero_heading == "عنوان نمونه صفحه اصلی"
assert about.title == "عنوان نمونه صفحه درباره ما" 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 not about.items.filter(title="انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی").exists()
assert events.title == "عنوان نمونه رویدادها" assert events.title == "عنوان نمونه رویدادها"
assert blog.title == "عنوان نمونه بلاگ" assert blog.title == "عنوان نمونه بلاگ"

View File

@@ -80,3 +80,32 @@ def action(value):
text(value["label"], 40, required=True) text(value["label"], 40, required=True)
if not isinstance(value["route_id"], str) or value["route_id"] not in ROUTES - {"home"}: if not isinstance(value["route_id"], str) or value["route_id"] not in ROUTES - {"home"}:
raise ValidationError("Action route is not approved.") 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.")

View File

@@ -1,15 +1,15 @@
# Public content API v1 # Public content API v1
Status: approved by the owner on 2026-09-27. These anonymous `GET` endpoints expose the latest saved CMS values. Content saves are live immediately; there are no revision, publication, or preview fields or routes. Status: direct-save API approved by the owner on 2026-09-27; Home/About and footer extensions follow the owner-reviewed redesign. These anonymous `GET` endpoints expose the latest saved CMS values. Content saves are live immediately; there are no revision, publication, or preview fields or routes.
## Routes ## Routes
- `GET /api/v1/content/site` returns shared association identity, navigation labels, contacts, footer copy, logo, and SEO defaults. - `GET /api/v1/content/site` returns shared association identity, navigation labels, contacts, footer copy, logo, the optional eNAMAD image/verification URL, and SEO defaults.
- `GET /api/v1/content/pages/home` returns the fixed Home fields and SEO. - `GET /api/v1/content/pages/home` returns fixed Home fields, editable CTA labels and destinations, and SEO.
- `GET /api/v1/content/pages/about` returns the fixed About fields, SEO, and its typed text items. - `GET /api/v1/content/pages/about` returns the fixed About fields, SEO, typed FAQ items, audience cards, association history, academic-year member archives with selected past event highlights, and its separate memories gallery.
- `GET /api/v1/content/pages/events` and `/api/v1/content/pages/blog` return listing copy and SEO. Event and blog records remain owned by their existing APIs. - `GET /api/v1/content/pages/events` and `/api/v1/content/pages/blog` return listing copy and SEO. Event and blog records remain owned by their existing APIs.
Unknown page slugs and missing fixed records return `404`. Successful content responses send `Cache-Control: no-store`, so a saved admin edit is available on the next API request. Image values are either `null` or `{ "url": "absolute media URL", "alt": "description" }`. Route paths are code-owned; the API returns only the fixed route keys and editable labels. Unknown page slugs and missing fixed records return `404`. Successful content responses send `Cache-Control: no-store`, so a saved admin edit is available on the next API request. Image values are either `null` or `{ "url": "absolute media URL", "alt": "description" }`. Navigation route paths are code-owned; Home CTA destinations are editable root-relative paths or secure `https` URLs. About member photos are optional. The footer eNAMAD badge has no link until an administrator enters its verification URL.
## Shapes ## Shapes
@@ -26,8 +26,28 @@ Site response:
"contacts": { "email": "", "phone": "", "telegram": "", "instagram": "", "website": "" }, "contacts": { "email": "", "phone": "", "telegram": "", "instagram": "", "website": "" },
"address": "", "address": "",
"footer_copy": "", "footer_copy": "",
"enamad_image": null,
"enamad_url": "",
"seo": { "title": "", "description": "", "image": null } "seo": { "title": "", "description": "", "image": null }
} }
``` ```
Every page response has `slug`, `title`, `intro`, and `seo: { title, description, image }`. Home adds its fixed hero, featured event/post introductions, and participation fields. About adds its fixed identity, mission, activities, values, committee, affiliation, FAQ, and contact fields plus `items: [{ kind, title, text, position }]`. Events and Blog listing responses contain only their page copy and SEO fields. Every page response has `slug`, `title`, `intro`, and `seo: { title, description, image }`. Home adds its hero, featured event/post sections, participation fields, and labels/destinations for section and card actions. About adds its identity, mission, vision, activities, values, FAQ, history, audience, annual archive, memories gallery, and CTA fields. Its public collections have these shapes:
```json
{
"items": [{ "kind": "mission", "title": "...", "text": "...", "position": 0 }],
"members": [{ "name": "...", "role": "...", "entry_year": "...", "bio": "...", "photo": null, "position": 0 }],
"audiences": [{ "title": "...", "text": "...", "icon": "students", "position": 0 }],
"milestones": [{ "year_label": "...", "title": "...", "description": "...", "position": 0 }],
"years": [{
"year_label": "۱۴۰۴–۱۴۰۵",
"position": 0,
"members": [{ "name": "...", "role": "...", "entry_year": "۱۴۰۴–۱۴۰۵", "bio": "...", "photo": null, "position": 0 }],
"selected_events": [{ "id": 1, "title": "...", "slug": "...", "start_time": "...", "featured_image": null }]
}],
"gallery_images": [{ "image": { "url": "...", "alt": "..." }, "caption": "...", "position": 0 }]
}
```
The legacy top-level `members` list remains available for API compatibility. New year-grouped members are the source for the About archive. Only selected past events that are published or completed are included in `years[].selected_events`; each card links to the existing event detail route. The separate memories gallery remains curated through the About admin page. Legacy committee and multi-association data remains stored for compatibility but is no longer returned or rendered. Events and Blog listing responses contain their page copy and SEO fields; their event/post records remain owned by existing APIs.