Skip to content

Commit 6cd32cd

Browse files
committed
Merge branch 'main' into process_picture_signal
2 parents 1d6b82e + e9b6583 commit 6cd32cd

File tree

10 files changed

+178
-29
lines changed

10 files changed

+178
-29
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ jobs:
4646
strategy:
4747
matrix:
4848
python-version:
49-
- "3.9"
5049
- "3.10"
50+
- "3.11"
5151
- "3.12"
5252
- "3.13"
53+
- "3.14"
5354
django-version:
54-
- "4.2" # LTS
55+
- "5.2" # LTS
5556
runs-on: ubuntu-latest
5657
steps:
5758
- uses: actions/checkout@v6
@@ -71,11 +72,11 @@ jobs:
7172
strategy:
7273
matrix:
7374
python-version:
74-
- "3.12"
75+
- "3.10"
7576
django-version:
7677
# LTS gets tested on all OS
78+
- "4.2"
7779
- "5.1"
78-
- "5.2"
7980
runs-on: ubuntu-latest
8081
steps:
8182
- uses: actions/checkout@v6

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ from pictures.models import PictureField
3333

3434
class Profile(models.Model):
3535
title = models.CharField(max_length=255)
36-
picture = PictureField(upload_to="avatars")
36+
picture = PictureField(
37+
upload_to="avatars", width_field="picture_width", height_field="picture_height"
38+
)
39+
picture_width = models.PositiveIntegerField(editable=False)
40+
picture_height = models.PositiveIntegerField(editable=False)
3741
```
3842

3943
```html
@@ -157,7 +161,11 @@ class Profile(models.Model):
157161
picture = PictureField(
158162
upload_to="avatars",
159163
aspect_ratios=[None, "1/1", "3/2", "16/9"],
164+
width_field="picture_width",
165+
height_field="picture_height",
160166
)
167+
picture_width = models.PositiveIntegerField(editable=False)
168+
picture_height = models.PositiveIntegerField(editable=False)
161169
```
162170

163171
```html
@@ -235,10 +243,15 @@ class Profile(models.Model):
235243
validators=[
236244
MinSizeValidator(400, 300), # At least 400x300 pixels
237245
MaxSizeValidator(4096, 4096), # At most 4096x4096 pixels
238-
]
246+
],
247+
width_field="picture_width",
248+
height_field="picture_height",
239249
)
250+
picture_width = models.PositiveIntegerField(editable=False)
251+
picture_height = models.PositiveIntegerField(editable=False)
252+
240253

