Skip to content
Snippets Groups Projects
Commit bdf83bd8 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Resolve "Hide an artist in the UI"

parent d4d4e60e
Branches
Tags
No related merge requests found
Showing
with 392 additions and 19 deletions
...@@ -40,6 +40,12 @@ v1_patterns += [ ...@@ -40,6 +40,12 @@ v1_patterns += [
r"^manage/", r"^manage/",
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"), include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
), ),
url(
r"^moderation/",
include(
("funkwhale_api.moderation.urls", "moderation"), namespace="moderation"
),
),
url( url(
r"^federation/", r"^federation/",
include( include(
......
class SkipFilterForGetObject:
def get_object(self, *args, **kwargs):
setattr(self.request, "_skip_filters", True)
return super().get_object(*args, **kwargs)
def filter_queryset(self, queryset):
if getattr(self.request, "_skip_filters", False):
return queryset
return super().filter_queryset(queryset)
from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.moderation import filters as moderation_filters
from . import models from . import models
class TrackFavoriteFilter(filters.FilterSet): class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter( q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"] search_fields=["track__title", "track__artist__name", "track__album__title"]
) )
...@@ -13,3 +12,6 @@ class TrackFavoriteFilter(filters.FilterSet): ...@@ -13,3 +12,6 @@ class TrackFavoriteFilter(filters.FilterSet):
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ["user", "q"] fields = ["user", "q"]
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
"TRACK_FAVORITE"
]
from funkwhale_api.moderation import filters as moderation_filters
from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet):
class Meta:
model = models.Listening
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG[
"LISTENING"
]
fields = ["hidden"]
...@@ -7,7 +7,7 @@ from funkwhale_api.activity import record ...@@ -7,7 +7,7 @@ from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions from funkwhale_api.common import fields, permissions
from funkwhale_api.music.models import Track from funkwhale_api.music.models import Track
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
from . import models, serializers from . import filters, models, serializers
class ListeningViewSet( class ListeningViewSet(
...@@ -25,6 +25,7 @@ class ListeningViewSet( ...@@ -25,6 +25,7 @@ class ListeningViewSet(
IsAuthenticatedOrReadOnly, IsAuthenticatedOrReadOnly,
] ]
owner_checks = ["write"] owner_checks = ["write"]
filterset_class = filters.ListeningFilter
def get_serializer_class(self): def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]: if self.request.method.lower() in ["head", "get", "options"]:
......
...@@ -28,3 +28,10 @@ class InstancePolicyAdmin(admin.ModelAdmin): ...@@ -28,3 +28,10 @@ class InstancePolicyAdmin(admin.ModelAdmin):
"summary", "summary",
] ]
list_select_related = True list_select_related = True
@admin.register(models.UserFilter)
class UserFilterAdmin(admin.ModelAdmin):
list_display = ["uuid", "user", "target_artist", "creation_date"]
search_fields = ["target_artist__name", "user__username", "user__email"]
list_select_related = True
...@@ -2,6 +2,8 @@ import factory ...@@ -2,6 +2,8 @@ import factory
from funkwhale_api.factories import registry, NoUpdateOnCreate from funkwhale_api.factories import registry, NoUpdateOnCreate
from funkwhale_api.federation import factories as federation_factories from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.music import factories as music_factories
from funkwhale_api.users import factories as users_factories
@registry.register @registry.register
...@@ -21,3 +23,17 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory): ...@@ -21,3 +23,17 @@ class InstancePolicyFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
for_actor = factory.Trait( for_actor = factory.Trait(
target_actor=factory.SubFactory(federation_factories.ActorFactory) target_actor=factory.SubFactory(federation_factories.ActorFactory)
) )
@registry.register
class UserFilterFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
user = factory.SubFactory(users_factories.UserFactory)
target_artist = None
class Meta:
model = "moderation.UserFilter"
class Params:
for_artist = factory.Trait(
target_artist=factory.SubFactory(music_factories.ArtistFactory)
)
from django.db.models import Q
from django_filters import rest_framework as filters
USER_FILTER_CONFIG = {
"ARTIST": {"target_artist": ["pk"]},
"ALBUM": {"target_artist": ["artist__pk"]},
"TRACK": {"target_artist": ["artist__pk", "album__artist__pk"]},
"LISTENING": {"target_artist": ["track__album__artist__pk", "track__artist__pk"]},
"TRACK_FAVORITE": {
"target_artist": ["track__album__artist__pk", "track__artist__pk"]
},
}
def get_filtered_content_query(config, user):
final_query = None
for filter_field, model_fields in config.items():
query = None
ids = user.content_filters.values_list(filter_field, flat=True)
for model_field in model_fields:
q = Q(**{"{}__in".format(model_field): ids})
if query:
query |= q
else:
query = q
final_query = query
return final_query
class HiddenContentFilterSet(filters.FilterSet):
"""
A filterset that include a "hidden" param:
- hidden=true : list user hidden/filtered objects
- hidden=false : list all objects user hidden/filtered objects
- not specified: hidden=false
Usage:
class MyFilterSet(HiddenContentFilterSet):
class Meta:
hidden_content_fields_mapping = {'target_artist': ['pk']}
Will map UserContentFilter.artist values to the pk field of the filtered model.
"""
hidden = filters.BooleanFilter(field_name="_", method="filter_hidden_content")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.data = self.data.copy()
self.data.setdefault("hidden", False)
def filter_hidden_content(self, queryset, name, value):
user = self.request.user
if not user.is_authenticated:
# no filter to apply
return queryset
config = self.__class__.Meta.hidden_content_fields_mapping
final_query = get_filtered_content_query(config, user)
if value is True:
return queryset.filter(final_query)
else:
return queryset.exclude(final_query)
# Generated by Django 2.1.5 on 2019-02-13 09:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("music", "0037_auto_20190103_1757"),
("moderation", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="UserFilter",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"target_artist",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_filters",
to="music.Artist",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="content_filters",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AlterUniqueTogether(
name="userfilter", unique_together={("user", "target_artist")}
),
]
...@@ -73,3 +73,22 @@ class InstancePolicy(models.Model): ...@@ -73,3 +73,22 @@ class InstancePolicy(models.Model):
return {"type": "actor", "obj": self.target_actor} return {"type": "actor", "obj": self.target_actor}
if self.target_domain_id: if self.target_domain_id:
return {"type": "domain", "obj": self.target_domain} return {"type": "domain", "obj": self.target_domain}
class UserFilter(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
target_artist = models.ForeignKey(
"music.Artist", on_delete=models.CASCADE, related_name="user_filters"
)
user = models.ForeignKey(
"users.User", on_delete=models.CASCADE, related_name="content_filters"
)
class Meta:
unique_together = ("user", "target_artist")
@property
def target(self):
if self.target_artist:
return {"type": "artist", "obj": self.target_artist}
from rest_framework import serializers
from funkwhale_api.music import models as music_models
from . import models
class FilteredArtistSerializer(serializers.ModelSerializer):
class Meta:
model = music_models.Artist
fields = ["id", "name"]
class TargetSerializer(serializers.Serializer):
type = serializers.ChoiceField(choices=["artist"])
id = serializers.CharField()
def to_representation(self, value):
if value["type"] == "artist":
data = FilteredArtistSerializer(value["obj"]).data
data.update({"type": "artist"})
return data
def to_internal_value(self, value):
if value["type"] == "artist":
field = serializers.PrimaryKeyRelatedField(
queryset=music_models.Artist.objects.all()
)
value["obj"] = field.to_internal_value(value["id"])
return value
class UserFilterSerializer(serializers.ModelSerializer):
target = TargetSerializer()
class Meta:
model = models.UserFilter
fields = ["uuid", "target", "creation_date"]
read_only_fields = ["uuid", "creation_date"]
def validate(self, data):
target = data.pop("target")
if target["type"] == "artist":
data["target_artist"] = target["obj"]
return data
from rest_framework import routers
from . import views
router = routers.SimpleRouter()
router.register(r"content-filters", views.UserFilterViewSet, "content-filters")
urlpatterns = router.urls
from django.db import IntegrityError
from rest_framework import mixins
from rest_framework import permissions
from rest_framework import response
from rest_framework import status
from rest_framework import viewsets
from . import models
from . import serializers
class UserFilterViewSet(
mixins.ListModelMixin,
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.UserFilter.objects.all()
.order_by("-creation_date")
.select_related("target_artist")
)
serializer_class = serializers.UserFilterSerializer
permission_classes = [permissions.IsAuthenticated]
ordering_fields = ("creation_date",)
def create(self, request, *args, **kwargs):
try:
return super().create(request, *args, **kwargs)
except IntegrityError:
content = {"detail": "A content filter already exists for this object"}
return response.Response(content, status=status.HTTP_400_BAD_REQUEST)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(user=self.request.user)
def perform_create(self, serializer):
serializer.save(user=self.request.user)
...@@ -2,12 +2,13 @@ from django_filters import rest_framework as filters ...@@ -2,12 +2,13 @@ from django_filters import rest_framework as filters
from funkwhale_api.common import fields from funkwhale_api.common import fields
from funkwhale_api.common import search from funkwhale_api.common import search
from funkwhale_api.moderation import filters as moderation_filters
from . import models from . import models
from . import utils from . import utils
class ArtistFilter(filters.FilterSet): class ArtistFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(search_fields=["name"]) q = fields.SearchFilter(search_fields=["name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
...@@ -17,13 +18,14 @@ class ArtistFilter(filters.FilterSet): ...@@ -17,13 +18,14 @@ class ArtistFilter(filters.FilterSet):
"name": ["exact", "iexact", "startswith", "icontains"], "name": ["exact", "iexact", "startswith", "icontains"],
"playable": "exact", "playable": "exact",
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ARTIST"]
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
return queryset.playable_by(actor, value) return queryset.playable_by(actor, value)
class TrackFilter(filters.FilterSet): class TrackFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"]) q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
...@@ -36,6 +38,7 @@ class TrackFilter(filters.FilterSet): ...@@ -36,6 +38,7 @@ class TrackFilter(filters.FilterSet):
"album": ["exact"], "album": ["exact"],
"license": ["exact"], "license": ["exact"],
} }
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["TRACK"]
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
...@@ -85,13 +88,14 @@ class UploadFilter(filters.FilterSet): ...@@ -85,13 +88,14 @@ class UploadFilter(filters.FilterSet):
return queryset.playable_by(actor, value) return queryset.playable_by(actor, value)
class AlbumFilter(filters.FilterSet): class AlbumFilter(moderation_filters.HiddenContentFilterSet):
playable = filters.BooleanFilter(field_name="_", method="filter_playable") playable = filters.BooleanFilter(field_name="_", method="filter_playable")
q = fields.SearchFilter(search_fields=["title", "artist__name"]) q = fields.SearchFilter(search_fields=["title", "artist__name"])
class Meta: class Meta:
model = models.Album model = models.Album
fields = ["playable", "q", "artist"] fields = ["playable", "q", "artist"]
hidden_content_fields_mapping = moderation_filters.USER_FILTER_CONFIG["ALBUM"]
def filter_playable(self, queryset, name, value): def filter_playable(self, queryset, name, value):
actor = utils.get_actor_from_request(self.request) actor = utils.get_actor_from_request(self.request)
......
...@@ -18,6 +18,7 @@ from taggit.models import Tag ...@@ -18,6 +18,7 @@ from taggit.models import Tag
from funkwhale_api.common import permissions as common_permissions from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.common import views as common_views
from funkwhale_api.federation.authentication import SignatureAuthentication from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation import api_serializers as federation_api_serializers from funkwhale_api.federation import api_serializers as federation_api_serializers
from funkwhale_api.federation import routes from funkwhale_api.federation import routes
...@@ -58,7 +59,7 @@ class TagViewSetMixin(object): ...@@ -58,7 +59,7 @@ class TagViewSetMixin(object):
return queryset return queryset
class ArtistViewSet(viewsets.ReadOnlyModelViewSet): class ArtistViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = models.Artist.objects.all() queryset = models.Artist.objects.all()
serializer_class = serializers.ArtistWithAlbumsSerializer serializer_class = serializers.ArtistWithAlbumsSerializer
permission_classes = [common_permissions.ConditionalAuthentication] permission_classes = [common_permissions.ConditionalAuthentication]
...@@ -82,7 +83,7 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -82,7 +83,7 @@ class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
) )
class AlbumViewSet(viewsets.ReadOnlyModelViewSet): class AlbumViewSet(common_views.SkipFilterForGetObject, viewsets.ReadOnlyModelViewSet):
queryset = ( queryset = (
models.Album.objects.all().order_by("artist", "release_date").select_related() models.Album.objects.all().order_by("artist", "release_date").select_related()
) )
...@@ -166,7 +167,9 @@ class LibraryViewSet( ...@@ -166,7 +167,9 @@ class LibraryViewSet(
return Response(serializer.data) return Response(serializer.data)
class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet): class TrackViewSet(
common_views.SkipFilterForGetObject, TagViewSetMixin, viewsets.ReadOnlyModelViewSet
):
""" """
A simple ViewSet for viewing and editing accounts. A simple ViewSet for viewing and editing accounts.
""" """
......
...@@ -17,7 +17,7 @@ class PlaylistQuerySet(models.QuerySet): ...@@ -17,7 +17,7 @@ class PlaylistQuerySet(models.QuerySet):
def with_covers(self): def with_covers(self):
album_prefetch = models.Prefetch( album_prefetch = models.Prefetch(
"album", queryset=music_models.Album.objects.only("cover") "album", queryset=music_models.Album.objects.only("cover", "artist_id")
) )
track_prefetch = models.Prefetch( track_prefetch = models.Prefetch(
"track", "track",
......
...@@ -117,9 +117,21 @@ class PlaylistSerializer(serializers.ModelSerializer): ...@@ -117,9 +117,21 @@ class PlaylistSerializer(serializers.ModelSerializer):
except AttributeError: except AttributeError:
return [] return []
try:
user = self.context["request"].user
except (KeyError, AttributeError):
excluded_artists = []
user = None
if user and user.is_authenticated:
excluded_artists = list(
user.content_filters.values_list("target_artist", flat=True)
)
covers = [] covers = []
max_covers = 5 max_covers = 5
for plt in plts: for plt in plts:
if plt.track.album.artist_id in excluded_artists:
continue
url = plt.track.album.cover.crop["200x200"].url url = plt.track.album.cover.crop["200x200"].url
if url in covers: if url in covers:
continue continue
......
...@@ -5,6 +5,7 @@ from django.db import connection ...@@ -5,6 +5,7 @@ from django.db import connection
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.music.models import Artist, Track from funkwhale_api.music.models import Artist, Track
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
...@@ -43,7 +44,14 @@ class SessionRadio(SimpleRadio): ...@@ -43,7 +44,14 @@ class SessionRadio(SimpleRadio):
return self.session return self.session
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
return Track.objects.all() qs = Track.objects.all()
if not self.session:
return qs
query = moderation_filters.get_filtered_content_query(
config=moderation_filters.USER_FILTER_CONFIG["TRACK"],
user=self.session.user,
)
return qs.exclude(query)
def get_queryset_kwargs(self): def get_queryset_kwargs(self):
return {} return {}
......
...@@ -13,6 +13,7 @@ import funkwhale_api ...@@ -13,6 +13,7 @@ import funkwhale_api
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.common import fields, preferences, utils as common_utils from funkwhale_api.common import fields, preferences, utils as common_utils
from funkwhale_api.favorites.models import TrackFavorite from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.moderation import filters as moderation_filters
from funkwhale_api.music import models as music_models from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils from funkwhale_api.music import utils
from funkwhale_api.music import views as music_views from funkwhale_api.music import views as music_views
...@@ -152,8 +153,14 @@ class SubsonicViewSet(viewsets.GenericViewSet): ...@@ -152,8 +153,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getArtists", url_path="getArtists",
) )
def get_artists(self, request, *args, **kwargs): def get_artists(self, request, *args, **kwargs):
artists = music_models.Artist.objects.all().playable_by( artists = (
utils.get_actor_from_request(request) music_models.Artist.objects.all()
.exclude(
moderation_filters.get_filtered_content_query(
moderation_filters.USER_FILTER_CONFIG["ARTIST"], request.user
)
)
.playable_by(utils.get_actor_from_request(request))
) )
data = serializers.GetArtistsSerializer(artists).data data = serializers.GetArtistsSerializer(artists).data
payload = {"artists": data} payload = {"artists": data}
...@@ -167,8 +174,14 @@ class SubsonicViewSet(viewsets.GenericViewSet): ...@@ -167,8 +174,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getIndexes", url_path="getIndexes",
) )
def get_indexes(self, request, *args, **kwargs): def get_indexes(self, request, *args, **kwargs):
artists = music_models.Artist.objects.all().playable_by( artists = (
utils.get_actor_from_request(request) music_models.Artist.objects.all()
.exclude(
moderation_filters.get_filtered_content_query(
moderation_filters.USER_FILTER_CONFIG["ARTIST"], request.user
)
)
.playable_by(utils.get_actor_from_request(request))
) )
data = serializers.GetArtistsSerializer(artists).data data = serializers.GetArtistsSerializer(artists).data
payload = {"indexes": data} payload = {"indexes": data}
...@@ -273,7 +286,11 @@ class SubsonicViewSet(viewsets.GenericViewSet): ...@@ -273,7 +286,11 @@ class SubsonicViewSet(viewsets.GenericViewSet):
def get_random_songs(self, request, *args, **kwargs): def get_random_songs(self, request, *args, **kwargs):
data = request.GET or request.POST data = request.GET or request.POST
actor = utils.get_actor_from_request(request) actor = utils.get_actor_from_request(request)
queryset = music_models.Track.objects.all() queryset = music_models.Track.objects.all().exclude(
moderation_filters.get_filtered_content_query(
moderation_filters.USER_FILTER_CONFIG["TRACK"], request.user
)
)
queryset = queryset.playable_by(actor) queryset = queryset.playable_by(actor)
try: try:
size = int(data["size"]) size = int(data["size"])
...@@ -308,8 +325,14 @@ class SubsonicViewSet(viewsets.GenericViewSet): ...@@ -308,8 +325,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getAlbumList2", url_path="getAlbumList2",
) )
def get_album_list2(self, request, *args, **kwargs): def get_album_list2(self, request, *args, **kwargs):
queryset = music_models.Album.objects.with_tracks_count().order_by( queryset = (
"artist__name" music_models.Album.objects.exclude(
moderation_filters.get_filtered_content_query(
moderation_filters.USER_FILTER_CONFIG["ALBUM"], request.user
)
)
.with_tracks_count()
.order_by("artist__name")
) )
data = request.GET or request.POST data = request.GET or request.POST
filterset = filters.AlbumList2FilterSet(data, queryset=queryset) filterset = filters.AlbumList2FilterSet(data, queryset=queryset)
......
from funkwhale_api.favorites import filters
from funkwhale_api.favorites import models
def test_track_favorite_filter_track_artist(factories, mocker, queryset_equal_list):
factories["favorites.TrackFavorite"]()
cf = factories["moderation.UserFilter"](for_artist=True)
hidden_fav = factories["favorites.TrackFavorite"](track__artist=cf.target_artist)
qs = models.TrackFavorite.objects.all()
filterset = filters.TrackFavoriteFilter(
{"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
)
assert filterset.qs == [hidden_fav]
def test_track_favorite_filter_track_album_artist(
factories, mocker, queryset_equal_list
):
factories["favorites.TrackFavorite"]()
cf = factories["moderation.UserFilter"](for_artist=True)
hidden_fav = factories["favorites.TrackFavorite"](
track__album__artist=cf.target_artist
)
qs = models.TrackFavorite.objects.all()
filterset = filters.TrackFavoriteFilter(
{"hidden": "true"}, request=mocker.Mock(user=cf.user), queryset=qs
)
assert filterset.qs == [hidden_fav]
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment