diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index 99c00bbe69..1e50f33271 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -1,3 +1,5 @@ +import uuid + import boto3 from django.conf import settings from django.contrib.auth import get_user_model @@ -14,6 +16,10 @@ WardSerializer, ) from care.utils.csp.config import BucketType, get_client_config +from care.utils.models.validators import ( + cover_image_validator, + custom_image_extension_validator, +) from config.serializers import ChoiceField from config.validators import MiddlewareDomainAddressValidator @@ -173,7 +179,11 @@ def create(self, validated_data): class FacilityImageUploadSerializer(serializers.ModelSerializer): - cover_image = serializers.ImageField(required=True, write_only=True) + cover_image = serializers.ImageField( + required=True, + write_only=True, + validators=[custom_image_extension_validator, cover_image_validator], + ) read_cover_image_url = serializers.URLField(read_only=True) class Meta: @@ -184,10 +194,15 @@ class Meta: def save(self, **kwargs): facility = self.instance image = self.validated_data["cover_image"] - image_extension = image.name.rsplit(".", 1)[-1] + config, bucket_name = get_client_config(BucketType.FACILITY) s3 = boto3.client("s3", **config) - image_location = f"cover_images/{facility.external_id}_cover.{image_extension}" + + if facility.cover_image_url: + s3.delete_object(Bucket=bucket_name, Key=facility.cover_image_url) + + image_extension = image.name.rsplit(".", 1)[-1] + image_location = f"cover_images/{facility.external_id}_{str(uuid.uuid4())[0:8]}.{image_extension}" boto_params = { "Bucket": bucket_name, "Key": image_location, diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index 1ef8dc2625..9a2cd68965 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -1,12 +1,15 @@ import re +from fractions import Fraction from typing import Iterable, List import jsonschema from django.core import validators from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import UploadedFile from django.core.validators import RegexValidator from django.utils.deconstruct import deconstructible from django.utils.translation import gettext_lazy as _ +from PIL import Image @deconstructible @@ -194,3 +197,125 @@ def __eq__(self, __value: object) -> bool: # pragma: no cover allow_floats=True, precision=4, ) + + +@deconstructible +class ImageSizeValidator: + message: dict[str, str] = { + "min_width": _( + "Image width is less than the minimum allowed width of %(min_width)s pixels." + ), + "max_width": _( + "Image width is greater than the maximum allowed width of %(max_width)s pixels." + ), + "min_height": _( + "Image height is less than the minimum allowed height of %(min_height)s pixels." + ), + "max_height": _( + "Image height is greater than the maximum allowed height of %(max_height)s pixels." + ), + "aspect_ratio": _( + "Image aspect ratio is not within the allowed range of %(aspect_ratio)s." + ), + "min_size": _( + "Image size is less than the minimum allowed size of %(min_size)s." + ), + "max_size": _( + "Image size is greater than the maximum allowed size of %(max_size)s." + ), + } + + def __init__( + self, + min_width: int | None = None, + max_width: int | None = None, + min_height: int | None = None, + max_height: int | None = None, + aspect_ratio: float | None = None, + min_size: int | None = None, + max_size: int | None = None, + ) -> None: + self.min_width = min_width + self.max_width = max_width + self.min_height = min_height + self.max_height = max_height + self.aspect_ratio = aspect_ratio + self.min_size = min_size + self.max_size = max_size + + def __call__(self, value: UploadedFile) -> None: + with Image.open(value.file) as image: + width, height = image.size + size: int = value.size + + errors: list[str] = [] + + if self.min_width and width < self.min_width: + errors.append(self.message["min_width"] % {"min_width": self.min_width}) + + if self.max_width and width > self.max_width: + errors.append(self.message["max_width"] % {"max_width": self.max_width}) + + if self.min_height and height < self.min_height: + errors.append( + self.message["min_height"] % {"min_height": self.min_height} + ) + + if self.max_height and height > self.max_height: + errors.append( + self.message["max_height"] % {"max_height": self.max_height} + ) + + if self.aspect_ratio: + if not (1 / self.aspect_ratio) < (width / height) < self.aspect_ratio: + aspect_ratio_fraction = Fraction( + self.aspect_ratio + ).limit_denominator() + aspect_ratio_str = f"{aspect_ratio_fraction.numerator}:{aspect_ratio_fraction.denominator}" + + errors.append( + self.message["aspect_ratio"] + % {"aspect_ratio": aspect_ratio_str} + ) + + if self.min_size and size < self.min_size: + errors.append(self.message["min_size"] % {"min_size": self.min_size}) + + if self.max_size and size > self.max_size: + errors.append(self.message["max_size"] % {"max_size": self.max_size}) + + if errors: + raise ValidationError(errors) + + value.seek(0) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ImageSizeValidator): + return False + return all( + getattr(self, attr) == getattr(other, attr) + for attr in [ + "min_width", + "max_width", + "min_height", + "max_height", + "aspect_ratio", + "min_size", + "max_size", + ] + ) + + +cover_image_validator = ImageSizeValidator( + min_width=400, + min_height=400, + max_width=1024, + max_height=1024, + aspect_ratio=1 / 1, + min_size=1024, + max_size=1024 * 1024 * 2, +) + +custom_image_extension_validator = validators.FileExtensionValidator( + allowed_extensions=["jpg", "jpeg", "png"] +)