feat: add direct-save CMS and About archive

This commit is contained in:
2026-09-28 20:29:00 +03:30
parent 94b32310af
commit 0f6220f72c
12 changed files with 957 additions and 41 deletions

View File

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