diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 2b4921631..438614385 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -6,6 +6,16 @@ All notable changes to this project are documented in this file. This project adheres to `Semantic Versioning `_. +========== +Unreleased +========== + +Added +----- +- Expose ``status`` on ``collection`` and ``entity`` viewset and allow + filtering and sorting by it + + =================== 42.0.1 - 2024-11-21 =================== diff --git a/resolwe/flow/filters.py b/resolwe/flow/filters.py index 00e1cde6e..7cd2d60f2 100644 --- a/resolwe/flow/filters.py +++ b/resolwe/flow/filters.py @@ -286,6 +286,10 @@ class Meta(BaseResolweFilter.Meta): model = DescriptorSchema +class CharInFilter(filters.BaseInFilter, filters.CharFilter): + """Basic filter for CharField with 'in' lookup.""" + + class BaseCollectionFilter(TextFilterMixin, UserFilterMixin, BaseResolweFilter): """Base filter for Collection and Entity endpoints.""" @@ -299,6 +303,8 @@ class BaseCollectionFilter(TextFilterMixin, UserFilterMixin, BaseResolweFilter): permission = filters.CharFilter(method="filter_for_user") tags = TagsFilter() text = filters.CharFilter(field_name="search", method="filter_text") + status = filters.CharFilter(field_name="status") + status__in = CharInFilter(field_name="status", lookup_expr="in") class Meta(BaseResolweFilter.Meta): """Filter configuration.""" diff --git a/resolwe/flow/serializers/collection.py b/resolwe/flow/serializers/collection.py index cb8acd4e6..ed2ff6c88 100644 --- a/resolwe/flow/serializers/collection.py +++ b/resolwe/flow/serializers/collection.py @@ -1,11 +1,10 @@ """Resolwe collection serializer.""" import logging -from typing import Optional from rest_framework import serializers -from resolwe.flow.models import Collection, Data, DescriptorSchema +from resolwe.flow.models import Collection, DescriptorSchema from resolwe.rest.fields import ProjectableJSONField from .base import ResolweBaseSerializer @@ -20,7 +19,7 @@ class BaseCollectionSerializer(ResolweBaseSerializer): settings = ProjectableJSONField(required=False) data_count = serializers.SerializerMethodField(required=False) - status = serializers.SerializerMethodField(required=False) + status = serializers.CharField(read_only=True) def get_data_count(self, collection: Collection) -> int: """Return number of data objects on the collection.""" @@ -32,44 +31,6 @@ def get_data_count(self, collection: Collection) -> int: else collection.data.count() ) - def get_status(self, collection: Collection) -> Optional[str]: - """Return status of the collection based on the status of data objects. - - When collection contains no data objects None is returned. - """ - - status_order = [ - Data.STATUS_ERROR, - Data.STATUS_UPLOADING, - Data.STATUS_PROCESSING, - Data.STATUS_PREPARING, - Data.STATUS_WAITING, - Data.STATUS_RESOLVING, - Data.STATUS_DONE, - ] - - # Use 'data_statuses' attribute when available. It is created in the - # BaseCollectionViewSet class. It contains all the distinct statuses of the - # data objects in the collection. - status_set = ( - set(collection.data_statuses) - if hasattr(collection, "data_statuses") - else collection.data.values_list("status", flat=True).distinct() - ) - - if not status_set: - return None - - for status in status_order: - if status in status_set: - return status - - logger.warning( - "Could not determine the status of a collection.", - extra={"collection": collection.__dict__}, - ) - return None - class Meta: """CollectionSerializer Meta options.""" diff --git a/resolwe/flow/tests/test_api.py b/resolwe/flow/tests/test_api.py index 9df4c2edb..1d4f8ccb4 100644 --- a/resolwe/flow/tests/test_api.py +++ b/resolwe/flow/tests/test_api.py @@ -1417,6 +1417,26 @@ def test_collection_status(self): ) self.assertEqual(get_collection(collections, "empty")["status"], None) + # Filter by status + response = self.client.get(self.list_url, {"status": "OK"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["name"], "done") + + response = self.client.get(self.list_url, {"status": "ER"}) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["name"], "error") + + response = self.client.get( + self.list_url, + {"status__in": f"{Data.STATUS_RESOLVING},{Data.STATUS_WAITING}"}, + format="json", + ) + self.assertEqual(len(response.data), 2) + self.assertCountEqual( + [response.data[0]["name"], response.data[1]["name"]], + ["resolving", "waiting"], + ) + class TestCollectionViewSetCaseDelete( TestCollectionViewSetCaseCommonMixin, TransactionTestCase @@ -1666,17 +1686,21 @@ def setUp(self): "resolwe-api:entity-detail", kwargs={"pk": pk} ) - def _create_data(self): + def _create_data(self, data_status=None): process = Process.objects.create( name="Test process", contributor=self.contributor, ) - - return Data.objects.create( + data = Data.objects.create( name="Test data", contributor=self.contributor, process=process, ) + if data_status: + data.status = data_status + data.save() + + return data class EntityViewSetTestTransaction(EntityViewSetTestCommonMixin, TransactionTestCase): @@ -1765,6 +1789,121 @@ def test_prefetch(self): self.assertEqual(len(response.data), 10) self.assertIn(len(captured_queries), [7, 8]) + def test_entity_status(self): + data_error = self._create_data(Data.STATUS_ERROR) + data_uploading = self._create_data(Data.STATUS_UPLOADING) + data_processing = self._create_data(Data.STATUS_PROCESSING) + data_preparing = self._create_data(Data.STATUS_PREPARING) + data_waiting = self._create_data(Data.STATUS_WAITING) + data_resolving = self._create_data(Data.STATUS_RESOLVING) + data_done = self._create_data(Data.STATUS_DONE) + + entity = Entity.objects.create(contributor=self.contributor, name="error") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add( + data_error, + data_uploading, + data_processing, + data_preparing, + data_waiting, + data_resolving, + data_done, + ) + + entity = Entity.objects.create(contributor=self.contributor, name="uploading") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add( + data_uploading, + data_processing, + data_preparing, + data_waiting, + data_resolving, + data_done, + ) + + entity = Entity.objects.create(contributor=self.contributor, name="processing") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add( + data_processing, data_preparing, data_waiting, data_resolving, data_done + ) + + entity = Entity.objects.create(contributor=self.contributor, name="preparing") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add(data_preparing, data_waiting, data_resolving, data_done) + + entity = Entity.objects.create(contributor=self.contributor, name="waiting") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add(data_waiting, data_resolving, data_done) + + entity = Entity.objects.create(contributor=self.contributor, name="resolving") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add(data_resolving, data_done) + + entity = Entity.objects.create(contributor=self.contributor, name="done") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add(data_done) + + entity = Entity.objects.create(contributor=self.contributor, name="empty") + entity.set_permission(Permission.VIEW, get_anonymous_user()) + entity.data.add() + + request = factory.get("/", {}, format="json") + force_authenticate(request, self.contributor) + entities = self.entity_list_viewset(request).data + + # entities = self.client.get(self.list_url).data + + get_entity = lambda collections, name: next( + x for x in collections if x["name"] == name + ) + self.assertEqual(get_entity(entities, "error")["status"], Data.STATUS_ERROR) + self.assertEqual( + get_entity(entities, "uploading")["status"], Data.STATUS_UPLOADING + ) + self.assertEqual( + get_entity(entities, "processing")["status"], Data.STATUS_PROCESSING + ) + self.assertEqual( + get_entity(entities, "preparing")["status"], Data.STATUS_PREPARING + ) + self.assertEqual(get_entity(entities, "waiting")["status"], Data.STATUS_WAITING) + self.assertEqual( + get_entity(entities, "resolving")["status"], Data.STATUS_RESOLVING + ) + self.assertEqual(get_entity(entities, "done")["status"], Data.STATUS_DONE) + self.assertEqual(get_entity(entities, "empty")["status"], None) + + # Filter by status + request = factory.get( + "/", {"status": "OK", "collection__isnull": True}, format="json" + ) + force_authenticate(request, self.contributor) + response = self.entity_list_viewset(request) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["name"], "done") + + request = factory.get( + "/", {"status": "ER", "collection__isnull": True}, format="json" + ) + response = self.entity_list_viewset(request) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["name"], "error") + + request = factory.get( + "/", + { + "status__in": f"{Data.STATUS_RESOLVING},{Data.STATUS_WAITING}", + "collection__isnull": True, + }, + format="json", + ) + response = self.entity_list_viewset(request) + self.assertEqual(len(response.data), 2) + self.assertCountEqual( + [response.data[0]["name"], response.data[1]["name"]], + ["resolving", "waiting"], + ) + def test_list_filter_collection(self): request = factory.get("/", {}, format="json") force_authenticate(request, self.contributor) diff --git a/resolwe/flow/views/collection.py b/resolwe/flow/views/collection.py index c251e6b0d..5993c7de9 100644 --- a/resolwe/flow/views/collection.py +++ b/resolwe/flow/views/collection.py @@ -6,6 +6,7 @@ from drf_spectacular.utils import extend_schema from rest_framework import exceptions, mixins, status, viewsets from rest_framework.decorators import action +from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response from resolwe.flow.filters import CollectionFilter @@ -91,11 +92,16 @@ class BaseCollectionViewSet( "id", "modified", "name", + "status", ) ordering = "id" def get_queryset(self): """Prefetch permissions for current user.""" + # Only annotate the queryset with status on safe methods. When updating + # the annotation interfers with update (as it adds group by statement). + if self.request.method in SAFE_METHODS: + self.queryset = self.queryset.annotate_status() return self.prefetch_current_user_permissions(self.queryset) def create(self, request, *args, **kwargs): diff --git a/resolwe/flow/views/entity.py b/resolwe/flow/views/entity.py index 2a14c94fb..5bfd1d615 100644 --- a/resolwe/flow/views/entity.py +++ b/resolwe/flow/views/entity.py @@ -61,6 +61,7 @@ class EntityViewSet(ObservableMixin, BaseCollectionViewSet): ) .annotate(data_statuses=Subquery(data_status_subquery)) .annotate(data_count=Subquery(data_count_subquery)) + .annotate_status() ) def order_queryset(self, queryset):