diff --git a/care/facility/tests/test_facility_api.py b/care/facility/tests/test_facility_api.py index 0f6fef256d..36aa09ca30 100644 --- a/care/facility/tests/test_facility_api.py +++ b/care/facility/tests/test_facility_api.py @@ -1,3 +1,7 @@ +import io + +from django.core.files.uploadedfile import SimpleUploadedFile +from PIL import Image from rest_framework import status from rest_framework.test import APITestCase @@ -147,3 +151,52 @@ def test_delete_with_active_patients(self): self.client.force_authenticate(user=state_admin) response = self.client.delete(f"/api/v1/facility/{facility.external_id}/") self.assertIs(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class FacilityCoverImageTests(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.facility = cls.create_facility(cls.super_user, cls.district, cls.local_body) + cls.user = cls.create_user("staff", cls.district, home_facility=cls.facility) + + def test_valid_image(self): + self.facility.cover_image = "http://example.com/test.jpg" + self.facility.save() + image = Image.new("RGB", (400, 400)) + file = io.BytesIO() + image.save(file, format="JPEG") + test_file = SimpleUploadedFile("test.jpg", file.getvalue(), "image/jpeg") + test_file.size = 2048 + + payload = {"cover_image": test_file} + + response = self.client.post( + f"/api/v1/facility/{self.facility.external_id}/cover_image/", + payload, + format="multipart", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_invalid_image_too_small(self): + image = Image.new("RGB", (100, 100)) + file = io.BytesIO() + image.save(file, format="JPEG") + test_file = SimpleUploadedFile("test.jpg", file.getvalue(), "image/jpeg") + test_file.size = 1000 + + payload = {"cover_image": test_file} + + response = self.client.post( + f"/api/v1/facility/{self.facility.external_id}/cover_image/", + payload, + format="multipart", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data["cover_image"][0], + "Image width is less than the minimum allowed width of 400 pixels.", + ) diff --git a/care/utils/models/validators.py b/care/utils/models/validators.py index 9a2cd68965..e20233c3fb 100644 --- a/care/utils/models/validators.py +++ b/care/utils/models/validators.py @@ -215,7 +215,7 @@ class ImageSizeValidator: "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." + "Image aspect ratio must be one of the following: %(aspect_ratio)s." ), "min_size": _( "Image size is less than the minimum allowed size of %(min_size)s." @@ -231,17 +231,23 @@ def __init__( 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, + aspect_ratio: list[float] | None = None, + min_size: int | None = None, # in bytes + max_size: int | None = None, # in bytes ) -> 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 + if aspect_ratio: + self.aspect_ratio = set( + Fraction(ratio).limit_denominator(10) for ratio in aspect_ratio + ) + self.aspect_ratio_str = ", ".join( + f"{ratio.numerator}:{ratio.denominator}" for ratio in self.aspect_ratio + ) def __call__(self, value: UploadedFile) -> None: with Image.open(value.file) as image: @@ -267,22 +273,24 @@ def __call__(self, value: UploadedFile) -> None: ) 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}" - + image_aspect_ratio = Fraction(width / height).limit_denominator(10) + if image_aspect_ratio not in self.aspect_ratio: errors.append( self.message["aspect_ratio"] - % {"aspect_ratio": aspect_ratio_str} + % {"aspect_ratio": self.aspect_ratio_str} ) if self.min_size and size < self.min_size: - errors.append(self.message["min_size"] % {"min_size": self.min_size}) + errors.append( + self.message["min_size"] + % {"min_size": self._humanize_bytes(self.min_size)} + ) if self.max_size and size > self.max_size: - errors.append(self.message["max_size"] % {"max_size": self.max_size}) + errors.append( + self.message["max_size"] + % {"max_size": self._humanize_bytes(self.max_size)} + ) if errors: raise ValidationError(errors) @@ -305,15 +313,22 @@ def __eq__(self, other: object) -> bool: ] ) + def _humanize_bytes(self, size: int) -> str: + for unit in ["B", "KB"]: + if size < 1024.0: + return f"{f"{size:.2f}".rstrip(".0")} {unit}" + size /= 1024.0 + return f"{f"{size:.2f}".rstrip(".0")} MB" + 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, + aspect_ratio=[1 / 1], + min_size=1024, # 1 KB + max_size=1024 * 1024 * 2, # 2 MB ) custom_image_extension_validator = validators.FileExtensionValidator( diff --git a/care/utils/tests/test_image_validator.py b/care/utils/tests/test_image_validator.py new file mode 100644 index 0000000000..1c8d2656f0 --- /dev/null +++ b/care/utils/tests/test_image_validator.py @@ -0,0 +1,74 @@ +import io + +from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import UploadedFile +from django.test import TestCase +from PIL import Image + +from care.utils.models.validators import ImageSizeValidator + + +class CoverImageValidatorTests(TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.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, + ) + + def test_valid_image(self): + image = Image.new("RGB", (400, 400)) + file = io.BytesIO() + image.save(file, format="JPEG") + test_file = UploadedFile(file, "test.jpg", "image/jpeg", 2048) + self.assertIsNone(self.cover_image_validator(test_file)) + + def test_invalid_image_too_small(self): + image = Image.new("RGB", (100, 100)) + file = io.BytesIO() + image.save(file, format="JPEG") + test_file = UploadedFile(file, "test.jpg", "image/jpeg", 1000) + with self.assertRaises(ValidationError) as cm: + self.cover_image_validator(test_file) + self.assertEqual( + cm.exception.messages, + [ + "Image width is less than the minimum allowed width of 400 pixels.", + "Image height is less than the minimum allowed height of 400 pixels.", + "Image size is less than the minimum allowed size of 1 KB.", + ], + ) + + def test_invalid_image_too_large(self): + image = Image.new("RGB", (2000, 2000)) + file = io.BytesIO() + image.save(file, format="JPEG") + test_file = UploadedFile(file, "test.jpg", "image/jpeg", 1024 * 1024 * 3) + with self.assertRaises(ValidationError) as cm: + self.cover_image_validator(test_file) + self.assertEqual( + cm.exception.messages, + [ + "Image width is greater than the maximum allowed width of 1024 pixels.", + "Image height is greater than the maximum allowed height of 1024 pixels.", + "Image size is greater than the maximum allowed size of 2 MB.", + ], + ) + + def test_invalid_image_aspect_ratio(self): + image = Image.new("RGB", (400, 800)) + file = io.BytesIO() + image.save(file, format="JPEG") + test_file = UploadedFile(file, "test.jpg", "image/jpeg", 2048) + with self.assertRaises(ValidationError) as cm: + self.cover_image_validator(test_file) + self.assertEqual( + cm.exception.messages, + ["Image aspect ratio must be one of the following: 1:1."], + )