feat: add direct-save CMS and About archive
This commit is contained in:
35
.env.development.example
Normal file
35
.env.development.example
Normal 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
|
||||||
54
README.md
54
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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", "پرسش نمونه", "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 == "عنوان نمونه بلاگ"
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user