Skip to content

Commit

Permalink
Extend DRF PictureField to accept parameters (#54)
Browse files Browse the repository at this point in the history
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 <johannes@maron.family>
  • Loading branch information
amureki and codingjoe authored Oct 25, 2022
1 parent 47ee91c commit 8eebd18
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 48 deletions.
42 changes: 40 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ Responsive cross-browser image library using modern codes like AVIF & WebP.
* serve files with or without a CDN
* placeholders for local development
* migration support
* async image processing for Celery or Dramatiq
* async image processing for [Celery] or [Dramatiq]
* [DRF] support

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

## Contrib

### Django Rest Framework (DRF)
### Django Rest Framework ([DRF])

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

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.

```bash
curl http://localhost:8000/api/path/?picture_ratio=16%2F9&picture_m=6&picture_l=4
# %2F is the url encoded slash
```

```json
{
"other_fields": "",
"picture": {
"url": "/path/to/image.jpg",
"width": 800,
"height": 800,
"ratios": {
"1/1": {
"sources": {
"image/webp": {
"100": "/path/to/image/1/100w.webp",
"200": ""
}
},
"media": "(min-width: 0px) and (max-width: 991px) 100vw, (min-width: 992px) and (max-width: 1199px) 33vw, 25vw"
}
}
}
}
```

Note that the `media` keys are only included, if you have specified breakpoints.

### Django Cleanup

`PictureField` is compatible with [Django Cleanup](https://github.com/un1t/django-cleanup),
which automatically deletes its file and corresponding `SimplePicture` files.

[drf]: https://www.django-rest-framework.org/
[celery]: https://docs.celeryproject.org/en/stable/
[dramatiq]: https://dramatiq.io/
42 changes: 41 additions & 1 deletion pictures/contrib/rest_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

__all__ = ["PictureField"]

from pictures import utils
from pictures.conf import get_settings
from pictures.models import PictureFieldFile, SimplePicture


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

def to_representation(self, obj: PictureFieldFile):
return json.loads(json.dumps(obj.aspect_ratios, default=default))
payload = {
"url": obj.url,
"width": obj.width,
"height": obj.height,
"ratios": {
ratio: {
"sources": {
f"image/{file_type.lower()}": sizes
for file_type, sizes in sources.items()
},
}
for ratio, sources in obj.aspect_ratios.items()
},
}
try:
query_params = self.context["request"].GET
except KeyError:
pass
else:
ratio = query_params.get(f"{self.source}_ratio")
container = query_params.get(f"{self.source}_container")
breakpoints = {
bp: int(query_params.get(f"{self.source}_{bp}"))
for bp in get_settings().BREAKPOINTS
if f"{self.source}_{bp}" in query_params
}
if ratio is not None:
try:
payload["ratios"] = {ratio: payload["ratios"][ratio]}
except KeyError as e:
raise ValueError(
f"Invalid ratio: {ratio}. Choices are: {', '.join(filter(None, obj.aspect_ratios.keys()))}"
) from e
else:
payload["ratios"][ratio]["media"] = utils.sizes(
container_width=container, **breakpoints
)

return json.loads(json.dumps(payload, default=default))
160 changes: 115 additions & 45 deletions tests/contrib/test_rest_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,53 +47,123 @@ def test_to_representation(self, image_upload_file, settings):

profile = models.Profile.objects.create(picture=image_upload_file)
serializer = ProfileSerializer(profile)

assert serializer.data["picture"] == {
"null": {
"WEBP": {
"800": "/media/testapp/profile/image/800w.webp",
"100": "/media/testapp/profile/image/100w.webp",
"200": "/media/testapp/profile/image/200w.webp",
"300": "/media/testapp/profile/image/300w.webp",
"400": "/media/testapp/profile/image/400w.webp",
"500": "/media/testapp/profile/image/500w.webp",
"600": "/media/testapp/profile/image/600w.webp",
"700": "/media/testapp/profile/image/700w.webp",
}
},
"1/1": {
"WEBP": {
"800": "/media/testapp/profile/image/1/800w.webp",
"100": "/media/testapp/profile/image/1/100w.webp",
"200": "/media/testapp/profile/image/1/200w.webp",
"300": "/media/testapp/profile/image/1/300w.webp",
"400": "/media/testapp/profile/image/1/400w.webp",
"500": "/media/testapp/profile/image/1/500w.webp",
"600": "/media/testapp/profile/image/1/600w.webp",
"700": "/media/testapp/profile/image/1/700w.webp",
}
},
"3/2": {
"WEBP": {
"800": "/media/testapp/profile/image/3_2/800w.webp",
"100": "/media/testapp/profile/image/3_2/100w.webp",
"200": "/media/testapp/profile/image/3_2/200w.webp",
"300": "/media/testapp/profile/image/3_2/300w.webp",
"400": "/media/testapp/profile/image/3_2/400w.webp",
"500": "/media/testapp/profile/image/3_2/500w.webp",
"600": "/media/testapp/profile/image/3_2/600w.webp",
"700": "/media/testapp/profile/image/3_2/700w.webp",
}
"url": "/media/testapp/profile/image.jpg",
"width": 800,
"height": 800,
"ratios": {
"null": {
"sources": {
"image/webp": {
"800": "/media/testapp/profile/image/800w.webp",
"100": "/media/testapp/profile/image/100w.webp",
"200": "/media/testapp/profile/image/200w.webp",
"300": "/media/testapp/profile/image/300w.webp",
"400": "/media/testapp/profile/image/400w.webp",
"500": "/media/testapp/profile/image/500w.webp",
"600": "/media/testapp/profile/image/600w.webp",
"700": "/media/testapp/profile/image/700w.webp",
}
}
},
"1/1": {
"sources": {
"image/webp": {
"800": "/media/testapp/profile/image/1/800w.webp",
"100": "/media/testapp/profile/image/1/100w.webp",
"200": "/media/testapp/profile/image/1/200w.webp",
"300": "/media/testapp/profile/image/1/300w.webp",
"400": "/media/testapp/profile/image/1/400w.webp",
"500": "/media/testapp/profile/image/1/500w.webp",
"600": "/media/testapp/profile/image/1/600w.webp",
"700": "/media/testapp/profile/image/1/700w.webp",
}
}
},
"3/2": {
"sources": {
"image/webp": {
"800": "/media/testapp/profile/image/3_2/800w.webp",
"100": "/media/testapp/profile/image/3_2/100w.webp",
"200": "/media/testapp/profile/image/3_2/200w.webp",
"300": "/media/testapp/profile/image/3_2/300w.webp",
"400": "/media/testapp/profile/image/3_2/400w.webp",
"500": "/media/testapp/profile/image/3_2/500w.webp",
"600": "/media/testapp/profile/image/3_2/600w.webp",
"700": "/media/testapp/profile/image/3_2/700w.webp",
}
}
},
"16/9": {
"sources": {
"image/webp": {
"800": "/media/testapp/profile/image/16_9/800w.webp",
"100": "/media/testapp/profile/image/16_9/100w.webp",
"200": "/media/testapp/profile/image/16_9/200w.webp",
"300": "/media/testapp/profile/image/16_9/300w.webp",
"400": "/media/testapp/profile/image/16_9/400w.webp",
"500": "/media/testapp/profile/image/16_9/500w.webp",
"600": "/media/testapp/profile/image/16_9/600w.webp",
"700": "/media/testapp/profile/image/16_9/700w.webp",
}
}
},
},
"16/9": {
"WEBP": {
"800": "/media/testapp/profile/image/16_9/800w.webp",
"100": "/media/testapp/profile/image/16_9/100w.webp",
"200": "/media/testapp/profile/image/16_9/200w.webp",
"300": "/media/testapp/profile/image/16_9/300w.webp",
"400": "/media/testapp/profile/image/16_9/400w.webp",
"500": "/media/testapp/profile/image/16_9/500w.webp",
"600": "/media/testapp/profile/image/16_9/600w.webp",
"700": "/media/testapp/profile/image/16_9/700w.webp",
}

@pytest.mark.django_db
def test_to_representation__with_aspect_ratios(
self, rf, image_upload_file, settings
):
settings.PICTURES["USE_PLACEHOLDERS"] = False

profile = models.Profile.objects.create(picture=image_upload_file)
request = rf.get("/")
request.GET._mutable = True
request.GET["picture_ratio"] = "1/1"
request.GET["picture_l"] = "3"
request.GET["picture_m"] = "4"
serializer = ProfileSerializer(profile, context={"request": request})

assert serializer.data["picture"] == {
"url": "/media/testapp/profile/image.jpg",
"width": 800,
"height": 800,
"ratios": {
"1/1": {
"sources": {
"image/webp": {
"800": "/media/testapp/profile/image/1/800w.webp",
"100": "/media/testapp/profile/image/1/100w.webp",
"200": "/media/testapp/profile/image/1/200w.webp",
"300": "/media/testapp/profile/image/1/300w.webp",
"400": "/media/testapp/profile/image/1/400w.webp",
"500": "/media/testapp/profile/image/1/500w.webp",
"600": "/media/testapp/profile/image/1/600w.webp",
"700": "/media/testapp/profile/image/1/700w.webp",
}
},
"media": "(min-width: 0px) and (max-width: 991px) 100vw, (min-width: 992px) and (max-width: 1199px) 33vw, 25vw",
}
},
}

@pytest.mark.django_db
def test_to_representation__raise_value_error(
self, rf, image_upload_file, settings
):
settings.PICTURES["USE_PLACEHOLDERS"] = False

profile = models.Profile.objects.create(picture=image_upload_file)
request = rf.get("/")
request.GET._mutable = True
request.GET["picture_ratio"] = "21/11"
request.GET["picture_l"] = "3"
request.GET["picture_m"] = "4"
serializer = ProfileSerializer(profile, context={"request": request})

with pytest.raises(ValueError) as e:
serializer.data["picture"]

assert str(e.value) == "Invalid ratio: 21/11. Choices are: 1/1, 3/2, 16/9"

0 comments on commit 8eebd18

Please sign in to comment.