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