"""ContentBlock model for flexible homepage content sections."""
from typing import TYPE_CHECKING
from django.core.validators import MaxValueValidator
from django.db import models
from tinymce.models import HTMLField
from coalition.content.html_sanitizer import HTMLSanitizer
if TYPE_CHECKING:
from typing import Any
[docs]
class ContentBlock(models.Model):
"""
Flexible content blocks that can be added to any page.
Allows for dynamic content sections beyond the fixed structure.
"""
[docs]
BLOCK_TYPES = [
("text", "Text Block"),
("image", "Image Block"),
("text_image", "Text + Image Block"),
("quote", "Quote Block"),
("stats", "Statistics Block"),
("custom_html", "Custom HTML Block"),
]
[docs]
PAGE_TYPES = [
("homepage", "Homepage"),
("about", "About Page"),
("campaigns", "Campaigns Page"),
("contact", "Contact Page"),
]
[docs]
LAYOUT_OPTIONS = [
("default", "Text Left, Image Right"),
("reversed", "Image Left, Text Right"),
("stacked", "Text Above Image"),
("stacked_reversed", "Image Above Text"),
]
[docs]
VERTICAL_ALIGNMENT_OPTIONS = [
("top", "Top"),
("middle", "Center"),
("bottom", "Bottom"),
]
[docs]
ANIMATION_OPTIONS = [
("fade-in", "Fade In"),
("slide-up", "Slide Up"),
("slide-left", "Slide from Left"),
("slide-right", "Slide from Right"),
("scale", "Scale In"),
("none", "No Animation"),
]
[docs]
page_type = models.CharField(
max_length=20,
choices=PAGE_TYPES,
default="homepage",
help_text="Which page this content block appears on",
)
[docs]
title = models.CharField(
max_length=200,
blank=True,
help_text="Optional title for this content block",
)
[docs]
block_type = models.CharField(
max_length=20,
choices=BLOCK_TYPES,
default="text",
help_text="Type of content block",
)
[docs]
content = HTMLField(
help_text="Main content for this block (text, HTML, etc.)",
)
[docs]
image = models.ForeignKey(
"content.Image",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="content_block_images",
help_text="Image for image or text+image blocks",
)
# Layout options
[docs]
layout_option = models.CharField(
max_length=20,
choices=LAYOUT_OPTIONS,
default="default",
blank=True,
help_text="Layout arrangement for Text + Image blocks",
)
[docs]
vertical_alignment = models.CharField(
max_length=10,
choices=VERTICAL_ALIGNMENT_OPTIONS,
default="middle",
blank=True,
help_text=(
"Vertical alignment of text relative to image for Text + Image blocks"
),
)
[docs]
css_classes = models.CharField(
max_length=200,
blank=True,
help_text="Additional CSS classes for styling (optional)",
)
[docs]
background_color = models.CharField(
max_length=7,
blank=True,
help_text="Background color in hex format (e.g., #ffffff)",
)
# Animation
[docs]
animation_type = models.CharField(
max_length=20,
choices=ANIMATION_OPTIONS,
default="none",
help_text="Animation effect when block enters viewport",
)
[docs]
animation_delay = models.PositiveIntegerField(
default=0,
validators=[MaxValueValidator(2000)],
help_text="Animation delay in milliseconds (0-2000)",
)
# Ordering and visibility
[docs]
order = models.PositiveIntegerField(
default=0,
help_text="Order in which this block appears (lower numbers first)",
)
[docs]
is_visible = models.BooleanField(
default=True,
help_text="Whether this block is visible on the homepage",
)
[docs]
created_at = models.DateTimeField(
auto_now_add=True,
help_text="When this content block was created",
)
[docs]
updated_at = models.DateTimeField(
auto_now=True,
help_text="When this content block was last updated",
)
[docs]
class Meta:
[docs]
db_table = "content_block"
[docs]
ordering = ["order", "created_at"]
[docs]
verbose_name = "Content Block"
[docs]
verbose_name_plural = "Content Blocks"
[docs]
def __str__(self) -> str:
page_type = self.get_page_type_display()
return (
f"Block: {self.title or self.block_type} ({page_type}, Order: {self.order})"
)
@property
[docs]
def image_url(self) -> str:
"""Return the URL of the uploaded image, or empty string if no image."""
if self.image and self.image.image and hasattr(self.image.image, "url"):
return self.image.image.url
return ""
@property
[docs]
def image_alt_text(self) -> str:
"""Return the alt text of the image, or empty string if no image."""
return self.image.alt_text if self.image else ""
@property
[docs]
def image_title(self) -> str:
"""Return the title of the image, or empty string if no image."""
return self.image.title if self.image else ""
@property
[docs]
def image_author(self) -> str:
"""Return the author of the image, or empty string if no image."""
return self.image.author if self.image else ""
@property
[docs]
def image_license(self) -> str:
"""Return the license of the image, or empty string if no image."""
return self.image.license if self.image else ""
@property
[docs]
def image_source_url(self) -> str:
"""Return the source URL of the image, or empty string if no image."""
return self.image.source_url if self.image else ""
@property
[docs]
def image_caption(self) -> str:
"""Return the caption of the image, or empty string if no image."""
return self.image.caption if self.image else ""
@property
[docs]
def image_caption_display(self) -> str:
"""Return the caption display setting of the image, or 'below' if no image."""
return self.image.caption_display if self.image else "below"
[docs]
def save(self, *args: "Any", **kwargs: "Any") -> None:
"""Sanitize content based on block type before saving."""
if self.content:
if self.block_type == "quote":
# Quotes should be plain text only
self.content = HTMLSanitizer.sanitize_plain_text(self.content)
else:
# All other block types get HTML sanitization
self.content = HTMLSanitizer.sanitize(self.content)
# Sanitize title (should be plain text)
if self.title:
self.title = HTMLSanitizer.sanitize_plain_text(self.title)
super().save(*args, **kwargs)