Skip to content

Commit

Permalink
Convert images endpoints to django-ninja
Browse files Browse the repository at this point in the history
  • Loading branch information
zachmullen committed Aug 19, 2023
1 parent 849624a commit 61869b7
Show file tree
Hide file tree
Showing 9 changed files with 270 additions and 308 deletions.
2 changes: 0 additions & 2 deletions isic/core/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
from .image import ImageViewSet
from .user import user_me

__all__ = [
"ImageViewSet",
"user_me",
]
207 changes: 108 additions & 99 deletions isic/core/api/image.py
Original file line number Diff line number Diff line change
@@ -1,109 +1,118 @@
from django.utils.decorators import method_decorator
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet

from isic.core.models.collection import Collection
from isic.core.models.image import Image
from isic.core.permissions import IsicObjectPermissionsFilter, get_visible_objects
from django.conf import settings
from django.http.request import HttpRequest
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from isic_metadata import FIELD_REGISTRY
from ninja import Field, ModelSchema, Query, Router, Schema
from ninja.pagination import paginate

from isic.core.models import Collection, Image
from isic.core.pagination import CursorPagination
from isic.core.permissions import get_visible_objects
from isic.core.search import build_elasticsearch_query, facets
from isic.core.serializers import ImageSerializer, SearchQuerySerializer
from isic.core.serializers import SearchQueryIn

router = Router()

