Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate to django-ninja #745

Merged
merged 31 commits into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b22350c
Add django-ninja dependency
danlamanna Aug 9, 2023
5274d53
Refactor quickfind API to use Ninja
danlamanna Jul 27, 2023
c83005b
Refactor collections API to use Ninja
danlamanna Aug 9, 2023
dcec972
Register ninja URLs
zachmullen Aug 12, 2023
39974f7
django-ninja cursor pagination implementation
zachmullen Aug 13, 2023
a58f83b
Add permission filtering to collection endpoints
zachmullen Aug 16, 2023
a4a33ad
Update tests to use Django's test client
zachmullen Aug 16, 2023
91dab43
Rectify schemas
zachmullen Aug 16, 2023
c23afb1
Fix logic in collection/populate-from-list/
zachmullen Aug 16, 2023
ce32a05
Improve error response from pydantic validator
zachmullen Aug 17, 2023
7714700
Update django-ninja for bugfix
zachmullen Aug 17, 2023
f1a3183
Ensure view permission check on collection endpoints
zachmullen Aug 17, 2023
b1560f4
Make swagger page mostly identical to old version
zachmullen Aug 18, 2023
849624a
Convert user endpoints to django-ninja
zachmullen Aug 18, 2023
61869b7
Convert images endpoints to django-ninja
zachmullen Aug 19, 2023
9ca745e
Convert ingest endpoints to ninja
zachmullen Aug 20, 2023
253bfeb
Add a test to exercise the auth behavior of SessionAuthStaffUser
zachmullen Aug 21, 2023
7c026e2
Convert studies endpoints to django-ninja
zachmullen Aug 21, 2023
f8d7eb4
Return 400 on image search query parse failures
zachmullen Aug 21, 2023
abac9e5
Remove obviated rest_framework code
zachmullen Aug 21, 2023
b2bd181
Convert zip_download to ninja
zachmullen Aug 22, 2023
5e7b162
Remove old SearchQuerySerializer
zachmullen Aug 22, 2023
c188eed
Convert quickfind serializers to ninja
zachmullen Aug 22, 2023
a3599be
Hide zip download endpoint in swagger
zachmullen Aug 22, 2023
c95bd76
Add test for ensuring image listing returns distinct images
danlamanna Aug 22, 2023
dec335f
Fix typo in endpoint summary
zachmullen Aug 23, 2023
1bfd43d
Remove banner and favicon from swagger page
zachmullen Aug 23, 2023
9eabdce
Reintroduce distinct() on Image queryset
zachmullen Aug 23, 2023
715cf03
Exempt ninja's Query from B008 lint rule
zachmullen Aug 23, 2023
395c9b9
Add test for pagination
zachmullen Aug 23, 2023
37d8bb3
Use custom ninja authentication class for zip download token management
zachmullen Aug 23, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions isic/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,15 @@ def setup_groups(request):

@pytest.fixture
def api_client() -> APIClient:
# TODO have this return a django.test.Client instead of DRF's APIClient
return APIClient()


@pytest.fixture
def client() -> Client:
return Client()


@pytest.fixture
def authenticated_client(user):
# Do not use the client fixture, to prevent mutating its state
Expand Down
4 changes: 0 additions & 4 deletions isic/core/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
from .collection import CollectionViewSet
from .image import ImageViewSet
from .user import user_me

__all__ = [
"CollectionViewSet",
"ImageViewSet",
"user_me",
]
203 changes: 112 additions & 91 deletions isic/core/api/collection.py
Original file line number Diff line number Diff line change
@@ -1,105 +1,126 @@
from django.contrib import messages
from django.contrib.auth.models import User
from django.http.response import JsonResponse
from django.utils.decorators import method_decorator
from django_filters.rest_framework import DjangoFilterBackend
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from django.shortcuts import get_object_or_404
from ninja import Field, ModelSchema, Router, Schema
from ninja.pagination import paginate
from pydantic.types import conlist, constr

from isic.core.constants import ISIC_ID_REGEX
from isic.core.models.collection import Collection
from isic.core.permissions import IsicObjectPermissionsFilter
from isic.core.serializers import CollectionSerializer, IsicIdListSerializer, SearchQuerySerializer
from isic.core.pagination import CursorPagination
from isic.core.permissions import get_visible_objects
from isic.core.serializers import SearchQueryIn
from isic.core.services.collection.image import (
collection_add_images_from_isic_ids,
collection_remove_images_from_isic_ids,
)
from isic.core.tasks import populate_collection_from_search_task

from .exceptions import Conflict
router = Router()


