From 0373ee527bed491009c6f0f318b8bcb63c473b40 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 20 Sep 2024 01:23:58 +0530 Subject: [PATCH 1/5] use random uuid suffix in cover image key --- care/facility/api/serializers/facility.py | 20 +++++++++++++++++--- care/utils/models/validators.py | 4 ++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index 99c00bbe69..a3a9d49a92 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,9 @@ WardSerializer, ) from care.utils.csp.config import BucketType, get_client_config +from care.utils.models.validators import ( + custom_image_extension_validator, +) from config.serializers import ChoiceField from config.validators import MiddlewareDomainAddressValidator @@ -173,7 +178,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], + ) read_cover_image_url = serializers.URLField(read_only=True) class Meta: @@ -184,10 +193,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..848b93362c 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -194,3 +194,7 @@ def __eq__(self, __value: object) -> bool: # pragma: no cover allow_floats=True, precision=4, ) + +custom_image_extension_validator = validators.FileExtensionValidator( + allowed_extensions=["jpg", "jpeg", "png"] +) From 0ad659a6b40f024c331c51075da1c4ecf7acdc61 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 20 Sep 2024 01:24:25 +0530 Subject: [PATCH 2/5] add image resolution and size validator for cover image uploads --- care/facility/api/serializers/facility.py | 3 +- care/utils/models/validators.py | 101 ++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/care/facility/api/serializers/facility.py b/care/facility/api/serializers/facility.py index a3a9d49a92..1e50f33271 100644 --- a/care/facility/api/serializers/facility.py +++ b/care/facility/api/serializers/facility.py @@ -17,6 +17,7 @@ ) 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 @@ -181,7 +182,7 @@ class FacilityImageUploadSerializer(serializers.ModelSerializer): cover_image = serializers.ImageField( required=True, write_only=True, - validators=[custom_image_extension_validator], + validators=[custom_image_extension_validator, cover_image_validator], ) read_cover_image_url = serializers.URLField(read_only=True) diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index 848b93362c..afcb9971cf 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -7,6 +7,7 @@ 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 @@ -195,6 +196,106 @@ def __eq__(self, __value: object) -> bool: # pragma: no cover precision=4, ) + +class ImageSizeValidator: + message = { + "min_width": _( + "Image width is less than the minimum allowed width of %(min_width)s." + ), + "max_width": _( + "Image width is greater than the maximum allowed width of %(max_width)s." + ), + "min_height": _( + "Image height is less than the minimum allowed height of %(min_height)s." + ), + "max_height": _( + "Image height is greater than the maximum allowed height of %(max_height)s." + ), + "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, + max_width: int = None, + min_height: int = None, + max_height: int = None, + aspect_ratio: float = None, + min_size: int = None, + max_size: int = 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): + with Image.open(value.file) as image: + width, height = image.size + size = value.size + + if self.min_width and width < self.min_width: + raise ValidationError( + self.message["min_width"] % {"min_width": self.min_width} + ) + + if self.max_width and width > self.max_width: + raise ValidationError( + self.message["max_width"] % {"max_width": self.max_width} + ) + + if self.min_height and height < self.min_height: + raise ValidationError( + self.message["min_height"] % {"min_height": self.min_height} + ) + + if self.max_height and height > self.max_height: + raise ValidationError( + self.message["max_height"] % {"max_height": self.max_height} + ) + + if self.aspect_ratio: + if not (1 / self.aspect_ratio) < (width / height) < self.aspect_ratio: + raise ValidationError( + self.message["aspect_ratio"] + % { + "aspect_ratio": f"{1/self.aspect_ratio} to {self.aspect_ratio}" + } + ) + + if self.min_size and size < self.min_size: + raise ValidationError( + self.message["min_size"] % {"min_size": self.min_size} + ) + + if self.max_size and size > self.max_size: + raise ValidationError( + self.message["max_size"] % {"max_size": self.max_size} + ) + value.seek(0) + + +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"] ) From 639e30b340acf3d3063b308bdaeff53b575794f3 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 20 Sep 2024 12:52:32 +0530 Subject: [PATCH 3/5] improve errors --- care/utils/models/validators.py | 36 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index afcb9971cf..c2c461925c 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -200,16 +200,16 @@ def __eq__(self, __value: object) -> bool: # pragma: no cover class ImageSizeValidator: message = { "min_width": _( - "Image width is less than the minimum allowed width of %(min_width)s." + "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." + "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." + "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." + "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." @@ -245,29 +245,27 @@ def __call__(self, value): width, height = image.size size = value.size + errors = [] + if self.min_width and width < self.min_width: - raise ValidationError( - self.message["min_width"] % {"min_width": self.min_width} - ) + errors.append(self.message["min_width"] % {"min_width": self.min_width}) if self.max_width and width > self.max_width: - raise ValidationError( - self.message["max_width"] % {"max_width": self.max_width} - ) + errors.append(self.message["max_width"] % {"max_width": self.max_width}) if self.min_height and height < self.min_height: - raise ValidationError( + errors.append( self.message["min_height"] % {"min_height": self.min_height} ) if self.max_height and height > self.max_height: - raise ValidationError( + 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: - raise ValidationError( + errors.append( self.message["aspect_ratio"] % { "aspect_ratio": f"{1/self.aspect_ratio} to {self.aspect_ratio}" @@ -275,14 +273,14 @@ def __call__(self, value): ) if self.min_size and size < self.min_size: - raise ValidationError( - self.message["min_size"] % {"min_size": self.min_size} - ) + errors.append(self.message["min_size"] % {"min_size": self.min_size}) if self.max_size and size > self.max_size: - raise ValidationError( - self.message["max_size"] % {"max_size": self.max_size} - ) + errors.append(self.message["max_size"] % {"max_size": self.max_size}) + + if errors: + raise ValidationError(errors) + value.seek(0) From 9291c8816d012a866eb47145bb59548433dc6ff5 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 20 Sep 2024 13:00:56 +0530 Subject: [PATCH 4/5] refine aspect ratio error message format --- care/utils/models/validators.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index c2c461925c..72ca4fdfb0 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -1,4 +1,5 @@ import re +from fractions import Fraction from typing import Iterable, List import jsonschema @@ -265,11 +266,14 @@ def __call__(self, value): 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": f"{1/self.aspect_ratio} to {self.aspect_ratio}" - } + % {"aspect_ratio": aspect_ratio_str} ) if self.min_size and size < self.min_size: From f5ffc2af885f2811b734b5449f68de81f34cc891 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Fri, 20 Sep 2024 13:59:58 +0530 Subject: [PATCH 5/5] make ImageSizeValidator serializeable and add types --- care/utils/models/validators.py | 42 +++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index 72ca4fdfb0..9a2cd68965 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -5,6 +5,7 @@ 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 _ @@ -198,8 +199,9 @@ def __eq__(self, __value: object) -> bool: # pragma: no cover ) +@deconstructible class ImageSizeValidator: - message = { + message: dict[str, str] = { "min_width": _( "Image width is less than the minimum allowed width of %(min_width)s pixels." ), @@ -225,14 +227,14 @@ class ImageSizeValidator: def __init__( self, - min_width: int = None, - max_width: int = None, - min_height: int = None, - max_height: int = None, - aspect_ratio: float = None, - min_size: int = None, - max_size: int = None, - ): + 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 @@ -241,12 +243,12 @@ def __init__( self.min_size = min_size self.max_size = max_size - def __call__(self, value): + def __call__(self, value: UploadedFile) -> None: with Image.open(value.file) as image: width, height = image.size - size = value.size + size: int = value.size - errors = [] + errors: list[str] = [] if self.min_width and width < self.min_width: errors.append(self.message["min_width"] % {"min_width": self.min_width}) @@ -287,6 +289,22 @@ def __call__(self, value): 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,