@method_decorator(
name="list", decorator=swagger_auto_schema(operation_summary="Return a list of images.")
# TODO this originally had "distinct()" on it; I don't think that's needed though
default_qs = Image.objects.select_related("accession__cohort").defer(
"accession__unstructured_metadata"
)
@method_decorator(
name="retrieve",
decorator=swagger_auto_schema(operation_summary="Retrieve a single image by ISIC ID."),


class FileOut(Schema):
url: str
size: int


class ImageFilesOut(Schema):
full: FileOut
thumbnail_256: FileOut


class ImageOut(ModelSchema):
class Config:
model = Image
model_fields = ["public"]

isic_id: str = Field(alias="isic_id")
copyright_license: str = Field(alias="accession.copyright_license")
attribution: str = Field(alias="accession.cohort.attribution")
files: ImageFilesOut
metadata: dict

@staticmethod
def resolve_files(image: Image) -> dict:
if settings.ISIC_PLACEHOLDER_IMAGES:
full_url = f"https://picsum.photos/seed/{image.id}/1000"
thumbnail_url = f"https://picsum.photos/seed/{image.id}/256"
else:
full_url = image.accession.blob.url
thumbnail_url = image.accession.thumbnail_256.url

full_size = image.accession.blob_size
thumbnail_size = image.accession.thumbnail_256_size

return ImageFilesOut(
full=FileOut(url=full_url, size=full_size),
thumbnail_256=FileOut(url=thumbnail_url, size=thumbnail_size),
)

@staticmethod
def resolve_metadata(image: Image) -> dict:
metadata = {
"acquisition": {"pixels_x": image.accession.width, "pixels_y": image.accession.height},
"clinical": {},
}

for key, value in image.accession.redacted_metadata.items():
# this is the only field that we expose that isn't in the FIELD_REGISTRY
# since it's a derived field.
if key == "age_approx":
metadata["clinical"][key] = value
else:
metadata[FIELD_REGISTRY[key]["type"]][key] = value

return metadata


@router.get("/", response=list[ImageOut], summary="Return a list of images.")
@paginate(CursorPagination)
def list_images(request: HttpRequest):
return get_visible_objects(request.user, "core.view_image", default_qs)


@router.get(
"/search/",
response=list[ImageOut],
summary="Search images with a key:value query string.",
description=render_to_string("core/swagger_image_search_description.html"),
)
class ImageViewSet(ReadOnlyModelViewSet):
serializer_class = ImageSerializer
queryset = (
Image.objects.select_related("accession__cohort")
.defer("accession__unstructured_metadata")
.distinct()
@paginate(CursorPagination)
def search_images(request: HttpRequest, search: SearchQueryIn = Query(...)): # noqa: B008
return search.to_queryset(user=request.user, qs=default_qs)


@router.get("/facets/", response=dict, include_in_schema=False)
def get_facets(request: HttpRequest, search: SearchQueryIn = Query(...)): # noqa: B008
query = build_elasticsearch_query(
search.query or "",
request.user,
search.collections,
)
filter_backends = [IsicObjectPermissionsFilter]
lookup_field = "isic_id"

@swagger_auto_schema(auto_schema=None)
@action(detail=False, methods=["get"], pagination_class=None)
def facets(self, request):
serializer = SearchQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
query = build_elasticsearch_query(
serializer.validated_data.get("query", ""),
# Manually pass the list of visible collection PKs through so buckets with
# counts of 0 aren't included in the facets output for non-visible collections.
collection_pks = list(
get_visible_objects(
request.user,
serializer.validated_data.get("collections"),
"core.view_collection",
Collection.objects.values_list("pk", flat=True),
)
# Manually pass the list of visible collection PKs through so buckets with
# counts of 0 aren't included in the facets output for non-visible collections.
collection_pks = list(
get_visible_objects(
request.user,
"core.view_collection",
Collection.objects.values_list("pk", flat=True),
)
)
response = facets(query, collection_pks)

return Response(response)

@swagger_auto_schema(
operation_summary="Search images with a key:value query string.",
operation_description="""
The search query uses a simple DSL syntax.
Some example queries are:
<pre>
# Display images diagnosed as melanoma from patients that are approximately 50 years old.
age_approx:50 AND diagnosis:melanoma
</pre>
<pre>
# Display images from male patients that are approximately 20 to 40 years old.
age_approx:[20 TO 40] AND sex:male
</pre>
<pre>
# Display images from the anterior, posterior, or lateral torso anatomical site where the diagnosis was confirmed by single image expert consensus.
anatom_site_general:*torso AND diagnosis_confirm_type:"single image expert consensus"
</pre>
The following fields are exposed to the query parameter:
<ul>
<li>diagnosis</li>
<li>age_approx</li>
<li>sex</li>
<li>benign_malignant</li>
<li>diagnosis_confirm_type</li>
<li>personal_hx_mm</li>
<li>family_hx_mm</li>
<li>clin_size_long_diam_mm</li>
<li>melanocytic</li>
<li>acquisition_day</li>
<li>nevus_type</li>
<li>image_type</li>
<li>dermoscopic_type</li>
<li>anatom_site_general</li>
<li>mel_class</li>
<li>mel_mitotic_index</li>
<li>mel_thick_mm</li>
<li>mel_type</li>
<li>mel_ulcer</li>
</ul>
""", # noqa: E501
query_serializer=SearchQuerySerializer,
)
@action(detail=False, methods=["get"])
def search(self, request):
serializer = SearchQuerySerializer(
data=request.query_params, context={"user": request.user}
)
serializer.is_valid(raise_exception=True)
qs = serializer.to_queryset(self.get_queryset())
page = self.paginate_queryset(qs)
serializer = self.get_serializer(page, many=True)
paginated_response = self.get_paginated_response(serializer.data)
return facets(query, collection_pks)


return paginated_response
@router.get("/{isic_id}/", response=ImageOut, summary="Retrieve a single image by ISIC ID.")
def retrieve_image(request: HttpRequest, isic_id: str):
qs = get_visible_objects(request.user, "core.view_image", default_qs)
return get_object_or_404(qs, isic_id=isic_id)
14 changes: 7 additions & 7 deletions isic/core/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from django.db.models.query import QuerySet
from django.http.request import HttpRequest
from ninja import Schema
from ninja import Field, Schema
from ninja.pagination import PaginationBase
from rest_framework.pagination import CursorPagination
from rest_framework.response import Response
Expand Down Expand Up @@ -71,14 +71,14 @@ def _replace_query_param(url: str, key: str, val: str):

class CursorPagination(PaginationBase):
class Input(Schema):
limit: int | None = None
cursor: str | None = None
limit: int | None = Field(None, description="Number of results to return per page.")
cursor: str | None = Field(None, description="The pagination cursor value.")

class Output(Schema):
results: list[Any]
count: int
next: str | None
previous: str | None
results: list[Any] = Field(description="The page of objects.")
count: int = Field(description="The total number of results across all pages.")
next: str | None = Field(description="URL of next page of results if there is one.")
previous: str | None = Field(description="URL of previous page of results if there is one.")

items_attribute = "results"
default_ordering = ("-created",)
Expand Down
83 changes: 2 additions & 81 deletions isic/core/serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import re
from typing import Optional

from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User
from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404
from isic_metadata import FIELD_REGISTRY
from ninja import Schema
from pydantic import validator
from pyparsing.exceptions import ParseException
Expand Down Expand Up @@ -65,7 +63,7 @@ def to_queryset(self, qs: Optional[QuerySet[Image]] = None) -> QuerySet[Image]:
# TODO: https://github.com/vitalik/django-ninja/issues/526#issuecomment-1283984292
# Update this to use context for the user once django-ninja supports it
class SearchQueryIn(Schema):
query: str | None
query: str | None = None
collections: list[int] | None = None

@validator("query")
Expand Down Expand Up @@ -111,7 +109,7 @@ def from_token_representation(cls, token):
# TODO
return cls(data=token, context={"user": user})

def to_queryset(self, user, qs: Optional[QuerySet[Image]] = None) -> QuerySet[Image]:
def to_queryset(self, user: User, qs: Optional[QuerySet[Image]] = None) -> QuerySet[Image]:
qs = qs if qs is not None else Image._default_manager.all()

if self.query:
Expand Down Expand Up @@ -184,80 +182,3 @@ def to_queryset(self, qs: Optional[QuerySet[Image]] = None) -> QuerySet[Image]:
)

return get_visible_objects(self.context["user"], "core.view_image", qs).distinct()


class ImageFileSerializer(serializers.Serializer):
full = serializers.SerializerMethodField()
thumbnail_256 = serializers.SerializerMethodField()

def get_full(self, obj: Image) -> dict:
if settings.ISIC_PLACEHOLDER_IMAGES:
url = f"https://picsum.photos/seed/{ obj.id }/1000"
else:
url = obj.accession.blob.url

return {
"url": url,
"size": obj.accession.blob_size,
}

def get_thumbnail_256(self, obj: Image) -> dict:
if settings.ISIC_PLACEHOLDER_IMAGES:
url = f"https://picsum.photos/seed/{ obj.id }/256"
else:
url = obj.accession.thumbnail_256.url

return {
"url": url,
"size": obj.accession.thumbnail_256_size,
}


class ImageSerializer(serializers.ModelSerializer):
class Meta:
model = Image
fields = [
"isic_id",
"public",
"copyright_license",
"attribution",
"metadata",
"files",
]

copyright_license = serializers.CharField(source="accession.copyright_license", read_only=True)
attribution = serializers.CharField(source="accession.cohort.attribution", read_only=True)
metadata = serializers.SerializerMethodField(read_only=True)
files = ImageFileSerializer(source="*", read_only=True)

def get_metadata(self, image: Image) -> dict:
metadata = {
"acquisition": {"pixels_x": image.accession.width, "pixels_y": image.accession.height},
"clinical": {},
}

for key, value in image.accession.redacted_metadata.items():
# this is the only field that we expose that isn't in the FIELD_REGISTRY
# since it's a derived field.
if key == "age_approx":
metadata["clinical"][key] = value
else:
metadata[FIELD_REGISTRY[key]["type"]][key] = value

return metadata


class CollectionSerializer(serializers.ModelSerializer):
class Meta:
model = Collection
fields = [
"id",
"name",
"description",
"public",
"pinned",
"locked",
"doi",
]

doi = serializers.URLField(source="doi_url")
Loading

0 comments on commit 61869b7

Please sign in to comment.