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

[#357] Refactor endpoints with search #687

Merged
merged 9 commits into from
Feb 13, 2025
12 changes: 8 additions & 4 deletions backend/src/openarchiefbeheer/destruction/api/backends.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from django_filters.rest_framework import DjangoFilterBackend
from django_filters.utils import translate_validation
from rest_framework.filters import OrderingFilter

from openarchiefbeheer.utils.django_filters.backends import (
OrderingWithPostFilterBackend,
)


class NestedFilterBackend(DjangoFilterBackend):
Expand Down Expand Up @@ -38,7 +41,7 @@ def get_nested_filterset(self, request, view):
return nested_filterset_class(**kwargs)


class NestedOrderingFilterBackend(OrderingFilter):
class NestedOrderingFilterBackend(OrderingWithPostFilterBackend):
def get_valid_fields(self, queryset, view, context={}) -> list[tuple[str, str]]:
valid_fields = getattr(
view, context.get("ordering_fields_view_attr", "ordering_fields"), []
Expand Down Expand Up @@ -72,12 +75,13 @@ def is_term_valid(self, term, valid_fields) -> bool:
return term in valid_fields

def get_ordering(self, request, queryset, view) -> list[str]:
ordering_filters = self.get_ordering_filters(request)
base_ordering_param = (
f"{view.nested_ordering_prefix}-{self.ordering_param}"
if view.nested_ordering_prefix
else self.ordering_param
)
base_params = request.query_params.get(base_ordering_param)
base_params = ordering_filters.get(base_ordering_param)
formatted_fields = []
if base_params:
fields = [param.strip() for param in base_params.split(",")]
Expand All @@ -88,7 +92,7 @@ def get_ordering(self, request, queryset, view) -> list[str]:
term for term in fields if self.is_term_valid(term, valid_fields)
]

nested_params = request.query_params.get(self.ordering_param)
nested_params = ordering_filters.get(self.ordering_param)
if nested_params:
fields = [param.strip() for param in nested_params.split(",")]
nested_queryset = view.nested_ordering_model.objects.all()
Expand Down
29 changes: 29 additions & 0 deletions backend/src/openarchiefbeheer/utils/django_filters/backends.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.filters import OrderingFilter


class NoModelFilterBackend(DjangoFilterBackend):
pass


class OrderingWithPostFilterBackend(OrderingFilter):
"""Support ordering params also in the request body."""

def get_ordering_filters(self, request):
return request.query_params or request.data

def get_ordering(self, request, queryset, view):
"""
Ordering is set by a comma delimited ?ordering=... query parameter.

The `ordering` query parameter can be overridden by setting
the `ordering_param` value on the OrderingFilter or by
specifying an `ORDERING_PARAM` value in the API settings.
"""
# Overriding where the filters are coming from (for us from the POST request body).
# Normally they are query params.
ordering_filters = self.get_ordering_filters(request)
params = ordering_filters.get(self.ordering_param)
if params:
fields = [param.strip() for param in params.split(",")]
ordering = self.remove_invalid_fields(queryset, fields, view, request)
if ordering:
return ordering

# No ordering was included, or all the ordering fields were invalid
return self.get_default_ordering(view)
14 changes: 14 additions & 0 deletions backend/src/openarchiefbeheer/utils/paginators.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,17 @@
class PageNumberPagination(_PageNumberPagination):
page_size_query_param = "page_size"
page_size = 100


class PageNumberPaginationWithPost(_PageNumberPagination):
"""Support pagination param also in request body"""

page_size_query_param = "page_size"
page_size = 100

def get_page_number(self, request, paginator):
params = request.query_params or request.data
page_number = params.get(self.page_query_param) or 1
if page_number in self.last_page_strings:
page_number = paginator.num_pages
return page_number
10 changes: 10 additions & 0 deletions backend/src/openarchiefbeheer/zaken/api/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
NumberFilter,
UUIDFilter,
)
from django_filters.rest_framework import DjangoFilterBackend

