Add direct-save public content API
This commit is contained in:
1
apps/content/api/__init__.py
Normal file
1
apps/content/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
85
apps/content/api/schemas.py
Normal file
85
apps/content/api/schemas.py
Normal file
@@ -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]
|
||||||
103
apps/content/api/views.py
Normal file
103
apps/content/api/views.py
Normal file
@@ -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")
|
||||||
56
apps/content/tests/integration/test_api.py
Normal file
56
apps/content/tests/integration/test_api.py
Normal file
@@ -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
|
||||||
@@ -4,6 +4,7 @@ from apps.analytics.api.views import analytics_router
|
|||||||
from apps.blog.api.views import blog_router
|
from apps.blog.api.views import blog_router
|
||||||
from apps.certificates.api.views import certificates_router
|
from apps.certificates.api.views import certificates_router
|
||||||
from apps.communications.api.views import communications_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.events.api.views import events_router
|
||||||
from apps.gallery.api.views import gallery_router
|
from apps.gallery.api.views import gallery_router
|
||||||
from apps.notifications.api.views import notifications_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("events/", events_router, tags=["Events"])
|
||||||
router.add_router("notifications/", notifications_router, tags=["Notifications"])
|
router.add_router("notifications/", notifications_router, tags=["Notifications"])
|
||||||
router.add_router("communications/", communications_router, tags=["Communications"])
|
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("payments/", payments_router, tags=["Payments"])
|
||||||
router.add_router("certificates/", certificates_router, tags=["Certificates"])
|
router.add_router("certificates/", certificates_router, tags=["Certificates"])
|
||||||
router.add_router("meta/", meta_router, tags=["Meta"])
|
router.add_router("meta/", meta_router, tags=["Meta"])
|
||||||
|
|||||||
33
docs/content-api-v1.md
Normal file
33
docs/content-api-v1.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user