I think the confusing part here is that width_field, height_field, and max_length don’t actually define validation limits.
width_field and height_field are names of model fields that Django populates with the dimensions of the uploaded image. max_length limits the length of the file path stored by the ImageField, not the file size.
If 360x80 and 32 KB are actual constraints for every Logo, I’d keep them in a reusable validator attached to the model field:
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from PIL import Image, UnidentifiedImageError
def validate_logo(image):
if image.size > 32 * 1024:
raise ValidationError("Image must be 32 KB or smaller.")
position = image.tell()
try:
with Image.open(image) as img:
if img.size != (360, 80):
raise ValidationError(
f"Expected 360x80, got {img.width}x{img.height}."
)
if img.format not in {"GIF", "JPEG", "PNG"}:
raise ValidationError("Unsupported image format.")
img.verify()
except (UnidentifiedImageError, OSError):
raise ValidationError("The uploaded file is not a valid image.")
finally:
image.seek(position)
Then:
image = models.ImageField(
upload_to="images/%Y/%m/%d/",
validators=[
FileExtensionValidator(["gif", "jpg", "jpeg", "png"]),
validate_logo,
],
)
I’d still treat the extension check as a convenience rather than proof of the file type. A filename can end in .jpg while containing something else, so inspecting the actual image with Pillow is still useful.
If this restriction only applies to one admin or form workflow, clean_image() on the ModelForm is also reasonable. If it’s an invariant of the Logo model, keeping the validator on the model field makes the rule reusable across ModelForms and explicit model validation.
One caveat is that Model.save() doesn’t call full_clean() automatically, so code that creates or updates Logo instances directly needs to run validation explicitly if you want the same guarantee outside forms.
A custom upload handler can make sense when you need to reject very large uploads before Django buffers them, but for a 32 KB limit I’d keep this in the normal validation layer unless early stream rejection is specifically required.