from openarchiefbeheer.destruction.constants import ListItemStatus
from openarchiefbeheer.destruction.models import (
Expand Down Expand Up @@ -282,3 +283,12 @@ def filter_behandelend_afdeling(
)

return zaken_with_afdeling.filter(behandelend_afdeling__contains=value)


class ZaakFilterBackend(DjangoFilterBackend):
def get_filterset_kwargs(self, request, queryset, view):
return {
"data": request.query_params or request.data,
"queryset": queryset,
"request": request,
}
46 changes: 34 additions & 12 deletions backend/src/openarchiefbeheer/zaken/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ class InternalZaaktypenChoicesView(GenericAPIView):
def get_queryset(self):
return Zaak.objects.all()

def _retrieve_zaaktypen(self, request):
filterset = ZaakFilterSet(data=request.query_params or request.data)
is_valid = filterset.is_valid()
if not is_valid:
raise ValidationError(filterset.errors)

zaaktypen = filterset.qs.distinct("_expand__zaaktype__url").values_list(
"_expand__zaaktype", flat=True
)
zaaktypen_choices = format_zaaktype_choices(zaaktypen)

serializer = ChoiceSerializer(data=zaaktypen_choices, many=True)
serializer.is_valid(raise_exception=True)
return Response(serializer.data, status=status.HTTP_200_OK)

@extend_schema(
summary=_("Retrieve local zaaktypen choices"),
description=_(
Expand All @@ -70,19 +85,26 @@ def get_queryset(self):
)
@method_decorator(cache_page(60 * 15))
def get(self, request, *args, **kwargs):
filterset = ZaakFilterSet(data=request.query_params)
is_valid = filterset.is_valid()
if not is_valid:
raise ValidationError(filterset.errors)

zaaktypen = filterset.qs.distinct("_expand__zaaktype__url").values_list(
"_expand__zaaktype", flat=True
)
zaaktypen_choices = format_zaaktype_choices(zaaktypen)
return self._retrieve_zaaktypen(request)

serializer = ChoiceSerializer(data=zaaktypen_choices, many=True)
serializer.is_valid(raise_exception=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema(
summary=_("Search local zaaktypen choices"),
description=_(
"Retrieve zaaktypen of the zaken stored in the OAB database and return a value and a label per zaaktype. "
"The label is the 'identificatie' field an the value is a string of comma separated URLs. "
"There are multiple URLs per identificatie if there are multiple versions of a zaaktype. "
"If there are no zaken of a particular zaaktype in the database, then that zaaktype is not returned. "
"The response is cached for 15 minutes.\n"
"All the filters for the zaken are available to limit which zaaktypen should be returned."
),
tags=["private"],
responses={
200: ChoiceSerializer(many=True),
},
)
@method_decorator(cache_page(60 * 15))
def post(self, request, *args, **kwargs):
return self._retrieve_zaaktypen(request)


class ExternalZaaktypenChoicesView(APIView):
Expand Down
27 changes: 21 additions & 6 deletions backend/src/openarchiefbeheer/zaken/api/viewsets.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,39 @@
from django.utils.translation import gettext_lazy as _

from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import mixins, viewsets
from rest_framework.filters import OrderingFilter
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated

from openarchiefbeheer.destruction.api.permissions import (
CanCoReviewPermission,
CanReviewPermission,
CanStartDestructionPermission,
)
from openarchiefbeheer.utils.paginators import PageNumberPagination
from openarchiefbeheer.utils.django_filters.backends import (
OrderingWithPostFilterBackend,
)
from openarchiefbeheer.utils.paginators import PageNumberPaginationWithPost

from ..models import Zaak
from .filtersets import ZaakFilterSet
from .filtersets import ZaakFilterBackend, ZaakFilterSet
from .serializers import ZaakSerializer


@extend_schema_view(
list=extend_schema(
summary=_("List zaken"),
tags=["Zaken"],
description=_("List cases retrieved and cached from Open Zaak."),
),
search=extend_schema(
tags=["Zaken"],
summary=_("Search zaken"),
description=_(
"Search cases retrieved and cached from Open Zaak. "
"You can use the same arguments in the JSON body as the query params of the 'List Zaken' endpoint "
),
),
)
class ZakenViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
queryset = Zaak.objects.all().order_by("pk")
Expand All @@ -31,7 +42,11 @@ class ZakenViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
IsAuthenticated
& (CanStartDestructionPermission | CanReviewPermission | CanCoReviewPermission)
]
pagination_class = PageNumberPagination
filter_backends = (DjangoFilterBackend, OrderingFilter)
pagination_class = PageNumberPaginationWithPost
filter_backends = (ZaakFilterBackend, OrderingWithPostFilterBackend)
filterset_class = ZaakFilterSet
ordering_fields = "__all__"

@action(detail=False, methods=["post"], name="search")
def search(self, request, *args, **kwargs) -> None:
return self.list(request, *args, **kwargs)
21 changes: 21 additions & 0 deletions backend/src/openarchiefbeheer/zaken/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,3 +410,24 @@ def test_filter_on_zaaktype(self):

self.assertIn(zaken_2[0].url, urls_zaken)
self.assertIn(zaken_2[1].url, urls_zaken)

def test_filter_on_zaaktype_with_post(self):
ZaakFactory.create_batch(3, identificatie="ZAAK-01")
zaken_2 = ZaakFactory.create_batch(2, identificatie="ZAAK-02")

user = UserFactory(username="record_manager", post__can_start_destruction=True)

self.client.force_authenticate(user)
response = self.client.post(
reverse("api:zaken-search"),
data={"zaaktype__in": ",".join([zaken_2[0].zaaktype, zaken_2[1].zaaktype])},
)
data = response.json()

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(data["count"], 2)

urls_zaken = [zaak["url"] for zaak in data["results"]]

self.assertIn(zaken_2[0].url, urls_zaken)
self.assertIn(zaken_2[1].url, urls_zaken)
56 changes: 41 additions & 15 deletions backend/src/openarchiefbeheer/zaken/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,28 +209,54 @@ def test_retrieve_zaaktypen_choices_for_destruction_list(self):
DestructionListItemFactory.create_batch(2, with_zaak=True)

self.client.force_authenticate(user=user)
endpoint = furl(reverse("api:retrieve-zaaktypen-choices"))
endpoint.args["in_destruction_list"] = destruction_list.uuid
url = reverse("api:retrieve-zaaktypen-choices")

response = self.client.get(endpoint.url)
with self.subTest("with GET"):
endpoint = furl(url)
endpoint.args["in_destruction_list"] = destruction_list.uuid

self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.get(endpoint.url)

choices = sorted(response.json(), key=lambda choice: choice["label"])
self.assertEqual(response.status_code, status.HTTP_200_OK)

self.assertEqual(len(choices), 2)
self.assertEqual(choices[0]["label"], "ZAAKTYPE 1.1 (ZAAKTYPE-1)")
self.assertEqual(choices[1]["label"], "ZAAKTYPE 2.0 (ZAAKTYPE-2)")
choices = sorted(response.json(), key=lambda choice: choice["label"])

values = choices[0]["value"].split(",")
self.assertEqual(len(choices), 2)
self.assertEqual(choices[0]["label"], "ZAAKTYPE 1.1 (ZAAKTYPE-1)")
self.assertEqual(choices[1]["label"], "ZAAKTYPE 2.0 (ZAAKTYPE-2)")

self.assertEqual(len(values), 2)
self.assertIn(items_in_list[0].zaak._expand["zaaktype"]["url"], values)
self.assertIn(items_in_list[1].zaak._expand["zaaktype"]["url"], values)
values = choices[0]["value"].split(",")

self.assertEqual(
choices[1]["value"], items_in_list[2].zaak._expand["zaaktype"]["url"]
)
self.assertEqual(len(values), 2)
self.assertIn(items_in_list[0].zaak._expand["zaaktype"]["url"], values)
self.assertIn(items_in_list[1].zaak._expand["zaaktype"]["url"], values)

self.assertEqual(
choices[1]["value"], items_in_list[2].zaak._expand["zaaktype"]["url"]
)

with self.subTest("with POST"):
response = self.client.post(
url, data={"in_destruction_list": destruction_list.uuid}
)

self.assertEqual(response.status_code, status.HTTP_200_OK)

choices = sorted(response.json(), key=lambda choice: choice["label"])

self.assertEqual(len(choices), 2)
self.assertEqual(choices[0]["label"], "ZAAKTYPE 1.1 (ZAAKTYPE-1)")
self.assertEqual(choices[1]["label"], "ZAAKTYPE 2.0 (ZAAKTYPE-2)")

values = choices[0]["value"].split(",")

self.assertEqual(len(values), 2)
self.assertIn(items_in_list[0].zaak._expand["zaaktype"]["url"], values)
self.assertIn(items_in_list[1].zaak._expand["zaaktype"]["url"], values)

self.assertEqual(
choices[1]["value"], items_in_list[2].zaak._expand["zaaktype"]["url"]
)

@tag("gh-303")
def test_retrieve_zaaktypen_choices_for_destruction_list_if_zaaktype_id_empty(self):
Expand Down
14 changes: 14 additions & 0 deletions frontend/.storybook/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
coReviewsFactory,
destructionListAssigneesFactory,
destructionListFactory,
paginatedZakenFactory,
recordManagerFactory,
userFactory,
usersFactory,
Expand Down Expand Up @@ -49,6 +50,12 @@ export const MOCKS = {
status: 200,
response: zaaktypeChoicesFactory(),
},
DESTRUCTION_SEARCH_ZAAKTYPE_CHOICES: {
url: "http://localhost:8000/api/v1/_zaaktypen-choices/",
method: "POST",
status: 200,
response: zaaktypeChoicesFactory(),
},
DESTRUCTION_LISTS: {
url: "http://localhost:8000/api/v1/destruction-lists/?ordering=-created",
method: "GET",
Expand Down Expand Up @@ -201,6 +208,13 @@ export const MOCKS = {
status: "201",
response: {},
},
// ZAKEN
ZAKEN_SEARCH: {
url: "http://localhost:8000/api/v1/zaken/search/",
method: "POST",
status: 200,
response: paginatedZakenFactory(),
},
};

export const MOCK_BASE = Object.values(MOCKS);
15 changes: 11 additions & 4 deletions frontend/src/lib/api/private.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,18 @@ export async function listZaaktypeChoices(
params.set("notInDestructionList", "true");
}

const endpoint = external
? "/_external-zaaktypen-choices/"
: "/_zaaktypen-choices/";
let response;
if (external) {
response = await request(
"GET",
"/_external-zaaktypen-choices/",
params,
);
} else {
const filters = Object.fromEntries(params.entries());
response = await request("POST", "/_zaaktypen-choices/", {}, filters);
}

const response = await request("GET", endpoint, params);
const promise: Promise<Option[]> = response.json();

return promise;
Expand Down
Loading