Skip to content

Commit

Permalink
convert image validator aspect ratio to list (#2475)
Browse files Browse the repository at this point in the history
  • Loading branch information
sainak authored Sep 21, 2024
1 parent 664739e commit 51b5c00
Show file tree
Hide file tree
Showing 3 changed files with 159 additions and 17 deletions.
53 changes: 53 additions & 0 deletions care/facility/tests/test_facility_api.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.",
)
49 changes: 32 additions & 17 deletions care/utils/models/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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(
Expand Down
74 changes: 74 additions & 0 deletions care/utils/tests/test_image_validator.py
Original file line number Diff line number Diff line change
@@ -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."],
)

0 comments on commit 51b5c00

Please sign in to comment.