@method_decorator(
name="list", decorator=swagger_auto_schema(operation_summary="Return a list of collections.")
class CollectionOut(ModelSchema):
class Config:
model = Collection
model_fields = ["id", "name", "description", "public", "pinned", "locked", "doi"]

doi_url: str | None = Field(alias="doi_url")


@router.get("/", response=list[CollectionOut], summary="Return a list of collections.")
@paginate(CursorPagination)
def collection_list(request, pinned: bool | None = None) -> list[CollectionOut]:
queryset = get_visible_objects(request.user, "core.view_collection", Collection.objects.all())
if pinned is not None:
queryset = queryset.filter(pinned=pinned)
return queryset


@router.get("/{id}/", response=CollectionOut, summary="Retrieve a single collection by ID.")
def collection_detail(request, id: int) -> CollectionOut:
qs = get_visible_objects(request.user, "core.view_collection", Collection.objects.all())
collection = get_object_or_404(qs, id=id)
danlamanna marked this conversation as resolved.
Show resolved Hide resolved
return collection


@router.post(
"/{id}/populate-from-search/",
response={202: None, 403: dict, 409: dict},
include_in_schema=False,
)
def collection_populate_from_search(request, id: int, payload: SearchQueryIn):
qs = get_visible_objects(request.user, "core.view_collection", Collection.objects.all())
collection = get_object_or_404(qs, id=id)

if not request.user.has_perm("core.add_images", collection):
return 403, {"error": "You do not have permission to add images to this collection."}

if collection.locked:
return 409, {"error": "Collection is locked"}

if collection.public and payload.to_queryset(request.user).private().exists():
return 409, {"error": "Collection is public and cannot contain private images."}

# Pass data instead of validated_data because the celery task is going to revalidate.
# This avoids re encoding collections as a comma delimited string.
populate_collection_from_search_task.delay(id, request.user.pk, payload.dict())

# TODO: this is a weird mixture of concerns between SSR and an API, figure out a better
# way to handle this.
messages.add_message(
request, messages.INFO, "Adding images to collection, this may take a few minutes."
)
return 202, {}


class IsicIdList(Schema):
isic_ids: conlist(constr(pattern=ISIC_ID_REGEX), max_length=500)


# TODO: refactor *-from-list methods
@router.post(
"/{id}/populate-from-list/", response={200: None, 403: dict, 409: dict}, include_in_schema=False
)
@method_decorator(
name="retrieve",
decorator=swagger_auto_schema(operation_summary="Retrieve a single collection by ID."),
def collection_populate_from_list(request, id, payload: IsicIdList):
qs = get_visible_objects(request.user, "core.view_collection", Collection.objects.all())
collection = get_object_or_404(qs, id=id)

if not request.user.has_perm("core.add_images", collection):
return 403, {"error": "You do not have permission to add images to this collection."}

if collection.locked:
return 409, {"error": "Collection is locked"}

summary = collection_add_images_from_isic_ids(
user=request.user,
collection=collection,
isic_ids=payload.isic_ids,
zachmullen marked this conversation as resolved.
Show resolved Hide resolved
)

return JsonResponse(summary)


@router.post(
"/{id}/remove-from-list/", response={200: None, 403: dict, 409: dict}, include_in_schema=False
)
class CollectionViewSet(ReadOnlyModelViewSet):
serializer_class = CollectionSerializer
queryset = Collection.objects.all()
filter_backends = [IsicObjectPermissionsFilter, DjangoFilterBackend]
filterset_fields = ["pinned"]

def _enforce_write_checks(self, user: User):
if self.get_object().locked:
raise Conflict("Collection is locked for changes.")

@swagger_auto_schema(auto_schema=None)
@action(detail=True, methods=["post"], pagination_class=None, url_path="populate-from-search")
def populate_from_search(self, request, *args, **kwargs):
if not request.user.has_perm("core.add_images", self.get_object()):
raise PermissionDenied

self._enforce_write_checks(request.user)
serializer = SearchQuerySerializer(data=request.data, context={"user": request.user})
serializer.is_valid(raise_exception=True)

if self.get_object().public and serializer.to_queryset().private().exists():
raise Conflict("You are attempting to add private images to a public collection.")

# Pass data instead of validated_data because the celery task is going to revalidate.
# This avoids re encoding collections as a comma delimited string.
populate_collection_from_search_task.delay(kwargs["pk"], request.user.pk, serializer.data)

# TODO: this is a weird mixture of concerns between SSR and an API, figure out a better
# way to handle this.
messages.add_message(
request, messages.INFO, "Adding images to collection, this may take a few minutes."
)
return Response(status=status.HTTP_202_ACCEPTED)

# TODO: refactor *-from-list methods
@swagger_auto_schema(auto_schema=None)
@action(detail=True, methods=["post"], pagination_class=None, url_path="populate-from-list")
def populate_from_list(self, request, *args, **kwargs):
if not request.user.has_perm("core.add_images", self.get_object()):
raise PermissionDenied

self._enforce_write_checks(request.user)
serializer = IsicIdListSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

summary = collection_add_images_from_isic_ids(
user=request.user,
collection=self.get_object(),
isic_ids=serializer.validated_data["isic_ids"],
)

return JsonResponse(summary)

@swagger_auto_schema(auto_schema=None)
@action(detail=True, methods=["post"], pagination_class=None, url_path="remove-from-list")
def remove_from_list(self, request, *args, **kwargs):
if not request.user.has_perm("core.remove_images", self.get_object()):
raise PermissionDenied

self._enforce_write_checks(request.user)
serializer = IsicIdListSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

summary = collection_remove_images_from_isic_ids(
user=request.user,
collection=self.get_object(),
isic_ids=serializer.validated_data["isic_ids"],
)

# TODO: this is a weird mixture of concerns between SSR and an API, figure out a better
# way to handle this.
messages.add_message(request, messages.INFO, f'Removed {len(summary["succeeded"])} images.')

return JsonResponse(summary)
def remove_from_list(request, id, payload: IsicIdList):
qs = get_visible_objects(request.user, "core.view_collection", Collection.objects.all())
collection = get_object_or_404(qs, id=id)

if not request.user.has_perm("core.remove_images", collection):
return 403, {"error": "You do not have permission to add images to this collection."}

if collection.locked:
return 409, {"error": "Collection is locked"}

summary = collection_remove_images_from_isic_ids(
user=request.user,
collection=collection,
isic_ids=payload.isic_ids,
)

# TODO: this is a weird mixture of concerns between SSR and an API, figure out a better
# way to handle this.
messages.add_message(request, messages.INFO, f'Removed {len(summary["succeeded"])} images.')

return JsonResponse(summary)
Loading
Loading