Skip to content

Commit 27de86f

Browse files
committed
Refactor viewsets for non-detail use case
1 parent 4c87ad2 commit 27de86f

File tree

9 files changed

+278
-139
lines changed

9 files changed

+278
-139
lines changed

README.md

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ to Django by providing a set of abstract, mixin API viewset classes that will
2727
handle tile serving, fetching metadata from images, and extracting regions of
2828
interest.
2929

30-
`django-large-image` is an optionally installable Django app with
30+
`django-large-image` is an installable Django app with
3131
a few classes that can be mixed into a Django project (or application)'s
32-
drf-based views to provide tile serving endpoints out of the box. Notably,
32+
drf-based viewsets to provide tile serving endpoints out of the box. Notably,
3333
`django-large-image` is designed to work specifically with `FileField`
3434
interfaces with development being tailored to Kitware's
3535
[`S3FileField`](https://github.com/girder/django-s3-file-field). We are working
@@ -89,9 +89,8 @@ pip install \
8989

9090
## Usage
9191

92-
Simply install the app and mixin the `LargeImageViewSetMixin` class to your
93-
existing `django-rest-framework` viewsets and specify the `FILE_FIELD_NAME` as
94-
the string name of the `FileField` in which your image data are saved.
92+
Simply install the app and mixin one of the mixing classes to your
93+
existing `django-rest-framework` viewset.
9594

9695
```py
9796
# settings.py
@@ -101,11 +100,24 @@ INSTALLED_APPS = [
101100
]
102101
```
103102

103+
The following are the provided mixin classes and their use case:
104+
105+
- `LargeImageMixin`: for use with a standard, non-detail `ViewSet`. Users must implement `get_path()`
106+
- `LargeImageDetailMixin`: for use with a detail viewset like `GenericViewSet`. Users must implement `get_path()`
107+
- `LargeImageFileDetailMixin`: (most commonly used) for use with a detail viewset like `GenericViewSet` where the associated model has a `FileField` storing the image data.
108+
- `LargeImageVSIFileDetailMixin`: (geospatial) for use with a detail viewset like `GenericViewSet` where the associated model has a `FileField` storing the image data that is intended to be read with GDAL. This will access the data over GDAL's Virtual File System interface (a VSI path).
109+
110+
Most users will want to use `LargeImageFileDetailMixin` and so the following
111+
example demonstrate how to use it:
112+
113+
Specify the `FILE_FIELD_NAME` as the string name of the `FileField` in which
114+
your image data are saved on the associated model.
115+
104116
```py
105117
# viewsets.py
106-
from django_large_image.rest import LargeImageViewSetMixin
118+
from django_large_image.rest import LargeImageFileDetailMixin
107119

108-
class MyModelViewSet(viewsets.GenericViewSet, LargeImageViewSetMixin):
120+
class MyModelViewSet(viewsets.GenericViewSet, LargeImageFileDetailMixin):
109121
... # configuration for your model's viewset
110122
FILE_FIELD_NAME = 'field_name'
111123
```
@@ -167,13 +179,13 @@ Then create the viewset, mixing in the `django-large-image` viewset class:
167179
from example.core import models
168180
from rest_framework import mixins, viewsets
169181

170-
from django_large_image.rest import LargeImageViewSetMixin
182+
from django_large_image.rest import LargeImageFileDetailMixin
171183

172184

173185
class ImageFileDetailViewSet(
174186
mixins.ListModelMixin,
175187
viewsets.GenericViewSet,
176-
LargeImageViewSetMixin,
188+
LargeImageFileDetailMixin,
177189
):
178190
queryset = models.ImageFile.objects.all()
179191
serializer_class = models.ImageFileSerializer
@@ -222,11 +234,11 @@ repository that shows how to use `django-large-image` in a `girder-4` project.
222234

223235
### Customization
224236

225-
The `BaseLargeImageViewSetMixin` is modularly designed and able to be subclassed
226-
for your project's needs. While the provided `LargeImageViewSetMixin` handles
237+
The mixin classes modularly designed and able to be subclassed
238+
for your project's needs. While the provided `LargeImageFileDetailMixin` handles
227239
`FileField`-interfaces, you can easily extend its base class,
228-
`BaseLargeImageViewSetMixin`, to handle any mechanism of data storage in any
229-
APIView.
240+
`LargeImageDetailMixin`, to handle any mechanism of data storage in your
241+
detail-oriented viewset.
230242

231243
In the following example, I will show how to use GDAL compatible VSI paths
232244
from a model that stores `s3://` or `https://` URLs.
@@ -254,23 +266,53 @@ class URLImageFileSerializer(serializers.ModelSerializer):
254266
from example.core import models
255267
from rest_framework import mixins, viewsets
256268

257-
from django_large_image.rest import BaseLargeImageViewSetMixin
269+
from django_large_image.rest import LargeImageDetailMixin
258270
from django_large_image.utilities import make_vsi
259271

260272

261-
class URLLargeImageViewSetMixin(BaseLargeImageViewSetMixin):
262-
def get_path(self, request, pk):
273+
class URLLargeImageMixin(LargeImageDetailMixin):
274+
def get_path(self, request, pk=None):
263275
object = self.get_object()
264276
return make_vsi(object.url)
265277

266278

267279
class URLImageFileDetailViewSet(
268280
mixins.ListModelMixin,
269281
viewsets.GenericViewSet,
270-
URLLargeImageViewSetMixin,
282+
URLLargeImageMixin,
271283
):
272284
queryset = models.URLImageFile.objects.all()
273285
serializer_class = models.URLImageFileSerializer
274286
```
275287

276288
Here is a good test image: https://oin-hotosm.s3.amazonaws.com/59c66c5223c8440011d7b1e4/0/7ad397c0-bba2-4f98-a08a-931ec3a6e943.tif
289+
290+
291+
#### Non-Detail ViewSets
292+
293+
The `LargeImageMixin` provides a mixin interface for non-detail viewsets (no
294+
associated model or primary key required). This can be particularly useful if
295+
your viewset has custom logic to retrieve the desired data.
296+
297+
For example, you may want a viewset that gets the data path as a URL embedded
298+
in the request's query parameters. To do this, you can make a standard ViewSet
299+
with the `LargeImageMixin` like so:
300+
301+
```py
302+
# viewsets.py
303+
from rest_framework import viewsets
304+
from rest_framework.exceptions import ValidationError
305+
306+
from django_large_image.rest import LargeImageMixin
307+
from django_large_image.utilities import make_vsi
308+
309+
310+
class URLLargeImageViewSet(viewsets.ViewSet, LargeImageMixin):
311+
def get_path(self, request, pk=None):
312+
try:
313+
url = request.query_params.get('url')
314+
except KeyError:
315+
raise ValidationError('url must be defined as a query parameter.')
316+
return make_vsi(url)
317+
318+
```
Lines changed: 6 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,8 @@
11
# flake8: noqa: F401
2-
from functools import wraps
3-
4-
from rest_framework.exceptions import APIException
5-
from rest_framework.request import Request
6-
from rest_framework.viewsets import ViewSet
7-
8-
from django_large_image import utilities
9-
from django_large_image.rest.data import DataMixin
10-
from django_large_image.rest.metadata import MetaDataMixin
112
from django_large_image.rest.standalone import ListColormapsView, ListTileSourcesView
12-
from django_large_image.rest.tiles import TilesMixin
13-
14-
15-
class BaseLargeImageViewSetMixin(DataMixin, MetaDataMixin, TilesMixin):
16-
"""Abstract mixin class for large-image endpoints in viewsets.
17-
18-
Subclasses must implement `get_path()`:
19-
20-
.. code:: python
21-
22-
def get_path(self, request: Request, pk: int = None):
23-
instance = Model.objects.get(pk=pk)
24-
return instance.file.name
25-
26-
"""
27-
28-
pass
29-
30-
31-
class LargeImageViewSet(ViewSet, BaseLargeImageViewSetMixin):
32-
"""Abstract class for large-image endpoints as a viewset.
33-
34-
Subclasses must implement `get_path()`:
35-
36-
.. code:: python
37-
38-
def get_path(self, request: Request, pk: int = None):
39-
instance = Model.objects.get(pk=pk)
40-
return instance.file.name
41-
42-
"""
43-
44-
pass
45-
46-
47-
class LargeImageViewSetMixin(BaseLargeImageViewSetMixin):
48-
"""Mixin specifically for ViewSets that have a file field on the mdoel.
49-
50-
Define `FILE_FIELD_NAME` as the string name of the file field on the
51-
subclassed ViewSet's model.
52-
53-
This mixin assumes `get_object()` is present - thus for ViewSets
54-
"""
55-
56-
FILE_FIELD_NAME: str = None
57-
58-
def get_field_file(self):
59-
"""Get `FileField` using `FILE_FIELD_NAME`."""
60-
try:
61-
return getattr(self.get_object(), self.FILE_FIELD_NAME)
62-
except (AttributeError, TypeError):
63-
# Raise 500 server error
64-
raise APIException('`FILE_FIELD_NAME` not properly set on viewset class.')
65-
66-
@wraps(BaseLargeImageViewSetMixin.get_path)
67-
def get_path(self, request: Request, pk: int = None):
68-
return utilities.field_file_to_local_path(self.get_field_file())
69-
70-
71-
class LargeImageVSIViewSetMixin(LargeImageViewSetMixin):
72-
USE_VSI: bool = True
73-
74-
@wraps(LargeImageViewSetMixin.get_path)
75-
def get_path(self, request: Request, pk: int = None):
76-
"""Wraps get_path with VSI support."""
77-
field_file = self.get_field_file()
78-
if self.USE_VSI:
79-
with utilities.patch_internal_presign(field_file):
80-
# Grab URL and pass back VSI path
81-
# DO NOT return here to make sure this context is cleared
82-
vsi = utilities.make_vsi(field_file.url)
83-
return vsi
84-
# Checkout file locally if no VSI
85-
return LargeImageViewSetMixin.get_path(self, request, pk)
3+
from django_large_image.rest.viewsets import (
4+
LargeImageDetailMixin,
5+
LargeImageFileDetailMixin,
6+
LargeImageMixin,
7+
LargeImageVSIFileDetailMixin,
8+
)

django_large_image/rest/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
CACHE_TIMEOUT = 60 * 60 * 2
1212

1313

14-
class LargeImageViewSetMixinBase:
14+
class LargeImageMixinBase:
1515
def get_path(self, request: Request, pk: int = None):
1616
"""Return path on disk to image file (or VSI str).
1717
@@ -23,7 +23,7 @@ def get_path(self, request: Request, pk: int = None):
2323
str : The local file path to pass to large_image
2424
2525
"""
26-
raise NotImplementedError
26+
raise NotImplementedError('You must implement `get_path` on your viewset.')
2727

2828
def get_style(self, request: Request):
2929
data = utilities.get_request_body_as_dict(request)

django_large_image/rest/data.py

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

1010
from django_large_image import tilesource
1111
from django_large_image.rest import params
12-
from django_large_image.rest.base import CACHE_TIMEOUT, LargeImageViewSetMixinBase
12+
from django_large_image.rest.base import CACHE_TIMEOUT, LargeImageMixinBase
1313

1414

15-
class DataMixin(LargeImageViewSetMixinBase):
15+
class DataMixin(LargeImageMixinBase):
1616
def thumbnail(self, request: Request, pk: int = None, format: str = None) -> HttpResponse:
1717
encoding = tilesource.format_to_encoding(format)
1818
source = self.get_tile_source(request, pk, encoding=encoding)
@@ -25,7 +25,7 @@ def thumbnail(self, request: Request, pk: int = None, format: str = None) -> Htt
2525
operation_summary='Returns thumbnail of full image as PNG.',
2626
manual_parameters=[params.projection] + params.STYLE,
2727
)
28-
@action(detail=True, url_path='thumbnail.png')
28+
@action(detail=False, url_path='thumbnail.png')
2929
def thumbnail_png(self, request: Request, pk: int = None) -> HttpResponse:
3030
return self.thumbnail(request, pk, format='png')
3131

@@ -35,7 +35,7 @@ def thumbnail_png(self, request: Request, pk: int = None) -> HttpResponse:
3535
operation_summary='Returns thumbnail of full image as JPEG.',
3636
manual_parameters=[params.projection] + params.STYLE,
3737
)
38-
@action(detail=True, url_path='thumbnail.jpeg')
38+
@action(detail=False, url_path='thumbnail.jpeg')
3939
def thumbnail_jpeg(self, request: Request, pk: int = None) -> HttpResponse:
4040
return self.thumbnail(request, pk, format='jpeg')
4141

@@ -78,10 +78,7 @@ def region(self, request: Request, pk: int = None, format: str = None) -> HttpRe
7878
operation_summary='Returns region tile binary from world coordinates in given EPSG as a tiled tif image.',
7979
manual_parameters=[params.projection] + params.REGION,
8080
)
81-
@action(
82-
detail=True,
83-
url_path=r'region.tif',
84-
)
81+
@action(detail=False, url_path=r'region.tif')
8582
def region_tif(self, request: Request, pk: int = None) -> HttpResponse:
8683
return self.region(request, pk, format='tif')
8784

@@ -90,10 +87,7 @@ def region_tif(self, request: Request, pk: int = None) -> HttpResponse:
9087
operation_summary='Returns region tile binary from world coordinates in given EPSG as a png image.',
9188
manual_parameters=[params.projection] + params.REGION,
9289
)
93-
@action(
94-
detail=True,
95-
url_path=r'region.png',
96-
)
90+
@action(detail=False, url_path=r'region.png')
9791
def region_png(self, request: Request, pk: int = None) -> HttpResponse:
9892
return self.region(request, pk, format='png')
9993

@@ -102,10 +96,7 @@ def region_png(self, request: Request, pk: int = None) -> HttpResponse:
10296
operation_summary='Returns region tile binary from world coordinates in given EPSG as a jpeg image.',
10397
manual_parameters=[params.projection] + params.REGION,
10498
)
105-
@action(
106-
detail=True,
107-
url_path=r'region.jpeg',
108-
)
99+
@action(detail=False, url_path=r'region.jpeg')
109100
def region_jpeg(self, request: Request, pk: int = None) -> HttpResponse:
110101
return self.region(request, pk, format='jpeg')
111102

@@ -114,7 +105,7 @@ def region_jpeg(self, request: Request, pk: int = None) -> HttpResponse:
114105
operation_summary='Returns single pixel.',
115106
manual_parameters=[params.projection, params.left, params.top] + params.STYLE,
116107
)
117-
@action(detail=True)
108+
@action(detail=False)
118109
def pixel(self, request: Request, pk: int = None) -> Response:
119110
left = float(request.query_params.get('left'))
120111
top = float(request.query_params.get('top'))
@@ -127,7 +118,7 @@ def pixel(self, request: Request, pk: int = None) -> Response:
127118
operation_summary='Returns histogram',
128119
manual_parameters=[params.projection] + params.HISTOGRAM,
129120
)
130-
@action(detail=True)
121+
@action(detail=False)
131122
def histogram(self, request: Request, pk: int = None) -> Response:
132123
kwargs = dict(
133124
# TODO: add openapi params for these
@@ -147,3 +138,33 @@ def histogram(self, request: Request, pk: int = None) -> Response:
147138
if key in entry:
148139
entry[key] = float(entry[key])
149140
return Response(result)
141+
142+
143+
class DataDetailMixin(DataMixin):
144+
@action(detail=True, url_path='thumbnail.png')
145+
def thumbnail_png(self, request: Request, pk: int = None) -> HttpResponse:
146+
return super().thumbnail_png(request, pk)
147+
148+
@action(detail=True, url_path='thumbnail.jpeg')
149+
def thumbnail_jpeg(self, request: Request, pk: int = None) -> HttpResponse:
150+
return super().thumbnail_jpeg(request, pk)
151+
152+
@action(detail=True, url_path=r'region.tif')
153+
def region_tif(self, request: Request, pk: int = None) -> HttpResponse:
154+
return super().region_tif(request, pk)
155+
156+
@action(detail=True, url_path=r'region.png')
157+
def region_png(self, request: Request, pk: int = None) -> HttpResponse:
158+
return super().region_png(request, pk)
159+
160+
@action(detail=True, url_path=r'region.jpeg')
161+
def region_jpeg(self, request: Request, pk: int = None) -> HttpResponse:
162+
return super().region_jpeg(request, pk)
163+
164+
@action(detail=True)
165+
def pixel(self, request: Request, pk: int = None) -> Response:
166+
return super().pixel(request, pk)
167+
168+
@action(detail=True)
169+
def histogram(self, request: Request, pk: int = None) -> Response:
170+
return super().histogram(request, pk)

0 commit comments

Comments
 (0)