Add structured content editing in Django admin

This commit is contained in:
2026-09-27 10:20:47 +03:30
parent f529f85bf0
commit 2f07158f14
8 changed files with 815 additions and 0 deletions

167
apps/content/forms.py Normal file
View File

@@ -0,0 +1,167 @@
from django import forms
from .models import ABOUT_KEYS, HOME_KEYS, MediaAsset
ROUTE_CHOICES = (("", "—"), ("about", "About"), ("events", "Events"), ("blog", "Blog"))
CONTACT_KINDS = ("email", "phone", "telegram", "instagram", "website")
class PageDraftForm(forms.Form):
title = forms.CharField(max_length=180)
intro = forms.CharField(max_length=600, required=False, widget=forms.Textarea(attrs={"rows": 3}))
seo_title = forms.CharField(max_length=120)
seo_description = forms.CharField(max_length=300, widget=forms.Textarea(attrs={"rows": 2}))
seo_image = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False)
def payload(self):
return {field: self.cleaned_data[field] for field in ("title", "intro", "seo_title", "seo_description", "seo_image")}
class SiteDraftForm(forms.Form):
official_name = forms.CharField(max_length=180)
short_name = forms.CharField(max_length=80)
faculty_subline = forms.CharField(max_length=140, required=False)
description = forms.CharField(max_length=500, required=False, widget=forms.Textarea(attrs={"rows": 3}))
logo = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False)
logo_alt = forms.CharField(max_length=180, required=False)
footer_copy = forms.CharField(max_length=300, required=False)
seo_title = forms.CharField(max_length=120, required=False)
seo_description = forms.CharField(max_length=300, required=False, widget=forms.Textarea(attrs={"rows": 2}))
seo_image = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False)
def __init__(self, *args, revision=None, **kwargs):
initial = kwargs.setdefault("initial", {})
if revision:
for field in ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt", "footer_copy", "seo_title", "seo_description", "seo_image"):
initial[field] = getattr(revision, field)
super().__init__(*args, **kwargs)
navigation = {item["route_id"]: item for item in revision.navigation} if revision else {}
contacts = {item["kind"]: item for item in revision.contacts} if revision else {}
for position, route in enumerate(("home", "about", "events", "blog")):
item = navigation.get(route, {})
self.fields[f"nav_{route}_label"] = forms.CharField(max_length=80, initial=item.get("label", route.title()))
self.fields[f"nav_{route}_position"] = forms.IntegerField(min_value=0, initial=item.get("position", position))
self.fields[f"nav_{route}_visible"] = forms.BooleanField(required=False, initial=item.get("visible", True))
for kind in CONTACT_KINDS:
item = contacts.get(kind, {})
self.fields[f"contact_{kind}_label"] = forms.CharField(max_length=80, required=False, initial=item.get("label", ""))
self.fields[f"contact_{kind}_url"] = forms.CharField(max_length=500, required=False, initial=item.get("url", ""))
self.fields[f"contact_{kind}_position"] = forms.IntegerField(min_value=0, required=False, initial=item.get("position"))
self.fields[f"contact_{kind}_visible"] = forms.BooleanField(required=False, initial=item.get("visible", True))
def clean(self):
cleaned = super().clean()
for kind in CONTACT_KINDS:
label = cleaned.get(f"contact_{kind}_label")
url = cleaned.get(f"contact_{kind}_url")
if bool(label) != bool(url):
self.add_error(f"contact_{kind}_url", "Contact label and URL must be filled together.")
return cleaned
def payload(self):
data = {field: self.cleaned_data[field] for field in ("official_name", "short_name", "faculty_subline", "description", "logo", "logo_alt", "footer_copy", "seo_title", "seo_description", "seo_image")}
data["navigation"] = [
{"route_id": route, "label": self.cleaned_data[f"nav_{route}_label"], "position": self.cleaned_data[f"nav_{route}_position"], "visible": self.cleaned_data[f"nav_{route}_visible"]}
for route in ("home", "about", "events", "blog")
]
data["contacts"] = []
for kind in CONTACT_KINDS:
url = self.cleaned_data[f"contact_{kind}_url"]
if url:
data["contacts"].append({
"kind": kind, "label": self.cleaned_data[f"contact_{kind}_label"], "url": url,
"position": self.cleaned_data[f"contact_{kind}_position"] if self.cleaned_data[f"contact_{kind}_position"] is not None else len(data["contacts"]),
"visible": self.cleaned_data[f"contact_{kind}_visible"],
})
return data
class EntryForm(forms.Form):
title = forms.CharField(max_length=120, required=False)
text = forms.CharField(max_length=600, required=False, widget=forms.Textarea(attrs={"rows": 2}))
def clean(self):
cleaned = super().clean()
if bool(cleaned.get("title")) != bool(cleaned.get("text")):
raise forms.ValidationError("Each entry needs both a title and text.")
return cleaned
EntryFormSet = forms.formset_factory(EntryForm, extra=1, max_num=20, validate_max=True, can_delete=True)
class SectionForm(forms.Form):
heading = forms.CharField(max_length=120, required=False)
body = forms.CharField(max_length=1200, required=False, widget=forms.Textarea(attrs={"rows": 3}))
intro = forms.CharField(max_length=300, required=False, widget=forms.Textarea(attrs={"rows": 2}))
all_link_label = forms.CharField(max_length=40, required=False)
position = forms.IntegerField(min_value=0, required=False)
visible = forms.BooleanField(required=False)
image = forms.ModelChoiceField(queryset=MediaAsset.objects.all(), required=False)
image_alt = forms.CharField(max_length=180, required=False)
primary_label = forms.CharField(max_length=40, required=False)
primary_route = forms.ChoiceField(choices=ROUTE_CHOICES, required=False)
secondary_label = forms.CharField(max_length=40, required=False)
secondary_route = forms.ChoiceField(choices=ROUTE_CHOICES, required=False)
def __init__(self, *args, key, slug, section=None, **kwargs):
initial = kwargs.setdefault("initial", {})
if section:
for field in ("heading", "body", "intro", "all_link_label", "position", "visible", "image", "image_alt"):
initial[field] = getattr(section, field)
for side in ("primary", "secondary"):
action = getattr(section, f"{side}_action") or {}
initial[f"{side}_label"] = action.get("label", "")
initial[f"{side}_route"] = action.get("route_id", "")
else:
initial["visible"] = True
if slug == "home":
initial["position"] = HOME_KEYS.index(key)
elif key == "identity":
initial["position"] = 0
elif slug == "about":
initial["position"] = ABOUT_KEYS.index(key)
super().__init__(*args, **kwargs)
self.key = key
self.slug = slug
self.title = key.replace("_", " ").title()
if key in {"hero", "identity", "contact"} or slug == "home":
self.fields["heading"].required = True
if slug == "home" or key in {"identity", "contact"}:
self.fields["position"].disabled = True
if key in {"hero", "identity", "contact"} or slug == "home":
self.fields["visible"].disabled = True
allowed = {"heading", "position", "visible"}
if key in {"hero", "participation"}:
allowed |= {"body", "image", "image_alt", "primary_label", "primary_route", "secondary_label", "secondary_route"}
self.fields["body"].max_length = 600 if key == "participation" else 500
elif key in {"featured_events", "featured_posts"}:
allowed |= {"intro", "all_link_label"}
elif key != "contact":
allowed.add("body")
self.fields["body"].max_length = 600 if key == "identity" else 1200
for field in tuple(self.fields):
if field not in allowed:
del self.fields[field]
def clean(self):
cleaned = super().clean()
for side in ("primary", "secondary"):
if bool(cleaned.get(f"{side}_label")) != bool(cleaned.get(f"{side}_route")):
self.add_error(f"{side}_label", "Action label and route must be filled together.")
return cleaned
def payload(self, entries=(), *, contact_position=None):
data = self.cleaned_data
position = contact_position if contact_position is not None else data["position"]
section = {
"key": self.key, "section_type": self.key, "position": position,
"visible": True if self.slug == "home" or self.key in {"identity", "contact"} else data["visible"],
"heading": data["heading"], "body": data.get("body", ""), "intro": data.get("intro", ""),
"all_link_label": data.get("all_link_label", ""), "image": data.get("image"), "image_alt": data.get("image_alt", ""),
"entries": list(entries),
}
for side in ("primary", "secondary"):
section[f"{side}_action"] = {"label": data[f"{side}_label"], "route_id": data[f"{side}_route"]} if data.get(f"{side}_label") else None
return section