From 149497d276343be9e36ffea0b985bb74cbedf4c3 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 27 Sep 2026 11:40:11 +0330 Subject: [PATCH] Add direct-save public content API --- apps/content/api/__init__.py | 1 + apps/content/api/schemas.py | 85 +++++++++++++++++ apps/content/api/views.py | 103 +++++++++++++++++++++ apps/content/tests/integration/test_api.py | 56 +++++++++++ config/api.py | 2 + docs/content-api-v1.md | 33 +++++++ 6 files changed, 280 insertions(+) create mode 100644 apps/content/api/__init__.py create mode 100644 apps/content/api/schemas.py create mode 100644 apps/content/api/views.py create mode 100644 apps/content/tests/integration/test_api.py create mode 100644 docs/content-api-v1.md diff --git a/apps/content/api/__init__.py b/apps/content/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/content/api/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/content/api/schemas.py b/apps/content/api/schemas.py new file mode 100644 index 0000000..5ce3d15 --- /dev/null +++ b/apps/content/api/schemas.py @@ -0,0 +1,85 @@ +from typing import Literal, Optional, Union + +from ninja import Schema + + +class MediaOut(Schema): + url: str + alt: str + + +class SeoOut(Schema): + title: str + description: str + image: Optional[MediaOut] = None + + +class SiteContentOut(Schema): + official_name: str + short_name: str + faculty_subline: str + description: str + logo: Optional[MediaOut] = None + navigation: dict[str, str] + contacts: dict[str, str] + address: str + footer_copy: str + seo: SeoOut + + +class PageBaseOut(Schema): + slug: str + title: str + intro: str + seo: SeoOut + + +class HomePageOut(PageBaseOut): + slug: Literal["home"] + hero_heading: str + hero_body: str + hero_image: Optional[MediaOut] = None + hero_action_label: str + featured_events_heading: str + featured_events_intro: str + featured_events_link_label: str + featured_posts_heading: str + featured_posts_intro: str + featured_posts_link_label: str + participation_heading: str + participation_body: str + participation_image: Optional[MediaOut] = None + participation_action_label: str + + +class AboutItemOut(Schema): + kind: str + title: str + text: str + position: int + + +class AboutPageOut(PageBaseOut): + slug: Literal["about"] + identity_heading: str + identity_body: str + mission_heading: str + mission_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 + items: list[AboutItemOut] + + +class ListingPageOut(PageBaseOut): + slug: Literal["events", "blog"] + + +PageContentOut = Union[HomePageOut, AboutPageOut, ListingPageOut] diff --git a/apps/content/api/views.py b/apps/content/api/views.py new file mode 100644 index 0000000..fae03e8 --- /dev/null +++ b/apps/content/api/views.py @@ -0,0 +1,103 @@ +from django.http import HttpResponse +from ninja import Router +from ninja.errors import HttpError + +from apps.content.current import AboutContent, HomeContent, ListingContent, SiteContent +from apps.content.api.schemas import AboutPageOut, HomePageOut, ListingPageOut, PageContentOut, SiteContentOut + + +content_router = Router(tags=["Public content"]) + + +def _media(request, field, alt): + if not field or not field.name: + return None + return {"url": request.build_absolute_uri(field.url), "alt": alt or ""} + + +def _seo(request, obj): + return { + "title": obj.seo_title, + "description": obj.seo_description, + "image": _media(request, obj.seo_image, obj.seo_image_alt), + } + + +@content_router.get("/site", response=SiteContentOut) +def get_site_content(request, response: HttpResponse): + site = SiteContent.objects.filter(pk=1).first() + if site is None: + raise HttpError(404, "Site content not found") + response["Cache-Control"] = "no-store" + return { + "official_name": site.official_name, + "short_name": site.short_name, + "faculty_subline": site.faculty_subline, + "description": site.description, + "logo": _media(request, site.logo, site.logo_alt), + "navigation": { + "home": site.nav_home, + "about": site.nav_about, + "events": site.nav_events, + "blog": site.nav_blog, + }, + "contacts": { + "email": site.email, + "phone": site.phone, + "telegram": site.telegram_url, + "instagram": site.instagram_url, + "website": site.website_url, + }, + "address": site.address, + "footer_copy": site.footer_copy, + "seo": _seo(request, site), + } + + +@content_router.get("/pages/{slug}", response=PageContentOut) +def get_page_content(request, response: HttpResponse, slug: str): + response["Cache-Control"] = "no-store" + if slug == "home": + page = HomeContent.objects.filter(pk=1).first() + if page is None: + raise HttpError(404, "Page content not found") + return { + "slug": "home", "title": page.title, "intro": page.intro, "seo": _seo(request, page), + "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, + "featured_events_heading": page.featured_events_heading, + "featured_events_intro": page.featured_events_intro, + "featured_events_link_label": page.featured_events_link_label, + "featured_posts_heading": page.featured_posts_heading, + "featured_posts_intro": page.featured_posts_intro, + "featured_posts_link_label": page.featured_posts_link_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, + } + + if slug == "about": + page = AboutContent.objects.filter(pk=1).first() + if page is None: + raise HttpError(404, "Page content not found") + 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, + "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")), + } + + if slug in ("events", "blog"): + page = ListingContent.objects.filter(slug=slug).first() + if page is None: + raise HttpError(404, "Page content not found") + return {"slug": page.slug, "title": page.title, "intro": page.intro, "seo": _seo(request, page)} + + raise HttpError(404, "Page content not found") diff --git a/apps/content/tests/integration/test_api.py b/apps/content/tests/integration/test_api.py new file mode 100644 index 0000000..94aa5c9 --- /dev/null +++ b/apps/content/tests/integration/test_api.py @@ -0,0 +1,56 @@ +import pytest + +from apps.content.current import AboutContent, HomeContent, ListingContent + + +pytestmark = [pytest.mark.django_db, pytest.mark.integration] + + +@pytest.mark.parametrize( + ("slug", "expected_title"), + (("home", "انجمن علمی کامپیوتر دانشگاه گیلان"), ("about", "انجمن‌های علمی کامپیوتر گیلان"), ("events", "رویدادها"), ("blog", "بلاگ")), +) +def test_fixed_page_api_returns_current_content_without_caching(client, slug, expected_title): + response = client.get(f"/api/v1/content/pages/{slug}") + + assert response.status_code == 200 + assert response.json()["slug"] == slug + assert response.json()["title"] == expected_title + assert response["Cache-Control"] == "no-store" + assert "seo" in response.json() + assert "status" not in response.json() + assert "revision" not in response.json() + + +def test_api_returns_an_admin_save_on_the_next_request(client): + page = HomeContent.objects.get(pk=1) + page.hero_heading = "Saved in admin" + page.save() + + response = client.get("/api/v1/content/pages/home") + + assert response.status_code == 200 + assert response.json()["hero_heading"] == "Saved in admin" + assert response["Cache-Control"] == "no-store" + + +def test_site_api_returns_current_identity_and_no_store_header(client): + response = client.get("/api/v1/content/site") + + assert response.status_code == 200 + assert response.json()["official_name"] + assert response.json()["navigation"]["home"] == "خانه" + assert response["Cache-Control"] == "no-store" + + +def test_unknown_and_missing_content_return_404(client): + assert client.get("/api/v1/content/pages/unknown").status_code == 404 + + HomeContent.objects.filter(pk=1).delete() + assert client.get("/api/v1/content/pages/home").status_code == 404 + + ListingContent.objects.filter(slug="events").delete() + assert client.get("/api/v1/content/pages/events").status_code == 404 + + AboutContent.objects.filter(pk=1).delete() + assert client.get("/api/v1/content/pages/about").status_code == 404 diff --git a/config/api.py b/config/api.py index 3586d78..2dd80fe 100644 --- a/config/api.py +++ b/config/api.py @@ -4,6 +4,7 @@ from apps.analytics.api.views import analytics_router from apps.blog.api.views import blog_router from apps.certificates.api.views import certificates_router from apps.communications.api.views import communications_router +from apps.content.api.views import content_router from apps.events.api.views import events_router from apps.gallery.api.views import gallery_router from apps.notifications.api.views import notifications_router @@ -20,6 +21,7 @@ router.add_router("gallery/", gallery_router, tags=["Gallery"]) router.add_router("events/", events_router, tags=["Events"]) router.add_router("notifications/", notifications_router, tags=["Notifications"]) router.add_router("communications/", communications_router, tags=["Communications"]) +router.add_router("v1/content/", content_router, tags=["Content"]) router.add_router("payments/", payments_router, tags=["Payments"]) router.add_router("certificates/", certificates_router, tags=["Certificates"]) router.add_router("meta/", meta_router, tags=["Meta"]) diff --git a/docs/content-api-v1.md b/docs/content-api-v1.md new file mode 100644 index 0000000..bd58a66 --- /dev/null +++ b/docs/content-api-v1.md @@ -0,0 +1,33 @@ +# 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. + +## 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/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. + +## Shapes + +Site response: + +```json +{ + "official_name": "Association name", + "short_name": "Association", + "faculty_subline": "Faculty name", + "description": "Shared description", + "logo": null, + "navigation": { "home": "Home", "about": "About", "events": "Events", "blog": "Blog" }, + "contacts": { "email": "", "phone": "", "telegram": "", "instagram": "", "website": "" }, + "address": "", + "footer_copy": "", + "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.