Replace content publishing workflow with direct Unfold editing
This commit is contained in:
198
apps/content/current.py
Normal file
198
apps/content/current.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""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"):
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Site content"
|
||||
constraints = [models.CheckConstraint(condition=Q(id=1), name="content_current_single_site")]
|
||||
|
||||
def __str__(self):
|
||||
return "Site content"
|
||||
|
||||
|
||||
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)
|
||||
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_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)
|
||||
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)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user