432 lines
18 KiB
Python
432 lines
18 KiB
Python
"""Directly editable public site content."""
|
|
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.db.models import Q
|
|
from PIL import Image, UnidentifiedImageError
|
|
|
|
from . import validators
|
|
|
|
|
|
def content_upload_path(instance, filename):
|
|
return f"content/{uuid4().hex}{Path(filename).suffix.lower()}"
|
|
|
|
|
|
def check_image(file):
|
|
if not file or getattr(file, "_committed", True):
|
|
return
|
|
if file.size > 5 * 1024 * 1024:
|
|
raise ValidationError("Images must be at most 5 MiB.")
|
|
try:
|
|
file.seek(0)
|
|
with Image.open(file) as image:
|
|
expected = {".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP"}.get(Path(file.name).suffix.lower())
|
|
if image.format != expected:
|
|
raise ValueError("Image format does not match its file extension.")
|
|
image.verify()
|
|
file.seek(0)
|
|
with Image.open(file) as image:
|
|
width, height = image.size
|
|
if not 1 <= width <= 4096 or not 1 <= height <= 4096:
|
|
raise ValueError("Image dimensions must be between 1 and 4096 pixels.")
|
|
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
|
raise ValidationError("Upload a valid PNG, JPEG, or WebP image.") from exc
|
|
finally:
|
|
file.seek(0)
|
|
|
|
|
|
class EditableContent(models.Model):
|
|
seo_title = models.CharField("SEO title", max_length=120, blank=True)
|
|
seo_description = models.CharField("SEO description", max_length=300, blank=True)
|
|
seo_image = models.ImageField("Social sharing image", upload_to=content_upload_path, blank=True)
|
|
seo_image_alt = models.CharField("Social image description", max_length=180, blank=True)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
errors = {}
|
|
for field in self._meta.fields:
|
|
if isinstance(field, (models.CharField, models.TextField)):
|
|
try:
|
|
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", "enamad_image"):
|
|
if not hasattr(self, image_name):
|
|
continue
|
|
image = getattr(self, image_name)
|
|
alt_name = f"{image_name}_alt" if image_name != "logo" else "logo_alt"
|
|
if image and not getattr(self, alt_name).strip():
|
|
errors[alt_name] = "Describe the image for screen readers."
|
|
if not image and getattr(self, alt_name):
|
|
errors[alt_name] = "Remove the description or upload an image."
|
|
try:
|
|
check_image(image)
|
|
except ValidationError as exc:
|
|
errors[image_name] = exc
|
|
if errors:
|
|
raise ValidationError(errors)
|
|
|
|
|
|
class SingleRecordContent(EditableContent):
|
|
id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.pk = 1
|
|
return super().save(*args, **kwargs)
|
|
|
|
|
|
class SiteContent(SingleRecordContent):
|
|
official_name = models.CharField(max_length=180, blank=True)
|
|
short_name = models.CharField(max_length=80, blank=True)
|
|
faculty_subline = models.CharField(max_length=140, blank=True)
|
|
description = models.TextField(blank=True)
|
|
logo = models.ImageField(upload_to=content_upload_path, blank=True)
|
|
logo_alt = models.CharField(max_length=180, blank=True)
|
|
nav_home = models.CharField("Home navigation label", max_length=80, default="Home")
|
|
nav_about = models.CharField("About navigation label", max_length=80, default="About")
|
|
nav_events = models.CharField("Events navigation label", max_length=80, default="Events")
|
|
nav_blog = models.CharField("Blog navigation label", max_length=80, default="Blog")
|
|
email = models.EmailField(blank=True)
|
|
phone = models.CharField(max_length=30, blank=True)
|
|
telegram_url = models.URLField(blank=True)
|
|
instagram_url = models.URLField(blank=True)
|
|
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"
|
|
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_current_single_site")]
|
|
|
|
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)
|
|
intro = models.TextField(blank=True)
|
|
hero_heading = models.CharField(max_length=120, blank=True)
|
|
hero_body = models.TextField(blank=True)
|
|
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"
|
|
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_current_single_home")]
|
|
|
|
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)
|
|
intro = models.TextField(blank=True)
|
|
identity_heading = models.CharField(max_length=120, blank=True)
|
|
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)
|
|
values_body = models.TextField(blank=True)
|
|
committees_heading = models.CharField(max_length=120, blank=True)
|
|
committees_body = models.TextField(blank=True)
|
|
affiliations_heading = models.CharField(max_length=120, blank=True)
|
|
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"
|
|
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_current_single_about")]
|
|
|
|
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")
|
|
kind = models.CharField(max_length=20, choices=[(key, key.title()) for key in ("mission", "activities", "values", "committees", "affiliations", "faq")])
|
|
title = models.CharField(max_length=120)
|
|
text = models.TextField()
|
|
position = models.PositiveSmallIntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ["kind", "position", "pk"]
|
|
verbose_name = "About page item"
|
|
|
|
def __str__(self):
|
|
return self.title
|
|
|
|
def clean(self):
|
|
super().clean()
|
|
validators.text(self.title, 120, required=True)
|
|
validators.text(self.text, 600, required=True)
|
|
|
|
|
|
class ListingContent(EditableContent):
|
|
slug = models.CharField(max_length=10, primary_key=True, choices=[("events", "Events"), ("blog", "Blog")])
|
|
title = models.CharField(max_length=180, blank=True)
|
|
intro = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Listing page content"
|
|
verbose_name_plural = "Listing page content"
|
|
|
|
def __str__(self):
|
|
return self.get_slug_display()
|