Add direct-save public content API

This commit is contained in:
2026-09-27 11:40:11 +03:30
parent f6b4ef7dd8
commit 149497d276
6 changed files with 280 additions and 0 deletions

View File

@@ -0,0 +1 @@

View 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
View 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")

View 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