Skip to content

Commit d2b92e2

Browse files
committed
Add Django image size validators
1 parent fcbc173 commit d2b92e2

File tree

10 files changed

+312
-12
lines changed

10 files changed

+312
-12
lines changed

.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,6 @@ insert_final_newline = false
2424
[*.{md,markdown}]
2525
indent_size = 2
2626
max_line_length = 80
27+
28+
[Makefile]
29+
indent_style = tab

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ jobs:
1111
dist:
1212
runs-on: ubuntu-latest
1313
steps:
14+
- uses: actions/checkout@v3
1415
- uses: actions/setup-python@v4
1516
with:
1617
python-version: "3.10"
17-
- uses: actions/checkout@v3
18+
- run: sudo apt install gettext -y
1819
- run: python -m pip install --upgrade pip build wheel twine
20+
- run: make gettext
1921
- run: python -m build --sdist --wheel
2022
- run: python -m twine check dist/*
23+
- uses: actions/upload-artifact@v3
24+
with:
25+
path: dist/*
2126

2227
lint:
2328
runs-on: ubuntu-latest

.github/workflows/release.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ jobs:
1313
- uses: actions/setup-python@v4
1414
with:
1515
python-version: "3.10"
16+
- run: sudo apt install gettext -y
1617
- run: python -m pip install --upgrade pip build wheel twine
17-
- run: python -m pip install pip~=21.0 # https://github.com/pypa/pip/issues/11133
18+
- run: make gettext
1819
- run: python -m build --sdist --wheel
1920
- run: python -m twine upload dist/*
2021
env:

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
MSGLANGS = $(wildcard pictures/locale/*/LC_MESSAGES/*.po)
2+
MSGOBJS = $(MSGLANGS:.po=.mo)
3+
4+
.PHONY: translations gettext gettext-clean
5+
6+
gettext: $(MSGOBJS)
7+
8+
gettext-clean:
9+
-rm $(MSGOBJS)
10+
11+
%.mo: %.po
12+
msgfmt --check-format --check-domain --statistics -o $@ $*.po
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Django-Pictures
2+
# Copyright (C) 2022 Johannes Maron
3+
# This file is distributed under the same license as the PACKAGE package.
4+
# Johannes Maron <[email protected]>, 2022.
5+
#
6+
msgid ""
7+
msgstr ""
8+
"Project-Id-Version: \n"
9+
"Report-Msgid-Bugs-To: \n"
10+
"POT-Creation-Date: 2022-08-06 13:58+0200\n"
11+
"PO-Revision-Date: 2022-08-06 14:14+0200\n"
12+
"Last-Translator: Johannes Maron <[email protected]>\n"
13+
"Language-Team: \n"
14+
"Language: de\n"
15+
"MIME-Version: 1.0\n"
16+
"Content-Type: text/plain; charset=UTF-8\n"
17+
"Content-Transfer-Encoding: 8bit\n"
18+
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
19+
"X-Generator: Poedit 3.1.1\n"
20+
21+
#: validators.py:47
22+
#, python-format
23+
msgid ""
24+
"The image you uploaded is too large. The required maximum resolution is: "
25+
"%(width)sx%(height)s px."
26+
msgstr ""
27+
"Das von Ihnen hochgeladene Bild ist zu groß. Die erforderliche maximale "
28+
"Auflösung beträgt: %(width)sx%(height)s px."
29+
30+
#: validators.py:65
31+
#, python-format
32+
msgid ""
33+
"The image you uploaded is too small. The required minimum resolution is: "
34+
"%(width)sx%(height)s px."
35+
msgstr ""
36+
"Das von Ihnen hochgeladene Bild ist zu klein. Die erforderliche "
37+
"Mindestauflösung beträgt: %(width)sx%(height)s px."

pictures/validators.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import io
2+
3+
from django.core.exceptions import ValidationError
4+
from django.core.validators import BaseValidator
5+
from django.utils.translation import gettext_lazy as _
6+
from PIL import Image
7+
8+
9+
class BaseSizeValidator(BaseValidator):
10+
"""Base validator that validates the size of an image."""
11+
12+
def compare(self, x):
13+
return NotImplemented
14+
15+
def __init__(self, width, height):
16+
self.limit_value = width or float("inf"), height or float("inf")
17+
18+
def __call__(self, value):
19+
cleaned = self.clean(value)
20+
if self.compare(cleaned, self.limit_value):
21+
params = {
22+
"width": self.limit_value[0],
23+
"height": self.limit_value[1],
24+
}
25+
raise ValidationError(self.message, code=self.code, params=params)
26+
27+
@staticmethod
28+
def clean(value):
29+
value.seek(0)
30+
stream = io.BytesIO(value.read())
31+
size = Image.open(stream).size
32+
value.seek(0)
33+
return size
34+
35+
36+
class MaxSizeValidator(BaseSizeValidator):
37+
"""
38+
ImageField validator to validate the max width and height of an image.
39+
40+
You may use None as an infinite boundary.
41+
"""
42+
43+
def compare(self, img_size, max_size):
44+
return img_size[0] > max_size[0] or img_size[1] > max_size[1]
45+
46+
message = _(
47+
"The image you uploaded is too large."
48+
" The required maximum resolution is:"
49+
" %(width)sx%(height)s px."
50+
)
51+
code = "max_resolution"
52+
53+
54+
class MinSizeValidator(BaseSizeValidator):
55+
"""
56+
ImageField validator to validate the min width and height of an image.
57+
58+
You may use None as an infinite boundary.
59+
"""
60+
61+
def compare(self, img_size, min_size):
62+
return img_size[0] < min_size[0] or img_size[1] < min_size[1]
63+
64+
message = _(
65+
"The image you uploaded is too small."
66+
" The required minimum resolution is:"
67+
" %(width)sx%(height)s px."
68+
)

tests/conftest.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,12 @@
99

1010

1111
@pytest.fixture
12-
def imagedata():
12+
def image_upload_file():
1313
img = Image.new("RGB", (800, 800), (255, 55, 255))
1414

15-
output = io.BytesIO()
16-
img.save(output, format="JPEG")
17-
18-
return output
19-
20-
21-
@pytest.fixture
22-
def image_upload_file(imagedata):
23-
return SimpleUploadedFile("image.jpg", imagedata.getvalue())
15+
with io.BytesIO() as output:
16+
img.save(output, format="JPEG")
17+
return SimpleUploadedFile("image.jpg", output.getvalue())
2418

2519

2620
@pytest.fixture(autouse=True, scope="function")

tests/test_validators.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import io
2+
3+
import pytest
4+
from django.core.exceptions import ValidationError
5+
from django.core.files.uploadedfile import SimpleUploadedFile
6+
from PIL import Image
7+
8+
from pictures import validators
9+
from tests.testapp.models import ValidatorModel
10+
11+
12+
class TestBaseSizeValidator:
13+
def test_init__none(self):
14+
assert validators.MinSizeValidator(None, None).limit_value == (
15+
float("inf"),
16+
float("inf"),
17+
)
18+
19+
def test_compare(self):
20+
assert validators.BaseSizeValidator(None, None).compare(None) == NotImplemented
21+
22+
23+
class TestMaxSizeValidator:
24+
def test_compare__inf(self):
25+
limit_value = float("inf"), float("inf")
26+
instance = validators.MaxSizeValidator(*limit_value)
27+
assert not instance.compare((300, 200), limit_value)
28+
29+
def test_compare__eq(self):
30+
assert not validators.MaxSizeValidator(300, 200).compare((300, 200), (300, 200))
31+
32+
def test_compare__gt(self):
33+
limit_value = 300, 200
34+
instance = validators.MaxSizeValidator(*limit_value)
35+
assert instance.compare((600, 400), limit_value)
36+
assert instance.compare((600, 200), limit_value)
37+
assert instance.compare((300, 400), limit_value)
38+
assert instance.compare((600, 100), limit_value)
39+
assert instance.compare((150, 400), limit_value)
40+
41+
def test_compare__lt(self):
42+
limit_value = 300, 200
43+
instance = validators.MaxSizeValidator(*limit_value)
44+
assert not instance.compare((150, 100), (300, 200))
45+
assert not instance.compare((300, 100), (300, 200))
46+
assert not instance.compare((150, 200), (300, 200))
47+
48+
@pytest.mark.django_db
49+
def test_integration(self):
50+
img = Image.new("RGB", (800, 800), (255, 55, 255))
51+
52+
with io.BytesIO() as output:
53+
img.save(output, format="JPEG")
54+
file = SimpleUploadedFile("image.jpg", output.getvalue())
55+
56+
obj = ValidatorModel(picture=file)
57+
with pytest.raises(ValidationError) as e:
58+
obj.full_clean()
59+
60+
assert "The required maximum resolution is: 800x600 px." in str(
61+
e.value.error_dict["picture"][0]
62+
)
63+
64+
65+
class TestMinSizeValidator:
66+
def test_compare__inf(self):
67+
limit_value = float("inf"), float("inf")
68+
instance = validators.MinSizeValidator(*limit_value)
69+
assert instance.compare((300, 200), limit_value)
70+
71+
def test_compare__eq(self):
72+
assert not validators.MinSizeValidator(300, 200).compare((300, 200), (300, 200))
73+
74+
def test_compare__gt(self):
75+
limit_value = 300, 200
76+
instance = validators.MinSizeValidator(*limit_value)
77+
assert not instance.compare((600, 400), limit_value)
78+
assert not instance.compare((600, 200), limit_value)
79+
assert not instance.compare((300, 400), limit_value)
80+
assert instance.compare((600, 100), limit_value)
81+
assert instance.compare((150, 400), limit_value)
82+
83+
def test_compare__lt(self):
84+
limit_value = 300, 200
85+
instance = validators.MinSizeValidator(*limit_value)
86+
assert instance.compare((150, 100), (300, 200))
87+
assert instance.compare((300, 100), (300, 200))
88+
assert instance.compare((150, 200), (300, 200))
89+
90+
@pytest.mark.django_db
91+
def test_integration(self):
92+
img = Image.new("RGB", (300, 200), (255, 55, 255))
93+
94+
with io.BytesIO() as output:
95+
img.save(output, format="JPEG")
96+
file = SimpleUploadedFile("image.jpg", output.getvalue())
97+
98+
obj = ValidatorModel(picture=file)
99+
with pytest.raises(ValidationError) as e:
100+
obj.full_clean()
101+
102+
assert "The required minimum resolution is: 400x300 px." in str(
103+
e.value.error_dict["picture"][0]
104+
)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Generated by Django 4.1 on 2022-08-06 11:46
2+
3+
from django.db import migrations, models
4+
5+
import pictures.models
6+
import pictures.validators
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("testapp", "0002_alter_profile_picture"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="ValidatorModel",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
("picture_width", models.PositiveIntegerField(null=True)),
29+
("picture_height", models.PositiveIntegerField(null=True)),
30+
(
31+
"picture",
32+
pictures.models.PictureField(
33+
aspect_ratios=[None, "3/2", "16/9"],
34+
blank=True,
35+
breakpoints={
36+
"l": 1200,
37+
"m": 992,
38+
"s": 768,
39+
"xl": 1400,
40+
"xs": 576,
41+
},
42+
container_width=1200,
43+
file_types=["WEBP"],
44+
grid_columns=12,
45+
height_field="picture_height",
46+
null=True,
47+
pixel_densities=[1, 2],
48+
upload_to="testapp/simplemodel/",
49+
validators=[
50+
pictures.validators.MaxSizeValidator(800, 600),
51+
pictures.validators.MinSizeValidator(400, 300),
52+
],
53+
width_field="picture_width",
54+
),
55+
),
56+
],
57+
),
58+
]

tests/testapp/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.db import models
22
from django.urls import reverse
33

4+
from pictures import validators
45
from pictures.models import PictureField
56

67

@@ -25,3 +26,20 @@ class Profile(models.Model):
2526

2627
def get_absolute_url(self):
2728
return reverse("profile_detail", kwargs={"pk": self.pk})
29+
30+
31+
class ValidatorModel(models.Model):
32+
picture_width = models.PositiveIntegerField(null=True)
33+
picture_height = models.PositiveIntegerField(null=True)
34+
picture = PictureField(
35+
upload_to="testapp/simplemodel/",
36+
aspect_ratios=[None, "3/2", "16/9"],
37+
width_field="picture_width",
38+
height_field="picture_height",
39+
validators=[
40+
validators.MaxSizeValidator(800, 600),
41+
validators.MinSizeValidator(400, 300),
42+
],
43+
blank=True,
44+
null=True,
45+
)

0 commit comments

Comments
 (0)