Repository: pulp/pulpcore

Issue:
task filter `worker__in` does not accept hrefs
**Version**
main branch, probably everything to at least 3.21

**Describe the bug**
Filter complains about the parameter not being a uuid.

**To Reproduce**
List task with `worker__in` query string.


Discussion:

Resolving patch:
diff --git a/pulpcore/app/viewsets/custom_filters.py b/pulpcore/app/viewsets/custom_filters.py
--- a/pulpcore/app/viewsets/custom_filters.py
+++ b/pulpcore/app/viewsets/custom_filters.py
@@ -12,6 +12,7 @@
 from django.urls import Resolver404, resolve
 from django.db.models import ObjectDoesNotExist
 from django_filters import BaseInFilter, CharFilter, DateTimeFilter, Filter
+from django_filters.constants import EMPTY_VALUES
 from django_filters.fields import IsoDateTimeField
 from rest_framework import serializers
 from rest_framework.serializers import ValidationError as DRFValidationError
@@ -19,6 +20,8 @@
 from pulpcore.app.models import ContentArtifact, Label, RepositoryVersion, Publication
 from pulpcore.app.viewsets import NamedModelViewSet
 
+EMPTY_VALUES = (*EMPTY_VALUES, "null")
+
 
 class ReservedResourcesFilter(Filter):
     """
@@ -142,15 +145,37 @@ def __init__(self, *args, **kwargs):
         self.allow_null = kwargs.pop("allow_null", False)
         super().__init__(*args, **kwargs)
 
-    def filter(self, qs, value):
-        """
-        Args:
-            qs (django.db.models.query.QuerySet): The Queryset to filter
-            value (string): href containing pk for the foreign key instance
+    def _resolve_uri(self, uri):
+        try:
+            return resolve(urlparse(uri).path)
+        except Resolver404:
+            raise serializers.ValidationError(
+                detail=_("URI couldn't be resolved: {uri}".format(uri=uri))
+            )
 
-        Returns:
-            django.db.models.query.QuerySet: Queryset filtered by the foreign key pk
-        """
+    def _check_subclass(self, qs, uri, match):
+        fields_model = getattr(qs.model, self.field_name).get_queryset().model
+        lookups_model = match.func.cls.queryset.model
+        if not issubclass(lookups_model, fields_model):
+            raise serializers.ValidationError(
+                detail=_("URI is not a valid href for {field_name} model: {uri}").format(
+                    field_name=self.field_name, uri=uri
+                )
+            )
+
+    def _check_valid_uuid(self, uuid):
+        if not uuid:
+            return True
+        try:
+            UUID(uuid, version=4)
+        except ValueError:
+            raise serializers.ValidationError(detail=_("UUID invalid: {uuid}").format(uuid=uuid))
+
+    def _validations(self, *args, **kwargs):
+        self._check_valid_uuid(kwargs["match"].kwargs.get("pk"))
+        self._check_subclass(*args, **kwargs)
+
+    def filter(self, qs, value):
 
         if value is None:
             # value was not supplied by the user
@@ -160,33 +185,27 @@ def filter(self, qs, value):
             raise serializers.ValidationError(
                 detail=_("No value supplied for {name} filter.").format(name=self.field_name)
             )
-        elif self.allow_null and (value == "null" or value == ""):
-            key = f"{self.field_name}__isnull"
-            lookup = True
-        else:
-            try:
-                match = resolve(urlparse(value).path)
-            except Resolver404:
-                raise serializers.ValidationError(detail=_("URI not valid: {u}").format(u=value))
 
-            fields_model = getattr(qs.model, self.field_name).get_queryset().model
-            lookups_model = match.func.cls.queryset.model
-            if not issubclass(lookups_model, fields_model):
-                raise serializers.ValidationError(detail=_("URI not valid: {u}").format(u=value))
+        if self.allow_null and value in EMPTY_VALUES:
+            return qs.filter(**{f"{self.field_name}__isnull": True})
 
+        if self.lookup_expr == "in":
+            matches = {uri: self._resolve_uri(uri) for uri in value}
+            [self._validations(qs, uri=uri, match=matches[uri]) for uri in matches]
+            value = [pk if (pk := matches[match].kwargs.get("pk")) else match for match in matches]
+        else:
+            match = self._resolve_uri(value)
+            self._validations(qs, uri=value, match=match)
             if pk := match.kwargs.get("pk"):
-                try:
-                    UUID(pk, version=4)
-                except ValueError:
-                    raise serializers.ValidationError(detail=_("UUID invalid: {u}").format(u=pk))
-
-                key = "{}__pk".format(self.field_name)
-                lookup = pk
+                value = pk
             else:
-                key = f"{self.field_name}__in"
-                lookup = match.func.cls.queryset
+                return qs.filter(**{f"{self.field_name}__in": match.func.cls.queryset})
 
-        return qs.filter(**{key: lookup})
+        return super().filter(qs, value)
+
+
+class HyperlinkRelatedInFilter(BaseInFilter, HyperlinkRelatedFilter):
+    pass
 
 
 class IsoDateTimeFilter(DateTimeFilter):
diff --git a/pulpcore/app/viewsets/task.py b/pulpcore/app/viewsets/task.py
--- a/pulpcore/app/viewsets/task.py
+++ b/pulpcore/app/viewsets/task.py
@@ -25,6 +25,7 @@
 from pulpcore.app.viewsets.base import DATETIME_FILTER_OPTIONS, NAME_FILTER_OPTIONS
 from pulpcore.app.viewsets.custom_filters import (
     HyperlinkRelatedFilter,
+    HyperlinkRelatedInFilter,
     IsoDateTimeFilter,
     ReservedResourcesFilter,
     ReservedResourcesInFilter,
@@ -39,6 +40,7 @@
 class TaskFilter(BaseFilterSet):
     state = filters.ChoiceFilter(choices=TASK_CHOICES)
     worker = HyperlinkRelatedFilter()
+    worker__in = HyperlinkRelatedInFilter(field_name="worker")
     name = filters.CharFilter()
     logging_cid = filters.CharFilter()
     started_at = IsoDateTimeFilter(field_name="started_at")
@@ -62,7 +64,6 @@ class Meta:
         model = Task
         fields = {
             "state": ["exact", "in"],
-            "worker": ["exact", "in"],
             "name": ["contains"],
             "logging_cid": ["exact", "contains"],
             "started_at": DATETIME_FILTER_OPTIONS,


Test patch:
diff --git a/pulpcore/tests/functional/api/test_tasking.py b/pulpcore/tests/functional/api/test_tasking.py
--- a/pulpcore/tests/functional/api/test_tasking.py
+++ b/pulpcore/tests/functional/api/test_tasking.py
@@ -269,3 +269,20 @@ def test_search_task_using_an_invalid_name(tasks_api_client):
     search_results = tasks_api_client.list(name=str(uuid4()))
 
     assert not search_results.results and not search_results.count
+
+
+@pytest.mark.parallel
+def test_filter_tasks_using_worker__in_filter(tasks_api_client, dispatch_task):
+
+    task1_href = dispatch_task("time.sleep", (0,))
+    task2_href = dispatch_task("time.sleep", (0,))
+
+    task1 = monitor_task(task1_href)
+    task2 = monitor_task(task2_href)
+
+    search_results = tasks_api_client.list(worker__in=(task1.worker, task2.worker))
+
+    tasks_hrefs = [task.pulp_href for task in search_results.results]
+
+    assert task1_href in tasks_hrefs
+    assert task2_href in tasks_hrefs