diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..8518a0d --- /dev/null +++ b/.env.development.example @@ -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 diff --git a/README.md b/README.md index 248abd5..690bd94 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,54 @@ guilan-ace-backend/ manage.py ``` -## Local setup -```bash -python -m venv .venv -.venv\\Scripts\\activate -pip install -r requirements.txt -cp .env.example .env -python manage.py migrate -python manage.py runserver +## Local development on Windows (no Compose) + +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. + +Use Python 3.13 for the pinned dependencies. From this directory in PowerShell: + +```powershell +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 = '' +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 Install test dependencies, then run the two pytest suites: diff --git a/apps/content/admin.py b/apps/content/admin.py index 326086b..0022bcc 100644 --- a/apps/content/admin.py +++ b/apps/content/admin.py @@ -1,7 +1,21 @@ from django.contrib import admin +from django.utils import timezone from unfold.admin import ModelAdmin, TabularInline -from .current import AboutContent, AboutItem, HomeContent, ListingContent, SiteContent +from apps.events.models import Event + +from .current import ( + AboutAudience, + AboutContent, + AboutGalleryImage, + AboutItem, + AboutMember, + AboutMilestone, + AboutYear, + HomeContent, + ListingContent, + SiteContent, +) class FixedContentAdmin(ModelAdmin): @@ -22,6 +36,7 @@ class SiteContentAdmin(FixedContentAdmin): ("Association", {"fields": ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt")}), ("Navigation labels", {"fields": ("nav_home", "nav_about", "nav_events", "nav_blog")}), ("Contact and footer", {"fields": ("email", "phone", "telegram_url", "instagram_url", "website_url", "address", "footer_copy")}), + ("eNAMAD certificate", {"fields": ("enamad_image", "enamad_image_alt", "enamad_url"), "description": "The badge stays unlinked until you add its verification URL."}), ("SEO defaults", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}), ) @@ -30,10 +45,21 @@ class SiteContentAdmin(FixedContentAdmin): class HomeContentAdmin(FixedContentAdmin): fieldsets = ( ("Page introduction", {"fields": ("title", "intro")}), - ("Hero", {"fields": ("hero_heading", "hero_body", "hero_image", "hero_image_alt", "hero_action_label")}), - ("Featured events", {"fields": ("featured_events_heading", "featured_events_intro", "featured_events_link_label")}), - ("Featured blog posts", {"fields": ("featured_posts_heading", "featured_posts_intro", "featured_posts_link_label")}), - ("Participation", {"fields": ("participation_heading", "participation_body", "participation_image", "participation_image_alt", "participation_action_label")}), + ("Hero", {"fields": ( + "hero_heading", "hero_body", "hero_image", "hero_image_alt", + "hero_action_label", "hero_action_url", "hero_secondary_action_label", "hero_secondary_action_url", + ), "description": "Destinations accept an internal path such as /events or a secure https URL."}), + ("Featured events", {"fields": ( + "featured_events_heading", "featured_events_intro", "featured_events_link_label", "featured_events_url", "featured_event_action_label", + ), "description": "Destinations accept an internal path such as /events or a secure https URL."}), + ("Featured blog posts", {"fields": ( + "featured_posts_heading", "featured_posts_intro", "featured_posts_link_label", "featured_posts_url", "featured_post_action_label", + ), "description": "Destinations accept an internal path such as /blog or a secure https URL."}), + ("Participation", {"fields": ( + "participation_heading", "participation_body", "participation_image", "participation_image_alt", + "participation_action_label", "participation_action_url", + "participation_secondary_action_label", "participation_secondary_action_url", + ), "description": "Destinations accept an internal path such as /auth or a secure https URL."}), ("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}), ) @@ -43,6 +69,100 @@ class AboutItemInline(TabularInline): extra = 0 fields = ("kind", "title", "text", "position") + def get_queryset(self, request): + return super().get_queryset(request).filter(kind="faq") + + def formfield_for_choice_field(self, db_field, request, **kwargs): + field = super().formfield_for_choice_field(db_field, request, **kwargs) + if db_field.name == "kind": + field.choices = tuple(choice for choice in field.choices if choice[0] == "faq") + return field + + def has_add_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_change_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_delete_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + +class AboutAudienceInline(TabularInline): + model = AboutAudience + extra = 0 + fields = ("title", "text", "icon", "position") + ordering = ("position",) + + def has_add_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_change_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_delete_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + +class AboutMilestoneInline(TabularInline): + model = AboutMilestone + extra = 0 + fields = ("year_label", "title", "description", "position") + ordering = ("position",) + + def has_add_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_change_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_delete_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + +class AboutMemberInline(TabularInline): + model = AboutMember + fk_name = "year_group" + extra = 0 + fields = ("name", "role", "bio", "photo", "photo_alt", "position") + ordering = ("position",) + + def has_add_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_change_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_delete_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + +class LegacyAboutMemberInline(TabularInline): + model = AboutMember + fk_name = "about" + extra = 0 + fields = ("name", "role", "entry_year", "bio", "photo", "photo_alt", "position") + ordering = ("entry_year", "position") + + def get_queryset(self, request): + return super().get_queryset(request).filter(year_group__isnull=True) + + def has_add_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_change_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + def has_delete_permission(self, request, obj=None): + return request.user.has_perm("content.change_aboutcontent") + + +class AboutGalleryInline(TabularInline): + model = AboutGalleryImage + extra = 0 + fields = ("image", "image_alt", "caption", "position") + ordering = ("position",) + def has_add_permission(self, request, obj=None): return request.user.has_perm("content.change_aboutcontent") @@ -55,19 +175,76 @@ class AboutItemInline(TabularInline): @admin.register(AboutContent) class AboutContentAdmin(FixedContentAdmin): - inlines = (AboutItemInline,) + inlines = ( + AboutItemInline, + AboutAudienceInline, + AboutMilestoneInline, + LegacyAboutMemberInline, + AboutGalleryInline, + ) fieldsets = ( ("Page introduction", {"fields": ("title", "intro", "identity_heading", "identity_body")}), - ("Mission", {"fields": ("mission_heading", "mission_body")}), + ("Mission, vision, and values", {"fields": ( + "mission_heading", "mission_body", "vision_heading", "vision_body", "values_heading", "values_body", + )}), ("Activities", {"fields": ("activities_heading", "activities_body")}), - ("Values", {"fields": ("values_heading", "values_body")}), - ("Committees", {"fields": ("committees_heading", "committees_body")}), - ("Affiliations", {"fields": ("affiliations_heading", "affiliations_body")}), - ("FAQ and contact", {"fields": ("faq_heading", "contact_heading")}), + ("Association history", {"fields": ("history_heading",)}), + ("Who the association serves", {"fields": ("audience_heading",)}), + ("Annual archive", {"fields": ("members_heading", "members_intro", "year_events_heading")}), + ("Memories gallery", {"fields": ("gallery_heading", "gallery_intro")}), + ("FAQ and call to action", {"fields": ( + "faq_heading", "cta_heading", "cta_body", "cta_action_label", "cta_action_url", + ), "description": "The CTA destination stays empty until you set an internal path or secure https URL."}), ("SEO", {"fields": ("seo_title", "seo_description", "seo_image", "seo_image_alt")}), ) +@admin.register(AboutYear) +class AboutYearAdmin(ModelAdmin): + list_display = ("year_label", "position") + list_editable = ("position",) + ordering = ("position", "pk") + search_fields = ("year_label",) + fieldsets = ( + ("Academic year", {"fields": ("year_label", "position", "events")}), + ) + filter_horizontal = ("events",) + inlines = (AboutMemberInline,) + + def has_module_permission(self, request): + return request.user.has_perm("content.change_aboutcontent") + + def has_view_permission(self, request, obj=None): + return self.has_module_permission(request) + + def has_add_permission(self, request): + return self.has_module_permission(request) + + def has_change_permission(self, request, obj=None): + return self.has_module_permission(request) + + def has_delete_permission(self, request, obj=None): + return self.has_module_permission(request) + + def get_queryset(self, request): + return super().get_queryset(request).filter(about_id=1) + + def save_model(self, request, obj, form, change): + if not obj.about_id: + obj.about = AboutContent.objects.get(pk=1) + super().save_model(request, obj, form, change) + + def formfield_for_manytomany(self, db_field, request, **kwargs): + field = super().formfield_for_manytomany(db_field, request, **kwargs) + if db_field.name == "events": + field.queryset = Event.objects.filter( + is_deleted=False, + status__in=(Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED), + start_time__lte=timezone.now(), + ).order_by("-start_time", "title") + return field + + @admin.register(ListingContent) class ListingContentAdmin(FixedContentAdmin): list_display = ("slug", "title") diff --git a/apps/content/api/schemas.py b/apps/content/api/schemas.py index 5ce3d15..17cb4a7 100644 --- a/apps/content/api/schemas.py +++ b/apps/content/api/schemas.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Literal, Optional, Union from ninja import Schema @@ -24,6 +25,8 @@ class SiteContentOut(Schema): contacts: dict[str, str] address: str footer_copy: str + enamad_image: Optional[MediaOut] = None + enamad_url: str seo: SeoOut @@ -40,16 +43,26 @@ class HomePageOut(PageBaseOut): hero_body: str hero_image: Optional[MediaOut] = None hero_action_label: str + hero_action_url: str + hero_secondary_action_label: str + hero_secondary_action_url: str featured_events_heading: str featured_events_intro: str featured_events_link_label: str + featured_events_url: str + featured_event_action_label: str featured_posts_heading: str featured_posts_intro: str featured_posts_link_label: str + featured_posts_url: str + featured_post_action_label: str participation_heading: str participation_body: str participation_image: Optional[MediaOut] = None participation_action_label: str + participation_action_url: str + participation_secondary_action_label: str + participation_secondary_action_url: str class AboutItemOut(Schema): @@ -59,23 +72,82 @@ class AboutItemOut(Schema): position: int +class AboutMemberOut(Schema): + name: str + role: str + entry_year: str + bio: str + photo: Optional[MediaOut] = None + position: int + + +class AboutGalleryImageOut(Schema): + image: MediaOut + caption: str + position: int + + +class AboutAudienceOut(Schema): + title: str + text: str + icon: str + position: int + + +class AboutMilestoneOut(Schema): + year_label: str + title: str + description: str + position: int + + +class AboutSelectedEventOut(Schema): + id: int + title: str + slug: str + start_time: datetime + featured_image: Optional[MediaOut] = None + + +class AboutYearOut(Schema): + year_label: str + position: int + members: list[AboutMemberOut] + selected_events: list[AboutSelectedEventOut] + + class AboutPageOut(PageBaseOut): slug: Literal["about"] identity_heading: str identity_body: str mission_heading: str mission_body: str + vision_heading: str + vision_body: str activities_heading: str activities_body: str values_heading: str values_body: str - committees_heading: str - committees_body: str - affiliations_heading: str - affiliations_body: str faq_heading: str contact_heading: str + members_heading: str + members_intro: str + gallery_heading: str + gallery_intro: str + past_events_heading: str + history_heading: str + audience_heading: str + year_events_heading: str + cta_heading: str + cta_body: str + cta_action_label: str + cta_action_url: str items: list[AboutItemOut] + members: list[AboutMemberOut] + gallery_images: list[AboutGalleryImageOut] + audiences: list[AboutAudienceOut] + milestones: list[AboutMilestoneOut] + years: list[AboutYearOut] class ListingPageOut(PageBaseOut): diff --git a/apps/content/api/views.py b/apps/content/api/views.py index fae03e8..f494ace 100644 --- a/apps/content/api/views.py +++ b/apps/content/api/views.py @@ -1,7 +1,9 @@ from django.http import HttpResponse +from django.utils import timezone from ninja import Router from ninja.errors import HttpError +from apps.events.models import Event from apps.content.current import AboutContent, HomeContent, ListingContent, SiteContent from apps.content.api.schemas import AboutPageOut, HomePageOut, ListingPageOut, PageContentOut, SiteContentOut @@ -23,6 +25,27 @@ def _seo(request, obj): } +def _about_member_out(request, member, year_label=None): + return { + "name": member.name, + "role": member.role, + "entry_year": year_label or member.entry_year, + "bio": member.bio, + "photo": _media(request, member.photo, member.photo_alt), + "position": member.position, + } + + +def _about_event_out(request, event): + return { + "id": event.pk, + "title": event.title, + "slug": event.slug, + "start_time": event.start_time, + "featured_image": _media(request, event.featured_image, f"پوستر {event.title}"), + } + + @content_router.get("/site", response=SiteContentOut) def get_site_content(request, response: HttpResponse): site = SiteContent.objects.filter(pk=1).first() @@ -50,6 +73,8 @@ def get_site_content(request, response: HttpResponse): }, "address": site.address, "footer_copy": site.footer_copy, + "enamad_image": _media(request, site.enamad_image, site.enamad_image_alt), + "enamad_url": site.enamad_url, "seo": _seo(request, site), } @@ -66,32 +91,105 @@ def get_page_content(request, response: HttpResponse, slug: str): "hero_heading": page.hero_heading, "hero_body": page.hero_body, "hero_image": _media(request, page.hero_image, page.hero_image_alt), "hero_action_label": page.hero_action_label, + "hero_action_url": page.hero_action_url, + "hero_secondary_action_label": page.hero_secondary_action_label, + "hero_secondary_action_url": page.hero_secondary_action_url, "featured_events_heading": page.featured_events_heading, "featured_events_intro": page.featured_events_intro, "featured_events_link_label": page.featured_events_link_label, + "featured_events_url": page.featured_events_url, + "featured_event_action_label": page.featured_event_action_label, "featured_posts_heading": page.featured_posts_heading, "featured_posts_intro": page.featured_posts_intro, "featured_posts_link_label": page.featured_posts_link_label, + "featured_posts_url": page.featured_posts_url, + "featured_post_action_label": page.featured_post_action_label, "participation_heading": page.participation_heading, "participation_body": page.participation_body, "participation_image": _media(request, page.participation_image, page.participation_image_alt), "participation_action_label": page.participation_action_label, + "participation_action_url": page.participation_action_url, + "participation_secondary_action_label": page.participation_secondary_action_label, + "participation_secondary_action_url": page.participation_secondary_action_url, } if slug == "about": page = AboutContent.objects.filter(pk=1).first() if page is None: raise HttpError(404, "Page content not found") + event_statuses = (Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED) + years_by_label = {} + for year in page.years.prefetch_related("members", "events").all(): + label = year.year_label.strip() or "سال نامشخص" + events = year.events.filter( + is_deleted=False, + status__in=event_statuses, + start_time__lte=timezone.now(), + ).order_by("-start_time", "-pk") + years_by_label[label] = { + "year_label": label, + "position": year.position, + "_pk": year.pk, + "members": [ + _about_member_out(request, member, label) + for member in year.members.all() + ], + "selected_events": [_about_event_out(request, event) for event in events], + } + + legacy_members = page.members.filter(year_group__isnull=True).order_by("entry_year", "position", "pk") + for member in legacy_members: + label = member.entry_year.strip() or "سال نامشخص" + year_data = years_by_label.setdefault( + label, + { + "year_label": label, + "position": 0, + "_pk": 0, + "members": [], + "selected_events": [], + }, + ) + year_data["members"].append(_about_member_out(request, member, label)) + + years = sorted( + years_by_label.values(), + key=lambda year: (year["position"], year["_pk"], year["year_label"]), + ) + all_members = [member for year in years for member in year["members"]] return { "slug": "about", "title": page.title, "intro": page.intro, "seo": _seo(request, page), "identity_heading": page.identity_heading, "identity_body": page.identity_body, "mission_heading": page.mission_heading, "mission_body": page.mission_body, + "vision_heading": page.vision_heading, "vision_body": page.vision_body, "activities_heading": page.activities_heading, "activities_body": page.activities_body, "values_heading": page.values_heading, "values_body": page.values_body, - "committees_heading": page.committees_heading, "committees_body": page.committees_body, - "affiliations_heading": page.affiliations_heading, "affiliations_body": page.affiliations_body, "faq_heading": page.faq_heading, "contact_heading": page.contact_heading, - "items": list(page.items.values("kind", "title", "text", "position")), + "members_heading": page.members_heading, "members_intro": page.members_intro, + "gallery_heading": page.gallery_heading, "gallery_intro": page.gallery_intro, + "past_events_heading": page.past_events_heading, + "history_heading": page.history_heading, + "audience_heading": page.audience_heading, + "year_events_heading": page.year_events_heading, + "cta_heading": page.cta_heading, + "cta_body": page.cta_body, + "cta_action_label": page.cta_action_label, + "cta_action_url": page.cta_action_url, + "items": list(page.items.exclude(kind__in=("committees", "affiliations")).values("kind", "title", "text", "position")), + "members": all_members, + "gallery_images": [ + { + "image": _media(request, item.image, item.image_alt), + "caption": item.caption, "position": item.position, + } + for item in page.gallery_images.all() + ], + "audiences": list(page.audiences.values("title", "text", "icon", "position")), + "milestones": list(page.milestones.values("year_label", "title", "description", "position")), + "years": [ + {key: value for key, value in year.items() if not key.startswith("_")} + for year in years + ], } if slug in ("events", "blog"): diff --git a/apps/content/current.py b/apps/content/current.py index b18eabd..fa0c458 100644 --- a/apps/content/current.py +++ b/apps/content/current.py @@ -56,7 +56,7 @@ class EditableContent(models.Model): validators.text(getattr(self, field.name), field.max_length or 5000) except ValidationError as exc: errors[field.name] = exc - for image_name in ("seo_image", "logo", "hero_image", "participation_image"): + for image_name in ("seo_image", "logo", "hero_image", "participation_image", "enamad_image"): if not hasattr(self, image_name): continue image = getattr(self, image_name) @@ -102,6 +102,9 @@ class SiteContent(SingleRecordContent): website_url = models.URLField(blank=True) address = models.TextField(blank=True) footer_copy = models.CharField(max_length=300, blank=True) + enamad_image = models.ImageField("eNAMAD certificate image", upload_to=content_upload_path, blank=True) + enamad_image_alt = models.CharField("eNAMAD image description", max_length=180, blank=True) + enamad_url = models.URLField("eNAMAD verification URL", blank=True) class Meta: verbose_name = "Site content" @@ -110,6 +113,11 @@ class SiteContent(SingleRecordContent): def __str__(self): return "Site content" + def clean(self): + super().clean() + if self.enamad_url: + validators.external_https_url(self.enamad_url) + class HomeContent(SingleRecordContent): title = models.CharField(max_length=180, blank=True) @@ -119,17 +127,27 @@ class HomeContent(SingleRecordContent): hero_image = models.ImageField(upload_to=content_upload_path, blank=True) hero_image_alt = models.CharField(max_length=180, blank=True) hero_action_label = models.CharField(max_length=40, blank=True) + hero_action_url = models.CharField(max_length=500, blank=True) + hero_secondary_action_label = models.CharField(max_length=40, blank=True) + hero_secondary_action_url = models.CharField(max_length=500, blank=True) featured_events_heading = models.CharField(max_length=120, blank=True) featured_events_intro = models.CharField(max_length=300, blank=True) featured_events_link_label = models.CharField(max_length=40, blank=True) + featured_events_url = models.CharField(max_length=500, blank=True) + featured_event_action_label = models.CharField(max_length=40, blank=True) featured_posts_heading = models.CharField(max_length=120, blank=True) featured_posts_intro = models.CharField(max_length=300, blank=True) featured_posts_link_label = models.CharField(max_length=40, blank=True) + featured_posts_url = models.CharField(max_length=500, blank=True) + featured_post_action_label = models.CharField(max_length=40, blank=True) participation_heading = models.CharField(max_length=120, blank=True) participation_body = models.TextField(blank=True) participation_image = models.ImageField(upload_to=content_upload_path, blank=True) participation_image_alt = models.CharField(max_length=180, blank=True) participation_action_label = models.CharField(max_length=40, blank=True) + participation_action_url = models.CharField(max_length=500, blank=True) + participation_secondary_action_label = models.CharField(max_length=40, blank=True) + participation_secondary_action_url = models.CharField(max_length=500, blank=True) class Meta: verbose_name = "Home page content" @@ -138,6 +156,21 @@ class HomeContent(SingleRecordContent): def __str__(self): return "Home page" + def clean(self): + super().clean() + errors = {} + for name in ( + "hero_action_url", "hero_secondary_action_url", + "featured_events_url", "featured_posts_url", + "participation_action_url", "participation_secondary_action_url", + ): + try: + validators.action_url(getattr(self, name)) + except ValidationError as exc: + errors[name] = exc + if errors: + raise ValidationError(errors) + class AboutContent(SingleRecordContent): title = models.CharField(max_length=180, blank=True) @@ -146,6 +179,8 @@ class AboutContent(SingleRecordContent): identity_body = models.TextField(blank=True) mission_heading = models.CharField(max_length=120, blank=True) mission_body = models.TextField(blank=True) + vision_heading = models.CharField(max_length=120, blank=True) + vision_body = models.TextField(blank=True) activities_heading = models.CharField(max_length=120, blank=True) activities_body = models.TextField(blank=True) values_heading = models.CharField(max_length=120, blank=True) @@ -156,6 +191,18 @@ class AboutContent(SingleRecordContent): affiliations_body = models.TextField(blank=True) faq_heading = models.CharField("FAQ heading", max_length=120, blank=True) contact_heading = models.CharField(max_length=120, blank=True) + members_heading = models.CharField(max_length=120, blank=True) + members_intro = models.CharField(max_length=300, blank=True) + gallery_heading = models.CharField(max_length=120, blank=True) + gallery_intro = models.CharField(max_length=300, blank=True) + past_events_heading = models.CharField(max_length=120, blank=True) + history_heading = models.CharField(max_length=120, blank=True) + audience_heading = models.CharField(max_length=120, blank=True) + year_events_heading = models.CharField(max_length=120, blank=True) + cta_heading = models.CharField(max_length=120, blank=True) + cta_body = models.CharField(max_length=300, blank=True) + cta_action_label = models.CharField(max_length=40, blank=True) + cta_action_url = models.CharField(max_length=500, blank=True) class Meta: verbose_name = "About page content" @@ -164,6 +211,192 @@ class AboutContent(SingleRecordContent): def __str__(self): return "About page" + def clean(self): + super().clean() + validators.action_url(self.cta_action_url) + + +class AboutYear(models.Model): + about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="years") + year_label = models.CharField("Academic year", max_length=32) + position = models.PositiveSmallIntegerField( + default=0, + help_text="Order years from oldest to newest; the last year is selected by default on the page.", + ) + events = models.ManyToManyField( + "events.Event", + blank=True, + related_name="about_year_highlights", + help_text="Choose past, published or completed events. Draft, cancelled, and upcoming events are not shown.", + ) + + class Meta: + ordering = ["position", "pk"] + constraints = [ + models.UniqueConstraint(fields=("about", "year_label"), name="content_about_year_label_unique"), + ] + verbose_name = "Association year" + verbose_name_plural = "Association years" + + def __str__(self): + return self.year_label + + def clean(self): + super().clean() + validators.text(self.year_label, 32, required=True) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.members.exclude(about_id=self.about_id, entry_year=self.year_label).update( + about_id=self.about_id, + entry_year=self.year_label, + ) + + +class AboutMember(models.Model): + about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="members") + entry_year = models.CharField("Academic year", max_length=32) + year_group = models.ForeignKey( + AboutYear, + on_delete=models.CASCADE, + related_name="members", + blank=True, + null=True, + ) + name = models.CharField(max_length=120) + role = models.CharField(max_length=120) + bio = models.CharField(max_length=500, blank=True) + photo = models.ImageField(upload_to=content_upload_path, blank=True) + photo_alt = models.CharField(max_length=180, blank=True) + position = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ["position", "pk"] + verbose_name = "Association member for this year" + verbose_name_plural = "Association members for this year" + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if self.year_group_id: + self.about_id = self.year_group.about_id + self.entry_year = self.year_group.year_label + super().save(*args, **kwargs) + + def clean(self): + super().clean() + errors = {} + for name, limit in (("name", 120), ("role", 120), ("entry_year", 32), ("bio", 500), ("photo_alt", 180)): + try: + required = name in {"name", "role"} or (name == "entry_year" and not self.year_group_id) + validators.text(getattr(self, name), limit, required=required) + except ValidationError as exc: + errors[name] = exc + if self.year_group_id and self.about_id and self.year_group.about_id != self.about_id: + errors["year_group"] = "Select an academic year from the same About page." + if self.photo and not self.photo_alt.strip(): + errors["photo_alt"] = "Describe the image for screen readers." + if not self.photo and self.photo_alt: + errors["photo_alt"] = "Remove the description or upload an image." + try: + check_image(self.photo) + except ValidationError as exc: + errors["photo"] = exc + if errors: + raise ValidationError(errors) + + +class AboutAudience(models.Model): + class IconChoices(models.TextChoices): + STUDENTS = "students", "Students" + ALUMNI = "alumni", "Alumni" + FACULTY = "faculty", "Faculty and researchers" + PARTNERS = "partners", "Industry and community partners" + + about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="audiences") + title = models.CharField(max_length=120) + text = models.CharField(max_length=300) + icon = models.CharField(max_length=20, choices=IconChoices.choices, default=IconChoices.STUDENTS) + position = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ["position", "pk"] + verbose_name = "About page audience" + verbose_name_plural = "About page audiences" + + def __str__(self): + return self.title + + def clean(self): + super().clean() + errors = {} + for name, limit in (("title", 120), ("text", 300)): + try: + validators.text(getattr(self, name), limit, required=True) + except ValidationError as exc: + errors[name] = exc + if errors: + raise ValidationError(errors) + + +class AboutMilestone(models.Model): + about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="milestones") + year_label = models.CharField("Year", max_length=32) + title = models.CharField(max_length=120) + description = models.CharField(max_length=500) + position = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ["position", "pk"] + verbose_name = "Association history milestone" + verbose_name_plural = "Association history milestones" + + def __str__(self): + return f"{self.year_label}: {self.title}" + + def clean(self): + super().clean() + errors = {} + for name, limit in (("year_label", 32), ("title", 120), ("description", 500)): + try: + validators.text(getattr(self, name), limit, required=True) + except ValidationError as exc: + errors[name] = exc + if errors: + raise ValidationError(errors) + + +class AboutGalleryImage(models.Model): + about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="gallery_images") + image = models.ImageField(upload_to=content_upload_path) + image_alt = models.CharField(max_length=180) + caption = models.CharField(max_length=180, blank=True) + position = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ["position", "pk"] + verbose_name = "About page gallery image" + verbose_name_plural = "About page gallery images" + + def __str__(self): + return self.caption or self.image_alt + + def clean(self): + super().clean() + errors = {} + for name, limit, required in (("image_alt", 180, True), ("caption", 180, False)): + try: + validators.text(getattr(self, name), limit, required=required) + except ValidationError as exc: + errors[name] = exc + try: + check_image(self.image) + except ValidationError as exc: + errors["image"] = exc + if errors: + raise ValidationError(errors) + class AboutItem(models.Model): about = models.ForeignKey(AboutContent, on_delete=models.CASCADE, related_name="items") diff --git a/apps/content/management/commands/seed_mock_content.py b/apps/content/management/commands/seed_mock_content.py index 88417e6..c531302 100644 --- a/apps/content/management/commands/seed_mock_content.py +++ b/apps/content/management/commands/seed_mock_content.py @@ -24,6 +24,9 @@ SITE_MOCK = { "website_url": "https://example.invalid", "address": "نشانی نمونه", "footer_copy": "متن نمونه پابرگ؛ محتوای نهایی را از پنل مدیریت وارد کنید.", + "enamad_image": "", + "enamad_image_alt": "", + "enamad_url": "", "seo_title": "عنوان نمونه سایت", "seo_description": "توضیحات نمونه برای موتورهای جست‌وجو.", "seo_image": "", @@ -37,18 +40,28 @@ HOME_MOCK = { "hero_body": "توضیحات نمونه برای معرفی انجمن و فعالیت‌های آن.", "hero_image": "", "hero_image_alt": "", - "hero_action_label": "مشاهده نمونه‌ها", + "hero_action_label": "مشاهده رویدادها", + "hero_action_url": "/events", + "hero_secondary_action_label": "مطالب بلاگ", + "hero_secondary_action_url": "/blog", "featured_events_heading": "رویدادهای نمونه", "featured_events_intro": "توضیحات نمونه برای بخش رویدادها.", "featured_events_link_label": "همه رویدادها", + "featured_events_url": "/events", + "featured_event_action_label": "مشاهده رویداد", "featured_posts_heading": "نوشته‌های نمونه", "featured_posts_intro": "توضیحات نمونه برای بخش نوشته‌ها.", "featured_posts_link_label": "همه نوشته‌ها", + "featured_posts_url": "/blog", + "featured_post_action_label": "خواندن مطلب", "participation_heading": "به ما بپیوندید", "participation_body": "این متن نمونه راه‌های مشارکت را توضیح می‌دهد.", "participation_image": "", "participation_image_alt": "", "participation_action_label": "اطلاعات بیشتر", + "participation_action_url": "/auth", + "participation_secondary_action_label": "درباره انجمن", + "participation_secondary_action_url": "/about", "seo_title": "عنوان نمونه صفحه اصلی", "seo_description": "توضیحات نمونه صفحه اصلی برای موتورهای جست‌وجو.", "seo_image": "", @@ -62,16 +75,26 @@ ABOUT_MOCK = { "identity_body": "اطلاعات نمونه درباره هویت انجمن.", "mission_heading": "ماموریت نمونه", "mission_body": "توضیحات نمونه درباره ماموریت انجمن.", + "vision_heading": "چشم‌انداز نمونه", + "vision_body": "جامعه‌ای برای یادگیری، تجربه‌کردن و رشد در کنار یکدیگر.", "activities_heading": "فعالیت‌های نمونه", "activities_body": "توضیحات نمونه درباره فعالیت‌ها.", "values_heading": "ارزش‌های نمونه", "values_body": "یادگیری، همکاری و مسئولیت‌پذیری نمونه.", - "committees_heading": "کمیته‌های نمونه", - "committees_body": "اطلاعات نمونه درباره کمیته‌ها.", - "affiliations_heading": "همکاری‌های نمونه", - "affiliations_body": "اطلاعات نمونه درباره گروه‌های همکار.", "faq_heading": "پرسش‌های نمونه", "contact_heading": "راه‌های ارتباطی نمونه", + "members_heading": "اعضای انجمن", + "members_intro": "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.", + "gallery_heading": "خاطرات انجمن", + "gallery_intro": "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.", + "past_events_heading": "رویدادهای برگزارشده", + "history_heading": "مسیر انجمن", + "audience_heading": "برای چه کسانی؟", + "year_events_heading": "لحظه‌های هر سال", + "cta_heading": "به جمع ما بپیوندید", + "cta_body": "اگر دوست دارید یاد بگیرید و در ساختن تجربه‌های تازه سهیم شوید، با ما همراه باشید.", + "cta_action_label": "راه‌های پیوستن", + "cta_action_url": "", "seo_title": "عنوان نمونه صفحه درباره ما", "seo_description": "توضیحات نمونه صفحه درباره ما برای موتورهای جست‌وجو.", "seo_image": "", @@ -101,8 +124,6 @@ ABOUT_ITEM_MOCKS = ( ("mission", "ماموریت نمونه", "این متن نمونه برای توضیح ماموریت انجمن است."), ("activities", "فعالیت نمونه", "این متن نمونه یکی از فعالیت‌های انجمن را معرفی می‌کند."), ("values", "ارزش نمونه", "این متن نمونه یکی از ارزش‌های انجمن را توضیح می‌دهد."), - ("committees", "کمیته نمونه", "این متن نمونه برای معرفی یک کمیته است."), - ("affiliations", "همکاری نمونه", "این متن نمونه برای معرفی یک گروه همکار است."), ("faq", "پرسش نمونه", "پاسخ نمونه؛ اطلاعات نهایی را از پنل مدیریت وارد کنید."), ) diff --git a/apps/content/migrations/0006_home_actions_about_members_gallery_enamad.py b/apps/content/migrations/0006_home_actions_about_members_gallery_enamad.py new file mode 100644 index 0000000..4486498 --- /dev/null +++ b/apps/content/migrations/0006_home_actions_about_members_gallery_enamad.py @@ -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"], + }, + ), + ] diff --git a/apps/content/migrations/0007_about_story_archive.py b/apps/content/migrations/0007_about_story_archive.py new file mode 100644 index 0000000..ec8c375 --- /dev/null +++ b/apps/content/migrations/0007_about_story_archive.py @@ -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"), + ), + ] diff --git a/apps/content/tests/integration/test_seed_mock_content_command.py b/apps/content/tests/integration/test_seed_mock_content_command.py index 85650b8..fedfb17 100644 --- a/apps/content/tests/integration/test_seed_mock_content_command.py +++ b/apps/content/tests/integration/test_seed_mock_content_command.py @@ -22,7 +22,9 @@ def test_command_replaces_unedited_migration_copy_with_mock_content_idempotently assert home.hero_heading == "عنوان نمونه صفحه اصلی" assert about.title == "عنوان نمونه صفحه درباره ما" - assert about.items.filter(title="کمیته نمونه").exists() + assert about.items.filter(kind="mission").exists() + assert about.items.filter(kind="faq").exists() + assert not about.items.filter(kind__in=["committees", "affiliations"]).exists() assert not about.items.filter(title="انجمن علمی مهندسی کامپیوتر دانشکده‌ی فنی").exists() assert events.title == "عنوان نمونه رویدادها" assert blog.title == "عنوان نمونه بلاگ" diff --git a/apps/content/validators.py b/apps/content/validators.py index 2bf9853..af2c49c 100644 --- a/apps/content/validators.py +++ b/apps/content/validators.py @@ -80,3 +80,32 @@ def action(value): text(value["label"], 40, required=True) if not isinstance(value["route_id"], str) or value["route_id"] not in ROUTES - {"home"}: raise ValidationError("Action route is not approved.") + + +def action_url(value): + if not value: + return + if not isinstance(value, str) or value != value.strip() or "\\" in value or any(ord(char) < 32 for char in value): + raise ValidationError("Use a root-relative path or a secure https URL.") + try: + parsed = urlsplit(value) + except ValueError as exc: + raise ValidationError("Invalid action URL.") from exc + if value.startswith("/"): + if value.startswith("//") or parsed.scheme or parsed.netloc: + raise ValidationError("Internal links must be root-relative paths.") + return + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: + raise ValidationError("External action URLs must use https and cannot include credentials.") + external_https_url(value) + + +def external_https_url(value): + try: + parsed = urlsplit(value) + hostname = parsed.hostname + parsed.port + except (AttributeError, ValueError) as exc: + raise ValidationError("Enter a valid https URL without credentials.") from exc + if parsed.scheme != "https" or not hostname or parsed.username or parsed.password: + raise ValidationError("Enter a valid https URL without credentials.") diff --git a/docs/content-api-v1.md b/docs/content-api-v1.md index bd58a66..6e4d297 100644 --- a/docs/content-api-v1.md +++ b/docs/content-api-v1.md @@ -1,15 +1,15 @@ # 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 -- `GET /api/v1/content/site` returns shared association identity, navigation labels, contacts, footer copy, logo, and SEO defaults. -- `GET /api/v1/content/pages/home` returns the fixed Home fields and SEO. -- `GET /api/v1/content/pages/about` returns the fixed About fields, SEO, and its typed text items. +- `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 fixed Home fields, editable CTA labels and destinations, and SEO. +- `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. -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 @@ -26,8 +26,28 @@ Site response: "contacts": { "email": "", "phone": "", "telegram": "", "instagram": "", "website": "" }, "address": "", "footer_copy": "", + "enamad_image": null, + "enamad_url": "", "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.