241-
Use `None` to limit only one dimension: `MaxSizeValidator(2048, None)` limits only width.
254+
# Use `None` to limit only one dimension: `MaxSizeValidator(2048, None)` limits only width.
242255
```
243256

244257
> [!IMPORTANT]

pictures/migrations.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ def update_pictures(self, from_field: PictureField, to_model: type[models.Model]
5656
new_field_file.update_all(old_field_file)
5757

5858
def from_picture_field(self, from_model: type[models.Model]):
59-
for obj in from_model._default_manager.all().iterator():
59+
for obj in from_model._default_manager.exclude(
60+
Q(**{self.name: ""}) | Q(**{self.name: None})
61+
).iterator():
6062
field_file = getattr(obj, self.name)
6163
field_file.delete_all()
6264

@@ -66,9 +68,13 @@ def to_picture_field(
6668
from_field = from_model._meta.get_field(self.name)
6769
if hasattr(from_field.attr_class, "delete_variations"):
6870
# remove obsolete django-stdimage variations
69-
for obj in from_model._default_manager.all().iterator():
71+
for obj in from_model._default_manager.exclude(
72+
Q(**{self.name: ""}) | Q(**{self.name: None})
73+
).iterator():
7074
field_file = getattr(obj, self.name)
7175
field_file.delete_variations()
72-
for obj in to_model._default_manager.all().iterator():
76+
for obj in to_model._default_manager.exclude(
77+
Q(**{self.name: ""}) | Q(**{self.name: None})
78+
).iterator():
7379
field_file = getattr(obj, self.name)
7480
field_file.save_all()

pictures/models.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import math
77
from fractions import Fraction
88
from pathlib import Path
9+
from types import NotImplementedType
910

1011
from django.core import checks
1112
from django.core.files.base import ContentFile
@@ -105,7 +106,7 @@ def name(self) -> str:
105106
def path(self) -> Path:
106107
return Path(self.storage.path(self.name))
107108

108-
def process(self, image) -> Image:
109+
def process(self, image) -> Image.Image:
109110
image = ImageOps.exif_transpose(image) # crates a copy
110111
height = self.height or self.width / Fraction(*image.size)
111112
size = math.floor(self.width), math.floor(height)
@@ -130,7 +131,7 @@ def delete(self):
130131

131132

132133
class PictureFieldFile(ImageFieldFile):
133-
def __xor__(self, other) -> tuple[set[Picture], set[Picture]]:
134+
def __xor__(self, other) -> tuple[set[Picture], set[Picture]] | NotImplementedType:
134135
"""Return the new and obsolete :class:`Picture` instances."""
135136
if not isinstance(other, PictureFieldFile):
136137
return NotImplemented
@@ -202,7 +203,7 @@ def height(self):
202203
return self._get_image_dimensions()[1]
203204

204205
@property
205-
def aspect_ratios(self) -> {Fraction | None: {str: {int: Picture}}}:
206+
def aspect_ratios(self) -> dict[Fraction | None, dict[str, dict[int, Picture]]]:
206207
self._require_file()
207208
return self.get_picture_files(
208209
file_name=self.name,
@@ -220,7 +221,7 @@ def get_picture_files(
220221
img_height: int,
221222
storage: Storage,
222223
field: PictureField,
223-
) -> {Fraction | None: {str: {int: Picture}}}:
224+
) -> dict[Fraction | None, dict[str, dict[int, Picture]]]:
224225
PictureClass = import_string(conf.get_settings().PICTURE_CLASS)
225226
return {
226227
ratio: {
@@ -301,7 +302,7 @@ def _check_aspect_ratios(self):
301302
return errors
302303

303304
def _check_width_height_field(self):
304-
if None in self.aspect_ratios and not (self.width_field and self.height_field):
305+
if not (self.width_field and self.height_field):
305306
return [
306307
checks.Warning(
307308
"width_field and height_field attributes are missing",

pictures/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def sizes(
6161

6262

6363
def source_set(
64-
size: (int, int), *, ratio: str | Fraction | None, max_width: int, cols: int
64+
size: tuple[int, int], *, ratio: str | Fraction | None, max_width: int, cols: int
6565
) -> set:
6666
ratio = Fraction(ratio) if ratio else None
6767
img_width, img_height = size
@@ -82,8 +82,8 @@ def placeholder(width: int, height: int, alt):
8282
hue = random.randint(0, 360) # NoQA S311
8383
img = Image.new("RGB", (width, height), color=f"hsl({hue}, 40%, 80%)")
8484
draw = ImageDraw.Draw(img)
85-
draw.line(((0, 0, width, height)), width=3, fill=f"hsl({hue}, 60%, 20%)")
86-
draw.line(((0, height, width, 0)), width=3, fill=f"hsl({hue}, 60%, 20%)")
85+
draw.line((0, 0, width, height), width=3, fill=f"hsl({hue}, 60%, 20%)")
86+
draw.line((0, height, width, 0), width=3, fill=f"hsl({hue}, 60%, 20%)")
8787
draw.rectangle(
8888
(width / 4, height / 4, width * 3 / 4, height * 3 / 4),
8989
fill=f"hsl({hue}, 40%, 80%)",

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,17 @@ classifiers = [
2323
"Topic :: Software Development",
2424
"Programming Language :: Python :: 3",
2525
"Programming Language :: Python :: 3 :: Only",
26-
"Programming Language :: Python :: 3.9",
2726
"Programming Language :: Python :: 3.10",
2827
"Programming Language :: Python :: 3.11",
2928
"Programming Language :: Python :: 3.12",
3029
"Programming Language :: Python :: 3.13",
30+
"Programming Language :: Python :: 3.14",
3131
"Framework :: Django",
3232
"Framework :: Django :: 4.2",
3333
"Framework :: Django :: 5.1",
3434
"Framework :: Django :: 5.2",
3535
]
36-
requires-python = ">=3.9"
36+
requires-python = ">=3.10"
3737
dependencies = ["django>=4.2.0", "pillow>=11.3.0"]
3838

3939
[project.optional-dependencies]
@@ -44,7 +44,7 @@ test = [
4444
"redis",
4545
]
4646
dramatiq = [
47-
"dramatiq<2",
47+
"dramatiq",
4848
"django-dramatiq",
4949
]
5050
celery = [

tests/test_migrations.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,54 @@ class Meta:
171171

172172
assert not luke.picture
173173

174+
@pytest.mark.django_db
175+
def test_update_pictures__with_empty_pictures(
176+
self, request, stub_worker, image_upload_file
177+
):
178+
"""Test that update_pictures skips objects with empty/null pictures."""
179+
180+
class ToModel(models.Model):
181+
name = models.CharField(max_length=100)
182+
picture = PictureField(
183+
upload_to="testapp/profile/", aspect_ratios=[None, "21/9"], blank=True
184+
)
185+
186+
class Meta:
187+
app_label = request.node.name
188+
db_table = "testapp_profile"
189+
190+
# Create profiles with different picture states
191+
luke = Profile.objects.create(name="Luke", picture=image_upload_file)
192+
leia = Profile.objects.create(name="Leia", picture="")
193+
han = Profile.objects.create(name="Han", picture=None)
194+
stub_worker.join()
195+
196+
path = luke.picture.aspect_ratios["16/9"]["AVIF"][100].path
197+
assert path.exists()
198+
199+
migration = migrations.AlterPictureField("profile", "picture", PictureField())
200+
from_field = Profile._meta.get_field("picture")
201+
202+
# This should not fail despite empty/null pictures
203+
migration.update_pictures(from_field, ToModel)
204+
stub_worker.join()
205+
206+
# Verify old path was deleted and new one was created for luke
207+
assert not path.exists()
208+
luke.refresh_from_db()
209+
path = (
210+
ToModel.objects.get(pk=luke.pk)
211+
.picture.aspect_ratios["21/9"]["AVIF"][100]
212+
.path
213+
)
214+
assert path.exists()
215+
216+
# Verify empty profiles still exist and remain empty
217+
leia_profile = Profile.objects.get(pk=leia.pk)
218+
assert not leia_profile.picture
219+
han_profile = Profile.objects.get(pk=han.pk)
220+
assert not han_profile.picture
221+
174222
@pytest.mark.django_db
175223
def test_from_picture_field(self, stub_worker, image_upload_file):
176224
luke = Profile.objects.create(name="Luke", picture=image_upload_file)
@@ -182,6 +230,30 @@ def test_from_picture_field(self, stub_worker, image_upload_file):
182230
stub_worker.join()
183231
assert not path.exists()
184232

233+
@pytest.mark.django_db
234+
def test_from_picture_field__with_empty_pictures(
235+
self, stub_worker, image_upload_file
236+
):
237+
"""Test that from_picture_field skips objects with empty/null pictures."""
238+
# Create profiles with different picture states
239+
luke = Profile.objects.create(name="Luke", picture=image_upload_file)
240+
Profile.objects.create(name="Leia", picture="")
241+
Profile.objects.create(name="Han", picture=None)
242+
stub_worker.join()
243+
244+
path = luke.picture.aspect_ratios["16/9"]["AVIF"][100].path
245+
assert path.exists()
246+
247+
migration = migrations.AlterPictureField("profile", "picture", PictureField())
248+
# This should not fail despite empty/null pictures
249+
migration.from_picture_field(Profile)
250+
stub_worker.join()
251+
252+
assert not path.exists()
253+
# Verify other profiles still exist and weren't affected
254+
assert Profile.objects.filter(name="Leia").exists()
255+
assert Profile.objects.filter(name="Han").exists()
256+
185257
@pytest.mark.django_db
186258
def test_to_picture_field(self, request, stub_worker, image_upload_file):
187259
class FromModel(models.Model):
@@ -234,6 +306,53 @@ class Meta:
234306
migration = migrations.AlterPictureField("profile", "picture", PictureField())
235307
migration.to_picture_field(FromModel, Profile)
236308

309+
@pytest.mark.django_db
310+
def test_to_picture_field__with_empty_pictures(
311+
self, request, stub_worker, image_upload_file
312+
):
313+
"""Test that to_picture_field skips objects with empty/null pictures."""
314+
315+
class FromModel(models.Model):
316+
picture = models.ImageField(blank=True)
317+
318+
class Meta:
319+
app_label = request.node.name
320+
db_table = "testapp_profile"
321+
322+
class ToModel(models.Model):
323+
name = models.CharField(max_length=100)
324+
picture = models.ImageField(upload_to="testapp/profile/", blank=True)
325+
326+
class Meta:
327+
app_label = request.node.name
328+
db_table = "testapp_profile"
329+
330+
# Create profiles with different picture states
331+
luke = ToModel.objects.create(name="Luke", picture=image_upload_file)
332+
leia = ToModel.objects.create(name="Leia", picture="")
333+
han = ToModel.objects.create(name="Han", picture=None)
334+
stub_worker.join()
335+
336+
migration = migrations.AlterPictureField("profile", "picture", PictureField())
337+
# This should not fail despite empty/null pictures
338+
migration.to_picture_field(FromModel, Profile)
339+
stub_worker.join()
340+
341+
luke.refresh_from_db()
342+
# Verify only luke's picture was processed
343+
path = (
344+
Profile.objects.get(pk=luke.pk)
345+
.picture.aspect_ratios["16/9"]["AVIF"][100]
346+
.path
347+
)
348+
assert path.exists()
349+
350+
# Verify empty profiles still exist
351+
leia_profile = Profile.objects.get(pk=leia.pk)
352+
assert not leia_profile.picture
353+
han_profile = Profile.objects.get(pk=han.pk)
354+
assert not han_profile.picture
355+
237356
@pytest.mark.django_db
238357
def test_to_picture_field__from_stdimage(
239358
self, request, stub_worker, image_upload_file

tests/test_models.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -825,9 +825,19 @@ def test_check_aspect_ratios(self):
825825
assert errors
826826
assert errors[0].id == "fields.E100"
827827

828-
def test_check_width_height_field(self):
829-
assert not PictureField(aspect_ratios=["3/2"])._check_width_height_field()
830-
with override_field_aspect_ratios(Profile.picture.field, [None]):
828+
@pytest.mark.parametrize(
829+
"aspect_ratios",
830+
[
831+
("3/2",),
832+
(None,),
833+
(
834+
"3/2",
835+
None,
836+
),
837+
],
838+
)
839+
def test_check_width_height_field(self, aspect_ratios):
840+
with override_field_aspect_ratios(Profile.picture.field, aspect_ratios):
831841
errors = Profile.picture.field._check_width_height_field()
832842
assert errors
833843
assert errors[0].id == "fields.E101"

tests/test_utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,14 @@ def test_placeholder():
145145
assert img.height == 1200
146146

147147

148-
class TestPicture(Picture):
148+
class SamplePicture(Picture):
149149
@property
150150
def url(self):
151151
return f"/media/{self.parent_name}"
152152

153153

154154
def test_reconstruct(image_upload_file):
155-
picture = TestPicture(
155+
picture = SamplePicture(
156156
image_upload_file.name,
157157
"WEBP",
158158
"16/9",
@@ -164,7 +164,7 @@ def test_reconstruct(image_upload_file):
164164
assert isinstance(reconstructed, Storage)
165165

166166
assert utils.reconstruct(
167-
"tests.test_utils.TestPicture",
167+
"tests.test_utils.SamplePicture",
168168
[],
169169
{
170170
"parent_name": "test.jpg",
@@ -173,7 +173,7 @@ def test_reconstruct(image_upload_file):
173173
"storage": default_storage,
174174
"width": 100,
175175
},
176-
) == TestPicture(
176+
) == SamplePicture(
177177
"test.jpg",
178178
"JPEG",
179179
"16/9",

0 commit comments

Comments
 (0)