Skip to content

Commit 8eebd18

Browse files
amurekicodingjoe
andauthored
Extend DRF PictureField to accept parameters (#54)
You may provide optional GET parameters to the serializer, to specify the aspect ratio and breakpoints you want to include in the response. The parameters are prefixed with the fieldname_ to avoid conflicts with other fields. Co-authored-by: Johannes Maron <[email protected]>
1 parent 47ee91c commit 8eebd18

File tree

3 files changed

+196
-48
lines changed

3 files changed

+196
-48
lines changed

README.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ Responsive cross-browser image library using modern codes like AVIF & WebP.
99
* serve files with or without a CDN
1010
* placeholders for local development
1111
* migration support
12-
* async image processing for Celery or Dramatiq
12+
* async image processing for [Celery] or [Dramatiq]
13+
* [DRF] support
1314

1415
[![PyPi Version](https://img.shields.io/pypi/v/django-pictures.svg)](https://pypi.python.org/pypi/django-pictures/)
1516
[![Test Coverage](https://codecov.io/gh/codingjoe/django-pictures/branch/main/graph/badge.svg)](https://codecov.io/gh/codingjoe/django-pictures)
@@ -196,7 +197,7 @@ You can follow [the example][migration] in our test app, to see how it works.
196197

197198
## Contrib
198199

199-
### Django Rest Framework (DRF)
200+
### Django Rest Framework ([DRF])
200201

201202
We do ship with a read-only `PictureField` that can be used to include all
202203
available picture sizes in a DRF serializer.
@@ -209,7 +210,44 @@ class PictureSerializer(serializers.Serializer):
209210
picture = PictureField()
210211
```
211212

213+
You may provide optional GET parameters to the serializer, to specify the aspect
214+
ratio and breakpoints you want to include in the response. The parameters are
215+
prefixed with the `fieldname_` to avoid conflicts with other fields.
216+
217+
```bash
218+
curl http://localhost:8000/api/path/?picture_ratio=16%2F9&picture_m=6&picture_l=4
219+
# %2F is the url encoded slash
220+
```
221+
222+
```json
223+
{
224+
"other_fields": "",
225+
"picture": {
226+
"url": "/path/to/image.jpg",
227+
"width": 800,
228+
"height": 800,
229+
"ratios": {
230+
"1/1": {
231+
"sources": {
232+
"image/webp": {
233+
"100": "/path/to/image/1/100w.webp",
234+
"200": ""
235+
}
236+
},
237+
"media": "(min-width: 0px) and (max-width: 991px) 100vw, (min-width: 992px) and (max-width: 1199px) 33vw, 25vw"
238+
}
239+
}
240+
}
241+
}
242+
```
243+
244+
Note that the `media` keys are only included, if you have specified breakpoints.
245+
212246
### Django Cleanup
213247

214248
`PictureField` is compatible with [Django Cleanup](https://github.com/un1t/django-cleanup),
215249
which automatically deletes its file and corresponding `SimplePicture` files.
250+
251+
[drf]: https://www.django-rest-framework.org/
252+
[celery]: https://docs.celeryproject.org/en/stable/
253+
[dramatiq]: https://dramatiq.io/

pictures/contrib/rest_framework.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
__all__ = ["PictureField"]
66

7+
from pictures import utils
8+
from pictures.conf import get_settings
79
from pictures.models import PictureFieldFile, SimplePicture
810

911

@@ -17,4 +19,42 @@ class PictureField(serializers.ReadOnlyField):
1719
"""Read-only field for all aspect ratios and sizes of the image."""
1820

1921
def to_representation(self, obj: PictureFieldFile):
20-
return json.loads(json.dumps(obj.aspect_ratios, default=default))
22+
payload = {
23+
"url": obj.url,
24+
"width": obj.width,
25+
"height": obj.height,
26+
"ratios": {
27+
ratio: {
28+
"sources": {
29+
f"image/{file_type.lower()}": sizes
30+
for file_type, sizes in sources.items()
31+
},
32+
}
33+
for ratio, sources in obj.aspect_ratios.items()
34+
},
35+
}
36+
try:
37+
query_params = self.context["request"].GET
38+
except KeyError:
39+
pass
40+
else:
41+
ratio = query_params.get(f"{self.source}_ratio")
42+
container = query_params.get(f"{self.source}_container")
43+
breakpoints = {
44+
bp: int(query_params.get(f"{self.source}_{bp}"))
45+
for bp in get_settings().BREAKPOINTS
46+
if f"{self.source}_{bp}" in query_params
47+
}
48+
if ratio is not None:
49+
try:
50+
payload["ratios"] = {ratio: payload["ratios"][ratio]}
51+
except KeyError as e:
52+
raise ValueError(
53+
f"Invalid ratio: {ratio}. Choices are: {', '.join(filter(None, obj.aspect_ratios.keys()))}"
54+
) from e
55+
else:
56+
payload["ratios"][ratio]["media"] = utils.sizes(
57+
container_width=container, **breakpoints
58+
)
59+
60+
return json.loads(json.dumps(payload, default=default))

tests/contrib/test_rest_framework.py

Lines changed: 115 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -47,53 +47,123 @@ def test_to_representation(self, image_upload_file, settings):
4747

4848
profile = models.Profile.objects.create(picture=image_upload_file)
4949
serializer = ProfileSerializer(profile)
50+
5051
assert serializer.data["picture"] == {
51-
"null": {
52-
"WEBP": {
53-
"800": "/media/testapp/profile/image/800w.webp",
54-
"100": "/media/testapp/profile/image/100w.webp",
55-
"200": "/media/testapp/profile/image/200w.webp",
56-
"300": "/media/testapp/profile/image/300w.webp",
57-
"400": "/media/testapp/profile/image/400w.webp",
58-
"500": "/media/testapp/profile/image/500w.webp",
59-
"600": "/media/testapp/profile/image/600w.webp",
60-
"700": "/media/testapp/profile/image/700w.webp",
61-
}
62-
},
63-
"1/1": {
64-
"WEBP": {
65-
"800": "/media/testapp/profile/image/1/800w.webp",
66-
"100": "/media/testapp/profile/image/1/100w.webp",
67-
"200": "/media/testapp/profile/image/1/200w.webp",
68-
"300": "/media/testapp/profile/image/1/300w.webp",
69-
"400": "/media/testapp/profile/image/1/400w.webp",
70-
"500": "/media/testapp/profile/image/1/500w.webp",
71-
"600": "/media/testapp/profile/image/1/600w.webp",
72-
"700": "/media/testapp/profile/image/1/700w.webp",
73-
}
74-
},
75-
"3/2": {
76-
"WEBP": {
77-
"800": "/media/testapp/profile/image/3_2/800w.webp",
78-
"100": "/media/testapp/profile/image/3_2/100w.webp",
79-
"200": "/media/testapp/profile/image/3_2/200w.webp",
80-
"300": "/media/testapp/profile/image/3_2/300w.webp",
81-
"400": "/media/testapp/profile/image/3_2/400w.webp",
82-
"500": "/media/testapp/profile/image/3_2/500w.webp",
83-
"600": "/media/testapp/profile/image/3_2/600w.webp",
84-
"700": "/media/testapp/profile/image/3_2/700w.webp",
85-
}
52+
"url": "/media/testapp/profile/image.jpg",
53+
"width": 800,
54+
"height": 800,
55+
"ratios": {
56+
"null": {
57+
"sources": {
58+
"image/webp": {
59+
"800": "/media/testapp/profile/image/800w.webp",
60+
"100": "/media/testapp/profile/image/100w.webp",
61+
"200": "/media/testapp/profile/image/200w.webp",
62+
"300": "/media/testapp/profile/image/300w.webp",
63+
"400": "/media/testapp/profile/image/400w.webp",
64+
"500": "/media/testapp/profile/image/500w.webp",
65+
"600": "/media/testapp/profile/image/600w.webp",
66+
"700": "/media/testapp/profile/image/700w.webp",
67+
}
68+
}
69+
},
70+
"1/1": {
71+
"sources": {
72+
"image/webp": {
73+
"800": "/media/testapp/profile/image/1/800w.webp",
74+
"100": "/media/testapp/profile/image/1/100w.webp",
75+
"200": "/media/testapp/profile/image/1/200w.webp",
76+
"300": "/media/testapp/profile/image/1/300w.webp",
77+
"400": "/media/testapp/profile/image/1/400w.webp",
78+
"500": "/media/testapp/profile/image/1/500w.webp",
79+
"600": "/media/testapp/profile/image/1/600w.webp",
80+
"700": "/media/testapp/profile/image/1/700w.webp",
81+
}
82+
}
83+
},
84+
"3/2": {
85+
"sources": {
86+
"image/webp": {
87+
"800": "/media/testapp/profile/image/3_2/800w.webp",
88+
"100": "/media/testapp/profile/image/3_2/100w.webp",
89+
"200": "/media/testapp/profile/image/3_2/200w.webp",
90+
"300": "/media/testapp/profile/image/3_2/300w.webp",
91+
"400": "/media/testapp/profile/image/3_2/400w.webp",
92+
"500": "/media/testapp/profile/image/3_2/500w.webp",
93+
"600": "/media/testapp/profile/image/3_2/600w.webp",
94+
"700": "/media/testapp/profile/image/3_2/700w.webp",
95+
}
96+
}
97+
},
98+
"16/9": {
99+
"sources": {
100+
"image/webp": {
101+
"800": "/media/testapp/profile/image/16_9/800w.webp",
102+
"100": "/media/testapp/profile/image/16_9/100w.webp",
103+
"200": "/media/testapp/profile/image/16_9/200w.webp",
104+
"300": "/media/testapp/profile/image/16_9/300w.webp",
105+
"400": "/media/testapp/profile/image/16_9/400w.webp",
106+
"500": "/media/testapp/profile/image/16_9/500w.webp",
107+
"600": "/media/testapp/profile/image/16_9/600w.webp",
108+
"700": "/media/testapp/profile/image/16_9/700w.webp",
109+
}
110+
}
111+
},
86112
},
87-
"16/9": {
88-
"WEBP": {
89-
"800": "/media/testapp/profile/image/16_9/800w.webp",
90-
"100": "/media/testapp/profile/image/16_9/100w.webp",
91-
"200": "/media/testapp/profile/image/16_9/200w.webp",
92-
"300": "/media/testapp/profile/image/16_9/300w.webp",
93-
"400": "/media/testapp/profile/image/16_9/400w.webp",
94-
"500": "/media/testapp/profile/image/16_9/500w.webp",
95-
"600": "/media/testapp/profile/image/16_9/600w.webp",
96-
"700": "/media/testapp/profile/image/16_9/700w.webp",
113+
}
114+
115+
@pytest.mark.django_db
116+
def test_to_representation__with_aspect_ratios(
117+
self, rf, image_upload_file, settings
118+
):
119+
settings.PICTURES["USE_PLACEHOLDERS"] = False
120+
121+
profile = models.Profile.objects.create(picture=image_upload_file)
122+
request = rf.get("/")
123+
request.GET._mutable = True
124+
request.GET["picture_ratio"] = "1/1"
125+
request.GET["picture_l"] = "3"
126+
request.GET["picture_m"] = "4"
127+
serializer = ProfileSerializer(profile, context={"request": request})
128+
129+
assert serializer.data["picture"] == {
130+
"url": "/media/testapp/profile/image.jpg",
131+
"width": 800,
132+
"height": 800,
133+
"ratios": {
134+
"1/1": {
135+
"sources": {
136+
"image/webp": {
137+
"800": "/media/testapp/profile/image/1/800w.webp",
138+
"100": "/media/testapp/profile/image/1/100w.webp",
139+
"200": "/media/testapp/profile/image/1/200w.webp",
140+
"300": "/media/testapp/profile/image/1/300w.webp",
141+
"400": "/media/testapp/profile/image/1/400w.webp",
142+
"500": "/media/testapp/profile/image/1/500w.webp",
143+
"600": "/media/testapp/profile/image/1/600w.webp",
144+
"700": "/media/testapp/profile/image/1/700w.webp",
145+
}
146+
},
147+
"media": "(min-width: 0px) and (max-width: 991px) 100vw, (min-width: 992px) and (max-width: 1199px) 33vw, 25vw",
97148
}
98149
},
99150
}
151+
152+
@pytest.mark.django_db
153+
def test_to_representation__raise_value_error(
154+
self, rf, image_upload_file, settings
155+
):
156+
settings.PICTURES["USE_PLACEHOLDERS"] = False
157+
158+
profile = models.Profile.objects.create(picture=image_upload_file)
159+
request = rf.get("/")
160+
request.GET._mutable = True
161+
request.GET["picture_ratio"] = "21/11"
162+
request.GET["picture_l"] = "3"
163+
request.GET["picture_m"] = "4"
164+
serializer = ProfileSerializer(profile, context={"request": request})
165+
166+
with pytest.raises(ValueError) as e:
167+
serializer.data["picture"]
168+
169+
assert str(e.value) == "Invalid ratio: 21/11. Choices are: 1/1, 3/2, 16/9"

0 commit comments

Comments
 (0)